做者 | 陈洁(墨封) 阿里云开发工程师node
导读: etcd 做为 K8s 集群中的存储组件,读写性能方面会受到不少压力,而 etcd 3.4 中的新特性将有效缓解压力,本文将从 etcd 数据读写机制的发展历史着手,深刻解读 etcd 3.4 新特性。git
etcd 是 Kubernetes 集群中存储元数据,保证分布式一致性的组件,它的性能每每影响着整个集群的响应时间。而在 K8s 的使用中,咱们发现除了平常的读写压力外,还存在某些特殊的场景会对 etcd 形成巨大的压力,好比 K8s 下 apiserver 组件重启或是其余组件绕过 apiserver cache 直接查询 etcd 最新数据的状况时,etcd 会收到大量的 expensive read(后文会介绍该概念)请求,这对 etcd 读写会形成巨大的压力。更为严重的是,若是客户端中存在失败重试逻辑或客户端数目较多,会产生大量这样的请求,严重状况可能形成 etcd crash。github
etcd 3.4 中增长了一个名为“Fully Concurrent Read”的特性,较大程度上解决了上述的问题。在这篇文章中咱们将重点解读它。本篇文章首先回顾 etcd 数据读写机制发展的历史,以后剖析为什么这个特性能大幅提高 expensive read 场景下 etcd 的读写性能,最后经过真实实验验证该特性的效果。算法
etcd 利用 Raft 算法实现了数据强一致性,它保证了读操做的线性一致性。在 raft 算法中,写操做成功仅仅觉得着写操做被 commit 到日志上,并不能确保当前全局的状态机已经 apply 了该写日志。而状态机 apply 日志的过程相对于 commit 操做是异步的,所以在 commit 后当即读取状态机可能会读到过时数据。api
为了保证线性一致性读,早期的 etcd(_etcd v3.0 _)对全部的读写请求都会走一遍 Raft 协议来知足强一致性。然而一般在现实使用中,读请求占了 etcd 全部请求中的绝大部分,若是每次读请求都要走一遍 raft 协议落盘,etcd 性能将很是差。缓存
所以在 etcd v3.1 版本中优化了读请求(PR#6275),使用的方法知足一个简单的策略:每次读操做时记录此时集群的 commit index,当状态机的 apply index 大于或者等于 commit index 时便可返回数据。因为此时状态机已经把读请求所要读的 commit index 对应的日志进行了 apply 操做,符合线性一致读的要求,即可返回此时读到的结果。微信
根据 Raft 论文 6.4 章的内容,etcd 经过 ReadIndex 优化读取的操做核心为如下两个指导原则:网络
ReadIndex 同时也容许了集群的每一个 member 响应读请求。当 member 利用 ReadIndex 方法确保了当前所读的 key 的操做日志已经被 apply 后,即可返回客户端读取的值。对 etcd ReadIndex 的实现,目前已有相对较多的文章介绍,本文再也不赘述。并发
即使 etcd v3.1 中经过 ReadIndex 方法优化了读请求的响应时间,容许每一个 member 响应读请求,但当咱们把视角继续下移到底层 k/v 存储 boltdb 层,每一个独立的 member 在获取 ReadIndex 后的读取任然存在性能问题。mvc
v3.1 中利用 batch 来提升写事务的吞吐量,全部的写请求会按固定周期 commit 到 boltDB。当上层向底层 boltdb 层发起读写事务时,都会申请一个事务锁(如如下代码片断),该事务锁的粒度较粗,全部的读写都将受限。对于较小的读事务,该锁仅仅下降了事务的吞吐量,而对于相对较大的读事务(后文会有详细解释),则可能阻塞读、写,甚至 member 心跳都有可能出现超时。
// release-3.2: mvcc/kvstore.go
func (s *store) TxnBegin() int64 {
...
s.tx = s.b.BatchTx()
// boltDB 事务锁,全部的读写事务都须要申请该锁
s.tx.Lock()
...
}
复制代码
针对以上提到的性能瓶颈,etcd v3.2 版本中对 boltdb 层读写进行优化,包含如下两个核心点:
// release-3.3: mvcc/kvstore_txn.go
func (s *store) Read() TxnRead {
tx := s.b.ReadTx()
// 获取读事务的 RLock 后进行读操做
tx.RLock()
}
// release-3.3: mvcc/backend/batch_tx.go
func (t *batchTxBuffered) commit(stop bool) {
// 获取读事务的 Lock 以确保 commit 以前全部的读事务都已经被关闭
t.backend.readTx.Lock()
t.unsafeCommit(stop)
t.backend.readTx.Unlock()
}
复制代码
etcd v3.2 的读写优化解决了大部分读写场景的性能瓶颈,但咱们再从客户端的角度出发,回到文章开头咱们提到的这种场景,仍然有致使 etcd 读写性能降低的危险。
这里咱们先引入一个 **expensive read **的概念,在 etcd 中,全部客户端的读请求最后都是转化为 range 的请求向 KV 层进行查询,咱们以一次 range 请求的 key 数量以及 value size 来衡量一次 read 请求的压力大小。综合而言,当 range 请求的 key 数量越多,平均每一个 key 对应的 value size 越大,则该 range 请求对 DB 层的压力就越大。而实际划分 expensive read 和 cheap read 边界视 etcd 集群硬件能力而定。
从客户端角度,在大型集群中的 apiserver 进行一次 pod、node、pvc 等 resource 的全量查询,能够视为一次 expensive read。简要分析下为什么 expensive read 会对 boltDB 带来压力。上文提到,为了防止脏读,须要保证每次 commit 时没有读事务进行,所以写事务每次 commit 以前,须要将当前全部读事务进行回滚,因此 commit interval 时间点上须要申请 readTx.lock
,会将该锁从 RLock()
升级成 Lock()
,该读写锁的升级会可能致使全部读操做的阻塞。
以下图(如下图中,蓝色条为读事务,绿色条为写事务,红色条为事务因锁问题阻塞),t1 时间点会触发 commit,然而有事务未结束,T5 commit 事务因申请锁被阻塞到 t2 时间点才进行。理想状态下大量的写事务会在一个 batch 中结束,这样每次 commit 的写事务仅仅阻塞少部分的读事务(如图中仅仅阻塞了 T6 这个事务)。
然而此时若是 etcd 中有很是大的读请求,那么该读写锁的升级将被频繁阻塞。以下图,T3 是一个很是长的读事务,跨过了多个 commit batch。每一个 commit batch 结束时间点照常触发了 commit 的写事务,然而因为读写锁没法升级,写事务 T4 被推迟,一样 t2 commit 点的写事务 T7 由于申请不到写锁同样也被推迟。
此外,在写事务的 commit 进行了以后,须要将写缓存里的 bucket 信息写入到读缓存中,此时一样须要升级 readTx.lock
到 Lock()
。而上层调用 backend.Read()
获取 readTx 时,须要确保这些 bucket 缓存已经成功写过来了,须要申请读锁 readTx.RLock()
,而若是这期间存在写事务,该锁则没法获得,这些读事务都没法开始。如上的情形下,在第三个 batch(t2-t3)中其余读事务由于得不到读锁都没法进行了。
总结而言,因 expensive read 形成读写锁频繁升级,致使写事务的 commit 不断被后移(一般咱们将这种问题叫作 head-of-line blocking),从而致使 etcd 读写性能雪崩。
etcd v3.4 中,增长了一个** “Fully Concurrent Read” **的 feature,核心指导思想是以下两点:
readTxn
,而是建立一个新的 concurrentReadTxn
实例去服务新的读请求,而原来的 readTxn
在全部事务结束后会被关闭。每一个 concurrentReadTxn
实例拥有一片本身的 buffer 缓存。除了以上两点变更外,fully concurrent read 在建立新的 ConcurrentReadTx
实例时须要从 ReadTx
copy 对应的 buffer map,会存在必定的额外开销,社区也在考虑将这个 copy buffer 的操做 lazy 化,在每一个写事务结束后或者每一个 batch interval 结束点进行。然而在咱们的实验中发现,该 copy 带来的影响并不大。改动的核心代码如如下片断所示:
// release-3.4: mvcc/backend/read_tx.go
type concurrentReadTx struct {
// 每一个 concurrentReadTx 实例保留一份 buffer,在建立时从 readTx 的 buffer 中得到一份 copy
buf txReadBuffer
...
}
// release-3.4: mvcc/backend/backend.go
func (b *backend) ConcurrentReadTx() ReadTx {
// 因为须要从 readTx 拷贝 buffer,建立 concurrentReadTx 时须要对常驻的 readTx 上读锁。
b.readTx.RLock()
defer b.readTx.RUnlock()
...
}
// release-3.4: mvcc/backend/read_tx.go
// concurrentReadTx 的 RLock 中不作任何操做,再也不阻塞读事务
func (rt *concurrentReadTx) RLock() {}
// release-3.4: mvcc/kvstore_tx.go
func (s *store) Read() TxnRead {
// 调用 Read 接口时,返回 concurrentReadTx 而不是 readTx
tx := s.b.ConcurrentReadTx()
// concurrentReadTx 的 RLock 中不作任何操做
tx.RLock()
}
复制代码
咱们再回到上文提到的存在 expensive read 的场景。在 fully concurrent read 的改动以后,读写场景以下图所示。
首先在 mvcc 建立 backend 时会建立一个常驻的 readTx
实例,和以后的写事务 batchTx
存在锁冲突的也仅仅只有这一个实例。以后的全部读请求(例如 T1,T2,T3 等),会建立一个新的 concurrentReadTx
实例进行服务,同时须要从 readTx
拷贝 buffer;当出现 expensive read 事务 T3 时,T4 再也不被阻塞并正常执行。同时 T5 须要等待 T4 commit 完成后, readTx
的 buffer 被更新后,再进行 buffer 拷贝,所以阻塞一小段时间。而 t二、t3 commit 时间点的写事务 T七、T8 也由于没有被阻塞而顺利进行。
在 fully concurrent read 的读写模式下, concurrentReadTx
仅在建立时可能存在阻塞(由于依赖从 readTx
进行 buffer 拷贝的操做),一旦建立后则再也不有阻塞的状况,所以整个流程中读写吞吐量有较大的提高。
针对 etcd v3.4 fully concurrent read 的新 feature,咱们在集群中进行了实验对比增长该 feature 先后读写性能的变化。为了排除网络因素干扰,咱们作了单节点 etcd 的测试,可是已经足以从结果上看出该 feature 的优点。如下是验证明验的设置:
实验结果以下表所示。对于普通读写场景,3.4 中的读写性能和 3.3 近似;对于存在较重的读事务的场景,3.4 中的 fully concurrent read feature 必定程度下降了 expensive read 的响应时间。而在该场景下的 cheap read 和 write,rc2 中因读写锁致使读写速度很是缓慢,而 rc3 中实现的彻底并行使得读写响应时间减小到约为原来的 1/7。
| etcd
version | cheap
read + write | | expensive
read + cheap read + write | ||
---|---|---|
p99 read (ms) | p99 write (ms) | |
read (ms) | p99 cheap read (ms) | p99 write (ms) |
3.3 | 14.1 | 15.1 |
3.4 (with FCR) | 16.1 | 14.2 |
其余场景下,如在 Kuberentes 5000节点性能测试,也代表在大规模读压力下,P99 写的延时下降 97.4%。
etcd fully concurrent read 的新 feature 优化 expensive 下降了近 85% 的写响应延迟以及近 80% 的读响应延迟,同时提升了 etcd 的读写吞吐量,解决了在读大压力场景下致使的 etcd 性能骤降的问题。调研和实验的过程当中感谢宇慕的指导,目前咱们已经紧跟社区应用了该新能力,通过长时间测试表现稳定。将来咱们也会不断优化 etcd 的性能和稳定性,并将优化以及最佳实践经验反馈回社区。
“ 阿里巴巴云原生微信公众号(ID:Alicloudnative)关注微服务、Serverless、容器、Service Mesh等技术领域、聚焦云原生流行技术趋势、云原生大规模的落地实践,作最懂云原生开发者的技术公众号。”