我第一次被分布式系统真正“教育”,不是因为读不懂 Paxos,而是因为一把 Redis 锁。

那是做一个账户余额扣减的服务,扣款前用 SETNX 加锁,过期时间设了 30 秒。压测的时候 QPS 一上去,偶发出现同一笔订单扣两次。查了两天才定位到:某台机器做了一次 Full GC,STW 停了大概 11 秒,业务线程恢复之后接着往下跑,但它手里那把锁其实早就过期了,另一个线程已经拿到锁,开始处理同一笔订单。日志里两条扣款记录的时间戳差了不到 200 毫秒。
那次之后我改了一个习惯:看分布式系统的代码,第一眼不看它用了什么共识算法,先看它在哪里依赖了“时间”。
时间在单机上是理所当然的,在多机上基本是个骗局。
Linux 上有两个时钟,CLOCK_REALTIME 是墙上时钟,NTP 会调它,可以往前也可以往后跳;CLOCK_MONOTONIC 只增不减,但重启归零,而且两台机器的 MONOTONIC 之间没有任何关系。Java 里对应的是 System.currentTimeMillis() 和 System.nanoTime(),前者会跳,后者的差值才有意义。算超时、算耗时用 currentTimeMillis,迟早要出事。
再往前一步,是硬件本身的漂移。普通服务器晶振精度大概在 10~100 ppm,取个中间值 50 ppm,一天下来就是 4.3 秒。所以 NTP 客户端默认 64 秒轮询一次,就是要把这个漂移拉回来。但拉回来的过程是“调整”不是“瞬移”,偏差大于 128 毫秒时 ntpd 默认会直接 step 一次——时间是真的会往回跳的。
Snowflake 生成的 ID 为什么怕时钟回拨?因为它那 41 位就是毫秒时间戳,时间倒流 1 秒,就可能生成一批和历史重复的 ID。美团 Leaf、百度 UidGenerator 都专门写了处理回拨的逻辑,短的等一等,长的直接抛错。
分布式锁失效,本质上不是锁的问题,是“租约”和“执行时间”没有绑定。
Martin Kleppmann 在 2016 年 2 月写过一篇 How to do distributed locking,点名批评 Redis 的 Redlock 算法;antirez 随后写了长文反驳。争的表面是 Redlock 安不安全,实际是:你到底拿锁当“性能优化”还是当“正确性保证”。
如果是前者——比如避免多个 worker 重复算同一个缓存——那把锁过期了、丢了,问题不大,重新算一遍而已。如果是后者——比如保证同一笔账户只被扣一次——那你的锁必须满足两个条件:第一,加锁本身走共识系统(etcd、ZooKeeper),不是靠 Redis 主从异步复制;第二,被保护的资源要能识别“过期的锁持有者”。
第二个条件就是 fencing token。Chubby 论文里叫 sequencer,做法是每次加锁返回一个单调递增的编号,客户端写存储的时候带上它,存储端只接受比已经见过的更大的编号,小的直接拒绝。这样就算第一个客户端 GC 卡了 11 秒之后才回来写,它的 token 也已经旧了,写不进去。
几种常见方案的取舍,我把关键参数列一下。
Redis 单实例 + SETNX:加锁是单点,从库异步复制,主挂了切从,锁可能丢。只适合“重复执行无副作用”的场景。
Redlock:要向 5 个独立 Redis 实例里凑多数派加锁,但每个实例自己的持久化和时钟仍然不可靠,Kleppmann 质疑的就是这一点。
ZooKeeper 临时顺序节点:session timeout 最小是 2 倍 tickTime,tickTime 默认 2000 毫秒,也就是最小 4 秒,意味着节点挂了要 4 秒以上才能被发现。它的 zxid 单调递增,天然可以当 fencing token。
etcd lease + revision:Raft 的 election timeout 默认 1000 毫秒,heartbeat 100 毫秒;lease 能设到秒级;revision 是集群级别单调递增的,也能做 token。但要注意 etcd 有 quota,默认 2GB,到阈值会告警并拒绝写,lease 堆太多会占资源。
数据库行锁 SELECT ... FOR UPDATE:最简单,但事务持锁期间连接会被占住,并发度上不去。没有哪个方案是“最好的”,只有“你的失败代价能不能接受”。
我有个观点可能有点反常识:分布式系统的设计目标,不是“把一致性做强”,而是“把需要一致性的状态压到最小”。
大部分人一聊分布式就开始比较 Paxos 和 Raft,但真正在生产里出事的地方,往往不是共识算法本身。GitHub 2018 年 10 月 21 日那次事故持续了 24 小时 11 分钟,根源不是 Raft 写错了,而是 Orchestrator 和 Raft 在“谁是主库”这件事上产生了分歧,结果两侧同时接受写入,约 5% 的写操作出现了不一致。
Spanner 用 TrueTime 把这种不确定性摊到台面上,论文里说 ε 通常只有 1~7 毫秒,靠 GPS 和原子钟把误差窗口压住,然后用“等待”换线性一致性。这思路其实很奢侈,绝大多数公司没有原子钟,也不需要线性一致。
所以更实际的做法是:能幂等的操作就别加锁;必须互斥的,用带 token 的锁;能拆成“事件 + 补偿”的,就别硬上分布式事务。
另一个被低估的事实:大部分“节点挂了”其实是“节点慢了”。
一次 Full GC、一次 CPU cgroup 的 throttling(默认周期 100 毫秒,配额用完就要等到下个周期)、一次磁盘 IO 抖动、一次虚拟机被宿主机偷走 CPU,都能让一个节点在几秒内完全不像活着。但它的 TCP 连接还在,心跳还在发,只是发得慢。
所以失败检测器不好写。静态阈值——比如 3 秒没心跳就踢——碰上一次 4 秒的 GC 就误判,碰上真挂的节点又反应太慢。Cassandra 用的是 Phi Accrual Failure Detector,它不判断“活着还是死了”,而是输出一个“怀疑程度”,默认阈值 phi_convict_threshold = 8,还会基于历史心跳间隔的分布动态算。这个思路我觉得比固定超时值得借鉴:把“是不是挂了”变成一个概率问题,而不是二值判断。
最后是我自己现在写这类代码的几条规矩,没什么技术含量,但很省事:
用 System.nanoTime() 而不是 currentTimeMillis() 算超时和耗时。
锁的有效期永远设得比业务最长执行时间长,宁可长一点,也不要刚刚好。
所有可能被重复执行的操作,先问自己“执行两次会怎样”,答案不明确就先加幂等键。
跨节点排序、互斥、抢主,一律用共识系统的 revision / zxid 当 token,不要用本地时间戳。
在线的机器、容器、虚拟机,确保 NTP 或 chrony 是活的,监控里加一条时钟偏差告警,超过 100 毫秒就报警。
分布式系统难的地方从来不是“怎么让两台机器达成一致”,而是“你怎么知道其中一台现在到底在干嘛”。