etcd 和 ZooKeeper 选型对比:压崩两次集群后,说点官网不会写的

🔑 关键词:分布式系统,etcd,ZooKeeper,Raft,选型对比

📖 摘要:从一次 etcd 集群被压崩说起(etcdserver: mvcc: database space exceeded),对比 etcd、ZooKeeper、Consul 在 watch 语义、租约机制、存储压缩上的真实差异,附具体参数默认值和同机房实测延迟数据,最后给几条不那么正确的选型建议。

先把翻车现场摆出来

图片

去年三季度压测,订单服务里有个防重复提交的分布式锁,锁存在 etcd 上。平时 QPS 2000 出头,压测拉到 12000,三个 etcd 节点四十秒内全挂。

日志刷屏就那么三行:

etcdserver: mvcc: database space exceeded
etcdserver: request timed out
rafthttp: failed to find member 8e9e05c52164694d in cluster

第一个是根因。当时用的是 etcd 3.3,--quota-backend-bytes 没人动过,默认 2GB,--auto-compaction-retention 默认是 0,也就是从不压缩。我们的锁 key 写成 lock/order/{orderId},写完就删,看着很干净——可 MVCC 把每次写入产生的旧版本都留在 boltdb 文件里,del 删掉的只是逻辑视图。四个月下来 backend db 涨到 1.9GB,再写一条就撑爆了。

坦白说那会儿我对 etcd 的全部理解就是「K8s 用的那个 KV」。教程里都是 etcdctl put foo barget foo,谁能想到还要配压缩。第二个错误是连锁的:quota 一满 etcd 进入 NOSPACE 告警状态,所有写请求直接报错,锁拿不到,业务线程全卡在重试上,CPU 打满,监控上看就像「etcd 挂了」。

图片

修的方式不难,但每条都是踩出来的:

etcd \
  --auto-compaction-mode=periodic \
  --auto-compaction-retention=1h \
  --quota-backend-bytes=$((8*1024*1024*1024))

还有一条更关键:别再给每个锁单独建 lease。我们原来每个进程给每把锁续约,12000 QPS 下就是每秒上万次 lease keepalive 请求,而 --max-request-bytes 只有 1.5MB,请求队列直接堵死。改成每个客户端一个长 lease,所有锁挂在同一个 lease 下面,续约请求从每秒上万掉到每秒几十。

三个东西到底在比什么

图片

网上讲选型的文章,十个里有八个在复读 CAP,说 etcd 是 CP,ZK 也是 CP,Consul 也是 CP。那还比什么。我按自己真会踩的维度拉了个表:

维度 etcd 3.5 ZooKeeper 3.8 Consul 1.17
共识协议 Raft ZAB Raft + Serf gossip
数据模型 平铺 KV,前缀扫描 树形 znode 平铺 KV
Watch 语义 流式,带 revision,可重放 一次性,触发即失效 blocking query 长轮询
会话机制 Lease,TTL 建议 ≥ 1s Session,超时按 tickTime 倍数 Session
运行语言 Go,GC 停顿通常 < 1ms Java,堆调不好就是灾难 Go
单条上限 1.5MB (--max-request-bytes) 1MB (jute.maxbuffer) 无硬限
默认自动压缩 无,必须手配 autopurge 默认关

Watch 那一行的差异,我认为比协议本身重要得多。

ZK 的 watch 是一次性的:你 getData(path, true),节点变一次,回调触发,然后这个 watch 就死了。你必须在回调里重新注册。中间那几十毫秒的窗口里发生的变化你就漏了——所以 ZK 上的配置中心基本都得配合 Stat.getVersion() 做全量兜底,逻辑写起来比 etcd 啰嗦。我第一次上 ZK 做配置推送的时候,半夜发现某台机器的配置没更新,查了两个小时才想起来是这回事。

etcd 的 watch 是流式的:给一个 rev,从那个版本之后的所有事件都会顺序吐给你,只要没被 compaction 掉。这个确实好用,代价就是前面说的 MVCC 存储膨胀——你想要历史可重放,就得付磁盘钱。

