注:本文中正体为个人理解后的文章内容,斜体为个人的主观补充和发散。如果本文中的任何内容有理解错误欢迎指出,感谢!
follower read 是 stale read 的实现,同时也使 stale read 更有意义,因为在跨地域部署的场景下,每个地域的读取请求可以路由到就近的 follower 副本上,从而减少读取的跨地域延迟。
CRDB 将所有数据被分为 512MB 的 range,每个 range 均使用 raft 进行复制,仅以 leaseholder 提供读写服务。
所带来的的好处:
但是也有缺点:
相比其他数据库实现,CRDB 额外抽象出了 leaseholder 角色,其通常是 raft leader,但仍有区别。虽然不影响本文中的其他内容,但笔者实际并不完全理解 leaseholder 的实现和用途,如果有文章能够清晰的描述这些,欢迎告知。
在某些场景下,过时的数据不光是可接受的,也许还是用户所预期的:
银行的程序需要为客户生成每日的账户报告,无论程序何时运行,都只需要计算出截止到当日凌晨 00:00 的数据,之后的数据应该在次日的报告中体现。
在另一些场景下,用户并不在乎信息是绝对实时的:
打开 Hacker News 时,用户并不在意主页展示的信息是这一刻的还是几秒前的(很多与写完全无关的的 只读操作都适合这种方式)。
不过也有反例,当读取和之前的写入有因果关系时,用户需要强一致读:
你告诉朋友你向他发了一条消息,当他刷新自己的消息列表时,你希望他能看到这条消息。
由于数据库很难感知到这种因果关系,所以默认偏向于提供强一致性的读取。
除了因果关系以外,还有就是实时性要求很高的场景:
股票自动交易系统需要看到最新的价格(毫秒级)来决定是否进行交易。
上述的例子都是只读事务,而对于读写事务来说,为了保证 serializable 的隔离级别,所有读写操作都应该是原子的,因此也不能提供过时的数据。
确定的时间点:
1 | // 具体时间 |
不确定的时间点:
返回尽量新的数据,不能超过 1 分钟,但可以是 1 分钟内的任意时间点
后者会在一些场景下更加灵活:
跨地域部署下,leaseholder 可能和客户端有着很高的延迟,使用 stale read 可以将读请求发送到更近的本地副本中,减少读取延迟。
诞生了两个玩法:
关于 GLOBAL table,文章没写实现,文档介绍是通过生成一个未来的时间戳,并通过类似 commit wait 的方式等待传播到所有副本。
套用 napa 论文中的三角,CRDB 在一致性、读延迟和写延迟中提供了不同的 tradeoffs。
leaseholder 定期向 follower 同步一个称之为 closed ts 的时间戳,并承诺在这之后不会接受小于 closed ts 的写入。
当 follower 收到 closed ts 时,意味着:
从执行 sql 的节点来看,如果读取的时间戳足够旧,就可以找到最近的副本(节点之间通过在后台定期探测统计延迟)进行读取,否则还是通过 leaseholder 进行读取。
leaseholder 通过 raft log 向 follower 同步 closed ts,由上图可知,closed ts 并不是单独的 raft log,而是携带到正常的数据同步日志中。
当 closed ts 被 apply 后,如果 leaseholder 接收到了时间戳小于 closed ts 的写请求,将会拒绝并推动事务使用更高的时间戳(类似 Read refreshing 的实现,最坏情况下会重启事务)。
CRDB 支持同一个集群中的不同 range 使用不同的 closed ts 策略(猜测和上述的 GLOBAL table 等有一定关系)。
简单来说 closed ts 就是 leader 向 follower 实时同步 <log position, timestamp>
的映射关系,即应用了 log position 的日志后,便可以提供小于 timestamp 的读取。
CRDB 通过两种方式同步 closed ts:
通过两种方式推送 closed ts 会带来复杂度,但是仍然遵循着一些规则:
原则上,只要保证以下约束,closed ts 可以取任何值:
在这个窗口内进行取值,需要权衡:
因为更换时间戳并重试的成本比较高,且可能会产生报错并需要客户端重试,所以目前是设置 3s 左右的延迟(可以配置)。
因为会有多个生产者并发 propose,所以同步 closed ts 时需要考虑到并发的写入,避免破坏其承诺。
通过一个名为 write tracker 的组件记录当前正在进行的写入,以及最小的时间戳(称之 t_eval)。
两种同步方式均需要考虑到 t_eval:
为了性能考虑,每个 range 都有一个 write tracker,因为每个 range 的 closed ts 是隔离的,这样也可以减少相互影响。
基础实现是一个最小堆,不过堆有锁,性能肯定上不去。之后为了性能舍弃了准确度,实现了一个统计近似值的数据结构,大概原理:
以 B1 的 ts 是 10,B2 的 ts 是 20 为例,当一个新的请求加入时,有三种情况:
相比最小堆,这个实现只能提供一个近似值,即一个桶内的所有请求都结束后才会推进 closed ts,但是其 track 是无锁的,性能会好很多。
CRDB 的一种常见代码模式,使用读写锁,持有读锁执行无锁操作,而持有写锁执行更复杂的操作并保证互斥
请求退出是在 raft 的 propose 中,当 raft 已经将所有写入请求定序之后,按照顺序从 tracker 中退出并获取 closed ts,这样能够保证 closed ts 在日志序上始终是递增的。
对 side transport 的通信层面的优化,简单概括就是只发 delta 不发全量。
以 n1 发送 closed ts 给 n3 举例:
除了无法应用 closed ts 的 range 以外,将 group 中的其它 range 和对应的 ts 发送给 n3,例如 group 1 中除了 r101 无法应用该 closed ts,其他 range 都需要发送
发送消息时,n1 更新 sender state,收到消息时,n3 更新 receiver state
定时器再次触发,对于每个 group,只发送新的 ts 和成员的变更,例如 group 1 的 ts 从 10 变成了 15,成员增加了 r101(可以应用该 closed ts),减少了 r100(无法应用该 closed ts)
发送消息时,n1 更新 sender state,收到消息时,n3 更新 receiver state
通过分组发送统一的 closed ts 和 range 的变更,使通信的规模不再和 range 的数量相关,而仅和变更的数量相关。因为假设走到 side transport 逻辑的 range 都是没有写入的 range,而这些 range 的变化通常很少,可以有效地减少网络带宽。
另一个优化是 closed ts 的查询路径,考虑到有两个来源:
为了尽量减少对 side transport 的访问,做了两个优化:
这里有些没能理解,side transport 已经是 leaseholder 推给 follower 了,follower 也不需要网络请求,仅是内存查询开销都很大么?还不如直接更新到状态机中呢。
The semantic difference between closed timestamps and resolved timestamps sometimes trip up even the best of us on occasion.
简单来说就是:上面提到的 closed ts 的承诺只能应用于“写入”,其实就是 write intent,但 CRDB 支持事务,除了 write intent 还有 commit,而 closed ts 的承诺对 commit 是无效的,即虽然不会有小于 closed ts 的 write intent,但是可能会有小于 closed ts 的 commit。
为什么允许这样做呢?因为 closed ts 是服务于 follower read,而 follower read 是可以处理这种情况的,只需要在遇到 write intent 时阻塞,并等待其提交或者回滚即可。
这时又引入了另一个时间戳 resolved ts,相当于 closed ts 打了个补丁:取 range 的 closed ts 和其所有未提交的 write intent 的 ts 的最小值,其承诺也仅仅从“写入”变为了“事务提交”:
resolved ts 的使用场景是 CDC,因为 CDC 的消费者必须全量捕获所有已经提交的事务并排序,另外 resolved ts 还可以应用于不确定时间点的 stale read,因为其可以保证不会遇到任何 write intent 并被阻塞,获得更好的读取延迟。
看上去 resolved ts 是功能性需求,既可以满足 stale read 也可以满足 CDC,而 closed ts 是 resolved ts 的一个优化,能够提供更加新的可读取时间点,但有概率被阻塞。
CRDB 中负责路由的组件叫 DistSender,它会维护每个 range 所有副本所在的节点信息。
正常情况下 DistSender 只会将请求路由给对应 range 的 leaseholder,但如果是只读请求,并且 ts 足够旧,就会发给副本所在最近(探测延迟最低)的节点。
看上去是每个副本都会上报其最新的 closed ts 和 resolved ts?
spanner 每个副本都可以提供强一致读和 stale read,当 follower 收到强一致读时,需要从 leader 获取对应的 log position,并等待本地回放超过这个 log position 之后才进行读取。
而对于 stale read,由于 spanner 在事务提交时才真正获取时间戳,并且是当前时间,因此 closed ts 不会影响到 spanner 的写入流程。而对于 CRDB 来说,事务启动时获取时间戳,写入时使用,如果小于之前同步的 closed ts,则需要更新时间戳并重写所有的 key,最坏情况下需要重启事务。
closed ts 对写入流程的影响导致了 CRDB 必须有几秒的延迟(延迟时间决定于事务的存活时间),spanner 就没这个烦恼。不过 CRDB 的这个设计可以做到不需要读锁而是使用 timestamp cache 就能实现 serializable 的隔离级别,而 spanner 则需要在读取前先加读锁,并在提交后释放读锁。
这块不确定理解的对不对,大概意思是 spanner 既然在 commit 时才取时间戳,那么只需要 track 下 in-flight 的 2pc 请求,取一下它们 prepare ts 的最小值就可以了,在这一刻如果还没开始 commit,那之后获取的 commit ts 也一定大于计算出的 resolved ts(spanner 应该没有 closed ts,对标的就是 resolved ts),实际 tikv 也是类似的实现。而 CRDB 的 commit ts 是开始事务时获取的,相对的临界区就大很多,并且 leaseholder 无法观测到开启事务这一事件,就会有之后拒绝写入的可能性(spanner 使用 prepare ts 就是因为 prepare 是 leaseholder 能观测到的获取 commit ts 前的最后一个事件)。
spanner 的实现是 leader 会追踪每一个 prepare 了但还没有 commit 的事务,然后将最小的时间戳放到 paxos 消息中,这样 follower 就知道这个时间戳之下的所有事务都已经回放到本地、且拥有确定的状态(commit or abort)。所以 CRDB 是在 closed ts 的语义上提供了 follower read,而 spanner 是在 resolved ts 的语义上提供了 follower read,各自优劣上面已经说得很清楚了:
spanner 论文中提到的改进是在 range 内部实现更细粒度的 resolved ts,这样能够减少影响,并且论文已经很久,不确定现在是否已经改进了。
个人理解 spanner 的劣势就是在 2pc 未决的场景下,在未决协商的过程中,即使没有发生实际的 commmit 行为,但 range 的 leaseholder 无法观测到这个状态,它能够感知到的最近的事件就是 prepare。
这个对比的结果似乎有些恶趣味,CRDB 的事务实现注定了他们的 closed ts 即使在常态下延迟也很大(否则会影响写入),在笔者看来他们对 closed ts(相比 resolved ts 的提升)所做的努力有些杯水车薪。而对于大部分 spanner 类型的设计(在 commit 阶段明确取“当前时间”作为 commit ts),都可以很轻松的把常态下的延迟压到一个很小的值(没有未决时也就 1~2 个 rpc 的延迟?),这样想想 CRDB 还挺可怜的。
]]>参考论文 Online, Asynchronous Schema Change in F1,本文就不详细介绍了。
算法的核心主要有两点:
tidb 按照论文中的要求实现了 schema lease,保证所有 tidb 节点中同时只存在两个相邻的 schema 版本,但仍然会遇到一些问题。
在分布式事务下,「数据组装和写入」以及「数据提交和可见」是两个不同的时间点,而 schema lease 只能保证某一个时间点下的 schema 版本。所以当数据提交时,schema lease 所保护的 schema 版本可能已经不是写入时的版本了,那就破坏了论文中的保证。
以加索引为例,在这里举两个实际的例子。
加索引的状态变更流程为:
1 | Absent => Delete Only => Write Only => Reorg => Public |
考虑以下时序一:
考虑以下时序二:
可以发现,这两个时序都是在 schema lease 推进 schema 版本到 N+1 后,事务提交了 schema 版本为 N-1 的数据。
对于同步提交,tidb 通过在 2pc 时进一步检查 schema 版本避免该问题。
具体的时机是在 prewrite 成功且获取 commit ts 之后进行检查,如果当前通过 schema lease 获取的 schema 版本和事务创建时的版本相同,说明全局的 schema 版本最多只有可能推进一个版本,便仍然是可兼容的版本,此时便可以继续 commit,否则本次 2pc 需要失败并重试。
实际检查会更精细一些,包括推进的 schema 版本是否和本次操作的表有关系,不过这些不在本文的核心内容中,就不详述了。
上面说完了同步提交,再说说 tidb 5.0 实现的异步提交,同样仅简述一些必要的背景知识。具体可以参考官方博客中的介绍或是分布式事务的时间戳这篇文章。
从流程上来看,同步提交和异步提交的区别为:
从正确性来看,异步提交的核心实现是 commit ts 由 prewrite 阶段动态计算得出,也就是 commit 的结果和「定序」均在 prewrite 时已经确定并持久化,之后便不可以再更改。
异步提交的实现在 prewrite 完成后便将结果返回给了客户端,而回复就意味着承诺,也就表示之后的结果一定不能改变。
如果还采用同步提交的方案,在 commit 之前才检查 schema 版本,那么就会出现已经返回成功后,检查 schema 版本却失败了的问题,此时违背了之前的承诺。
看到此处读者可能会想,那在 prewrite 成功返回结果之前就检查 schema 版本,如果检查失败就直接返回失败,不再走 commit 流程,是否就能解决这个问题了。
很遗憾,答案还是不能,考虑一下极端情况,tidb 节点完成检查 schema 版本后返回成功,但是在发起 commit 之前宕机了。之后的恢复流程需要再次检查 schema 版本(因为不知道是检查前宕机还是检查后宕机),此时有可能检查 schema 版本失败并回滚,同样违背了之前的承诺。
考虑一下,为什么第一次检查 schema 版本成功后,第二次检查 schema 版本会失败呢?
因为同步提交下的 schema 版本检查实际是一个「单向屏障」,在获取 commit ts 之后获取最新的 schema 版本并检查,只能做单向保证:「通过检查表示该 commit ts 一定符合要求」,而不是「符合要求的 commit ts 都能通过检查」。因为获取 commit ts 和获取最新的 schema 版本中间会有缝隙,检查失败不一定不符合要求。
因此,即使 commit ts 不变,随着时间的推移,schema 检查会逐渐趋向于失败,而此时的失败就是被误判的场景。
那么是否有办法将这个检查变为一个「双向屏障」,即保证 commit ts 和 schema 版本一定是一一对应的,只要 commit ts 不变,对应的 schema 版本也就不会变,那么检查 schema 版本也会变为确定的结果。
在此介绍 tidb 的第一版解决方案,主要的工作在 txn: add schema version check for async commit recovery #20186 下。
其核心改动是增加了 checkSchemaVersionForAsyncCommit
函数,实现异步提交下的 schema 版本检查,代码如下:
1 | // checkSchemaVersionForAsyncCommit is used to check schema version change for async commit transactions |
这个实现使用了 start ts 和 commit ts 进行快照读获取当时的 schema 版本,这样当 commit ts 确定时(异步提交在 prewrite 完成后即确定 commit ts,并且在故障恢复时也可以通过持久化的数据计算出相同的值),schema 版本也是确定的,不受真正提交和检查 schema 版本时机的影响。
不过这个方案的缺点也很明显,在检查 schema 版本时需要进行两次快照读(理论上可以合并为一次),在主路径上增加 rpc 的开销并不小,可能会影响到异步提交带来的性能提升,因此这个方案也没能成为最终方案。
鉴于方案一有一些不完美的地方,tidb 在 ddl, tikv: add delay during AddIndex DDL and remove schema check for async commit #20550 中又实现了一个新的方案,并沿用至今。
主要实现是增加了 calculateMaxCommitTS
函数,用于在 prewrite 前计算一个确定合法的 commit ts 上界,代码如下:
1 | func (c *twoPhaseCommitter) calculateMaxCommitTS(ctx context.Context) error { |
首先对当前时间生成一个当前时间的 tso(在 start ts 的基础上偏移事务创建时间),如果当前时间通过了 schema 版本检查,就在当前时间的基础上增加 2s(safe window)作为 max commit ts。之后在 prewrite 计算 commit ts 时,如果计算出的 commit ts 超过了 max commit ts,则返回失败。
除此之外,在执行 reorg 时也需要等待 2.5s(2s 的 safe window + 0.5s 的时钟漂移) 才能真正开始:
1 | func (w *worker) runReorgJob(t *meta.Meta, reorgInfo *reorgInfo, tblInfo *model.TableInfo, lease time.Duration, f func() error) error { |
我不太清楚该如何系统性的描述整个方案的正确性,因此下面的内容是以 Q&A 的方式进行记录。
以下部分疑惑请教了上述 PR 的原作者 @sticnarf,感谢他的解答。
最开始我以为设置的 2s 是依赖了 schema lease 的实现,即每个操作都会等 2 * leasa 的时间。后来想到 tidb 支持在 pd 上注册和监听 schema 版本变更以加速推进 ddl,实际并没有这个保证(并且 lease time 是可配的,肯定不会在代码里写死 2s)。
所以这应该是一个经验值,认为异步提交的事务在 prewrite 阶段基本都会在 2s 内完成,并且代码中动态计算了 current ts 而不是直接用 start ts 也是为了尽量减少事务时间的限制,对长事务更加友好。
在异步提交下 reorg 出现问题的时序是确定的:
根据异步提交的实现,reorg 扫描时会将 tikv 对应的 max ts 推进到 reorg read ts,而之后 prewrite 时事务 commit ts 的计算区间即为 [reorg read ts, max commit ts),此时如果需要拦截住所有事务提交,就需要让 max commit ts 小于等于 reorg read ts。
而从上面的时序中可以得到 reorg read ts 一定大于 current ts,那么 reorg read ts + 2s 则一定大于 max commit ts,基于此使得 reorg 在执行前也需要等待 2s。
注意上述的前提都是不考虑时钟漂移的情况下。
有两处地方会受到始终漂移的影响:
只要发生了上述任意一种场景,都会导致 max commit ts < reorg read ts 这个保证不成立,也就有可能导致索引丢失。
所以,依赖 tso 和现实时间进行“绑定”是一个有风险的行为,例如:从 pd 拿到一个 tso A,在其基础上增加 2s 得到 A’,然后在本地 sleep 2.1s 后从 pd 在拿到 tso B,此时无法保证 B 一定比 A’ 更大。
在本文的最开始介绍了发生错误的两个时序,在不同方案中检测出问题的时机也是不同的:
calculateMaxCommitTS
时就能检查出来(因为两个事务都是写操作),只有时序一才需要通过 reorg 的等待进行保证(因为 reorg 是读操作)最早了解到这篇论文是在 2020 TiDB DevCon 上有一个简短的分享,后来发现这篇论文中的一部分贡献也作为了 Titan(PingCAP 的 KV 分离引擎)中的 Level Merge GC 实现,因此产生了兴趣。
注:本文中涉及到论文内容的章节,斜体为个人的理解和补充,非论文中内容,仅供参考。如果本文中的任何内容有理解错误欢迎指出,感谢!
为了利用顺序 IO 获得更好的性能,以及通过保证数据有序提高扫描性能,现代的 KV 存储一般都使用 LSM-Tree 作为存储结构。LSM-Tree 的实现一般有以下特点:
因此 LSM-Tree 存在着高额的读写放大,尤其是当层数随着数据量增加而增加时。
为了减少 compaction 的开销,业界有许多优化方向,和本文相关的优化方向主要有两个:
DiffKV 的思路:
下图展示了三种形态的 LSM-Tree,分别是传统的 LSM-Tree、放松全局有序和 KV 分离:
放松全局有序(PebblesDB)的特点是:
KV 分离(例如 WiscKey 和 Titan)的特点是:
通过一些测试观测 RocksDB、PebblesDB 和 Titan 的读写以及扫描性能。
写性能:
读和扫描性能:
总结:LSM-Tree 的设计主要在读写和扫描的性能中进行权衡。
DiffKV 的目标是在读写和扫描之间得到更加均衡的表现。
DiffKV 实现了一种类似 LSM-Tree 的数据结构(称之为 vTree)用于管理 Value,在 flush 的过程中将 Key 和 Value 分离。除此之外,为了提高扫描性能,vTree 保证 Value 是部分有序的。
vTree 和 LSM-Tree 一样有多个级别,每个级别仅能以追加的方式写入,为了实现 Value 的部分有序,vTree 有一个类似 compaction 的操作称之为 merge,并且会和 LSM-Tree 中的 compaction 协调进行,以减少 merge 的开销。
除此之外,DiffKV 的 memtable 和 WAL 和传统的 LSM-Tree 完全相同。
vTree 的整体架构由 vTable、Sorted Group 和 vTree 三层组成。
vTable 的设计:
Sorted Group 的设计:
vTree 的设计:
vTree 需要定期进行 merge 操作,每次 merge 时会读取一部分 vTable 并检查 Value 是否有效,检查的方式是通过查询 LSM-Tree 判断。并且合并后需要将 Value 的最新位置更新回 LSM-Tree。为了减少 merge 的开销,使用 LSM-Tree 中的 compaction 触发 vTree 的 merge。
一个设计的前提是让 LSM-Tree 中的每一层和 vTree 中的每一层完全对应(称之为 Li 和 vLi),即如果 Key 在 Li,那么 Value 一定在 vLi,只是相比之下,Li 是完全有序的,而 vLi 是部分有序的。
当 Li 需要 compact 到 Li+1 时,同时会触发 vLi 到 vLi+1 的 merge 操作,这里有两个问题:
DiffKV 的实现是:
每次 merge 都会在 vLi+1 中生成一个新的 Sorted Group,因为不重写 vLi+1 的 vTable 所以减少了写放大。且 vLi 中原有的 vTable 不会被删除,因为其中可能还会包含有效的 Value,需要在之后通过 GC 处理。
主要有两点收益:
数据布局和 PebblesDB 接近,也是 PebblesDB 减少写放大的核心思路。
由 compaction 触发 merge 个人觉得是个很好的设计,Key 和 Value 的生命周期是相同的,因此一定会有一方的变更触发另一方的变更。在此基础上 Key 是能够感知到生命周期变更的,而 Value 则做不到,所以传统 WiscKey 的做法以 Value 进行触发必然伴随了大量回查和重写,其中很大程度都能通过 Key 触发的方式避免掉
compaction-triggered merge 减少了回写的开销,但是如果每一次 compaction 都要触发 merge,开销同样很大。并且为了保证 LSM-Tree 中的层级和 vTree 中的层级一定对应,每次 compaction 都必须要触发 merge。
为此提出了两个优化:Lazy merge 和 Scan-optimized merge。
lazy merge 的实现是将 LSM-Tree 的多个较低级别(例如 L0 到 Ln-2)对应同一个 vTree 中的级别,当这些级别中触发 compaction 时,不会触发 merge 操作。只有到 Ln-1 的 compaction 时,才会触发对应到 vLn-1 的 merge。
lazy merge 减少了合并的次数和涉及到的数据量,但是牺牲了 vTree 中的有序程度。不过由于大部分数据都应该在最后两层,因此较低层级的有序程度对扫描的影响应该是相对有限的,频繁的合并操作对其的帮助也是有限的。
现有的 merge 行为是将 vLi-1 层的数据合并后,追加写入到 vLi 层,vLi 层涉及到的 vTable 并不会重写,这样虽然减少了写放大,但是可能会导致 vLi 层产生过多的 Sorted Group,影响扫描性能。所以希望能够通过检查那些重叠比较多的 vTable,主动参与 merge,使 vTree 中的有序程度更高。
在正常的 compaction-triggered merge 之后,进一步检查 vLi+1 的 vTable,找到满足以下两个条件的 vTable 集合:
如果存在这样的集合,就说明对应区域的扫描效率可能会很低,那么就给相关的 vTable 都打上标记,表示需要参加下一次 compaction-triggered merge。这个标记是会持久化到 manifest 中的,因为 merge 本身就需要更新 manifest,因此这个开销可以忽略不计。
检查的算法是遍历每个 vTable 的最小 Key 和最大 Key,并进行排序。这样可以通过一次扫描得到每个 vTable 有所重叠的其他 vTable 的数量。
例如上图中,对于 [26-38] 这个文件,找到 38 之前 Start Key 的数量(5 个)和 26 之前 End Key 的数量(1 个),相减就可以得到重叠文件的数量(4 个)。
思路有些像 PebblesDB 的 Seek-based compaction,只是没有基于真实流量去判断。不过从最后的测试效果来看,这个 merge 的成本其实不是很高,因此可能也没必要引入更复杂的判断方式。
每次 merge 时,vTree 都会将相关的 Value 重写到新的 vTable 中,因此需要 GC 掉无效 Value 的空间。为了减少 GC 开销,这里提出一种基于感知 vTable 中无效 Value 数量的惰性回收方法。
DiffKV 通过一个 hash 表记录每一个 vTable 中无效 Value 的数量,每当 vTable 参与 merge 时,都会记录 vTable 中读取 Value 的数量,并更新到 hash 表中。因为只在 merge 时更新,所以 hash 表的性能开销很小,并且对每个 vTable 记录的数据也不多,因此内存开销也很小。
如果 vTable 中的无效 Value 数量大于阈值,则会成为 GC 候选,类似 scan-optimized merge 一样打一个标记,等待下次 compaction-triggered merge 触发,这样做的好处同样是避免回查和回写 LSM-Tree。
疑问:是否会有极端情况(特殊负载),打完标记的 vTable 没有机会参与到后面的 compaction?
一些其他的小问题:
这个 hash 表还是挺重要的,如果丢了 GC 就完全不准了。
KV 分离对大 Value 的效果很显著,但是对小 Value 则是劣势。然而不同 KV 大小的混合负载也很常见,因此需要通过 Value 大小区分 KV,进一步优化以适应混合的工作负载。
分布式数据库就是典型的混合负载,对于同一张表来说,Record 的 Value 可能会很大,而 Index 的 Value 基本都很小。
根据两个参数(value_small/value_large)根据 Value 大小将 KV 分成三组:
大 Value 在写 memtable 之前就做了 KV 分离,主要有两个好处:
vLog 的实现就是一个简单的环形追加日志(circular append-only log),由一组无序的 vTable 组成(vTable 内部的 KV 也是无序的),因为 KV 都很大,写入时也不需要批量写。
用一个简单的冷热分离减少 GC 开销:
一方面减少空洞以及空洞产生的无效搬运(GC 过很多轮的数据之后会一直存在,而用户的数据是不断写入的,后者会导致前者一直被 GC,就像 WiscKey 论文中的 GC 策略一样),另一方面是可以考虑使用不同存储介质,或是缓存策略提速。
GC 策略:
疑问:GC 还是需要回写 LSM-Tree 的吧?这点应该和 WiscKey 没什么区别?论文中似乎没有介绍。
如果是和上述 vTree 的 GC 一样通过 compaction 触发的话,一个问题是 vLog 是按照写入的顺序存储的,而 LSM-Tree 是按照 Key 的顺序存储,那么一次 compaction 涉及到的 vTable 是不可控的,可能会造成过多文件的重写,从而阻塞 compaction?
一些实验,原文中占了很多篇幅,不详细介绍了,只贴结论。
场景:加载 100G -> 插入 10G -> 更新 300G -> 查询 10G -> 扫描 10G
吞吐:
可以看到 GC 的开销还是很大的。
平均延迟:
空间占用:
长尾(99 线):
场景:加载 100G -> 操作 100M,以下 6 个 workload:
吞吐:
长尾:
结论:各个 workload 下的表现都非常均衡。
评估 compaction-triggered merge 的效果。
场景:加载 100G -> 更新 300G
结论:
评估 lazy merge 和 scan-optimized merge 的效果。
lazy merge 的效果:
scan-optimized merge 的效果:
结论:以有限的合并开销保证了一定的排序程度,因此在各个方面都表现出均衡的性能。
测试不同扫描数量和线程数下的表现。
结论:
通过测试得到参数的最佳值。
结论:
基础炼丹:把所有参数都跑一跑,找一个优势开始提升不明显或劣势开始下降很严重的点作为默认参数。
vTree 的设计主要是 WiscKey 和 PebblesDB 两篇论文的融合,继承了两篇论文的大部分核心理念。
从设计来说,LSM-Tree 的性能就是写性能和扫描性能的取舍,也就是写放大和有序程度的取舍,而 WiscKey 和 PebblesDB 等论文告诉我们这个取舍中还有更多因素:
那么是否能理解为:vTree 希望最底层是有序的,这样对扫描性能的影响就是有限的,同时又希望能以更小的写放大将数据 compact 到最底层,所以在中间层将 Value 分离出来并放松有序。
而 DiffKV 整体的设计是一个很简单但是也很有效的工程实践:把数据按照规则分类并放入到更加合适的存储引擎中,这样每个存储引擎都能充分发挥性能优势且避免遇到劣势场景。
对个人的感受主要有两点:
PS:这篇论文笔记拖了很久,导致和过几天的 DB Paper Reading 撞车了,因此也在这里推荐这个来自论文作者的分享,也许能解答本文中的一些疑问。
]]>正体是原文中的内容,斜体是我个人的想法。笔记内容和原文不一定完全对应,原文的翻译可以参考这篇,本文编写后也通过翻译进行了一些校对,确保没有理解错作者的意思,十分感谢译者。
文章中涉及到的一些名词:
如何设置告警规则,或者要设置哪些告警规则,才能让我们更愉悦的值班:
作者对告警和处理告警的观点:
以此为标准,设置告警规则时需要审视一下这些问题:
当然如果都能做到未免太理想化,不过作者下面提供了一些技巧可以帮助我们更接近这个目标。
作者将监控分为两类,称之为「基于症状的监控」,与之相对的是「基于原因的监控」。作者认为监控的关注点应该是用户,例如用户其实并不会关心我们的 MySQL 服务器宕机了,他只关心他的查询是否失败;用户也不会关心我们的软件在反复重启,他只关心功能是否正常;同样用户也不会关心我们的推送是否失败,他只关心消息是否及时。
并且用户关心的东西其实很少:
所以,数据库不可用和用户查询不可用看上去很相似,实际前者是原因,而后者是症状。当我们没有办法模拟用户的真实行为时,我们其实很难区分出这两者的区别,但是如果我们有办法,则应该去尝试关注后者。
我个人比较推崇建设和关注端到端成功率,有些时候可能数据库频繁断连接,但是用户的程序有重试逻辑,只要最终没有报错,那么就没有任何影响。反过来,也许数据库只是延迟上升了一些,但是刚好触发了用户的超时和熔断,那么很可能会出现数据库看上去没有异常,而用户已经大面积报错了的故障。
为什么作者不推荐配置基于原因的告警,有以下几个理由:
但是在一些场景下,我们也需要基于原因的告警,例如内存或是磁盘空间即将耗尽,这些问题没有症状,并且即将导致严重问题。除此之外,不推荐为能够配置症状告警的问题配置同样的原因告警。
以前有个系统,对 uptime 配置了告警,用于监控异常退出后重启的情况。但是滚动重启时也会导致 uptime 归零,因此当时的逻辑是在滚动重启时禁用掉该告警五分钟,看上去这不是一个优雅的做法,因为依赖外部工具动态修改告警配置增加了告警规则的复杂度。也许更好的做法是直接监控异常退出和 OOM。
在 client/server 架构中,在客户端配置告警要优于在服务端配置告警。有以下几个原因:
对很多服务来说,意味着在离用户最近的负载均衡去评估延迟和错误,这样只会在故障真正影响到用户时才会发送告警信息,也能比服务端发现更多的问题。
但是也要注意将告警控制在能掌控的范围内,作者举了个例子是如果能够配置基于浏览器的告警,那么就几乎可以感知所有用户可以感知到的问题了。但是同时也会带来大量的噪音(例如用户本身的网络质量或是电脑性能),所以不太可能当做唯一依赖的来源。
之前也遇到过同样的问题,在做分布式事务时,计算层会将一个 2pc 请求并行的发送给所有的存储层参与者,并等待所有参与者返回,因此计算层完整的 2pc 的响应时间由最慢的参与者的响应时间所决定。当某台存储节点的负载明显高于其他节点时,计算层的执行 999 线就可能和存储层的执行 999 线截然不同。
前段时间我收到了一个端到端可用性直接降到 80% 的告警(配置的告警阈值是 99.9%),后来发现是因为业务的新写的逻辑没有考虑到数据库中的已有数据,产生了大量主键冲突的异常,这个异常和数据库的可用性其实没有明显关系,但是因为我们这边统一监控了 JDBC 层面的所有异常,很难区分出系统异常(例如超时、断连接等)和用户异常(例如语法错误、主键冲突)等情况。比较有意思的是业务侧没有收到任何告警,而数据库侧光看告警的内容会吓死人。
基于原因的告警仍然是有用的,它的作用是帮助我们快速的从问题的症状跳转到问题的根因。
如果我们希望能够自动将症状和原因关联起来,就需要减少一些无法控制的原因告警,作者提倡使用以下方法:
在每个告警中简要描述可能产生的原因,帮助处理告警的人能够快速的确认问题是否已经有确定的对应原因,例如:
1 | TooMany500StatusCodes |
这里出现了 5xx 过多,可以快速的推断出最大的可能是数据库异常,而如果出现了磁盘空间不足或是页面返回空结果,则更有可能是另外两个原因。
删除或调整其他低价值的原因告警,以减少噪音
最后,作者提到基于原因的告警更多是和监控面板的复杂度做取舍,如果我们需要一个干净的监控面板,那么就可以配置更多的原因告警。相反如果我们已经有了一个很完善的监控面板,那么其实不需要原因告警也可以快速的定位问题。
监控面板的复杂度也是我最头疼的问题之一,很多时候监控面板看上去很完善,但是出现问题后其实很难快速的找到某一个异常的指标,也就是从症状推到原因的效率并不高。
这里主要介绍如何处理一些不需要立刻处理的告警,作者称为「sub-critical alerts」,下面为了方便称为隐患,作者也提供了一些经验:
作者想表述的重点在于,需要有一个系统能够同时满足两个目标:一定有人会对这类隐患负责,并且没有人需要为此付出高昂的成本。
大公司的常见毛病,每天都会有乱七八糟的无用告警发来发去,而且没人在乎,想推动相关人员把这些无用告警去掉,他们又怕之后出问题了担责任,从没有认真思考过如何改善这个问题。
Playbook 是告警系统中的另一个重要组成部分,作者建议给每一个告警项都编写对应的 Playbook,进一步解释告警的含义以及如何解决。
一般来说每个 Playbook 会是一个详细的流程图,大部分的篇幅是介绍哪里可能出现问题,少部分的篇幅介绍如何修复它。此外还有一些情况是这个问题超出控制,必须寻求人工协助。因为一般篇幅不是很多,记录在 wiki 中是一个好的选择。
这里作者介绍的信息很少,可能是因为 google sre 都太牛逼了没什么需要人工处理的问题…
如果一个告警正在触发,但是有人说“我看过了,没什么问题”,这表明我们需要重新调整这个告警的规则,或是干脆将它删掉。准确率低于 50% 的告警可以认为是坏掉的,及时是 10% 的误报也需要考虑是否能进行调整。
需要有个系统(例如每周审查所有告警,或是每个季度统计告警数据)帮助我们了解系统的现状,以及分析一些告警在不同人之间转移时出现的问题。
理想很丰满,但是现实很骨感,一些可能会出现的情况将会违反上述的规则,但仍然是合理的:
个人很赞同最后一点,不要通过复杂的告警规则或是人肉运维去解决系统设计问题,很多问题的发现和降级都应该在系统内部完成。
]]>在 2019 年年底,我从之前负责微服务/云原生方向的团队转到了现在的分布式数据库团队。去年写年终总结时对这个方向的了解还比较少,所以也没有提到具体的工作内容,今年的认知相对清晰很多,可以简单介绍一下。
我们团队的工作是做一个 Share-Nothing 架构的分布式数据库,类似市面上更加知名的 TiDB 或 Oceanbase。在架构层面上是标准的存储计算分离,存储层也是分布式事务型的 KV 存储引擎,使用自己实现的 LSM-Tree 作为单机存储引擎,多个副本间使用 Raft 进行同步,并且也实现了分区动态分裂等功能。
我在加入团队后主要负责存储引擎层的相关工作,其中最重要的一块就是 LSM-Tree 中 Compaction 的实现和优化。
在我看来,LSM-Tree 的 Compaction 机制是非常值得研究的方向。LSM-Tree 的设计提出许久,Compaction 的设计和优化几乎是其中最重要的部分之一,所以也积累了非常多优秀的论文。不仅有 Dostoevsky 这种偏向理论分析的论文,也有像 Facebook 的几篇 MyRocks 论文会介绍很多工程实现层面遇到的问题和优化,都是非常值得学习和实践的。
而在分布式系统,尤其上层是一个分布式数据库的场景,能做的事情又会更多一些。一方面是基于分布式数据库的数据存储方式,KV 层的读写负载相对会更加明确。那么当一个实例中存在上千个不同负载的 LSM-Tree 时,如何提高整体的内存利用率、均衡读写放大、减少缓存失效的影响,Compaction 的调度策略是一个非常有意思的研究方向。
另外,NVM 这样的新型硬件也在挑战着 LSM-Tree 原有的设计,LSM-Tree 在设计之初,在 HDD 上进行随机读写是完全无法接受的行为。而到了 SSD 普及后,随机读的性能劣势相对缓解了很多,所以也才会有像 WiscKey 这种破坏存储强有序以减少写放大的设计。而到了 NVM 中,这种差距在进一步的缩小。另一方面,传统的 LSM-Tree 由于 SST 不可修改的特性,每次 Compaction 后重写一部分文件并产生缓存失效,从而引起系统抖动。而在应用了 NVM 之后,在 NVM 上精心设计的数据结构将会承担起存储系统中「只读暖数据」的职责,使得热数据淘汰更加平滑。
总而言之,我个人还是比较喜欢这个方向的,所以对明年的工作也充满期待。明年主要有两个目标:一个是在自己实现的数据库上应用更多的 Compaction 优化。像是上面描述的分布式数据库下的 Compaction 调度以及 NVM 的引用,希望能够产生一些真正有价值的思考和创新。另一个是希望能够更加了解分布式数据库会遇到的通用问题和解决方法,例如如何优化分布式事务或是尽可能减少分布式事务、如何进行调度能够将热点均匀的分散到整个集群、如何在保证可用性的前提下减少成本,这些都是需要未来几年不断积累和探索的方向。
最后说说心态,今年工作上最大的感受是「孤独」,毕竟从云原生这样一个”网红行业“转到了分布式数据库这种”夕阳产业“,日新月异的变化和交流讨论的人都少了很多。但是其实孤独可能也是件好事,因为同样可以远离一些浮躁的人和不靠谱的事,总算能抽出一些时间静下心来看看论文、跑跑 benchmark、以及在夜深人静的时候和兴趣相投的同事畅谈新的灵感。
除了技术方向本身之外,从摩拜这样轻松愉快的小团队转到美团这样严肃的大公司团队,我本身也非常不适应(当然现在美团单车也已经成为一个标准的大公司团队了)。
我可能是一个对环境很敏感的人,在 ENJOY 工作时就会非常放松,和同事们每天中午在三里屯闲逛、外出吃饭时可以玩 UNO、团建首选是密室、一起在公司通宵看 WWDC。在摩拜时可以发掘亮马桥的日料店,天气好的时候可以在甲板上发呆(摩拜的办公室是亮马河上的一艘船),还有生日会、万圣节这样偶尔放松一下的活动。
而到了美团,在真正做技术的时间之外。各种完全不感兴趣的培训、晋升(还好今年职级合并了,明年不用操心晋升的事情了)、汇报和会议耗尽了我的所有情绪。但是躲也躲不过,逃也逃不掉,最后就只能躺平了。
前一周正好在和老板 one on one,聊完之后汇总了一下我今年所有沟通上的反馈,发现有一个词贯穿了我的一整年,就是焦虑。
当然焦虑本身没有错,我会把焦虑分为三级,即良性焦虑 > 恶性焦虑 > 完全不思考。我很享受因为焦虑的情绪迫使我去思考更多问题,尽更多的努力,并最终获得更好的结果,这便是良性焦虑。但是我每天的焦虑仍有很大一部分都是担忧不会遇到的或是无法解决的问题,这便是恶性焦虑。
举个栗子,我的一部分恶性焦虑源于对「努力」的认知上,认为只要足够努力就能缩短和他人的差距,就能做好所有事,尤其是在今年刚刚转换工作方向的背景下,基本上就是无时无刻都在焦虑自己是否足够努力,遇到技术问题心态就会很崩溃,和周围一些优秀的同事相比总觉得自己什么都不会。但其实只靠努力是解决不了所有问题的。
到写这篇文章时仔细想想,我对今年的工作产出以及技术成长还是比较满意的,所以也希望明年能够减少这些无意义的恶性焦虑,对自己有更清晰的认知。
今年基本没有学习工作外的技术,博客也写得很少,主要还是把大部分的精力放在掌握工作所需的知识上了。
我之前没有看论文的习惯,今年大概看了十篇左右的论文,基本都是分布式数据库和 LSM-Tree 的一些知名论文。其实大部分论文阅读后都有对应的笔记,但是却没有放在博客中。一方面是觉得自己积累还不够,可能很多理解会有偏差,另一方面是觉得现有的论文基本上在网上都有相关的笔记了,相比之下也没有什么新的思考。
今年印象里也只看了《数据库系统内幕》这一本技术书,相比论文,一直没有找到比较感兴趣的书籍。团队内部还有《Oracle Core》的读书计划,但是我对这本书实在一点兴趣都提不起来,也就没有去参加。
明年可能主要还是以追踪论文为主,还是想把一些论文的笔记发到博客中,尽量多输出自己的想法。另外其实今年在内网还是记录了不少的思考和笔记,不过这些大多涉及到内部项目的背景,很难写成博客。明年希望能够找到一些合适的专题,总结一下现有开源项目的实现方式,尽量剥离开内部项目的实现去讲明白一个知识点。
不得不说,和大多数人相比,疫情对我的影响已经很小了,所以我今年的生活还算安稳且充实。
在我刚刚转到美团总部,完全适应不了工作环境的时候,疫情发生了,立马切换成了舒适的远程办公环境。我个人还是比较喜欢远程工作的,早上能够自己做点早饭吃,中午能够高质量的休息,而且我一般晚上状态会比较好,自己在家时晚上会更加放松,不像在公司时总觉得加班很压抑。
从 2018 年三刻停止营业后我就很少再因为兴趣爱好做料理了,但是今年疫情有了大把时间让我自己解决伙食,也把之前失去的热情又找了回来,直到现在我还在坚持着每周末从盒马买菜自己做一顿饭吃。
另外自从上班地点搬到了望京,我在三里屯的健身卡就彻底废了,而我又不愿意在公司的健身房锻炼,最后深思熟虑还是选择了买了一台划船机,让我家本不富裕的使用面积雪上加霜。不过偶尔能够在家边划船边看美剧,可能也是我目前最好的运动选择了。
今年最意料之外的事情是旅行,本来我今年的大部分旅行计划都是去日本,一直等到下半年彻底放弃了,就把攒下来的年假都放到了国内旅行上。最终整理的时候发现还是去了不少地方,随便贴点废照片记录一下。
镇江的一碗锅盖面,当地的鹅肉也非常好吃,但是应季的河豚却比在日本吃过的感觉差了很多。
在丽江的几天是我今年旅行遇到过最好的天气,几乎每天都是蓝天白云。
有一天在天津闲逛,偶然发现了海河边上老大爷跳水这项神奇的运动,不知不觉看了一下午,是我今年过得最安逸的下午。
在西安临潼的悦椿泡温泉时刚好在下小雨,人很少并且气温很舒适。很推荐这家悦椿,住一晚还能参观一下兵马俑。
泉州虽然是沿海城市,但是却不临海,当地也不怎么吃海鲜,反而是鸭子和牛肉比较受欢迎。除了当地的宗教文化给我留下了很深的印象之外,上图的粽子是我至今为止吃过的最好吃的粽子。
在汕头几乎每天都吃十顿饭,给我留下很深印象的是有一天晚上去喝白粥,看到这个排挡就随意的摆在一个小区里。想起了我小时候也会偶尔吃这样的排挡,只是现在的北京已经几乎见不到了。
十一的最后请了两天假顺道去了东山岛,只要错开游客和网红店体验真的很好,当地的海鲜好吃又便宜,上图是当时住的民宿,真的超出预期。
最后是年底去了三亚,之前没去过三亚,可能是我见识少,去了之后感觉旅游业(尤其是酒店)真的是吊打国内其他城市。这一个礼拜除了泡在酒店里哪里也没去,就随便贴几张酒店的照片吧。
海棠湾的亚特兰蒂斯,水世界太好玩了,就是爬楼太累了,傍晚时分在水族馆里静静地看着游来游去的鱼也很有治愈。
香水湾的君澜,人很少很安静,步行五分钟就是海滩,每天都能看到十几对情侣在酒店的各个角落拍婚纱照。
亚龙湾的鸟巢度假村,住在山里的小木屋很有感觉,司机一个个都是秋名山车神。
今年几乎没玩什么游戏,在疫情发生前我屯了一堆游戏,最后也只玩了《幻影异闻录 FE》,剩下的游戏到现在都没有拆封。后来到了三月,跟风玩了一波《动物森友会》,但是发现对社畜实在不太友好,现在就只是偶尔上去随便逛逛了。
和去年一样,今年也没有什么感兴趣的动画,不过倒是看了不少漫画。其中很多都不是长篇连载,但确实很有意思。想了下我去年没有推荐过漫画,所以今年可以连带着去年的份一起推荐一下。
我个人非常喜欢的漫画:
我个人一般喜欢,但是普遍接受程度很高的漫画:
最后是日剧,今年依旧看了不少日剧,但是一整年下来竟然没有什么印象深刻的,只好再加几部二刷的老剧凑凑数。
我个人非常喜欢的日剧:
我个人一般喜欢,但是普遍接受程度很高的日剧:
写完发现我的年终总结真的是一年比一年水… 你看了开头以为我要聊一大堆技术话题,聊聊自己新的一年又卷了多少人,没想到我中途画风一转,直接跳到旅游和推荐日剧/漫画了吧。总之,这一年我仍然能够时刻保持对技术的热情,工作压力和焦虑都是客观存在的,而享受生活也是不可缺少的,在此也祝大家在新的一年也都能够 Work hard, Play hard!
]]>这篇论文的核心思想理解起来还是很简单的,但是具体涉及到实现还有一些想不明白的地方,后来看到 TiKV 的 Titan 实现也很有趣,索性把这些问题都记录下来并抛出来。
本文中和论文相关的内容,斜体均为我个人的主观想法,关于 Titan 的实现,我只看过几篇公开文章以及粗浅的扫过一遍代码,如果这两部分的内容有理解错误欢迎指出,感谢!
基于 LSM 树(Log-Structured Merge-Trees)的键值存储已经广泛应用,其特点是保持了数据的顺序写入和存储,利用磁盘的顺序 IO 得到了很高的性能(在 HDD 上尤其显著)。但是同一份数据会在生命周期中写入多次,随之带来高额的写放大。
以 LevelDB 为例,数据写入的整个流程为:
由此可以计算出 LevelDB 的写放大比率:
另一方面,由于数据在 LevelDB 中的每一层(memtable/L0/L1~L6)都有可能存在,所以对于读请求,也会有一定的读放大:
论文中提供了一个实际的数据:
WiscKey 的核心思想是将数据中的 Key 和 Value 分离,只在 LSM-Tree 中有序存储 Key,而将 Value 存放在单独的 Log 中。这样带来了两点好处:
另外,WiscKey 的设计很大一部分还建立在 SSD 的普及上,相比 HDD,SSD 有一些变化:
下图展示了在不同请求大小和并发度时,随机读和顺序读的吞吐量,可以看到在请求大于 16KB 时,32 线程的随机读已经接近了顺序读的吞吐:
在 LSM-Tree 的基础上,WiscKey 引入了一个额外的存储用于存储分离出的值,称为 Value Log。整体的读写路径为:
假设 Key 的大小为 16 Bytes,Value 的大小为 1KB,优化后的效果为:
看上去实现很简单,效果也很好,但是背后也存在了一些挑战和优化。
在标准的 LSM-Tree 中,由于 Key 和 Value 是按照顺序存储在一起的,所以范围查询只需要顺序读即可遍历整个 SSTable 的所有数据。但是在 WiscKey 中,每个 Key 都需要额外的一次随机读才能读取到对应的 Value,因此效率会很差。
论文中的解决方案是利用上文中所提到的 SSD 内部的并行能力。WiscKey 内部会有一个 32 线程的线程池,当用户使用迭代器迭代一行时,迭代器会预先取出多个 Key,并放入到一个队列中,线程池会从队列中读取 Key 并行的查找对应的 Value。
疑问:
上文中提到了,当用户删除一个 Key 时,WiscKey 只会将 LSM-Tree 中的 Key 删除掉,所以需要一个额外的方式清理 Value-Log 中的值。
最简单的方法是定期扫描整个 LSM-Tree,获得所有还有引用的 Value 地址,然后将没有引用的 Value 删除,但是这个逻辑非常重。
论文中介绍的方式是通过维护一个 Value Log 的有效区间(由 head 和 tail 两个地址组成),通过不断地搬运有效数据来达到淘汰无效数据。整个流程为:
因为需要重新写入一次 Value,并且需要将 Key 回填到 LSM-Tree 中,所以这个 GC 策略会造成额外的写放大。并且即使不做 GC,也只会影响到空间放大(删除的数据没有真正清理),所以感觉可以配置一些策略:
当系统崩溃时,LSM-Tree 可以保证数据写入的原子性和恢复的有序性,所以 WiscKey 也需要保证这两点。
WiscKey 通过查询时的容错机制保证 Key 和 Value 的原子性:
这个前提建立在于 WiscKey 通过一个 Write Buffer 批量提交 Value Log(下面有详细介绍),所以才会出现 Key 写入成功后 Value 丢失的场景,用户也可以通过设置同步写入,这样在刷新 Value Log 之后,才会将 Key 写入 LSM-Tree 中。
另外,WiscKey 通过现代的文件系统的特性保证了写入的有序性,即写入一个字节序列 b1, b2, b3…bn,如果 b3 在写入时丢失了,那么 b3 之后的所有值也一定会丢失。
为了提高写入效率,WiscKey 首先会将 Value 写入到 Write Buffer 中,等待 Write Buffer 达到一定大小再一起刷新到文件中。所以查询时首先也要先从 WriteBuffer 中查询。当崩溃时,Write Buffer 中的数据会丢失,此时的行为就是上文中的崩溃一致性。
疑问:
LSM-Tree 通过 WAL 保证了在系统崩溃时 memtable 中的数据可恢复,但是也带来了额外的一倍写放大。
而在 WiscKey 中,Value-Log 和 WAL 都是基于用户的写入顺序进行存储的,并且也具备了恢复数据的所有内容(前提是基于上文中的 GC 实现,Value Log 里存有 Key),所以理论上 Value-Log 是可以同时作为 WAL 的,从而减少 WAL 的写放大。
由于 Value Log 的 GC 比 WAL 更加低频,并且包含了大量已经持久化的数据,直接通过 Value-Log 进行恢复的话可能会导致回放大量已经持久化到 SST 的数据。所以 WiscKey 会定期将已经持久化到 SST 的 head 写入到 LSM-Tree 中,这样当恢复时只需要从最新持久化的 head 开始恢复即可。
疑问:
说完实现再看看效果,论文中有 db_bench 和 YCSB 的数据,为了节约篇幅,只贴一部分 db_bench 的数据。
db_bench 的场景分两种,一种是所有 Key 按顺序写入(这样写放大会更低,数据在每一层会更紧凑),另一种是随机写入(写放大更高,数据在每一层分布更均匀)。
效果应该来自两部分:
效果对比顺序写入,如果说为什么差距会这么大,只有可能是每一层合并造成的写放大了。
这个有点看不懂…:
上文提到了 GC 会重写 Value 以及写回 LSM-Tree,造成额外的写入。当空余空间的占比越高时(大部分数据都已经被删了),回写的数据越少,对性能的影响也就越小。
BlobDB 和 Badger 的实现都和论文比较接近,并且也都是玩具。反而 TiKV 的 Titan 有一些独特的设计可以学习和讨论,所以下面只介绍这一案例。
和 WiscKey 的主要区别在于:Titan 在 flush/compaction 时才开始分离键值,并且用于存储分离后 Value 的文件(BlobFile)会按照 Key 的顺序存储,而不是写入的顺序(其实在这个阶段,已经没有写入顺序了)。
因此导致实现上的差异有:
第一种策略(传统 GC):
这个实现和论文中的 GC 方案类似,只不过论文为了 WAL 需要写入一条完整的 Value Log,所以需要维护 head 和 tail。Titan 的实现只需要每次都生成新的 BlobFile 即可。
不同点在于:WiscKey 是随机读,Value Log 的大小不会影响到读 Value 的成本。GC 策略在于写放大和空间放大的权衡,所以 GC 可以更加低频。而 BlobFile 是顺序读,如果 BlobFile 中的无效数据太多,会影响到预取的效率,间接也会影响到读的性能。
第二种策略(Level-Merge):
开启 Level Merge 后相当于 GC 频率和 compaction 频率持平了(GC 频率最多也只能和 compaction 持平),并且在这个基础上,直接在 compaction 里做 GC,可以减少一次回写 LSM-Tree 的成本(因为在 compaction 的过程中就能将老的 Value 地址替换掉)。
这种策略的优点在于 BlobFile 中不再有无效数据,可以用更加激进的预取策略提高范围查询的性能,缺点是写放大肯定会比之前更大(个人觉得开启后,写放大就和标准 LSM-Tree 完全一样了吧(一次 compaction 需要合并的 Key 和 Value 都需要重写一遍)?),所以只在最后两层开启。
Titan 的性能测试结果摘自官网的文章,大部分结论都和 WiscKey 类似,并且文章中也分析了原因,就不在此赘述了。
因为文章是 19 年初的,所以还没有上文中的 Level Merge GC,不过 GC 策略理论上只影响范围查询的性能,所以在此贴一下范围查询的性能:
在实现 Level Merge GC 的策略之前,Titan 的范围查询只有 RocksDB 的 40%,主要原因应该还是分离后需要额外读一次 Value,以及没办法并行预取增加吞吐。 这点文章最后也提到了:
我们通过测试发现,目前使用 Titan 做范围查询时 IO Util 很低,这也是为什么其性能会比 RocksDB 差的重要原因之一。因此我们认为 Titan 的 Iterator 还存在着巨大的优化空间,最简单的方法是可以通过更加激进的 prefetch 和并行 prefetch 等手段来达到提升 Iterator 性能的目的。
另外在 TiDB in Action 也提到了 Level Merge GC 可以「大幅提升 Titan 的范围查询性能」,不知道除了完全去掉无效数据之外,是否还有其他的优化,还需要再看下代码。
个人认为 WiscKey 的核心思想还是比较有意义的,毕竟适用的场景很典型而且还比较常见:大 Value、写多读少、点查多范围查询少,只要业务场景命中一个特点,效果应该就会非常显著了。
对于论文中的具体实现是否能套用在一个真实的工业实现中,我觉得大部分实现还是简单有效的,但是也有一些设计个人不太喜欢,例如使用 Value Log 替代 WAL 的方案,感觉有些过于追求减少写放大了,可能反而会引入其他问题,以及默认的 GC 策略还要写回 LSM-Tree 也有些别扭。
在和其他同事讨论内部项目的实现时,也畅想过一些其他玩法,例如只将 Value 中的一部分分离出来单独存储,或是一个分布式的 WAL 是否也能转换为 Value Log,会有哪些问题。包括看到 Titan 的实现时,我也很好奇设计成 BlobFile 这种顺序读的方式是否有什么深意(毕竟论文都把利用 SSD 写到标题里了),或者只是因为从 compaction 才开始分离键值最简单的做法就是按顺序存储 KV。
总之,期待将来能有更多工业实现落地,看到更多有趣的案例。
今年工作上做了一个重大的转型,目前来看还算是痛并快乐着。
在 去年的总结 中曾经提到,我在今年主要的方向是云原生领域,希望能围绕着 Service Mesh 做一些能落地的实践,例如可以细粒度控制流量以及根据监控自动控制灰度的发布系统。
然而很不幸,随着去年下半年摩拜被美团收购,团队的技术发展路线产生了一些变化:摩拜自身对基础设施的投入变得更少,转而努力融入到美团现有的中间件中。
被收购的公司融入收购方的技术栈,得到成本更低、稳定性更好的方案,这当然是符合历史规律的。只是我所在的团队在落地上却出现了问题:在云原生方向一边减少实质性的技术投入,一边盲目的跟风 CNCF 引进一些无脑的黑盒项目。
对我而言,即使是搞 kubernetes 的相关技术,每天写一些简单的 webhook/controller 插件和 CRUD 也没有什么本质区别。再加上业务体量所限制的集群规模,以及业务方对调度策略、资源隔离、QoS、可观测性、灰度等平台赋能并没有很强烈的需求,导致个人觉得运维这样的无状态集群并没有办法积累有深度的经验。
另一方面我觉得整个云原生技术领域对于中小型公司来说有点太虚无缥缈了,很多流行但是问题多多的黑盒项目可能只是隐藏掉了我们当前可见的问题,却又增加了很多未知的隐患,还会带给很多程序员盲目的自信。在我的身边就有很多的例子,每天连代码都不写的人整天聊着各种 CNCF 的明星项目,执着于用 YAML 跑通一个 hello world 就拿去给别人用,在这点和大公司的应用场景还是有很大区别的。
所以今年很长的一段时间内都在浑浑噩噩的搞这些黑盒软件并且自我怀疑,后来在晋升答辩的时候被所有评委都评价深度不够也证实了这点,直到最后觉得自己在当前的团队实在是找不到一条出路了,只能被迫寻求改变。
因为一些前同事和摩拜同事的认可,发现转岗还是有一些选择的。具体过程就不细说了,最终选择加入了美团基础平台的分布式数据库团队。
转岗之前一度担心自己的技术栈和新团队很不匹配,毕竟毕业之后就再也没正经写过 C++,对数据库的实现原理也只停留在书本上。转岗之后之后才发现自己多虑了,新团队的技术氛围很好,让我很快的找回了久违的学习热情,不光新技术栈的上手速度超出预期,而且感觉未来很长一段时间都会过得非常充实。
在 2016 年的总结 中我曾经提到自己很爱鼓捣编程语言,当前流行的编程语言也都或多或少的用在一些个人项目和跑在生产的项目中,但是其中并不包含 C++。
之前我只在学生阶段做作业和学习 Mooc 课程的时候用过一些 C++,在我的固有印象里一直认为 C++ 陈旧、复杂、不安全,并且对新手十分不友好。但是真正开始在实际的大型项目中写 C++ 之后,我才发现自己的很多印象可能是错误的,C++ 实际也是一门很有趣的语言,像智能指针、RAII、SFINAE 等技巧以及其应用场景是在其他编程语言中很难遇到的,所以在学习的过程中我也改变了很多思维方式,对 Rust 中一些相同的功能也有了更深的了解。
和去年相同的子标题,去年我觉得自己做了很多错误的选择,可能今年也是一样。但是今年至少还是遵从本心做了一个最重要的决定。并且过了几个月之后来看,虽然失去了很多东西,但我依旧认为这个决定无比正确。
通过这件事也更新了我的很多认知,举一些例子:
明年的主要目标是积累足够的数据库领域的相关经验,以及能够熟练地写出高质量的 C++ 代码,更重要的是,把手头上的这个项目成为一个能让自己的自豪的项目。
和工作一样,上半年整体状态都不太好,没有什么学习欲望,零碎的在一些开源项目里打工,最大的产出可能是在 SOFA RPC 中添加了 Hystrix 支持和 Consul 注册中心,也因此成为了这个项目的社区 Committer。
同样博客的产出也少得可怜,我在上半年做过一些内部分享,不过基本上都是向其他同事介绍 Istio 这种目前比较流行的技术栈,或是 Arthas 这样的排查工具,没有太多技术含量。
明年除了工作相关的技术栈以外,还想好好学习下 Rust,以及系统性的学些一些系统开发的知识,另外等有一些积累之后,重新开始写一些更有质量的博客。
生活上没什么特殊的,主要就是到处乱跑,以及在家窝着看日剧。
今年去了两次日本,并且顺利的办下了三年多次签证(北京户口极简竟然卡在了年龄上,只能老实的提交各种流水证明)。
第一次是在春节,去了大阪、京都、奈良,最后从东京回程。春节的日本没有想象的人那么多,体验还是很不错的。
第二次是在秋天枫叶季,结果今年降温慢,枫叶开的普遍较晚,我提前三个月定好的行程不出意外去早了。
明年暂时打算樱花季再去日本,不过不太想去关西了,可能会去九州地区或是名古屋吧,之前坐船的时候去过一次福冈,印象还挺好的。
感觉自己年龄越大,追番的兴趣越来越小了。今年追的番基本上可以分为两类,一类是以前看过的漫画动画化了(比如 BEASTARS、辉夜姬),另一类是看过的动画出了续集(比如灵能百分百、齐木楠雄),基本没接触全新的番剧,以后可能看的会越来越少吧。
与之相对的是,今年花了大量的时间看日剧,基本上每季度都看了三、四部,其中最喜欢的是《我的事说来话长》,推荐给各位。
整体的推荐列表如下:
我一向只玩任天堂,上半年因为工作比较清闲,就一直在玩风花雪月,打完三条线之后觉得自己还是挺难接受剧情上的互相残杀,就没打教团线。后来还玩了一段时间哆啦A梦牧场物语,但是始终找不回小时候玩矿石镇的感觉了,所以矿石镇重置也没买。
下半年工作比较忙了之后,几乎找不到完整的时间玩 NS 了,所以开始用碎片时间玩明日方舟,氪度适中并且整体不怎么肝,应该还会玩挺长一阵子。
除此之外,为了保持运动还购入了有氧拳击、健身环、尬舞等游戏,不过除了健身环其他玩的频次都不怎么高。
]]>目前网上介绍 MyRocks 的文章虽然不少,但是大部分都只介绍了一些 RocksDB 的核心特性和读写原理,却几乎不会提到 MyRocks 在实现 MySQL 存储引擎相关的内容,并且由于 MySQL 官方对于存储引擎的开发资料也提供的很单薄,所以对于新人来说难免有些手足无措。
这个系列希望通过从 MySQL 存储引擎的 API 作为起点,结合 MyRocks 的实现,记录下每一个功能的全貌,包括自定义的存储引擎在每一个 API 中具体需要实现哪些功能,以及 MyRocks 是如何通过 RocksDB 实现这些功能的,其优缺点是什么。希望能够帮助一些初学者(包括我自己)如何从零开始或是二次开发一个 MySQL 存储引擎。
这篇笔记是第一章,介绍了创建表(Creating Tables)的流程。
官方文档的 Creating Tables 章节简要的介绍了自定义的存储引擎如何实现创建表的功能,只需要实现 create
这个虚函数即可。
1 | virtual int create(const char *name, TABLE *form, HA_CREATE_INFO *info)=0; |
存储引擎需要在这个函数中创建所有与表结构和索引结构相关的数据文件,它有三个参数:
name
: 该表的表名form
: 该表的元数据信息,主要包含表结构、字段和索引的信息info
: 创建表时的额外的配置信息,基本都是 CREATE TABLE
时附带的选项MyRocks 的实现在 ha_rocksdb.cc
的 ha_rocksdb::create
方法中。主要逻辑分为两部分:
MyRocks 首先会对创建表的配置信息进行前置处理,包括配置的检查和转换,拦截该存储引擎不支持的配置等,主要流程为:
DATA DIRECTORY
和 INDEX DIRECTORY
支持将该表的数据文件和索引文件存放在一个指定的路径。MyRocks 不支持这两个配置,而是通过 rocksdb_datadir
配置 RocksDB 存放数据的地址。./$dbname/$tablename
,MyRocks 会将其格式化为 $dbname.$tablename
,便于之后处理。接下来还需要检查当前这个表是否已经存在了,在 TRUNCATE TABLE
语句下需要删除重名的表信息,其他情况下报错。
1 | Rdb_tbl_def *tbl = ddl_manager.find(str); |
其中包含两个细节:
什么时候会出现 CREATE TABLE
到存储引擎时,ddl_manager 中已经有了表的数据,却没有被上层拦截?
在这个 Issue 中提到了一个场景,即 frm 文件丢失(例如被人工删除)的情况,会进入该逻辑,需要做容错处理。
为什么需要判断 sql_command == SQLCOM_TRUNCATE
,什么场景会出现?
通过看 sql_truncate.cc
中的逻辑猜测,如果存储引擎支持通过重建表实现 TRUNCATE TABLE
功能,那么上层会直接通过 create
方法创建一个结构完全相同的空表,而不是通过存储引擎实现的 truncate
方法。
1 | bool hton_can_recreate; |
并且 MyRocks 是支持 HTON_CAN_RECREATE
功能的。
1 | rocksdb_hton->flags = HTON_TEMPORARY_NOT_SUPPORTED | |
所以需要考虑到这种情况,删除当前该表的数据并继续执行创建流程。
创建表和索引的主要流程也就是将表结构以及索引结构存储到硬盘的流程。其中 ddl_manager
对象就是 MyRocks 中对 RocksDB 操作的封装。顾名思义,这个类只负责 DDL 相关操作的存储。
1 | const std::unique_ptr<rocksdb::WriteBatch> wb = dict_manager.begin(); |
WriteBatch 是 RocksDB 中原子操作和批量操作的封装类。之后所有对 RocksDB 的写入操作都将写入到该 WriteBatch 中,这样可以保证这些操作可以合并成一个原子操作提交到 RocksDB 中,不会出现一部分逻辑报错导致数据不一致的情况。
1 | /* |
MyRocks 支持表不设置主键,但是 RocksDB 底层的 KV 存储强依赖表的主键,所以在这里会自动增加隐藏主键列,并对上层透明。
1 | /* MyRocks supports only the following collations for indexed columns */ |
当索引字段为 varchar/string/blob
等字符类型时,MyRocks 只支持编码为 binary/utf8_bin/latin1_bin
。
通过关闭 rocksdb-strict-collation-check
或是在 rocksdb-strict-collation-exceptions
配置表名可以跳过这个检查。
在 RocksDB 中,每一个 KV 都会关联一个列族(Column Family,之后简称为 CF),而 MyRocks 是以索引为粒度存储 KV 数据的,所以支持为每个索引配置一个可选的 CF,默认存放在 default
中。
CF 的名称可以通过索引的整个注释内容或是 cfname=$name
选项进行配置,例如:
1 | CREATE TABLE sample ( |
其中 id 的主键索引会关联到 default
CF 中,uid 的索引会关联到 cf_uid
中,而 name 的索引会关联到 cf_name
中。
在代码中的实现逻辑很简单,只是遍历每个索引,通过注释截取出 CF 的值。
CF 不能是 __system__
,这个 CF 已经预留给了存放系统的数据,包括之后将会存放表结构和索引结构的数据。
在之前的版本中,还可以通过 cfname=$per_index_cf
自动生成格式为 $tablename.$indexname
的名称,但是在最新版本的代码中已经不支持了。
光从建表的流程中我们还不知道索引的 CF 具体的用途是什么,会在之后的写入数据的文章再详细介绍。
因为 RocksDB 本身支持 TTL,所以 MyRocks 也支持在建表时设置每一条记录的 TTL 选项,通过表级别的注释 ttl_duration=1;ttl_col=ts
进行设置。
Rdb_ddl_manager
在内存中维护了一个自增的索引 id,启动时会从本地 RocksDB 中读取并初始化。当需要创建索引时,会通过调用 get_and_update_next_number
方法申请一个 id。其会在内存中加锁自增后写入 RocksDB,其格式为:
Rdb_key_def::MAX_INDEX_ID
Rdb_key_def::MAX_INDEX_ID_VERSION, val
如果建表时指定自增主键的初始值 auto_increment
,MyRocks 则会将其写入 system CF 中,格式为:
Rdb_key_def::AUTO_INC, cf_id, index_id
Rdb_key_def::AUTO_INCREMENT_VERSION, auto_increment_value
这里通过 RocksDB 的 merge operator 实现了更高性能的自增操作,不过建表时肯定是初始化,所以语义应该和 Put 相同。
目前 MyRocks 有两个 CF 级别的配置,需要额外存储到以 CF 为单位的数据中,被称为 CF Flags,包括:
is_per_partition_cf
表示这个 CF 是否为某个分区表特定的 CF,例如配置 p0_cfname=cf_p0
。is_reverse_cf
表示这个 CF 中存储的数据是否要反向存储,这样会使降序查询(order by desc
)更快,配置方法是 cfname=rev:xxx
。这两个 flag 分别占用 1bit,最终会合并保存在 RocksDB 中,格式为:
Rdb_key_def::CF_DEFINITION, cf_id
Rdb_key_def::CF_DEFINITION_VERSION, flags
因为多个索引可以共享同一个 CF,所以需要保证索引在创建时,CF 的配置不能和之前索引的冲突。
在 Rdb_dict_manager::add_or_update_index_cf_mapping
方法中,会将每一个 Index 的信息存储在 RocksDB 中。
Rdb_key_def::INDEX_INFO, cf_id, index_id
Rdb_key_def::INDEX_INFO_VERSION_LATEST, index_type, kv_version, index_flags, ttl_duration
在 Rdb_tbl_def::put_dict
中,会将一个表所对应的 CF 和 Index 存储到 RocksDB 中。
Rdb_key_def::DDL_ENTRY_INDEX_START_NUMBER, db_table_name
DDL_ENTRY_INDEX_VERSION, cf_id1, index_id1[, cf_id2, index_id2...]
之后 Rdb_ddl_manager::put
方法会将这些信息同样缓存在内存中,便于之后其他操作使用,主要存放了两部分数据:
m_ddl_map
中缓存 db_table_name
对应的 Rdb_tbl_def
m_index_num_to_keydef
中缓存 index_id, cf_id
和 db_table_name, index_no
的映射关系在所有操作处理完后,Rdb_dict_manager::commit
方法将以上所有的改动都通过 WriteBatch 提交到 RocksDB 中,同最开始提到的一样,这是一个原子操作,只会全部成功或是全部失败。
光看代码肯定会存在遗漏或是理解错误的地方,接下来让我们实际创建一张表验证一下。
以下是用来测试的表,一共有四个字段、一个主键索引和两个普通索引,并且通过注释指定了 CF 和自增起始值。
1 | CREATE TABLE sample ( |
创建成功后,我们查看一下存储在系统表中的 DDL 信息。
1 | select * from INFORMATION_SCHEMA.ROCKSDB_DDL; |
接下来查看 RocksDB 当中的数据,并与上面的 DDL 信息以及代码分析进行比对。
1 | sst_dump --command=scan --file='000038.sst' --output_hex |
第一条数据为表和索引的对应关系:
1 | 00000001 => 常量 DDL_ENTRY_INDEX_START_NUMBER |
第二条到第四条数据为索引信息,以第二条为例:
1 | 00000002 => 常量 INDEX_INFO |
第五条到第八条数据为 CF 信息,以第五条为例:
1 | 00000003 => 常量 CF_DEFINITION |
第九条数据为当前系统内最大的索引 id:
1 | 00000007 => 常量 MAX_INDEX_ID |
第十条数据是自增数据:
1 | 00000009 => 常量 AUTO_INC |
总体来说,对自己今年的表现并不满意…
今年一整年都在 Mobike 度过,关于 Mobike 这一年的大起大落,各位读者可能了解的很多了,所以我在此只写技术相关的。
在去年的总结中我曾经提到,自己对「如何提升整个团队的工作效率」这个话题很感兴趣。所以在今年年初,我加入了新组建的效能团队。
工程效率是我个人一直很看重的事情,除了擅长实际编码,包括软硬件工具、时间管理、任务管理、沟通等综合能力对我都很重要。并且在这点上我认为自己比大部分人都要做的更好。
但是一旦这件事涉及到了整个团队就变得很复杂了,我很难去衡量一件事对其他人的意义和对我一样重要,所以只能靠学习一些其他公司的经验掌握一个度量的标准。我比较推荐《The DevOps Handbook》和《SRE 谷歌运维解密》这两本书,前者在公司层面所描述的目标和我所希望达到的目标是近乎一致的,并且其中的很多方法论确实是可操作的。而后者则是我理想成为的开发者,以及希望加入的团队。
具体到实施上我主要关注 CI/CD 方向,因为这部分工作包含着相对较多的编码时间,以及可以在短期显著地看到效果。对我个人而言,使用 Groovy 写 Jenkins Pipeline 很有趣,即使是 Jenkins 这种五六年前的产品依旧能和现代的很多理念所融合,例如 Infra as Code 或是 Serverless。其次是哪怕真正编写了几百上千行 Shell 之后,我依旧还是只能靠查 StackOverflow 完成功能,但是日常工作中的简单操作变得顺畅了很多,也算是很有用的能力了。
从我的第一份工作开始,就在写着一版又一版的 Java Framework,在前几年流行的微服务理念下,无非是一套又一套的 RPC、Service Registration and Discovery、Tracing 等组件。
在 ENJOY 时,因为 Spring Cloud 才刚刚 Release,我们选择了整合已有的开源技术自己编写一套完整的框架:以 Etcd 作为注册中心,Thrift 作为 RPC 实现,Zipkin 作为链路追踪方案,这些在之前的文章中都或多或少的提到过。
而 Mobike 则采用的是标准的 Spring Cloud Netflix 体系,包括 Consul、Ribbon、Hystrix、Feign、Sleuth 等,所以我也有机会去学习 Spring Cloud 的团队是如何抽象和实现这些功能,虽然没有刻意生啃过源码,但是在漫长的排查问题和二次开发过程中,也基本上对这些组件的实现有了很深的了解,并且对管理一个大型的微服务集群,框架需要提供哪些赋能有了更清楚的认知。
另一部分内容是关于容器化相关的技术,在离开 ENJOY 时最悔恨的事情就是没有参与公司自研并开源的容器编排系统 Eru,导致自己对容器相关的知识一直似懂非懂。而来到 Mobike 后,在工作中长时间使用了 Swarm 和 Kubernetes 之后,我对其有了更深的了解,并且也解决了许多只看文档不会遇见的问题。目前还在尝试了一些和容器相关的云原生技术,比如 FaaS 和 Service Mesh。
明年的主要的方向也主要在云原生技术,希望能把 Service Mesh 落地在我司的业务场景,以及能够把自动灰度发布流程完成,虽然后者的可能性不大,但是目标总是要有的:)
很遗憾,今年的工作成果在我看来是交上了一份不及格的答卷,我认为其原因是做了很多错误的「选择」。
从个人能力来说,我一直更偏向于一个「强力协助者」或是「救火队员」,我的技术能力和执行力使我在这一年中帮助无数项目快速发展,为它们快速落地或是解决核心问题。但是很遗憾的是,这些事中没有一件是由我来 Own 的,包括之前也有一些我非常感兴趣的项目,也因为公司内的资源分配问题转让给了其他人。
在写本文的前几天,我司的数据库团队开始陆续开源他们的产品(恭喜他们:)),相比之下,我却没有任何积累能带到新的一年,甚至已经对很多事情失去了热情,让我感到非常挫败。
自称「当年北京最年轻的架构师」的 CMGS 曾经对我说过:「25岁不成事就转行吧」,当时只有 23 岁的我想的是还有那么多时间,我肯定能做出自己可以持续付出热情且为之自豪的作品,然而在还有半年就到了 25 岁的这个时间点,我才发现自己似乎一直在退步。
今年唯一能拿出来说的可能是开源项目了,即使我的 Github 数据并不好看。
我很认同「一定要尝试了解和贡献你所使用的开源项目」,正确的使用开源项目是一项完整的技能,大多数人对其的认知可能只是引入一个依赖库或是中间件,将其按照 Quick Start 运行成功就算完成了,这实际是不正确的。
从开源项目的选型,到对其文档以及源码的掌握,包括调试方式、拓展点,遇到问题时查找资料的途径,在社区反馈问题的方式,二次开发等等,这些都属于使用开源项目所需要的能力,并且这些综合能力非常重要。
很多人会认为像是 Spring、MyBatis 这样的成熟框架是不会存在任何 Bug 的,而事实却不是这样。在我还很短的职业生涯中就遇到过很多成熟框架在特殊场景才会出现的 Bug,其中大部分又是由于使用者不够了解所以用了不规范的写法所导致的,没有任何人愿意看到这些愚蠢的问题造成严重的损失,包括开源软件的作者,所以总要有人为此负起责任。
在今年我所贡献过的开源项目有:
总体来看,这些都不是很重要的贡献,但是也和我最初表达的观点一样,我做这些事都只是为了更好地使用我选择的开源软件,并且这样做对我个人的收获也很大,如果还能为整个开源社区产生一些微末的贡献那就更好了。
反观今年的博客的数量非常少,仅有四篇,这是一个很大的退步。
今年其实积攒了很多博客素材,但是大部分最终都没有写完,我总是觉得自己没有办法把握好一篇博客内容的广度与深度,再加上今年的工作很多都是在解决一些细节问题或是小众场景,这些问题很难整理为一篇对读者有意义的文章(举个例子:我花了好几个小时 debug 一个关于 Spring Cloud 的死锁问题,但是我该如何向读者介绍这个问题呢?发一大堆断点 debug 的截图?这些截图又能对读者产生什么价值呢?)。
不过之前恰好看到 Jake Wharton 发了一个推文「Writing blog posts which are based on presentations you already prepared/presented is crazy easy.」,这个观点我非常赞同,在这之前我也曾经将一篇介绍 Istio 的博客修改为了 Slide 作为内部分享,整个流程无比顺利,只花了不到半天的时间,我想今后一部分博客可能也会通过这种方式进行创作。
内部分享今年也做的不太好,我本来有非常多的想法,比如介绍 JUnit 5、Kotlin、Reactor 这些在 2018 年编写 Java 应用需要了解的「Modern Java Frameworks」系列,或是编写爬虫、脚本或是日常办公软件这些软技能技能。但是我司很少有单纯的技术交流分享,大部分都是每个部门介绍自己工作的内容,做的产品,而且就是单纯的产品介绍,很少会涉及到技术细节。在这种氛围下我觉得做分享太流于形式了,反而是在耽误我和其他听众的时间。
由于最近几年糟糕的生活习惯,今年上半年身体各处都开始显著地产生不适,最为可怕的是我的左眼每隔一段时间会发生一次短暂的视力损失,表现为视野中会慢慢产生一股白雾状覆盖,越来越浓直到眼睛看不清任何东西,紧接着又会慢慢缓解最后消失,整体持续时间不到五分钟。再加上当时因为身边一些事弄得心情很差,非常焦虑和烦躁,整体状态非常差。
七月的时候觉得这样下去肯定会出事,就开始调整生活习惯和心情。把年初买的 Switch 拆了,办了张健身卡每周做 3-5 次有氧运动,正常饮食和作息。之后很幸运的是身体的不适缓解了很多,眼疾也再也没有出现过,现在想想真的很庆幸。
后来为了放松心情还坐游轮去了一次日本,游轮上的生活真的很惬意,不需要做任何规划,饿了就去餐厅吃饭,累了就会房间睡觉,平时就在甲板上晒太阳或是在船里闲逛,很好的缓解了我紧绷的精神。
总体来说今年算是因祸得福把,目前身体状态和精神状态都变得更好了,而且也更加懂得享受生活的重要性。
]]>对于大多数 RPC 框架来说,都会有一个封装抽象的比较上层的接口,即不需要考虑序列化以及通信相关的实现。所以只需要直接 mock 这类的接口,作为本地方法调用并返回对应的结果即可,不必进行真实的 RPC 请求。
以 Spring Cloud Feign 为例,Feign 的定义本身就是完全抽象的 Java 接口,同时每一个 Feign Client 又会注册成一个 Spring Bean,所以就可以通过 Spring 原生提供的 @MockBean
进行 mock,例如:
1 |
|
在最开始,我以为 gRPC 也会有类似的支持,可以通过现有的框架 mock 一个 Stub,使其返回指定的 protobuf 对象。
不幸的是,由于 gRPC 的所有源码都是由 protobuf 文件生成而来,而最重要的是:其生成的 Java Class 都是 final 的,这导致我们没有办法使用基于动态代理实现的 mock 框架去直接代理一个 Stub。
在 gRPC Java 的 Github Issues 中也有着一些类似的讨论,一部分开发者认为 Stub 不应该被定义为 final 类型,这样就可以进行 mock 了。而核心开发者认为 mock Stub 的做法本身就是错误的,真正的作法应该是 mock 一个 Server 实现,并通过 in-process 的传输方式和 Client 进行通信。
在明确了 gRPC 的 mock 只能在 Server 端进行之后,官方为此也提供了一些对应的支持,其中最核心的实现是一个 Junit4 的 Rule GrpcServerRule
。
在这个 Rule 中,每次进行测试之前都会启动一个 in-process Server 以及一个 MutableHandlerRegistry
作为注册中心。之后使用者可以 mock 对应的 Server 实现并将其添加到其中,之后再使用 in-process Server 返回的 Channel 构造 Stub,最终调用该 Stub 的对应方法就可以进入到对应的 Server 逻辑中了。
下面是一个最简单的代码实现:
1 | (MockitoJUnitRunner.class) |
使用这种方式需要注意的是,Server 的实现必须严格的使用 StreamObserver.class
进行结果返回,否则会一直卡在请求中,无法正确的得到结果。
当了解了最核心的 mock 实现后,让我们回到真实世界。
在大多数情况下的实际场景并没有这么简单,例如我们使用了 yidongnan/grpc-spring-boot-starter 将 gRPC 和 Spring 所结合,其实现了一个 PostBeanProcessor 用于将 Channel 或是 Stub 注入到 Bean 的字段中,例如:
1 |
|
在这种场景下 Mockito 的 @InjectMocks
和 Spring Boot 的 @MockBean
都是非常优秀的实现,但是由于篇幅有限,这里只展示一个参考 MockitoAnnotations#initMocks
的类似实现。
这个方法只需要做三件事:
@Mock
或是 @Spy
的字段,如果其是一个 gRPC Server 实现(继承了 BindableService
),则将其添加到 grpcServiceRule
中。@Autowired
的字段,递归遍历所有包含 @GrpcClient
和 @GrpcStub
的字段,将 grpcServiceRule
中的 Channel 注入到其中。下面是代码示例:
1 | 4j |
如此一来,使用者只需要在每个测试运行前调用下 GrpcAnnotations#initMocks
即可完成所有 Server 的 mock 声明和对应 Client 的注入了。
1 |
|
在 17 年 9 月的时候,open zipkin 的作者 adriancole 对 Span 模型进行了一些调整,目的是简化原有的 Span 模型,新版 Span 模型主要的变化为:
kind
字段标识该 Span 是 client 端产生的还是 server 端产生的,原先的方式是通过 annotations
中的 cs
、cr
、ss
、sr
信息进行判断的。annotations
中记录的调用方/接受方的信息也转移到了 localEndpoint
和 remoteEndpoint
中,这两个 object 会记录 ipv4
、port
和 serviceName
三个信息。binaryAnnotations
中记录的自定义 key/value 变成了一个 object tags
,从 key/value 数组变为了 field 和 value。可以参考下面的模型示例:
1 | { |
在 Elasticsearch 的存储模型中,新的格式带来了一个巨大的好处:整个 Span 模型不再存在嵌套(Nested)字段。这样不但查询和聚合语句写起来会方便很多,也避免了 Elasticsearch 不支持嵌套字段聚合时使用外层字段排序的问题。
但是这个改变也带来一个新的问题:原先的 binaryAnnotations
作为嵌套字段只有 key
和 value
两个子字段,而变为了 tags
这个 object 之后,key
本身也变为了动态的 field。但是在 Elasticsearch 中动态 field 会造成 mapping 过于庞大严重影响性能,所以 Zipkin 默认不对 tags
里面的任何信息做索引。
那么 Zipkin UI 中的关键词搜索又是如何实现的呢?Zipkin 会将所有的标签都直接拼成了一个词元数组放到了 _q
字段中。
例如原有的模型为:
1 | { |
则会转变为:
1 | { |
建立索引之后,前端提交的搜索就可以直接使用 term
在 Elasticsearch 中查询相应的记录。
不过这种实现在聚合时就有些麻烦了,不但必须要用 include
过滤出真正想要聚合的字段,还会影响一部分聚合性能,但整体还可以接受。当然对于一些固定的标准 tag,例如 cluster、version 等信息,也可以自己额外建立索引。
Zipkin 中的 Span 模型可以收集到极其详尽的数据,这让我们既可以纵向的按层级展示出一条链路中所产生的所有调用,分析出一类请求的具体瓶颈。也可以横向的统计某一类 Span 的特征,分析出整个系统中的异常行为。
但是由于 Span 过于详细以及通用,导致整个系统会产生非常多的 Span,每天可能会产生上百亿的数据,其产生的 Elasticsearch 存储成本相比收益来说实在太大,所以在这种场景下我们必须要设置采样,只采集部分 Trace 的数据。
此时,如何优化采样率,在系统所能支撑的成本中存储尽量多有意义的数据便成为了新的问题。
Sleuth 中提供了 PercentageBasedSampler
,或是 Brave 提供的 CountingSampler
都可以设置一个采样率,按照一定比例采集数据。这样就可以将数据量控制在一个我们可以接受的范围内。
但是一个很重要的问题是,通过采样率只能均衡的采集数据,但是在很多时候数据本身的价值却不一样。例如我们可能会有一个首页 Tab 的接口每天调用数千万次,但是这个接口的逻辑极其简单,而且所有请求返回的数据都类似,那么这样的 Trace 其实只采样 1% 甚至 1‰ 即可。而像是充值或是退款之类的重要接口,我们是希望每一次请求都可以完全被采样的,所以其采样率就应该是 100%。
Sleuth 中的 Sampler 定义的比较尴尬,只能通过 Span 中的信息选择是否采样,所以很难通过判断 Request 的信息决定是否采样,不过依旧可以用比较取巧的方式实现,只需要确保以下前提:
http:$uri
。所以我们可以在 Sampler 中通过 Span name 截取出当前的请求的 Uri,并在配置文件中查找该 Uri 所对应的采样率,一个参照的源码如下:
1 | /** |
既然 Elasticsearch 的存储成本如此昂贵,那么我们能不能用一种更加廉价的方案完成一部分需求呢?比如直接消费 Kafka 中的 Span 数据,通过 Spark 或是 Flink 实时计算某些指标后存储并展示,就可以节约大量的存储成本了。
在一般的 Zipkin 实现中,应用会在 Span 上报到 Kafka 之前完成采样,Zipkin Collector 会把从 Kafka 消费到的所有数据都存储在 Elasticsearch 中。而引入了实时计算之后,采样分为两个阶段,应用上报 Kafka 的采样依旧保留,并且 Zipkin Collector 也会再次进行采样,这样可以在保证不增加 Elasticsearch 成本的同时,增加上报 Kafka 的采样率,在 Spark/Flink 任务中消费尽可能多的数据。
另外,判断某一个 Span 是否采样和具体实施采样这个行为不一定要绑定在一起,个人建议一个 Span 在两个阶段是否采样的结果都在应用上报 Kafka 之前计算,这样有两个好处:
在大部分时候,使用 Grafana 或是 Kibana 都可以非常快速的生成简单的图表,但是这两者又都很难支持 Elasticsearch 中比较复杂的查询和聚合。所以在某些场景下,我们需要通过自己实现前后端,提供更友好的交互以及更复杂的数据图表。
在这里不准备介绍后端的实现了,无非是在原有的 Zipkin Server 上增加一个原生的 Elasticsearch client(或是 Jest),定制一些复杂的 query 并将返回结果构造的更简易。对于大部分人来说,比较复杂的反而是如何在现有的 Zipkin UI 上增加自己的页面。
Zipkin UI 使用的技术栈是 Bootstrap + Flight.js + jQuery,这是一套非常轻量级的前端技术栈,应用于长期的拓展显得有些单薄了。所以综合了我个人的前端技术栈之后,我打算在其之上使用 React + Ant Design + G2 构建自己的图表组件。
在现有的项目中增加 jsx 支持非常简单,只需要在 babel 中增加对应的 presets 和 plugins 即可:
1 | { |
而使用时其实也很简单,Flight.js 会暴露原生的 dom 节点,只需要使用 ReactDOM 将组件渲染到该节点上。
1 | import {component} from 'flightjs'; |
不过在整体使用上还是会遇到一些小问题:
其最终的效果大概是:
]]>本文会以一个最简单的示例介绍如何在一个 Spring Boot 应用中使用 Spring REST Docs,并在最后与目前最常见的 SpringFox 进行一些对比,分别介绍其特点和优劣。
本文的示例源码托管在 Github 上,你可以通过这个地址下载并在本地运行。
首先需要一个 Spring Boot 项目,并通过 MockMvc 编写一些简单的测试。
1 |
|
在上面代码中提供了一个最简单的 Controller,其接收请求参数中的 name
属性,并返回一个包含 code
和 msg
的 Result 对象。
接下来需要为其编写一个测试:
1 |
|
在这里使用了 JUnit5 和 Spring 的 MockMvc 编写 API 测试,只是简单的请求这个 API 并校验返回值。
完成以上工作,就可以开始通过修改测试代码,为这个 API 自动生成相关的描述文档了。
当使用 MockMvc 时,只需要添加 spring-restdocs-mockmvc
依赖:
1 | <dependency> |
之后,需要修改测试代码,添加对应的文档支持:
1 |
|
@ExtendWith
中增加 RestDocumentationExtension
(JUnit5 的 Extension 相当于 JUnit4 中的 Rule)。MockMvc
由直接注入改为手动构建,增加 documentationConfiguration(restDocumentation)
配置。andDo(document("hello"))
给测试调用所生成的文档命名。完成配置后,运行 mvn clean package
进行构建,当测试运行成功后查看 target/generated-snippets
下出现的一系列 adoc 文档:
其中 curl/httpie-request.adoc
记录了测试请求通过 curl 和 httpie 的调用方式, http-request/response.adoc
记录了测试请求和返回的 raw 信息,request/response-body.adoc
记录了请求和返回的 Payload。
不过这些都只是一个个文档片段,还需要将其拼凑到一起才能成为一份完整的 API 文档,框架本身不提供直接生成完整文档的功能,所以需要编写一个文档主页并引入这些自动生成的文档片段。
默认的文档主页可以放在 src/main/asciidoc/index.adoc
中,例如:
1 | = Learn Spring REST Docs |
其中最重要的一行是 operation::hello[]
,它表示将 hello 下的所有片段都引入进入,或者也可以指定 operation::hello[snippets='curl-request,http-request,http-response']
的方式只引入部分代码片段。
编写好文档主页后,需要使用 asciidoctor-maven-plugin
使其可以在打包时与片段整合起来,并生成最终的 HTML 文件:
1 | <plugin> |
此时再次运行 mvn clean package
之后,可以看到 target/generated-docs
下生成了最终的网页,其最终效果如下图所示。
至此,最简单的请求文档便构建完成了。
对于目前这份文档来说,其仅仅记录了最原始的请求信息,却没有任何相关的文字描述,所以接下来需要给请求和返回增加额外的描述信息。
1 | mockMvc.perform(get("/hello").param("name", "ScienJus")) |
在上面代码中增加了 requestParameters
定义请求参数的描述,以及通过 responseFields
定义返回值的描述,除此之外,还有 pathParameters
、requestHeaders
、requestFields
等分别用于描述路径变量、Header 信息、Payload 信息的方法。
需要注意的是,所有增加描述的字段都会在测试请求中进行校验,如果文档中定义的参数在实际的测试中并没有出现,测试会直接失败,这样可以保证文档描述和最终运行结果是一致的。
再次重新构建,可以看到生成的文档片段中多出了 request-parameters.adoc
和 response-fields.adoc
两个文件,就是在测试代码中定义的描述信息了。
介于篇幅有限,本文对 Spring REST Docs 的基本使用介绍就到此为止了,更多的配置和自定义项可以在官方文档 中查看。
相较于传统且更流行的 SpringFox(Swagger),Spring REST Docs 的实现方式相当新颖,而且有着鲜明的区别,那么不妨在此列举一下两者的区别以及优劣,以便更好的根据实际需求和使用场景选择最合适的工具。
首先,两者最大的区别就在于根本定位,SpringFox 的定位是和应用一起启动的在线文档,文档的浏览者可以很简单的填写表单并发起一个真实的请求,而 Spring REST Docs 更倾向于导出一份离线文档作为展示,并配合 curl、httpie 这种工具请求真实部署的服务。
其次,SpringFox 最大的特点是使用简单,只需要在源码中增加一些描述性的注解即可完成整份文档,而使用 Spring REST Docs 的前提条件是需要在项目中对 API 进行单元测试,并且要保证测试是可以稳定执行的,这对于很多团队来说无疑增加了很高的门槛。
但是对于已经有完整单元测试的团队来说,增加额外的文档描述几乎和 SpringFox 一样简单,并且还能完整的去除源码依赖。除此之外,依靠测试本身也正是 Spring REST Docs 的最大亮点:
首先,每一次测试都是一个真实的请求(不追究 MockMvc 具体实现细节),它所对应的请求和返回都是真实的,可以轻松将其记录下来作为 Demo 展示。而 SpringFox 只是对 Controller 层的方法进行了扫描,却无法感知 Interceptor、MethodArgumentResolver 这类中间件的存在,只能通过一些全局配置进行额外的描述。
其次,每一次测试也都是一个独立的请求,使得 Spring REST Docs 可以描述同一个 API 在不同请求参数中返回的不同结果的场景(例如成功或是各种失败情况),而 SpringFox 只能描述单一的方法签名和返回值 Model,却无法描述其具体可能出现的场景。
最后,错误的文档比没有文档还要糟糕,所以 Spring REST Docs 不仅仅是做 API 文档化,同时也是在做 API 契约化,如果 API 的实现修改破坏了已有的测试,哪怕仅仅是字段定义,都会导致测试的失败。这可以督促 API 的制定者保证对外提供的契约,也可以让 API 的使用者更加放心。
所以相比之下,如果一个技术氛围良好,对服务严格负责,且愿意尝试 API 单元测试和契约测试的团队来说,我更推荐使用 Spring REST Docs,而如果只是在已有的服务上增加描述性的文档,SpringFox 会是性价比更高的选择。
]]>那么,Spring Cloud Config 究竟会配置多么复杂的规则呢?举一个真实的栗子:
1 | spring: |
上面是 Spring Cloud Config Server 的配置,它做了两个特殊的配置:
pattern
将不同的应用映射到不同的配置仓库,实现应用间的配置独立管理。searchPaths
设置配置文件所在的文件夹,在默认情况 Config Server 只会搜索根目录下的配置文件,而上面的设置可以搜索 /
、common
以及和应用同名的文件夹。而请求配置时可以传入 application
、profile
和 label
三个动态配置,例如:
1 | # application/profile/label(optional) |
这个请求会匹配到哪些配置文件呢?
在此首先忽略 label
,因为在大部分情况下它只和 git 所使用的分支有关,所以它不涉及到具体配置文件的匹配。
在 Spring Boot 中,配置文件的搜索条件主要由三个参数组成:
spring.config.name
:应用的名称,默认是 application
(常量)加上 spring.application.name
的值,在 Spring Cloud Config 中对应请求的 application
。spring.profiles.active
:当前生效的 profile,在 Spring Cloud Config 中对应请求的 profile
spring.config.location
:搜索配置文件的路径,在 Spring Cloud Config 对应配置文件中的 searchPaths
Spring Cloud Config 本质是通过创建了一个临时的 Spring Boot Application,设置这些配置并复用 Spring Boot 的逻辑加载对应的配置文件。所以上面的请求最终的结果集为搜索路径 ['./', '/common', '/user-api']
乘以应用名 ['application', 'user-api']
乘以环境 ['', 'production', 'beta']
,共计 18 个配置文件,也就是:
1 | ./application.yml |
那么这些配置文件中的优先级是什么样呢?
Spring Boot 严格按照以下顺序进行排序:
其中定义了多个 profile 和 location 时,越靠后的优先级越高,所以 beta
的优先级要大于 production
,./user-api
的优先级要大于 ./common
。
所以最终的优先级为:
1 | # profile/location/application 均为最高 |
因为源码实现比较多而且绕,就不在这里大段的贴代码了,基本上只需要关注两处:
NativeEnvironmentRepository#findOne
。ConfigFileApplicationListener.Loader#load
。默认情况 Spring Cloud Config 的配置会覆盖掉所有本地配置,包括命令行参数和环境变量,不过可以通过以下配置修改:
1 | spring: |
注意这些配置本身必须放在 Spring Cloud Config 中才会生效。
]]>今年工作上最大的改变是离开了 ENJOY,来到了 Mobike。
17 年上半年在 ENJOY 完成了优惠券的重构,并开始订单的重构。同时将 Zuul 推上了生产环境,接入了所有线上流量。至此,ENJOY 的后端架构对于同规模公司成熟度已经非常高了。有一套还算好用的微服务开发框架,线上应用全部通过自研的 PaaS 平台部署 Docker 容器。
我在 ENJOY 工作的一年中主要做了四件事:
在 4 月份的时候,这些事基本都进入了尾声,同时因为一些团队内的氛围、工作方式的改变,自己对于工作上的热情开始大幅度下降。我最终决定在 6 月底主动提出了离职。
纵观在 ENJOY 的一年,实际上是过得非常充实的,同事中有 CMGS、Flex 这样的大牛,也有像 wzyboy、timfeirg 等很多优秀的同龄人。工作上能够真正去实施自己认为正确的方案,能够认同自己最终做出来的东西,能够承担更多责任,并带来更多的技术提升,产生非常好的良性循环,这段经历是非常宝贵的。
离开 ENJOY 的时候我并没有想过自己要去哪家公司,也一向不擅长找工作和面试,所以最后只参加了三次面试,分别是「出门问问」、「LeanCloud」和「摩拜单车」。我一直都非常认同 LeanCloud 的工程师文化,对里面的大部分工程师都有一些了解,也非常敬佩庄晓丹这样的技术人,但是纠结了很久最终还是选择去了摩拜。
相比 ENJOY 摩拜的团队更加大一些,而且职责也分得更加细粒度一些,导致我呆了很久也没有完全适应。好在同事也都非常的 Nice,使我在完成本职工作之后,可以和更多的人交流,讨论和学习更多的技术。
目前我的大部分工作都和在 ENJOY 时没有太大区别,而我在工作外比较感兴趣的事是观察「如何提升整个团队的工作效率」上,举个例子,在 ENJOY 时我们希望一个 10 人的团队能做好 15 人的工作量,而在摩拜更像是希望一个 100 人的团队能做好 80 人的工作量,实现这两个目标努力的方向是完全不同的,有些甚至可能是完全相反的,当我站在完全不同的位置上去解决问题时,会发现给出的答案也会完全不同,这是非常有收获的。
摩拜是一家还在高速发展的公司,明年希望能够接受更多的技术挑战,做出更多稳定、健壮、优秀的系统,尝试更多新技术,以及支持更多的人更加快速的完成开发工作。
今年发生了很多事,我的业余时间并不多,主要做了以下事:
我有一个很大的坏习惯就是对于很多事都会很快的付诸行动,但是却没有一个长远的规划,这会导致这些事最终只会持续很短的一段时间便暂时搁置掉了,最终并不会有什么实质性的结果。所以我从下半年开始尝试写子弹笔记,开始重新续费 Things,希望能有所改善。
明年我希望自己的主要精力放在看书上,因为工作内容很有可能带来更偏向广度的知识增长,所以我需要通过看书获取一些更深度的知识保持平衡,否则很容易变成做了很多事技术能力却没有提升的窘境。
还有一点是总是感觉自己精力不够用,后来认为还是工作方式有一些问题,浪费了很多时间和精力,明年也希望多系统性的学习一些效率工具相关的知识。
]]>Spring Boot 作为加快 Spring 项目开发的扩展框架,其中一个很重要的特性就是引入了 Starter 套件。Starter 可以在程序启动时自动初始化程序所需要的 Bean,开发者只需要关注如何使用组件本身。
Spring Boot 通过 AutoConfiguration 机制使得应用可以在启动时根据引入的类和配置,自动加载配置类(Configuration),从而在这些类中初始化所需的 Spring Bean。
一个完善的 AutoConfiguration 组件应该由四部分组成:
从 Spring 3 开始,开发者就可以通过 Java Config 的方式配置 Bean 了。
一个最简单的配置类由 @Configuration
和 @Bean
组成,其中前者将该类声明为一个配置类,同时也作为一个 Spring 的组件以便被扫描、注入。后者作为方法上的注解可以将方法的返回值注册为 Spring Bean。
1 | @Configuration |
Spring Boot 中的所有配置类都在 spring-boot-autoconfigure
项目中,在这个项目中可以看到 Spring 为每个组件定义了哪些类、初始化了哪些 Bean,以及是如何进行配置的。
对于传统的配置类,在 Spring 项目中一般都是由组件扫描(@ComponentScan
)或是主动引入(@Import
)的方式去进行装载。这种方式使得使用者被迫的去了解组件的配制方法和源码,极易出现配置错误等问题。
而在 Spring Boot 中,则提供了具有自动装载功能的 @EnableAutoConfiguration
注解,只要在项目中使用了该注解(或是其作为元注解的 @SpringBootApplication
),就会在应用启动时自动装载所有 Spring Boot 提供的配置类。
其实现原理也是基于 Spring 现有的组件。
逻辑的入口是 @EnableAutoConfiguration
,它唯一的功能就是使用了 @Import
作为元注解,并引入了 EnableAutoConfigurationImportSelector
类。
@Import
是在 Spring 3 中作为 Java Config 功能所引入的,其最初作用是为了实现 XML 配置中 <import>
标签的功能:将多个配置引入到主配置中。所以它最开始的用途也是为了引入一些 @Configuration
类。
其实 Java Config 的配置方式具有更强大的表现力,例如使用者可以在注解中设置不同的值,或是通过运行一段 Java 代码动态的加载不同的配置,于是在 Spring 3.1 中就引入了两个新的功能:
ImportSelector
:通过读取注解属性,动态引入一些配置。ImportBeanDefinitionRegistrar
:通过读取注解属性,动态注册 Bean。其中 EnableAutoConfigurationImportSelector
就是一个 ImportSelector
的实现类。该接口只有一个方法 selectImports
,这个方法会传入注解的元信息,最后返回需要装载的配置类的类名。
在这个实现中,最终会调用 SpringFactoriesLoader.loadFactoryNames
加载配置类的类名。
而 SpringFactoriesLoader.loadFactoryNames
则会去寻找项目中所有 META-INF/spring.factories
文件,并将其转化为 Properties,读取注解类名对应的值作为配置类的列表返回。
查看 spring-boot-autoconfigure 项目,确实能发现其中包含着该文件,以及对应的配置项。
至此可以得出一个结论,如果想要将一个配置类变为自动装载,只需要在项目中增加 META-INF/spring.factories
文件,并该类的类名作为 ENableAutoConfiguration
的值即可。
这个文件不光可以定义配置类,还可以定义
ApplicationListener
或是ApplicationContextInitializer
,一样用来实现组件自动装载的功能。
阻止 AutoConfiguration 加载的方式有两种,一种是在 @EnableAutoConfiguration
的 exclude
属性中定义这个配置类的类名,另一种方式是在 spring.autoconfigure.exclude
配置中定义配置类的类名。一般更推荐前者,因为这种场景一般是在开发期都可以确定的。
条件化加载是在 Spring 4 中引入的新特性,而到了 Spring Boot 配合自动装载才真正发挥出其强大的功能。
这个特性就是在配置类上或是某个配置 Bean 的方法上定义一系列条件。而只有这些条件满足时,这个配置类才会进行装载,或是这个方法才会被执行。
在传统的 Spring 项目中,一般配置类都是由自己定义,所以基本上定义的配置类都是实际需要使用的,也就自然不需要添加额外的加载条件。
而 Spring Boot 遵从约定大于配置,每一个 AutoConfiguration 都会根据项目中是否引入了必要的依赖,以及是否配置了必须的配置项决定是否加载,完美的契合了条件化加载的使用场景。
例如上图中初始化 Freemarker 的配置类,一共使用了 4 个条件化注解:
@ConditionalOnClass
:指定的 Class 存在于当前 Classpath @ConditionalOnWebApplication
:是一个 Web 应用@ConditionalOnMissingBean
:指定的 Bean 不存在@ConditionalOnProperty
:指定的配置项满足条件(存在、等于某个值、不等于某个值等)由于用户定义的配置类永远会在 AutoConfiguration 之前进行装载,所以 @ConditionalOnMissingBean
可以很轻易的实现使用者自定义的 Bean 替代掉自动生成的 Bean 的功能。
另一个比较特殊的注解是 @Profile
,这个注解允许使用者通过 spring.profiles.active
来控制一些 Bean 是否初始化,或是初始化不同的实例。
@Profile
实际是在 Spring 3 就有的功能,但是在 Spring 4 条件化注解出现后,其实现也通过相关 API 进行重写了。
虽然 Spring Boot 已经提供了很多常用的条件实现,但是在某些特殊场景依旧需要自定义加载条件。
所有的条件注解实现都是由 @Conditional
这个注解作为元注解,并指向一个 Condition
接口的实现类。
以一个具体场景举例,在 Java 应用中开发者一般使用 Slf4j 作为通用的日志桥接,从而隐藏具体的 Log4j 或是 Logback 实现。而当需要开发与日志相关的配置类时,就需要根据不同日志实现加载相关的配置类,就需要自定义一个条件注解。
首先编写一个自定义的条件注解 @ConditionalOnSlf4jBinding
,这个注解用于指定期望 Slf4j 绑定的具体实现,在使用这个注解时可以将期望实现的类名放在 value
中。
1 | @Retention(RetentionPolicy.RUNTIME) |
同时按照规范,这个注解使用了 @Conditional
作为元注解,并指向了 OnSlf4jBindingCondition
。这个类是一个 SpringBootCondition
的实现类,通过调用 Slf4j 的方法获取当前绑定的日志实现,并查看是否与期望值相同:
1 | public class OnSlf4jBindingCondition extends SpringBootCondition { |
使用时就像这样:
1 | @ConditionalOnSlf4jBinding("ch.qos.logback.classic.util.ContextSelectorStaticBinder") |
或是更进一步的枚举出所有日志实现:
1 | @Retention(RetentionPolicy.RUNTIME) |
使用时会更加简单:
1 | @ConditionalOnLogbackBinding |
配置 debug
属性启动 Spring Boot 应用时,会打印一系列 AUTO-CONFIGURATION REPORT 的记录。
在这个记录中首先会把所有 AutoConfiguration 分为两组,一组为命中条件自动加载,另一组为未命中条件不进行自动加载。在这个报告中开发者可以很清晰的看到当前应用中每一个配置类因为满足/不满足某些条件最终会/不会被自动加载。
在配置文件中定义属性是 AutoConfiguration 唯一与使用者相关的功能。
一般在普通的应用开发中,开发者一般会使用 @Value
注入配置文件中的值。而在定义配置类时,更好的做法是定义一个与配置文件映射的 Model,并通过 @ConfigurationProperties
进行标识。
而当 Configuration 装载时,可以使用 @EnableConfigurationProperties
将对应的配置类引入,会自动注册为 Spring Bean。
使用这种做法的好处有很多:
spring-configuration-metadata.json
用来描述配置项,配合 IDE 可以在编写配置文件时有提示。]]>
@Value
实际也是支持热刷新的,但是必须定义为@RefreshScope
,而且实现也有不同,理论上@ConfigurationProperties
的热刷新更加轻量级。使用
spring-boot-configuration-processor
依赖可以在编译时扫描项目中的@ConfigurationProperties
类,并自动生成spring-configuration-metadata.json
文件。
顾名思义,ContextRefresher
用于刷新 Spring 上下文,在以下场景会调用其 refresh
方法。
/refresh
Endpoint。RefreshRemoteApplicationEvent
事件(任意集成 Bus 的应用,请求 /bus/refresh
Endpoint 后都会将事件推送到整个集群)。这个方法包含了整个刷新逻辑,也是本文分析的重点。
首先看一下这个方法的实现:
1 | public synchronized Set<String> refresh() { |
首先是第一步 extract
,这个方法接收了当前环境中的所有属性源(PropertySource),并将其中的非标准属性源的所有属性汇总到一个 Map 中返回。
这里的标准属性源指的是 StandardEnvironment
和 StandardServletEnvironment
,前者会注册系统变量(System Properties)和环境变量(System Environment),后者会注册 Servlet 环境下的 Servlet Context 和 Servlet Config 的初始参数(Init Params)和 JNDI 的属性。个人理解是因为这些属性无法改变,所以不进行刷新。
第二步 addConfigFilesToEnvironment
是核心逻辑,它创建了一个新的 Spring Boot 应用并初始化:
1 | SpringApplicationBuilder builder = new SpringApplicationBuilder(Empty.class) |
这个应用只是为了重新加载一遍属性源,所以只配置了 BootstrapApplicationListener
和 ConfigFileApplicationListener
,最后将新加载的属性源替换掉原属性源,至此属性源本身已经完成更新了。
此时属性源虽然已经更新了,但是配置项都已经注入到了对应的 Spring Bean 中,需要重新进行绑定,所以又触发了两个操作:
将刷新后发生更改的 Key 收集起来,发送一个 EnvironmentChangeEvent
事件。
调用 RefreshScope.refreshAll
方法。
在上文中,ContextRefresher
发布了一个 EnvironmentChangeEvent
事件,接下来看看这个事件产生了哪些影响。
The application will listen for an EnvironmentChangeEvent and react to the change in a couple of standard ways (additional ApplicationListeners can be added as @Beans by the user in the normal way). When an EnvironmentChangeEvent is observed it will have a list of key values that have changed, and the application will use those to:
Re-bind any @ConfigurationProperties beans in the context
Set the logger levels for any properties in logging.level.*
官方文档的介绍中提到,这个事件主要会触发两个行为:
@ConfigurationProperties
注解的 Spring Bean。logging.level.*
配置发生了改变,重新设置日志级别。这两段逻辑分别可以在 ConfigurationPropertiesRebinder
和 LoggingRebinder
中看到。
这个类乍一看代码量特别少,只需要一个 ConfigurationPropertiesBeans
和一个 ConfigurationPropertiesBindingPostProcessor
,然后调用 rebind
每个 Bean 即可。但是这两个对象是从哪里来的呢?
1 | public void rebind() { |
ConfigurationPropertiesBeans
需要一个 ConfigurationBeanFactoryMetaData
, 这个类逻辑很简单,它是一个 BeanFactoryPostProcessor
的实现,将所有的 Bean 都存在了内部的一个 Map 中。
而 ConfigurationPropertiesBeans 获得这个 Map 后,会查找每一个 Bean 是否有 @ConfigurationProperties
注解,如果有的话就放到自己的 Map 中。
绕了一圈好不容易拿到所有需要重新绑定的 Bean 后,绑定的逻辑就要简单许多了:
1 | public boolean rebind(String name) { |
其中 postProcessBeforeInitialization
方法将 Bean 重新绑定了所有属性,并做了校验等操作。
而 initializeBean
的实现如下:
1 | protected Object initializeBean(final String beanName, final Object bean, RootBeanDefinition mbd) { |
其中主要做了三件事:
applyBeanPostProcessorsBeforeInitialization
:调用所有 BeanPostProcessor
的 postProcessBeforeInitialization
方法。invokeInitMethods
:如果 Bean 继承了 InitializingBean
,执行 afterPropertiesSet
方法,或是如果 Bean 指定了 init-method
属性,如果有则调用对应方法applyBeanPostProcessorsAfterInitialization
:调用所有 BeanPostProcessor
的 postProcessAfterInitialization
方法。之后 ConfigurationPropertiesRebinder
就完成整个重新绑定流程了。
相比之下 LoggingRebinder
的逻辑要简单许多,它只是调用了 LoggingSystem
的方法重新设置了日志级别,具体逻辑就不在本文详述了。
首先看看这个类的注释:
Note that all beans in this scope are only initialized when first accessed, so the scope forces lazy initialization semantics. The implementation involves creating a proxy for every bean in the scope, so there is a flag
If a bean is refreshed then the next time the bean is accessed (i.e. a method is executed) a new instance is created. All lifecycle methods are applied to the bean instances, so any destruction callbacks that were registered in the bean factory are called when it is refreshed, and then the initialization callbacks are invoked as normal when the new instance is created. A new bean instance is created from the original bean definition, so any externalized content (property placeholders or expressions in string literals) is re-evaluated when it is created.
这里提到了两个重点:
@RefreshScope
的 Bean 都是延迟加载的,只有在第一次访问时才会初始化再看一下方法实现:
1 | public void refreshAll() { |
这个类中有一个成员变量 cache
,用于缓存所有已经生成的 Bean,在调用 get
方法时尝试从缓存加载,如果没有的话就生成一个新对象放入缓存,并通过 getBean
初始化其对应的 Bean:
1 | public Object get(String name, ObjectFactory<?> objectFactory) { |
所以在销毁时只需要将整个缓存清空,下次获取对象时自然就可以重新生成新的对象,也就自然绑定了新的属性:
1 | public void destroy() { |
清空缓存后,下次访问对象时就会重新创建新的对象并放入缓存了。
而在清空缓存后,它还会发出一个 RefreshScopeRefreshedEvent
事件,在某些 Spring Cloud 的组件中会监听这个事件并作出一些反馈。
Zuul 在收到这个事件后,会将自身的路由设置为 dirty 状态:
1 | private static class ZuulRefreshListener implements ApplicationListener<ApplicationEvent> { |
并且当路由实现为 RefreshableRouteLocator
时,会尝试刷新路由:
1 | public void setDirty(boolean dirty) { |
当状态为 dirty 时,Zuul 会在下一次接受请求时重新注册路由,以更新配置:
1 | if (this.dirty) { |
在 Eureka 收到该事件时,对于客户端和服务端都有不同的处理方式:
1 | protected static class EurekaClientConfigurationRefresher { |
对于客户端来说,只是调用了下 eurekaClient.getApplications
,理论上这个方法是没有任何效果的,但是查看上面的注释,以及联想到 RefreshScope
的延时初始化特性,这个方法调用应该只是为了强制初始化新的 EurekaClient
。
事实上这里很有趣的是,在 EurekaClientAutoConfiguration
中,实际为了 EurekaClient
提供了两种初始化方案,分别对应是否有 RefreshScope
,所以以上的猜测应该是正确的。
而对于服务端来说,EurekaAutoServiceRegistration
会将服务端先标记为下线,在进行重新上线。
至此,Spring Cloud 的热更新流程就到此结束了,从这些源码中可以总结出以下结论:
ContextRefresher
可以进行手动的热更新,而不需要依靠 Bus 或是 Endpoint。@ConfigurationProperties
的对象,另一类是使用了 @RefreshScope
的对象。RefreshScope
的缓存和延迟加载机制,生成了新的对象。EnvironmentChangeEvent
事件,也可以获得更改的配置项,以便实现自己的热更新逻辑。本文主要是介绍使用 Zuul 且在不强制使用其他 Neflix OSS 组件时,如何搭建生产环境的 Gateway,以及能使用 Gateway 做哪些事。不打算介绍任何关于如何快速搭建 Zuul,或是一些轻易集成 Eureka 之类的的方法,这些在官方文档上已经介绍的很明确了。
API Gateway 是随着微服务(Microservice)这个概念一起兴起的一种架构模式,它用于解决微服务过于分散,没有一个统一的出入口进行流量管理的问题。
用 Kong 官网的两张图来解释再合适不过。
当使用微服务构建整个 API 服务时,一般会有许许多多职责不同的应用在运行着,这些应用会需要一些通用的功能,例如鉴权、流控、监控、日志统计。
在传统的单体应用中,这些功能一般都是内嵌在应用中,作为一个组件运行。但是在微服务模式下,不同种类且独立运行的应用可能会有数十甚至数百种,继续使用这种方式会造成非常高的管理和发布成本。所以就需要在这些应用上抽象出一个统一的流量入口,完成这些功能的实现。
在我看来,API Gateway 的职责主要分为两部分:
对于 API Gateway,常见的选型有基于 Openresty 的 Kong、基于 Go 的 Tyk 和基于 Java 的 Zuul。
这三个选型本身没有什么明显的区别,主要还是看技术栈是否能满足快速应用和二次开发,例如我司原有的技术栈就是使用 Go/Openresty 的平台组和使用 Java 的后端组,讨论后觉得 API Gateway 未来还是处理业务功能的场景更多些,而且后端这边有很多功能可以直接移植过来,最终就选择了 Zuul。
关于 Zuul,大部分使用 Java 做微服务的人可能都会或多或少了解 Spring Cloud 和 Netflix 全家桶。而对于完全不了解的人,可以暂时将它想象为一个类似于 Servlet 中过滤器(Filter)的概念。
就像上图中所描述的一样,Zuul 提供了四种过滤器的 API,分别为前置(Pre)、后置(Post)、路由(Route)和错误(Error)四种处理方式。
一个请求会先按顺序通过所有的前置过滤器,之后在路由过滤器中转发给后端应用,得到响应后又会通过所有的后置过滤器,最后响应给客户端。在整个流程中如果发生了异常则会跳转到错误过滤器中。
一般来说,如果需要在请求到达后端应用前就进行处理的话,会选择前置过滤器,例如鉴权、请求转发、增加请求参数等行为。在请求完成后需要处理的操作放在后置过滤器中完成,例如统计返回值和调用时间、记录日志、增加跨域头等行为。路由过滤器一般只需要选择 Zuul 中内置的即可,错误过滤器一般只需要一个,这样可以在 Gateway 遇到错误逻辑时直接抛出异常中断流程,并直接统一处理返回结果。
以下介绍一些 Zuul 中不同过滤器的应用场景。
一般来说整个服务的鉴权逻辑可以很复杂。
而对于后端应用来说,它们其实只需要知道请求属于谁,而不需要知道为什么,所以 Gateway 可以友善的帮助后端应用完成鉴权这个行为,并将用户的唯一标示透传到后端,而不需要、甚至不应该将身份信息也传递给后端,防止某些应用利用这些敏感信息做错误的事情。
Zuul 默认情况下在处理后会删除请求的 Authorization
头和 Set-Cookie
头,也算是贯彻了这个原则。
流量转发的含义就是将指向 /a/xxx.json
的请求转发到指向 /b/xxx.json
的请求。这个功能可能在一些项目迁移、或是灰度发布上会有一些用处。
在 Zuul 中并没有一个很好的办法去修改 Request URI。在某些 Issue 中开发者会建议设置 requestURI
这个属性,但是实际在 Zuul 自身的 PreDecorationFilter
流程中又会被覆盖一遍。
不过对于一个基于 Servlet 的应用,使用 HttpServletRequestWrapper
基本可以解决一切问题,在这个场景中只需要重写其 getRequestURI
方法即可。
1 | class RewriteURIRequestWrapper extends HttpServletRequestWrapper { |
使用 Gateway 做跨域相比应用本身或是 Nginx 的好处是规则可以配置的更加灵活。例如一个常见的规则。
Access-Control-Allow-Origin
为 *
,且 Access-Control-Allow-Credentials
为 true
,这是一个常用的允许任意源跨域的配置,但是不允许请求携带任何 CookieOrigin
增加到白名单中。对于白名单中的请求,返回 Access-Control-Allow-Origin
为该域名,且 Access-Control-Allow-Credentials
为 true
,这样请求者可以正常的请求接口,同时可以在请求接口时携带 CookieAccess-Control-Allow-Origin
为 *
,否则重定向后的请求携带的 Origin
会为 null
,有可能会导致 iOS 低版本的某些兼容问题Gateway 可以统一收集所有应用请求的记录,并写入日志文件或是发到监控系统,相比 Nginx 的 access log,好处主要也是二次开发比较方便,比如可以关注一些业务相关的 HTTP 头,或是将请求参数和返回值都保存为日志打入消息队列中,便于线上故障调试。也可以收集一些性能指标发送到类似 Statsd 这样的监控平台。
错误过滤器的主要用法就像是 Jersey 中的 ExceptionMapper
或是 Spring MVC 中的 @ExceptionHandler
一样,在处理流程中认为有问题时,直接抛出统一的异常,错误过滤器捕获到这个异常后,就可以统一的进行返回值的封装,并直接结束该请求。
虽然将这些逻辑都切换到了 Gateway,省去了很多维护和迭代的成本,但是也面临着一个很大的问题,就是 Gateway 只有逻辑却没有配置,它并不知道一个请求要走哪些流程。
例如同样是后端服务 API,有的可能是给网页版用的、有的是给客户端用的,亦或是有的给用户用、有的给管理人员用,那么 Gateway 如何知道到底这些 API 是否需要登录、流控以及缓存呢?
理论上我们可以为 Gateway 编写一个管理后台,里面有当前服务的所有 API,每一个开发者都可以在里面创建新的 API,以及为它增加鉴权、缓存、跨域等功能。为了简化使用,也许我们会额外的增加一个权限组,例如 /admin/*
下的所有 API 都应该为后台接口,它只允许内部来源的鉴权访问。
但是这样做依旧太复杂了,而且非常硬编码,当开发者开发了一个新的 API 之后,即使这个应用已经能正常接收特定 URI 的请求并处理之后,却还要通过人工的方式去一个管理后台进行额外的配置,而且可能会因为不谨慎打错了路径中的某个单词而造成不必要的事故,这都是不合理的。
我个人推荐的做法是,在后端应用中依旧保持配置的能力,即使应用里已经没有真实处理的逻辑了。例如在 Java 中通过注解声明式的编写 API,且在应用启动时自动注册 Gateway 就是一种比较好的选择。
1 | /** |
这样 API 的编写者就会根据业务场景考虑该 API 需要哪些功能,也减少了管理的复杂度。
除此之外还会有一些后端应用无关的配置,有些是自动化的,例如恶意请求拦截,Gateway 会将所有请求的信息通过消息队列发送给一些实时数据分析的应用,这些应用会对请求分析,发现恶意请求的特征,并通过 Gateway 提供的接口将这些特征上报给 Gateway,Gateway 就可以实时的对这些恶意请求进行拦截。
在 Nginx 和后端应用之间又建立了一个 Java 应用作为流量入口,很多人会去担心它的稳定性,亦或是担心它能否像 Nginx 一样和后端的多个 upstream 进行交互,以下主要介绍一下 Zuul 的隔离机制以及重试机制。
在微服务的模式下,应用之间的联系变得没那么强烈,理想中任何一个应用超过负载或是挂掉了,都不应该去影响到其他应用。但是在 Gateway 这个层面,有没有可能出现一个应用负载过重,导致将整个 Gateway 都压垮了,已致所有应用的流量入口都被切断?
这当然是有可能的,想象一个每秒会接受很多请求的应用,在正常情况下这些请求可能在 10 毫秒之内就能正常响应,但是如果有一天它出了问题,所有请求都会 Block 到 30 秒超时才会断开(例如频繁 Full GC 无法有效释放内存)。那么在这个时候,Gateway 中也会有大量的线程在等待请求的响应,最终会吃光所有线程,导致其他正常应用的请求也受到影响。
在 Zuul 中,每一个后端应用都称为一个 Route,为了避免一个 Route 抢占了太多资源影响到其他 Route 的情况出现,Zuul 使用 Hystrix 对每一个 Route 都做了隔离和限流。
Hystrix 的隔离策略有两种,基于线程或是基于信号量。Zuul 默认的是基于线程的隔离机制,这意味着每一个 Route 的请求都会在一个固定大小且独立的线程池中执行,这样即使其中一个 Route 出现了问题,也只会是某一个线程池发生了阻塞,其他 Route 不会受到影响。
一般使用 Hystrix 时,只有调用量巨大会受到线程开销影响时才会使用信号量进行隔离策略,对于 Zuul 这种网络请求的用途使用线程隔离更加稳妥。
一般来说,后端应用的健康状态是不稳定的,应用列表随时会有修改,所以 Gateway 必须有足够好的容错机制,能够减少后端应用变更时造成的影响。
Zuul 的路由主要有 Eureka 和 Ribbon 两种方式,由于我一直使用的都是 Ribbon,所以简单介绍下 Ribbon 支持哪些容错配置。
重试的场景分为三种:
okToRetryOnConnectErrors
:只重试网络错误okToRetryOnAllErrors
:重试所有错误OkToRetryOnAllOperations
:重试所有操作(这里不太理解,猜测是 GET/POST 等请求都会重试)重试的次数有两种:
MaxAutoRetries
:每个节点的最大重试次数MaxAutoRetriesNextServer
:更换节点重试的最大次数一般来说我们希望只在网络连接失败时进行重试、或是对 5XX 的 GET 请求进行重试(不推荐对 POST 请求进行重试,无法保证幂等性会造成数据不一致)。单台的重试次数可以尽量小一些,重试的节点数尽量多一些,整体效果会更好。
如果有更加复杂的重试场景,例如需要对特定的某些 API、特定的返回值进行重试,那么也可以通过实现 RequestSpecificRetryHandler
定制逻辑(不建议直接使用 RetryHandler
,因为这个子类可以使用很多已有的功能)。
一般在相同语言的多个项目中如何共享 IDL 呢?
在我们大部分的 Java 项目中,服务提供方都会定义一个 $project-api
的模块,专门用来放给其他项目调用相关类,其中自然也包括了 Thrift IDL 生成后的类。我们甚至会在 Thrift.Iface/Client
上再包一层自己实现的 Client,使整个接口定义与 Thrift 这个实现方案彻底无关,即使未来有一天我们将通讯协议换成了其他方案(HTTP/gRPC),使用方也只需要更新一下依赖版本,而不需要改任何代码。
而在多语言的项目中又该如何共享 IDL 呢?
一般的 RPC 服务会存在一个 Service 和多个 Client,在我们开发的 Python 和 Java 项目中,除了 Java Client 使用 Java Service 可以用上述的方式直接共享生成后的 class 文件,其他使用方式都需要将 IDL 文件放入项目源码中,这样就会导致一份 IDL 会存在与多个项目中,而随着项目的迭代,很难做到所有项目之间的版本是相同的,混乱由此而生。而 Thrift 序列化的高效只建立在 Field Id 作为序列化索引实现的基础上,一旦 Field Id 出现了不一致,就会出现很难排查的数据丢失问题。
面对这个问题,核心诉求就是希望 IDL 可以只存一份,并对所有项目共享(而不仅仅是通过 Java/Maven 的方式在部分语言中共享)。
使用 Git 子模块是一个很简单的解决方案,但是我很难认同这种做法,大多数情况下它增加了普通项目管理的复杂度,如果目前服务的调用方都是 Java,那么我们根本不需要这种方式,而如果有一天一个新的 Python 项目需要接入了,Service 就需要把 IDL 放到一个子模块中,那么之前的那些 Java 项目要不要也跟着进行修改?其实修改是无意义的,但是不修改又会造成多个项目之间管理的不统一。
另一种方式是单独使用一个仓库存放 IDL 文件,在发生变更的时候去触发所有语言的构建和包管理,例如打一个 Java 包上传到 Maven、生成一些 Python 文件放到 pip(虽然 Python 的 thriftpy 已经强大到不需要任何依赖就可以直接在运行时读取并解析 IDL 文件了)。
我个人认为理想的解决方案是不在任何项目中直接引用 IDL 文件,而是引用其被托管的一个网络地址(如果你了解 Java,应该会联想到 Spring Cloud Config)。当 Java 或 Python 项目
构建时可以选择性的去拉取变更的 IDL 文件,和上面的区别主要就是 Push 和 Pull 的区别,而且不需要为了几个文件去发一个个依赖包。
比较遗憾的是:这种做法在 Gradle 中或许只是几行代码的事情,而在 Maven 中却需要额外的添加自定义插件或是去魔改 Maven Thrift Plugin,而前者依旧会增加项目的复杂度,后者的话我连这个插件的源码托管在何处都不知道。
回到问题的源头,我们究竟为什么希望所有项目中共享的 IDL 完全同步?
IDL 不同步带来的最坏结果就是由序列化/反序列化无法对应导致的字段丢失或是报错,其次是可能出现服务升级后提供新的 API 无法有效地通知调用方升级。
而这些在 HTTP/JSON 服务中也会出现,而且其没有任何强约束办法,只能通过 Swagger 这类文档工具进行信息同步。换言之,对于一个能够做好向后兼容的服务来说(当然这也是一个服务的最基本要求),调用方的所有更新都应该是可选的,我们只需要一个平台去展示每一个版本的 IDL 和其描述信息(文档)。
Armeria 由 Netty 作者 Trustin Lee 开发的 RPC 框架,也是我目前知道的唯一可以将 Thrift 生成文档的框架。
但是由于一些坑,我们无法直接使用 Armeria 生成整套文档
Thrift Java Compiler 本身的有一个 Bug,当 IDL 中 Struct 没有严格按照使用顺序定义时,生成的 class 文件中的 FieldMetaData
是错误的。
例如一个 IDL 正确的顺序应该如下:
1 | struct A { |
在某些语言的 Thrift Compiler 实现中,因为 B 引用了 A,所以定义顺序必须是 A 在 B 前面(例如 thriftpy 就强制要求,否则会解析错误)。
但是在 Java 中却可以将 A 定义在 B 之后,而且使用时完全没有任何问题。直到我们开始用 FieldMetaData 去生成文档。
在正常顺序下生成的 B.class 当中 A 的 FieldMetaData 为:
1 | tmpMap.put(B._Fields.A, new FieldMetaData("a", (byte)3, new StructMetaData((byte)12, A.class))); |
它正确的使用了 StructMetaData
并引用到了 A.class,这样我们就可以找到这个字段相关的 Struct。
但是如果顺序是错误的,将 A 定义在了 B 的后面,生成的 FieldMetaData 为:
1 | tmpMap.put(B._Fields.A, new FieldMetaData("a", (byte)3, new FieldValueMetaData((byte)12, "A"))); |
可以看到 StructMetaData
变成了 FieldValueMetaData
,并且丢失了 Class 信息。
Armeria 的文档系统是建立在其基础方案之上的,这意味着如果你本身就使用其作为 RPC 框架,那么生成文档只需要额外的加一个 DocService
即可。并且它还能直接在文档页面中通过 TText 的协议方式直接进行在线调用。
但是我目前所使用的系统并没有直接使用 Armeria,而是内部通过 Etcd 实现的 Thrift 服务注册与发现,这样使得不光没办法使用自带的在线调用功能,连生成文档界面都需要额外引入 Thrift 相关类并将空实现注册到 Armeria,整个系统能直接用到的功能非常少。
如果你之前了解过 Swagger(一个 HTTP 文档的协议规范),你应该能明白一个良好的文档工具最重要的就是 Schema,它能够将生成程序和页面渲染程序直接解耦,方便对已有组件进行改造和复用。
很幸运的是,Armeria 就拥有一套用来描述 Thrift API 的 Schema。这样我们可以不去使用它通过读取 Java class 生成 Schema 的组件,只是用将 Schema 渲染成页面的组件去渲染我们自己生成的 Schema。
在与 Python 同事联调时,我发现 thriftpy 就可以帮助我生成 Schema,而且实际写下来整个代码逻辑会非常简单,一共只需要不到 100 行便可以完成。
由于我本身写 Python 很少,代码看上去比较丑陋就不展示了,只是介绍下大概思路:
thriftpy.load
加载 IDL 生成的模块拥有 __thrift_meta__
属性,可以从中获取这个 IDL 中有哪些 Struct、Service、Enum 和 Exception。thrift_spec
可以拿到所有字段信息,这是个字典,Key 是字段 ID,Val 是一个元祖,包含字段信息。_NAMES_TO_VALUES
就可以拿到所有枚举和值之间的对应关系了。${service_name}_args
和返回值 ${service_name}_result
,解析这两个结构基本和解析 Struct 一样。当然在使用中也需要魔改部分 thriftpy 的源码,比如在 [Parser][3] 中恢复对 namespace 的执行,并读取的 Java 的 namespace。因为我们在 Etcd 上注册节点就是以 namespace 为路径的,用于查看当前节点或是未来重新支持在线调试都是必不可少的。
最后整个项目的目录为:
1 | idl/ |
目前我将其托管为了一个 Gitlab Pages 项目,只要该项目由变更(多数为 IDL 修改)就会触发 Ci build 重新生成最新的文档。
长久以来用 Thrift 积累了很多经验和疑惑,现在个人认为对于一般小公司,如果网络请求还没有成为整个 RPC 调用的性能瓶颈,使用 HTTP/JSON API 可能会更适合一些,毕竟其拥有更多的可扩展能力以及更丰富的生态环境,能够节省很多精力(例如 Java 就可以选择 Netflix 全家桶啊!)。
比如说一些我曾遇到的使用场景:
所以虽然本文的内容是如何更好地使用 Apache Thrift,但是最终要表达的意思却是没遇到性能瓶颈之前,用 HTTP/JSON 可能会更好。
函数式语言将函数作为一等公民,这意味着函数可以像其他值一样作为参数或是返回值,这种做法提高了程序的灵活性。
将其他函数作为参数或者返回值的函数被称为高阶函数(Higher Order Functions)。
一个例子,下面这个方法用于计算两个整数之间的所有整数之和:
1 | def sumInts(a: Int, b: Int): Int = |
另一个方法用于计算两个整数之间的所有整数立方的和:
1 | def cube(x: Int): Int = x * x * x |
还有一个方法用于计算两个整数之间所有整数阶乘的和:
1 | def fact(x: Int): Int = if (x == 0) 1 else x * fact(x - 1) |
可以看出,这三个方法的大部分模式都是相同的,它们都是通过递归获得 a 到 b 的所有整数,通过某个方法进行转换,最后将转换得到的值进行累加。
那么就可以将这个转换的函数提取成为方法参数:
1 | def sum(f: Int => Int, a: Int, b: Int): Int = |
使用时在不同的实现中传入不同的函数即可:
1 | def id(x: Int): Int = x |
在 Scala 中, A => B
代表一个接受一个 A
类型参数,并返回一个 B
类型参数的方法。例如上文中的 Int => Int
代表将一个整数转换为另一个整数的方法。
在使用高阶函数时,不可避免的需要定义很多小函数,但是其实很多时候不需要通过 def
定义函数并为其起一个名字。
以字符串举例,当需要打印一个常量字符串时,以下的代码是多余的:
1 | def str = "ABC" |
它可以直接写为:
1 | println("ABC") |
就像字符串一样,函数也可以作为一个常量存在,它们被称为匿名函数(Anonymous Functions)。
上文中 cube
的匿名函数形式为:
1 | (x: Int) => x * x * x |
其中 (x: Int)
是该函数的参数,x * x * x
是该函数的函数体。
如果函数有多个参数,那么彼此之间需要用 ,
分隔,例如:
1 | (x: Int, y: Int) => x + y |
如果函数的类型可以通过上下文推断得出,那么是可以省略的。
一个匿名函数
1 | (x1 : T1, ..., xn : Tn) => E |
{ def f(x1 : T1, …, xn : Tn) = E; f }1
2
3
4
5
6
7
8
9
的表达式,其中 `f` 是一个任意的未被占用的名称。所以可以把匿名函数当做一个语法糖(Syntactic Sugar)。
通过匿名函数,上文中的方法又可以进一步简化:
```scala
def sumInts(a: Int, b: Int) = sum(x => x, a, b)
def sumCubes(a: Int, b: Int) = sum(x => x * x * x, a, b)
再次观察上面的函数,它们是否还有进一步优化的空间?
在上面的函数实现中,参数 a
和 b
都没有经过任何处理,而是直接传到了 sum
函数中,是否有更好的写法隐藏这些参数呢?
1 | def sum(f: Int => Int): (Int, Int) => Int = { |
这个重写的函数不再接受两个 Int 类型的参数,而是直接将另一个函数作为了返回值,这个返回的函数才接受两个 Int 类型参数,并返回最终的结果。
上文中的函数定义将会变得更加简单:
1 | def sumInts = sum(x => x) |
甚至可以避免定义这些中间变量,直接通过原始方法调用:
1 | sum(cube)(1, 10) |
在这当中 sum(cube)
返回了一个计算阶乘之和的方法,它和 sumCubes
是完全一样的,并且可以直接通过紧接着的 (1, 10)
参数调用这个方法。
在函数中返回另一个函数是非常有用的,为此 Scala 有一种特殊的语法:
1 | def sum(f: Int => Int)(a: Int, b: Int): Int = |
这段方法和上面返回 sumF
的实现几乎是一样的,但是写起来更简洁。
如果定义了一个含有多个参数列表的方法:
1 | def f(args1)...(argsn) = E |
它实际等同于:
1 | def f(args1)...(argsn−1) = { def g(argsn) = E; g } |
或是像匿名函数一样:
1 | def f(args1)...(argsn−1) = (argsn ⇒ E) |
往复替换 n 次之后,就会变为:
1 | def f = (args1 ⇒ (args2 ⇒ ...(argsn ⇒ E)...) |
这种风格被称为柯里化(Currying)。
最终定义的 sum
方法的类型为:
1 | (Int => Int) => (Int, Int) => Int |
以下定义中所用到的符号的含义为:
|
:替代关系[...]
0 或 1 个{...}
0 或 多个类型可以是:
Int => Int
或者 (Int, Int) => Int
表达式可以是:
x
或是 isGoodEnough
0
、1.0
或是 "abc"
sqrt(x)
-x
、x + y
math.abs
(这里不太懂 selection
是指的什么,该方法的内部实现是用的选择表达式?)if (x < 0) -x else x
{ val x = math.abs(y) ; x * 2 }
x => x + 1
定义可以是:
def square(x: Int) = x
val y = square(2)
其中参数可以是:
(x: Int)
(y: => Double)
本节通过一个例子介绍如何在 Scala 中使用函数创建和封装结构体。
一个分数由一个整数分子和另一个整数分母组成。如果需要计算两个分数的和,就需要定义两个如下的方法:
1 | def addRationalNumerator(n1: Int, d1: Int, n2: Int, d2: Int): Int |
但是这样做明显增加了代码的维护成本,一种更好的方式是将分子和分母共同维护在一个结构体中。
在 Scala 中,可以用下面这种方式定义一个类(Classes):
1 | class Rational(x: Int, y: Int) { |
这段定义包含了两部分:
Scala 会保证定义的名称和值在不同的命名空间(Namespace)中,所以多个 Rational 定义彼此之间不会冲突(?)
每个类型的元素被称为对象(Objects),通过 new
加上构造方法可以创建一个新的对象:
1 | new Rational(1, 2) |
每个 Rational 对象都有两个成员变量(Members):numer
和 denom
。通过 .
操作符可以获取对象的成员变量:
1 | val x = new Rational(1, 2) > x: Rational = Rational@2abe0e27 |
在拥有 Rational 对象之后,就可以对其定义一些计算函数了:
1 | def addRational(r: Rational, s: Rational): Rational = |
在此之上,还可以直接将函数抽象为结构体本身,这样的函数被称为方法(Methods)。
Rational 类本身就可以有 add
和 toString
方法:
1 | class Rational(x: Int, y: Int) { |
注意:toString
是由 java.lang.Object
继承而来的方法,所以需要加上 override
关键词。
这样调用时就可以变为:
1 | val x = new Rational(1, 3) |
在上面的例子中,可以发现通过计算而得出的分数有可能不是最简形态(例如 3/6
可以被约为 1/2
)。
为此我们可以在每一个计算分数的方法中都加入化简的逻辑,但是这会使代码难以维护,很有可能在某个计算中忘记加入这部分逻辑。
一个更好的办法是直接在构造分数时就进行化简:
1 | class Rational(x: Int, y: Int) { |
上面方法中的 gcd
和 g
都是私有成员,它们只能在该对象内部被访问到。
另一种方式是将 numer
和 denom
都声明为 val
,然后直接用 gcd
方法去计算,这样可以保证 numer
和 denom
只会初始化一次:
1 | class Rational(x: Int, y: Int) { |
上面两种方式对调用方都是无感知的,但是可以通过具体的情况选择不同的实现方案,这种方式称之为抽象(Abstraction)。
抽象是软件工程中的基石。
在类的内部可以使用 this
关键词指代当前执行方法的对象,也就是自引用(Self Reference)。
例如为 Rational 添加 less
和 max
方法:
1 | class Rational(x: Int, y: Int) { ... def less(that: Rational) = this.numer * that.denom < that.numer * this.denom |
假设 Rational 类要求分母必须是一个正整数,就可以通过 require
方法进行校验:
1 | class Rational(x: Int, y: Int) { require(y > 0, ”denominator must be positive”) ...} |
require
是一个预定义方法,它需要一个条件以及可选的提示信息。当条件为假时,将会抛出一个携带提示信息的 IllegalArgumentException
异常。
另一种校验的方式是使用断言(Assert),它同样接受一个条件和可选的提示信息,而当条件不满足时,它会抛出 AssertionError
异常。
两个异常的不同代表着这两种方式分别适合用于不同的场景:
require
适合在方法执行前校验外部传入的参数assert
用于校验方法执行过程中的逻辑在 Scala 中,类定义就会隐式的引入一个构造函数,它被称为主构造函数(Primary Constructor)。
构造函数的主要用途是:
除了主构造函数以外,还可以定义辅助构造函数,例如:
1 | class Rational(x: Int, y: Int) { def this(x: Int) = this(x, 1) ...}new Rational(2) > 2/1 |
在之前的笔记中有提到 Scala 的函数执行是通过一种称为代换模型的计算模型,在类和对象中也是如此。
当构建一个类实例 new C(e1, ..., em)
时,它的表达式参数依旧会像普通函数一样会被返回值所替代,成为 new C(v1, ..., vm)
。
假设有一个包含方法的类定义:
1 | class C(x1, ..., xm){ ... def f(y1, ..., yn) = b ... } |
它拥有类的形参 x1, ..., xn
和类实例方法的形参 y1, ..., yn
,那么当执行 new C(v1, ..., vm).f(w1, ..., wn)
时,这整个表达式会被重写为:
1 | [w1/y1, ..., wn/yn][v1/x1, ..., vm/xm][new C(v1, ..., vm)/this] b |
这里有三处地方被代换了:
w1, ..., wn
被代换为了方法 f
的形参 y1, ..., yn
v1, ..., vn
被代换为了类 C
的形参 x1, ..., xm
new C(v1, ..., vn)
被代换为了自引用 this
以 Rational 作为一个具体的例子,当调用以下方法时:
1 | new Rational(1, 2).less(new Rational(2, 3)) |
首先会进行代换:
1 | [1/x, 2/y] [newRational(2, 3)/that] [new Rational(1, 2)/this] |
于是该方法的实现:
1 | this.numer * that.denom < that.numer * this.denom |
就会被替换为:
1 | new Rational(1, 2).numer * new Rational(2, 3).denom < new Rational(2, 3).numer * new Rational(1, 2).denom |
最后可以轻松的计算出结果:
1 | 1 * 3 < 2 * 2 |
原则上来说,通过 Rational 定义的分数和整数没有什么区别,但是在使用时却有一些差异。
当我们想要计算两个整数的和时,只需要调用 x + y
,而当需要计算两个 Rational 的和时,却需要调用 r.add(s)
。
在 Scala 中,可以通过两步消除这种差异。
任何只有一个参数的方法都可以使用中缀运算符(Infix Operator)的方式进行调用:
1 | r add s = r.add(s) |
在 Scala 中标识符可以有两种形态:
_
)也算是字母的一种vector_++
所以 Rational 类中的部分方法可以通过运算符进行替换:
1 | class Rational(x: Int, y: Int) { |
这样使用时就可以像 Int 或是 Double 一样了:
1 | val x = new Rational(1, 2) |
运算符的优先级由其第一个字符决定,下图为优先级有低至高的运算符。
]]>这几年静态博客相关的方案已经有很多了,但是基本上都是基于 Markdown 去写文章,然后生成为 HTML 直接发布。
本来作为一个 Ruby 爱好者,再加上有 Github Pages 官方支持的加成,自然应该会去选择 Jekyll。但是无奈找了半天都找不到一款合适的主题,又懒得自己折腾,就选择了烂大街的 Hexo 和 NexT。这个主题功能很全面,配置起来很简单,非常适合我这种懒人。
关于如何安装 Hexo 以及相关配置就不在这里详述了,感兴趣可以直接去 Hexo 官网查看。
虽然我很早开始就通过 Markdown 写博客了,但是最开始在 WordPress 上找不到一个合适的 Markdown 渲染插件,一时脑残就选择了自己在本地渲染成 HTML 然后再通过 WordPress 发布,导致迁移的时候造成了不必要的麻烦。
我的文章迁移的流程为:
我通过一个 Ruby 脚本完成这一切,使用 oga
解析 XML,reverse_markdown
将 HTML 转为 Markdown,auto-correct
优化排版,整个脚本的代码大概如下(因为只是用一次所以写的比较随意):
1 | require 'oga' |
需要注意的是在将 HTML 转成 Markdown 后可能会出现一些边边角角的转义字符,上面的程序并没有列出这部分逻辑,需要自己观察博客并修改。
之前的图片是直接上传到 WordPress 资源库,用七牛云做 CDN,如果当初直接把图片丢到七牛也就没这破事了,但是我这次依旧没有把图片丢到七牛(主要就是懒,应该也不会再迁移第三回了)。
个人比较建议将所有图片都放在 source
下,也就是和 _posts
平级,这样当使用 MWeb 这样对其有支持的编辑器时,可以比较好的提供本地预览的支持
Hexo 官方推荐的另外一种做法是设置 post_asset_folder: true
然后将每个文章使用的资源都放在与文章同名的文件夹下。但是由于我需要将以前的图片迁移过来,这样做只会增加额外的迁移工作量。
而且这种做法据说还会带来图片在首页或归档页无法正常显示的问题,以至于兼容这个问题还需要在 Markdown 里人为的添加 Hexo 独有的标签,这太不清真了,果断拒绝。
在最开始就用了 Disqus,之后依旧会使用它,所以整个迁移过程很简单。
Disqus 是通过 URL 识别每篇文章的,官方也提供了非常丰富的迁移工具,如果只是域名发生了更改可以直接使用 Domain Migration Tool,如果文章的地址也发生了改变就需要使用 URL Mapper 了。
在博客完全替换之前,为了预览效果我给新博客分配了一个二级域名,而当我在博客迁移完、将主域名转到新博客后,却发现评论没有如预期的那样同步过来。
此时我在 Disqus 上正常评论是可以显示的,看后台导出的记录发现新评论依旧是挂在二级域名下,最后将所有评论都从主域名转到二级域名下,评论终于能够在新博客上默认显示了。
所以在此也建议在博客完全迁移完之前,不要进行 Disqus 相关的配置。
在文章的最初曾经提到 Github Pages 默认支持的引擎是 Jekyll,这意味着如果你使用 Jekyll 写博客,只需要将作为源文件的 Markdown 发布到 Github,就能自动跑一套 CI 生成静态页面,整个版本管理会简单很多。
很遗憾的是 Hexo 目前并不具备这种条件,这意味着只能先在本地生成静态页面,再将静态页面发布到 Github 上,而源文件没有任何版本控制,只有本地存储了一份。
目前我在同一仓库中又建立了一个 source
分支,每次写好博客后,先会通过普通 Git 操作流程将源文件发布到该分支,然后再通过 hexo g -d
将最新的页面发布到 master
分支。也有朋友建议我提交源文件后通过第三方 CI 进行发布,但是我觉得写博客作为一个低频行为(至少对于我来说是低频行为),不值得搞得这么复杂。
最后,虽然还有很多东西没有弄(统计、计数、CDN),但是至少可以开始产出文章了,愿这个新平台给我带来更良好、更高效的写作体验。
]]>