降低 80% 的读写响应延迟!我们测评了 etcd 3.4 新特性(内含读写发展史)
作者 | 陈洁(墨封) 阿里云开发工程师
导读:etcd 作为 K8s 集群中的存储组件,读写性能方面会受到很多压力,而 etcd 3.4 中的新特性将有效缓解压力,本文将从 etcd 数据读写机制的发展历史着手,深入解读 etcd 3.4 新特性。
背景
etcd 是 Kubernetes 集群中存储元数据,保证分布式一致性的组件,它的性能往往影响着整个集群的响应时间。而在 K8s 的使用中,我们发现除了日常的读写压力外,还存在某些特殊的场景会对 etcd 造成巨大的压力,比如 K8s 下 apiserver 组件重启或是其他组件绕过 apiserver cache 直接查询 etcd 最新数据的情况时,etcd 会收到大量的 expensive read(后文会介绍该概念)请求,这对 etcd 读写会造成巨大的压力。更为严重的是,如果客户端中存在失败重试逻辑或客户端数目较多,会产生大量这样的请求,严重情况可能造成 etcd crash。
etcd 3.4 中增加了一个名为“Fully Concurrent Read”的特性,较大程度上解决了上述的问题。在这篇文章中我们将重点解读它。本篇文章首先回顾 etcd 数据读写机制发展的历史,之后剖析为何这个特性能大幅提升 expensive read 场景下 etcd 的读写性能,最后通过真实实验验证该特性的效果。
etcd 读写发展历史
etcd v3.0 及之前早期版本
etcd 利用 Raft 算法实现了数据强一致性,它保证了读操作的线性一致性。在 raft 算法中,写操作成功仅仅以为着写操作被 commit 到日志上,并不能确保当前全局的状态机已经 apply 了该写日志。而状态机 apply 日志的过程相对于 commit 操作是异步的,因此在 commit 后立即读取状态机可能会读到过期数据。
为了保证线性一致性读,早期的 etcd(etcd v3.0 )对所有的读写请求都会走一遍 Raft 协议来满足强一致性。然而通常在现实使用中,读请求占了 etcd 所有请求中的绝大部分,如果每次读请求都要走一遍 raft 协议落盘,etcd 性能将非常差。
etcd v3.1
因此在 etcd v3.1 版本中优化了读请求(PR#6275),使用的方法满足一个简单的策略:每次读操作时记录此时集群的 commit index,当状态机的 apply index 大于或者等于 commit index 时即可返回数据。由于此时状态机已经把读请求所要读的 commit index 对应的日志进行了 apply 操作,符合线性一致读的要求,便可返回此时读到的结果。
根据 Raft 论文 6.4 章的内容,etcd 通过 ReadIndex 优化读取的操作核心为以下两个指导原则:
- 让 Leader 处理 ReadIndex 请求,Leader 获取的 commit index 即为状态机的 read index,follower 收到 ReadIndex 请求时需要将请求 forward 给 Leader;
- 保证 Leader 仍然是目前的 Leader,防止因为网络分区原因,Leader 已经不再是当前的 Leader,需要 Leader 广播向 quorum 进行确认。
ReadIndex 同时也允许了集群的每个 member 响应读请求。当 member 利用 ReadIndex 方法确保了当前所读的 key 的操作日志已经被 apply 后,便可返回客户端读取的值。对 etcd ReadIndex 的实现,目前已有相对较多的文章介绍,本文不再赘述。
etcd v3.2
即便 etcd v3.1 中通过 ReadIndex 方法优化了读请求的响应时间,允许每个 member 响应读请求,但当我们把视角继续下移到底层 k/v 存储 boltdb 层,每个独立的 member 在获取 ReadIndex 后的读取任然存在性能问题。
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 层读写进行优化,包含以下两个核心点:
- 实现“N reads 或 1 write”的并行,将上文提到的粗粒度锁细化成一个读写锁,所有读请求间相互并行;
- 利用 buffer 来提高了吞吐量。3.2 中对 readTx,batchTx 分别增加了一个 buffer,所有读事务优先从 buffer 进行读取,未命中再通过事务访问 boltDB。同样,写事务在写 boltDB 的同时,也会向 batchTx 的 buffer 写入数据,而 batch commit 结束时,batchTx 的 buffer 会 writeBack 回 readTx 的 buffer 防止脏读。
// 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 这个事务)。