图片

我的观点:决定 p99 的是 fsync,不是协议

写请求在 Raft 和 ZAB 里的路径其实差不多:leader 追加 WAL → fsync → 广播给 follower → 等多数派 ack → commit → 应用到状态机 → 返回客户端。

这条链路上,跟算法无关的两件事吃掉了绝大部分时间:一次 fsync,一次网络往返

我们在自己机器上测过一组,三节点同机房,8 个并发客户端,value 128 字节,读写各半:

图片

  • WAL 放 SATA SSD(860 EVO):写 p99 大约 14ms
  • WAL 放 Optane P4800X:写 p99 大约 3.5ms
  • --heartbeat-interval 从 100ms 调到 50ms:p99 变化在 ±0.3ms,基本是噪声

也就是说,把协议从 Raft 换成 ZAB,只要硬件不动,你的 p99 不会有数量级差别。但把 fsync 的盘从 SATA 换成 NVMe,能差 4 倍。

所以每次看到有人写「我们把 etcd 优化到 p99 1ms」,我第一反应都是问他 WAL 是不是单独挂了一块盘。大概率是。这个结论带来一个挺反直觉的选型建议:写入压力大的时候,你真正该关心的是那台机器有几块盘、RAID 卡有没有电池,而不是协议实现。

那实际怎么选

我不给「看场景」这种废话,给几条我自己的判断:

图片

  1. 已经在 K8s 里跑着,别犹豫,etcd。 原因不全是技术,是生态。Operator、client-go、CRD 全绑在 etcd 上,你再引一套 ZK 进来,运维面直接翻倍。
  2. 团队清一色 Java,没人碰 Go,选 ZK。 Curator 的 InterProcessMutexLeaderLatchDistributedBarrier 都是开箱即用,比 etcd 的 concurrency 包成熟。ZK 的中文运维资料多到烂大街,出问题搜得到答案,这在凌晨三点比什么都值钱。
  3. 要做服务发现 + 健康检查 + 多机房,选 Consul。 它的 Serf gossip 层探测节点存活,比 etcd 那种硬 TTL 的 lease 在网络抖动下宽容得多。etcd 的 lease 是到点就回收,GC 一卡,你的锁就没了。
  4. 写入 QPS 超过 5000,三个都别用。 共识协议天生不适合这个量级,每笔写都要多数派 fsync。这种场景要么用 Redis 加定期对账,要么把唯一性约束直接丢给数据库的唯一索引,让数据库自己去扛。

几个真会让人半夜爬起来的参数

  • etcd 的 --election-timeout 默认 1000ms,官方建议是 10 × heartbeat-interval,而且必须大于 10 倍的节点间 RTT。跨机房部署时这个值不调到 3000ms 以上,网络抖一下就开始重新选举,选主期间整个集群写不可用。我们有个北京-上海的集群一开始没调,一周内重新选主 17 次。
  • ZK 的 jute.maxbuffer 默认 1048575 字节(1MB 减一),而且客户端和服务端都要设。只改一边会出现「服务端能写,某个客户端读不出来」这种查半天的怪问题。
  • ZK 的 syncLimit 默认 5,含义是 follower 和 leader 的 ack 超时上限为 syncLimit × tickTime。tickTime 默认 2000ms,也就是 10 秒,超了就被踢出集群;踢出去之后 initLimit 又得重新同步一遍全量数据。
  • etcd 官方建议 backend db 别超过 8GB。不是说超过就坏,是超过之后 defrag 要跑很久,而且 bolt 的 page 管理开始拉胯。
  • 不管哪套,fsync 千万别关。关了之后 p99 会漂亮得像假数据,直到机房断电那天。

最后说句可能不中听的:大部分团队纠结 etcd 还是 ZK 的时候,其实两个都够用,问题在别处。我们那个集群崩掉,不是因为选错了协议,是因为没人去看过那个已经 1.9GB 的 db 文件。

🏷️ 标签: