3113 字
约 10 分钟
18
S17-A09 · 分布式锁的边界:什么时候不该加锁

"加了分布式锁还是有并发问题"——多数情况不是锁实现有 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. 自测

  1. 互斥、防死锁、防误删这三件事,为什么说彼此独立?
  2. 为什么"锁过期但业务没做完"不能靠调大 TTL 解决?
  3. 看门狗续期在哪种故障下会失效?为什么它救不了这种故障?
  4. fencing token 为什么需要下游配合?只在上游校验够不够?
  5. 唯一约束、状态机、乐观锁各自替代锁的条件是什么?
  6. 跨服务长事务为什么该用补偿而不是锁?中间态由谁承担?
S17-A09 · 分布式锁的边界:什么时候不该加锁
https://www.clxhxhhr.top/posts/6195/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。