先把翻车现场摆出来
去年三季度压测,订单服务里有个防重复提交的分布式锁,锁存在 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 bar 再 get 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 卡有没有电池,而不是协议实现。
那实际怎么选
我不给「看场景」这种废话,给几条我自己的判断:
- 已经在 K8s 里跑着,别犹豫,etcd。 原因不全是技术,是生态。Operator、client-go、CRD 全绑在 etcd 上,你再引一套 ZK 进来,运维面直接翻倍。
- 团队清一色 Java,没人碰 Go,选 ZK。 Curator 的
InterProcessMutex、LeaderLatch、DistributedBarrier都是开箱即用,比 etcd 的concurrency包成熟。ZK 的中文运维资料多到烂大街,出问题搜得到答案,这在凌晨三点比什么都值钱。 - 要做服务发现 + 健康检查 + 多机房,选 Consul。 它的 Serf gossip 层探测节点存活,比 etcd 那种硬 TTL 的 lease 在网络抖动下宽容得多。etcd 的 lease 是到点就回收,GC 一卡,你的锁就没了。
- 写入 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 文件。