3205 字
约 10 分钟
18
S17-A20 · 锁的两种用途:互斥与顺序保证不是一件事

加分布式锁的动机通常很具体:同一条记录被两个请求同时处理,重复扣减、重复发券、重复推送。锁加上去之后,重复执行确实没了,对账差异也归零。然后过一段时间会发现另一类问题:同一笔业务的处理顺序还是乱的——状态从已支付跳回了待支付,通知在出库之后才发,退款先于扣款生效。

这两件事被当成了一件事。锁能回答的只有一个问题:同一时刻是否只有一个执行者。它不回答另一个问题:后发生的事有没有可能先被处理。前者靠互斥,后者靠顺序载体,两者的失效方式、观测手段、修复手段全不一样。


1. 互斥与顺序的判据不同

维度 互斥 顺序
要保证的命题 任一时刻持有者数 ≤ 1 同一实体的处理次序与发生次序一致
判据 临界区内不出现并发写 状态序列单调、无回退、无跳跃
失败形态 并发冲突、重复扣减(硬错误,对账能发现) 覆盖、回退、错序(软错误,业务视角才看得见)
天然载体 锁、唯一约束、乐观锁版本号 序列号 + 单分区 + 单消费者 + 状态机
生效范围 只在临界区内 跨越整个业务生命周期
谁能提供 存储层(Redis / DB / ZK) 消息分区键、数据库序列、状态机定义
观测方式 争用率、持锁时长、超时次数 业务不变量校验(状态回退次数)
加锁能否替代 — 不能:出了临界区,先后关系无人负责

加锁之后仍然乱序,是因为锁的作用域只有临界区,而顺序问题是跨请求的:

// 两个请求,各自加锁,互斥完全成立,顺序仍然错
// t1  请求 A 抢到锁,读到 order.status = PAID(快照)
// t2  请求 B 排队等锁,此时 A 尚未提交
// t3  A 提交,status = SHIPPED,释放锁
// t4  B 拿到锁,用 t1 的旧快照写回 PAID 相关状态
// 互斥成立:任一时刻只有一个持有者
// 顺序失效:B 的处理基于过期事实,且没有人能发现
// 结论:锁保证"不同时",不保证"读到最新、按序应用"

2. 把顺序需求塞进锁里的三个后果

后果 机理 可观测症状
锁粒度被迫放大 为覆盖先后关系,锁要从"单条记录"扩到"整个用户 / 租户 / 全局" 争用率上升,P99 排队时间与业务复杂度无关地变长
持锁时间变长 校验、等待、重放、下游调用都被塞进临界区 超时率上升,续期开始成为关键依赖
超时后顺序彻底乱掉 锁过期,第二个执行者进场,第一个仍在跑 同一实体的状态被两个执行者交替写,无法重放

粒度与吞吐近似反比:锁粒度放大到覆盖 N 个实体,可并行度大致降到 1/N。判断是否越界有一个简单信号:加锁后吞吐不再随实例数上升——说明并行度已经被锁本身吃掉了,扩容只是在给锁加更多竞争者。

顺序需求一旦跨越多个资源(订单 + 库存 + 优惠券),锁就从一把变成一组,这时只剩两条路:固定全局加锁顺序,或者接受死锁。而"固定顺序"本身就是一个顺序约定——它挂在人脑和代码评审里,不在任何机制里:

// 多资源场景:顺序约定必须被写进代码路径,而不是被记住
// 约定:所有涉及 {order, stock, coupon} 的路径按 order → stock → coupon 加锁
// 违反约定的路径不会报错,只会偶发死锁,且只在并发窗口够宽时出现
// 替代:把三把锁合并成一个实体键(粒度更大,但顺序天然唯一)

正确的做法是把顺序从锁里拿出来,让锁退回到只保护一次本地写:

// 反例:用锁串行化整条流水线(持锁跨越 RPC,粒度被顺序需求撑大)
lock(entity:{id}) { 读状态 → 校验 → 调下游 → 写状态 }

// 正例:顺序由序列号与分区保证,锁只保护单次写
seq  = next_seq(entity)              // 单调递增,来自存储或分区位移
part = partition_of(entity)          // 同一实体固定落在一个分区、一个消费者
if (seq != expected_seq + 1) 拒绝并交由重排逻辑
lock(row) { 写入 + expected_seq = seq }   // 临界区只剩一次本地写

四条顺序载体,各自的代价与失效条件完全不同:

承载方式 保证什么 代价 失效条件
序列号 + 期望值校验 乱序可被检测并拒绝 要持久化期望值,需要重排逻辑 期望值被并发更新覆盖
单分区(分区键 = 实体) 同一实体顺序消费 分区数即并行上限 分区键选错、重平衡期乱序
单消费者 全局顺序 吞吐上限 = 单线程 无法水平扩展
状态机校验 非法迁移被拒绝 必须穷举状态与合法迁移 状态定义不全,漏掉迁移

3. 被牺牲的是顺序保证

共用一把锁时,被牺牲的永远是顺序保证。原因是互斥的收益立刻可见:重复执行消失、对账差异归零、监控变干净,于是"加锁 = 修好了"被写进结论;而顺序的收益要等到状态被覆盖才显现。

