"加了分布式锁还是有并发问题"——多数情况不是锁实现有 bug,而是锁保护的范围和业务不变量不重合。锁能保证"同一时刻只有一个人在跑",保证不了"跑完的结果正确"。
更值得先问的是另一句:这个场景到底需不需要互斥?分布式锁是四种无锁方案都不成立时的兜底,不是并发问题的默认答案。
1. 三件事与各自的失效方式
| 要保证的 | 含义 | 失效方式 | 失效后的表现 |
|---|---|---|---|
| 互斥 | 同一时刻只有一个持有者 | 加锁非原子、主从切换丢锁 | 偶发重复执行,难复现 |
| 防死锁 | 持有者崩溃后锁终会释放 | TTL 过长或没有 TTL | 业务永久卡死,需人工删 key |
| 防误删 | 只删自己加的锁 | 解锁不校验持有者 | 删掉别人的锁,互斥失效 |
三者是独立的:原子加锁不保证不误删,有 TTL 不保证互斥。很多"锁没生效"的现场,实际是第 1 条与第 3 条同时破了,表现为"偶发并发写"。
2. 四种实现的正确性前提
| 实现 | 正确性前提 | 失效时的后果 | 代价 |
|---|---|---|---|
| Redis 单实例 | 单点不挂、时钟不跳变 | 实例挂 → 全部锁丢失 | 低,一次 RTT |
| Redlock(多实例多数派) | 各实例时钟近似同步、网络延迟远小于 TTL | 时钟漂移或长时间 GC → 互斥可能失效 | 中,N 次 RTT |
| ZooKeeper / etcd | 依赖共识层,会话与临时节点语义 | 会话超时误判 → 同锁双持有 | 中高,需维护集群 |
| 数据库行锁 | 事务边界清晰、隔离级别足够 | 长事务锁表、连接耗尽 | 低(复用现有库),但耦合 DB |
判断顺序不是"哪个更快",而是"我能接受哪种失效"。Redis 单实例的失效模式是"实例挂掉后锁全丢",对账、去重这类场景可以接受;资金类操作不能接受,那就得往共识型实现走,代价是运维面变大。
3. "锁过期但业务没做完"是本质缺陷
这条不是配置问题。TTL 是对业务耗时的猜测,而业务耗时是分布,不是常量;只要分布有长尾,就一定存在"锁已过期、业务仍在写"的窗口。
| 处置 | 效果 | 遗留问题 |
|---|---|---|
| 把 TTL 调大 | 降低概率 | 崩溃后锁滞留更久,可用性下降 |
| 加看门狗续期 | 覆盖长耗时 | 进程挂掉就没人续;续期与业务不同步 |
| 业务内自检"锁是否仍属于我" | 能在写前发现易主 | 检查与写之间仍有间隙 |
| fencing token(单调递增令牌) | 下游拒绝旧令牌的写 | 需要下游配合校验 |
只有最后一条把问题从"概率"变成"不可能":锁服务发放单调递增的令牌,下游存储拒绝比已见令牌更小的写。前三条都在缩小窗口,第四条才关闭窗口。
4. 续期与网络分区
续期循环的本质问题:
T0 加锁,TTL=30s,启动续期线程
T10 续期成功
T20 进程被 STW / 网络分区 / 容器被暂停 → 续期线程同样停摆
T30 锁过期,另一实例获得锁
T31 旧进程恢复,仍以为自己是持有者,继续写
| 场景 | 续期是否还活着 | 结果 |
|---|---|---|
| 业务慢但进程健康 | 是 | 锁被续住,另一个实例一直等 |
| 进程崩溃 | 否 | 锁到期释放,符合预期 |
| 长时间 GC / CPU 饥饿 | 否(续期线程也停) | 双持有窗口,最危险 |
| 网络分区(锁服务不可达) | 否 | 视实现:可能本地仍认为持有 |
看门狗救不了第三种:续期线程和被暂停的业务线程在同一个进程里。这也是为什么"TTL + 续期"只能算缓解手段,不能作为正确性论证。
5. 误删的根因与 fencing
误删的机制是一条时间线,不是一次失误:
A 加锁(TTL 30s) → A 卡住 → 锁过期 → B 加锁成功
→ A 恢复,执行 del key → 删掉的是 B 的锁
# 加锁:value 必须是本次持有者的唯一标识,而不是常量
redis-cli SET lock:order:8848 "$TOKEN" NX PX 30000
# 解锁:比对后删除,且必须原子(Lua 保证 compare-and-delete 不可分割)
redis-cli EVAL "if redis.call('get',KEYS[1])==ARGV[1] then \
return redis.call('del',KEYS[1]) else return 0 end" 1 lock:order:8848 "$TOKEN"
即便如此,"比对 + 删除"原子化只解决了误删,解决不了双持有:B 在 A 的锁过期后合法持锁,A 的下游写依然会发生。所以真正的防线在下游:
下游存储维护 last_token(每行或每个聚合根一个):
write(..., token):
if token <= last_token: 拒绝(返回冲突,让调用方重试或放弃)
else: 执行写,更新 last_token = token
| 方案 | 防误删 | 防双持有写入 | 需要下游配合 |
|---|---|---|---|
| 只加 TTL | 否 | 否 | 否 |
| TTL + value 校验 + Lua 解锁 | 是 | 否 | 否 |
| 上述 + 续期 | 是 | 概率降低 | 否 |
| 上述 + fencing token | 是 | 是 | 是 |
6. 锁粒度与多锁顺序
| 粒度 | 互斥强度 | 吞吐 | 风险 |
|---|---|---|---|
| 全局单锁 | 最强 | 最低,等于串行化 | 一处慢全站等 |
| 按聚合根(订单号、用户 ID) | 够用 | 高 | 需保证 key 选取一致 |
| 按字段/子资源 | 弱 | 最高 | 组合操作可能跨多个子资源 |
多锁的经典问题是顺序:两个请求分别锁 A→B 与 B→A,必然死锁。
固定顺序加锁:所有路径按 key 的字典序加锁
请求1: lock("order:1") → lock("stock:9")
请求2: lock("order:2") → lock("stock:9") # 同样先 order 后 stock
规则:排序后依次加锁,任一失败则全部释放并重试
顺序规则只在所有代码路径都遵守时有效,而"所有路径"包括后来加的那个。所以更稳的做法是尽量只锁一个 key,把跨聚合的操作用状态机或补偿来解。
7. 锁保护幂等,还是幂等替代锁
| 方案 | 适用面 | 不适用 |
|---|---|---|
| 唯一约束 | 同一业务键只能有一条记录 | 需要"更新已有记录"的语义 |
| 状态机(CAS 状态转移) | 有限状态、转移明确 | 状态维度多且组合爆炸 |
| 乐观锁(版本号/条件更新) | 冲突少、重试便宜 | 高冲突场景重试风暴 |
| 分布式锁 | 上述都不成立、必须串行 | 操作本身幂等,或冲突极少 |
分界线很清楚:如果操作天然幂等,锁是多余的一层;如果"重复执行会写坏数据",先问能不能用唯一约束或状态机表达,那两种方案没有锁的过期问题。
-- 用唯一约束替代锁:重复请求在数据库层被拒绝,而不是在应用层排队
ALTER TABLE payment ADD UNIQUE KEY uk_biz (biz_no);
INSERT INTO payment (biz_no, amount) VALUES ('P20261109001', 100)
ON DUPLICATE KEY UPDATE updated_at = NOW();
-- 用状态机替代锁:把"只能执行一次"编码进合法转移,而不是编码进互斥
UPDATE order SET status='PAID'
WHERE id=8848 AND status='PENDING'; -- 影响行数 0 即已被处理
两条语句的共同点:正确性由存储的约束保证,不依赖任何一方"记得加锁"。
8. 锁的成本与可观测
| 成本项 | 量级(典型) | 什么时候成为问题 |
|---|---|---|
| 每次业务一次 RTT | 亚毫秒到毫秒级 | 高频短操作,锁本身成了瓶颈 |
| 等待与重试 | 取决于持锁时长分布 | 长尾请求把队列拖长 |
| 锁服务可用性 | 与业务强耦合 | 锁服务抖动 → 业务面抖动 |
| 运维复杂度 | 中(Redis/etcd 集群) | 小团队、低并发时不成比例 |
# 持有时长分布:p99 远大于 p50 说明有长尾持有者
histogram_quantile(0.99, rate(lock_hold_seconds_bucket[5m]))
# 等待超时率:这个指标上升通常先于业务错误率
rate(lock_acquire_timeout_total[5m]) / rate(lock_acquire_total[5m])
持有时长分布比获锁成功率更有诊断价值:成功率常年 99.9% 也可能掩盖了个别请求持有数秒的事实。等待超时率则是最早的预警信号——它先于业务报错上升。
9. 跨服务长事务不该用锁
一个跨三个服务、耗时数秒的流程,用锁把整段串行化,等于把三个服务的可用性和延迟绑在一起:任何一个慢,锁就被长时间持有,其它请求排队。
| 手段 | 适用 | 代价 |
|---|---|---|
| 长事务加锁 | 几乎不适用 | 锁持有时间 = 全链路耗时 |
| 补偿(Saga) | 跨服务、可回滚 | 需要写补偿逻辑与幂等 |
| 状态机 + 定时对账 | 最终一致可接受 | 存在中间态暴露给用户 |
| 单服务内事务 | 同库、短事务 | 不跨服务 |
判据:锁适合保护"毫秒级、单资源"的临界区;跨服务、秒级的流程要用补偿与状态机,让中间态可见、可修正,而不是靠互斥假装它不存在。
10. 排错
| 现象 | 最可能原因 | 核对方式 |
|---|---|---|
| 偶发重复执行 | 加锁非原子,或主从切换丢锁 | 加锁是否单命令;是否有 failover 日志 |
| 业务卡死不恢复 | TTL 缺失或过长 | 检查 key 的剩余 TTL 与持有者 |
| 锁被"别人"删掉 | 解锁未校验 value | 解锁是否 Lua 比对删除 |
| 高峰期大量超时 | 持锁时长有长尾 | 看持有时长 p99 |
| 死锁 | 多锁顺序不一致 | 检查所有加锁路径的 key 顺序 |
| 续期后仍双持有 | 进程停顿导致续期中断 | 看 GC 日志与该时段的时间线 |
11. 边界
| 场景 | 结论 | 理由 |
|---|---|---|
| 单实例部署 | 本地锁足够 | 进程内互斥即可,分布式锁只增加故障点 |
| 操作天然幂等(覆盖写、SET 语义) | 不必加锁 | 重复执行结果相同 |
| 能用唯一约束表达 | 不必加锁 | 约束比锁更难被绕过 |
| 冲突极少(同一资源一天几次) | 乐观锁 + 重试 | 锁的固定成本高于冲突成本 |
| 临界区极短(微秒级)且 QPS 很高 | 重新设计分片,而非加锁 | 锁的 RTT 会成为吞吐上限 |
| 只需要"限速"或"排队" | 用队列/令牌桶 | 互斥不是限流工具 |
12. 自测
- 互斥、防死锁、防误删这三件事,为什么说彼此独立?
- 为什么"锁过期但业务没做完"不能靠调大 TTL 解决?
- 看门狗续期在哪种故障下会失效?为什么它救不了这种故障?
- fencing token 为什么需要下游配合?只在上游校验够不够?
- 唯一约束、状态机、乐观锁各自替代锁的条件是什么?
- 跨服务长事务为什么该用补偿而不是锁?中间态由谁承担?