它不被发现,同样是三件事同时成立:

  1. 乱序不产生错误码。状态被写成旧值,业务看起来仍在"正常流转",没有 5xx,没有失败率,只有偶发的客诉和对账时的差异。
  2. 归因总是落在别处。发现状态回退时,第一反应是"上游重复推送"或"人工操作失误",于是去加幂等、去查操作日志,而真正的机制是"旧快照写回"。
  3. 没有人给乱序建指标。检测乱序需要业务不变量(例如状态只允许单向迁移),这类不变量通常写在文档里,没有变成监控口径。

锁超时的语义是分裂的,两种选择对应不同的一致性要求:

超时选择 适用一致性 后果 还需要什么
视为失败,立即放弃 强一致、可重试 本次不做,等上游重试 上游重试 + 序列号兜底
继续执行,放弃锁 最终一致、可补偿 两个执行者并存 下游 fencing 校验 + 补偿

"我用了 Redis 锁所以是安全的"这句话缺三样东西:缺超时语义的选择(失败还是继续)、缺fencing(下游能拒绝旧持有者,否则"锁还在但你已经不该执行"时没人拦得住)、缺顺序载体(锁从来没被问过先后关系)。租约续期也一样:它解决的是持有时间的延长,解决不了"续期成功但进程已被暂停、醒来后按旧事实继续执行"。

// 三件事各有各的答案,锁只回答了第一件
// 1) 同时只有一个   → 锁 / 唯一约束 / 乐观锁
// 2) 先后关系不错   → 序列号 + 单分区 + 状态机
// 3) 旧持有者不生效 → fencing token 由下游校验(不是由上游自证)
// 少任何一件,另外两件都还在,但会被误以为已经覆盖
// 四个口径:前三个测互斥,第四个测顺序
lock_contention_ratio    // 等待过锁的请求 / 总请求
lock_hold_p99            // 持锁时长分布;p99 远大于 p50 说明有长尾持有者
lock_timeout_total       // 超时次数,按资源维度聚合
order_violation_total    // 业务不变量被违反次数:状态回退、期望值跳跃
// 判据:前三个好看而第四个上升 = 互斥做到了,顺序被牺牲了

租约续期值得单独说一句:它延长的是持有时间,不改变"我是否还有资格执行"。进程被长时间暂停(GC、调度饥饿、容器冻结)后醒来,续期可能已经失败、也可能刚好成功,两种情况下它手里的业务事实都是旧的。这类故障不需要新机制,只需要下游能拒绝旧持有者——fencing 的位置在下游,不在锁服务里。

锁与幂等的关系是替代而不是叠加:能靠幂等(覆盖写、唯一约束、状态机幂等迁移)表达的操作,不必加锁;必须加锁的场景通常只剩"读改写"这类无法用幂等表达的操作。加锁的场合越少,被顺序问题污染的面积越小。


4. 排错

现象 可能原因 验证方式 处置方向
加锁后同一实体状态来回跳 锁只互斥,旧快照被写回 统计状态回退次数 引入期望值校验或版本号
吞吐不随实例数增长 锁粒度被顺序需求放大 看争用率与并行度曲线 按实体分区,缩小临界区
超时集中在少数资源 长尾持有者(GC、慢 RPC) 看持锁时长 p99 与临界区内容 把 RPC 移出临界区
锁过期后数据被覆盖 超时语义未定义 对比超时时刻与写入时刻 明确失败/继续,加下游校验
死锁或加锁先后不一致 多资源多把锁 收集各线程的锁获取序列 固定全局顺序,或合并为一把粗锁
对账无差异但客诉出现 互斥修好了,顺序没修 回溯客诉实体的处理序列 上序列号 + 单消费者

5. 边界

场景 结论 理由
单实例、单线程消费 不需要锁 并发在结构上不存在
资源天然分区(按用户/租户) 分区内串行即可 并发已被结构消除,锁是冗余
幂等已足够(覆盖写、SET 语义、唯一约束) 不需要锁 重复执行结果相同
只需要限速或排队 用队列或令牌桶 互斥不是限流工具
冲突极少(同一资源一天几次) 乐观锁 + 重试 锁的固定成本高于冲突成本
需要顺序但没有状态机定义 先补不变量,再加分区 没有不变量的顺序无从校验
// 加锁前的三问,任一答"是"就不该用锁解决
// 1) 这个资源是否天然单写者(单实例、单分区、单队列)?
// 2) 重复执行是否无害(覆盖写、幂等迁移、唯一约束)?
// 3) 我要的到底是"不同时",还是"按顺序"?后者换载体,不换锁

6. 自测

  1. 互斥与顺序各自的判据是什么?为什么前者能测而后者难测?
  2. 两个请求都加了同一把锁,为什么状态还可能被旧值覆盖?
  3. 把顺序需求塞进锁,会依次引发哪三个后果?
  4. 锁超时后"视为失败"与"继续执行"分别对应什么一致性要求?
  5. "我用了 Redis 锁所以安全"这句话缺哪三样东西?
  6. 什么时候该用序列号加单分区,而不是加大锁粒度?
S17-A20 · 锁的两种用途:互斥与顺序保证不是一件事
https://www.clxhxhhr.top/posts/6366/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。