qinghuan
← 黑马点评 / 全部笔记

单元 106 / 黑马点评

P0 · 必会主线L3 · 深入

黑马点评:分布式锁与异步订单可靠性

沿着拿锁、写数据库和确认消息的顺序,检查程序暂停或重试时会发生什么,并防止重复下单。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能解释从获取消息到恢复挂起订单的完整状态变化,并识别锁不提供的保证。

学习理由、准备要求与知识点

为什么学它

锁只限制部分并发,异步下单还要保证消息能恢复、落库能去重且失败可处理。

先读这些内容

知识点清单

  • 锁持有者令牌
  • 租约窗口
  • Stream 消费组
  • PEL
  • 数据库事务
  • 幂等落库
  • XACK 时机
  • XAUTOCLAIM

运行前先确认Redis消费组基础以6.2+为基线,XAUTOCLAIM细节需匹配实际版本;源码桥梁OpenJDK17,完整算法为Java17单线程内存资源模型。

01先弄懂它是什么

拿到分布式锁,只说明当前有资格进入受保护代码;确认消息,只说明消费者报告处理了这次投递。两者都不能单独保证订单只写一次。进程暂停、锁到期、网络重试,以及数据库提交后立刻崩溃,都可能导致再次执行。因此数据库还要守住业务约束,处理同一个请求时也要避免重复产生结果。

Redis 单实例锁常用带 NX 和 PX 的 SET 设置有效期,并保存随机的持有者标识。释放时必须把“比较标识”和“删除”一起完成,防止旧任务删掉后来者的锁。随机标识只用于证明这一把锁属于谁,不是递增代次的 fencing token。锁过期也不会停止旧进程,它恢复后仍可能继续写外部资源。

消费组读到消息后,通常会先把它记入 Pending,也就是“已经交给消费者、还没确认完成”的列表。消费者应等数据库事务成功提交,再用 XACK 确认。若提交后、确认前崩溃,消息可能再来一次,必须识别并沿用已有订单。恢复时不能只读新消息,还要按合适的闲置时间接管旧 Pending;确定无法处理的消息,也要留下可靠的失败记录或后续处理安排。

文中这几个词,先认一认

租约
只在一段时间内有效的占用许可;过期后别人能拿锁,旧代码却可能仍在运行。
fencing token
不断递增的持有代次,由真正被写入的资源检查并拒绝旧代次请求。
PEL
消费组中已经交给消费者、但还没有被确认完成的消息列表状态。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

本题模拟真正接收写入的资源怎样挡住旧持有者,并避免同一操作重复修改。每个新操作带正数 token、操作 ID 和金额变化;资源保存已经成功接受过的最大 token。新操作的 token 更小时拒绝;相同 token 下允许同一租约里的不同操作。若操作 ID 和内容都与已完成操作相同,就返回原结果,不再改一次金额。

A拿到token十并暂停,B拿到十一完成写入后,A恢复提交十的新操作,资源因十小于十一拒绝。这要求资源本身原子检查并保存最高代次;仅在客户端比较,或者生成token后让数据库忽略它,都不能阻止旧写。

代次与幂等解决不同问题。高代次不阻止同一请求因重试执行两次,所以还要保存操作结果;幂等操作ID也不自动阻止旧租约提出一个全新的写入。本模型缓存旧操作响应,即使后来有新代次,也只返回历史结果而不产生新副作用。

正在加载流程图…

图中状态只描述本篇可执行机制;业务调用仍需由外层应用负责。
查看流程图源码(Mermaid)
flowchart TD
  Q["读取或接管消息"] --> L["租约与稳定操作身份"]
  L --> F["最终资源代次及幂等检查"]
  F --> D["数据库事务提交"]
  D --> A["XACK"]
  F --> E["有限重试或持久失败"]

按顺序看,每一步做了什么

  1. 明确锁保护的资源与业务不变量。
  2. 区分随机锁所有者与可验证的单调代次。
  3. 在最终资源原子检查代次、幂等身份和业务条件。
  4. 数据库提交后确认消息,合理接管空闲Pending。
  5. 监控重试、永久失败和可靠性交接。

03对照实现代码

OpenJDK 17 AtomicLong 源码 (新标签页)↗

OpenJDK 17 GA,jdk-17+35 · AtomicLong.incrementAndGet / compareAndSet

下方为原创 Java 教学片段,调用已核验的 JDK API 说明局部机制;不是该类原样源码,不代表Redis或数据库内核实现。完整程序见算法模块。

核心机制代码 · java
AtomicLong localEpoch = new AtomicLong();
long next = localEpoch.incrementAndGet(); // 仅在这个JVM实例内递增,重启后状态会丢失。
boolean updated = acceptedEpoch.compareAndSet(expectedEpoch, next); // 局部CAS不等于远程资源事务。
if (!updated) retryFromCurrentState(); // 应用策略重新读取,不能无条件重复外部副作用。
// 真正fencing需要可靠代次来源和最终资源共同执行检查。

AtomicLong提供单个JVM共享变量的原子读改写,incrementAndGet和compareAndSet通过底层原子机制实现。它不持久化,也不在多个进程间自动共享值。

本地递增计数器不能直接作为生产分布式fencing来源,进程重启或多实例会产生重复与倒退。代次来源需满足业务需要的单调性和故障恢复约束。

CAS 只保护它操作的变量,不会把数据库余额、业务结果和代次记录自动一起提交。完整模型在单线程里修改几个字段,真实资源端仍需要受保护的条件写入或同一数据库事务,才能让这些变化一起生效。

锁随机标识仍有价值,它避免错误释放他人锁;但随机字符串无法通过大小比较表达新旧租约,不应与本篇有序token混用。

代码先调用谁,再调用谁

  1. 产生可靠所有权代次
  2. 请求携带代次与稳定操作ID
  3. 最终资源核对已接受代次
  4. 原子提交业务与幂等记录
  5. 持久结果确认后ACK消息

04动手写一个例子

带fencing和操作幂等的资源写入

单线程资源维护非负余额,apply接收正token、操作ID与delta。拒绝旧代次的新操作及同ID不同内容,允许精确重放返回历史结果。Java17自测。

输入 1
token11已成功,随后token10的新写
输出 1
拒绝旧写
输入 2
同token同ID同delta重复
输出 2
返回原结果,余额不再变化

首先检查操作ID历史,存在时核对token和delta完全一致,返回已经保存的结果。这个分支不修改资源,因此可以安全回答旧操作的查询式重放。

新操作必须满足token不小于资源最高已接受值,再用精确加法计算新余额并验证非负。全部业务校验通过后,才一起更新余额、最高代次和操作结果。

相同token不同ID允许一个租约完成多个操作;更高token成功后,所有旧token的新操作被拒绝。一般余额语义只是教学载体,订单资源应替换为库存与状态约束。

为什么这样写是对的最高已接受token单调不减;每个操作ID最多产生一次余额变化,旧token无法在更高token成功后引入新的业务效果。

时间 每次平均O(1)额外空间 O(n)

固定宽度数值处理常数,HashMap平均查找常数,n个操作结果保留用于重放;字符串哈希另计ID长度,生产记录需定义保留期限。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    record Applied(long token,long delta,long result){}
    static class Resource {
        long highest=0,balance;Map<String,Applied> operations=new HashMap<>();
        Resource(long value){if(value<0)throw new IllegalArgumentException("balance");balance=value;}
        long apply(long token,String id,long delta){
            if(token<=0||id==null||id.isBlank())throw new IllegalArgumentException("request");Applied old=operations.get(id);
            if(old!=null){if(old.token()!=token||old.delta()!=delta)throw new IllegalArgumentException("conflict");return old.result();} // 精确重放只返回已存结果。
            if(token<highest)throw new IllegalStateException("stale"); // 检查发生在最终资源,而非只有客户端。
            long next=Math.addExact(balance,delta);if(next<0)throw new IllegalStateException("insufficient");
            balance=next;highest=token;operations.put(id,new Applied(token,delta,next));return next; // 单线程模型一次提交三个状态。
        }
    }
    static void eq(long e,long a){if(e!=a)throw new AssertionError(a);}
    public static void main(String[] args){
        Resource r=new Resource(10);eq(5,r.apply(10,"a",-5));eq(5,r.apply(10,"a",-5));eq(5,r.balance);
        eq(8,r.apply(11,"b",3));
        try{r.apply(10,"late",-2);throw new AssertionError();}catch(IllegalStateException expected){}eq(8,r.balance);
        eq(5,r.apply(10,"a",-5));eq(8,r.balance);
        eq(7,r.apply(11,"c",-1));
        try{r.apply(11,"c",-2);throw new AssertionError();}catch(IllegalArgumentException expected){}
        try{r.apply(12,"bad",-100);throw new AssertionError();}catch(IllegalStateException expected){}eq(11,r.highest);
        try{new Resource(Long.MAX_VALUE).apply(1,"x",1);throw new AssertionError();}catch(ArithmeticException expected){}
        System.out.println("PASS 8 scenarios");
    }
}

这些特殊情况也要试一试

  • 最高值指成功接受的代次,失败操作不改变资源状态。
  • 随机锁值不具备有序token含义。
  • 模型单线程且内存存储,不证明分布式可靠性。
进阶选读 · 后续再读:多资源代次与外部服务

场景一个任务写数据库、对象存储和第三方系统,支持能力不同。

做法逐个确认资源是否支持条件版本和稳定操作键,不支持时采用业务状态查询、补偿与串行协调;不要假定一个锁能覆盖所有资源。

复杂度网络故障可能使操作结果未知,复杂度不能只写本地O(1);恢复请求数量取决于可查询状态和重试政策。

好处与代价更强资源协议需要适配成本,但缺少最终检查时租约本身无法防止暂停后的旧执行者。

05项目里怎么用

Redis租约获取、续期和释放都需要检查结果,释放比较标识必须原子执行。进程长暂停超过租约时,即使续期机制通常正常也可能失去所有权;数据库唯一约束、条件更新和必要的代次验证应继续守住最终写入。

Stream使用XREADGROUP正常投递后,成功事务再XACK;XAUTOCLAIM从Redis6.2提供基于空闲时间的接管,接管不代表旧消费者已停止,因此仍需幂等。各版本返回字段和被裁剪消息处理有所差异,客户端应按实际版本解析。

确定无法继续处理的消息,应先可靠保存失败原因或交给下一处理方,再结束 Pending。XACK 只是确认这次待处理状态,不表示 Stream 正文被删掉,也不表示库存已补偿。Redis 的持久化、复制和消息保留方式仍决定能承受哪些故障,不能只建了消费组就说消息永不丢失。

容易踩的坑

  • 把随机锁标识当作fencing token。
  • 租约过期后假设旧线程自动停止。
  • 先ACK后提交业务。
  • 接管Pending后假设无需幂等。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
租约互斥降低正常并发竞争暂停和过期窗口仍存在热点临界区协调
资源代次检查拒绝已过期执行者的新写要求可靠代次与资源支持可条件更新的关键资源
至少一次加幂等接受提交与确认之间的重复需持久操作身份和结果异步订单消费者

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01锁归属与代次区别?

展开参考答案与推导

随机值用于比较是否同一所有者,有序代次用于让资源识别旧执行者,两者不能互相替换。

02为什么ACK在提交后?

展开参考答案与推导

避免已确认但业务未持久化的丢失窗口,代价是提交后重投,需要幂等。

03AtomicLong能提供跨进程代次吗?

展开参考答案与推导

不能,它只保证本JVM变量原子性,重启和多实例没有共同持久顺序。

换个情况,还会处理吗

01旧消费者在接管后恢复,如何防重复?

展开参考答案与推导

业务事务核对稳定操作ID与唯一约束,必要时资源检查代次;接管不停止旧进程。

02新token写失败,最高值是否必须提升?

展开参考答案与推导

取决于资源协议;本题只记录成功接受代次,失败不改变状态,不能混用不同协议结论。

03消息不在Pending却没订单,查什么?

展开参考答案与推导

检查是否提前ACK、进入持久失败出口、消息保留或投递链缺失,不能只看Pending为空。

写代码试一试

01旧token重放已有操作如何处理?

输入 / 条件
a已成功,后续token更高,再重放a
输出 / 预期
返回a历史结果

提示 不产生新写。

展开参考答案与推导

核对完全相同内容后返回存储响应,余额保持当前值,不能用历史结果覆盖当前状态。

02相同token不同操作允许吗?

输入 / 条件
同租约两个合法操作
输出 / 预期
允许

提示 代次不等于操作ID。

展开参考答案与推导

每个操作仍需独立幂等身份,相同租约可执行多次不同业务动作。

03为什么先计算再修改?

输入 / 条件
余额增加发生long溢出
输出 / 预期
拒绝且状态不变

提示 精确加法。

展开参考答案与推导

在更新余额和最高token之前完成Math.addExact及业务检查,避免半更新模型。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能解释从获取消息到恢复挂起订单的完整状态变化,并识别锁不提供的保证。

本篇重点加练

待确认列表和确认时机是故障恢复的关键证据,必须能区分未处理、处理成功和记录已丢失。

  • PEL
  • XACK 时机

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    订单工作者处理一个任务需 80~800 毫秒,锁租约为 300 毫秒。分析正常执行、长暂停和响应丢失时“仍持锁”的证据各是什么。

    提交内容
    租约时间线与证据表
    怎样算完成
    • 区分本地判断和远端状态
    • 覆盖租约到期边界

    练到的知识点锁持有者令牌 · 租约窗口

  2. 作业 02代码推演

    消费组 A 有两个消费者,依次出现读到未确认、消费者退出、重新启动三种事件。追踪消息在新消息、待确认和已完成记录中的归属。

    提交内容
    事件表与状态图
    怎样算完成
    • 注明消费组和消费者身份
    • 不将消费位置等同于数据库完成状态

    练到的知识点Stream 消费组 · PEL

  3. 作业 03原理问答

    运维看到一个消费组 PEL 为零,拟据此宣布订单全部完成。列举作出判断前必须核对的 Stream、消费组和业务记录范围。

    提交内容
    完成声明的证据清单
    怎样算完成
    • 包括不同消费组与保留期
    • 区分无待确认和无未处理业务

    练到的知识点PEL

综合应用

  1. 作业 04设计评审

    同一订单事件被两个工作者同时处理,其中一个更新订单金额后失败。审查业务写入与去重记录需要满足的事务契约。

    提交内容
    事务边界图与冲突规则
    怎样算完成
    • 覆盖回滚及提交结果未知
    • 校验重复 ID 的负载一致性

    练到的知识点数据库事务 · 幂等落库

  2. 作业 05排错实验

    在本地 Redis Stream 中制造两条不同空闲时长的待确认消息,接管其中符合实验条件的消息,再在不同数据库处理阶段确认。

    提交内容
    命令记录与消息状态快照
    怎样算完成
    • 记录 idle 时间和领取条件
    • 对每次 XACK 说明依据而非只列命令

    练到的知识点XACK 时机 · XAUTOCLAIM

  3. 作业 06编程实践

    实现一个本地异步订单消费者演示,注入业务异常和确认失败两类可控故障;运行恢复后按事件 ID 对照订单表、待确认列表和处理日志。

    提交内容
    源码、注入开关和对账报告
    怎样算完成
    • 实验可重置且仅访问本地资源
    • 包含重放前后业务副作用数量

    练到的知识点Stream 消费组 · PEL · 数据库事务 · 幂等落库 · XACK 时机 · XAUTOCLAIM

  4. 作业 07代码推演

    一个工作者一次读取五条消息,前三条已落库,第四条失败,第五条尚未开始。设计逐条确认的状态记录,评审按批次全部确认的语义。

    提交内容
    批次状态表
    怎样算完成
    • 每条消息有独立处理证据
    • 考虑确认请求结果未知

    练到的知识点XACK 时机

  5. 作业 08面试场景

    Stream 在消费前后持续裁剪,某些待确认条目对应的原始负载不再可用。制定调查字段与恢复资格,避免凭记录 ID 猜测订单内容。

    提交内容
    缺失消息处置单
    怎样算完成
    • 区别待确认元数据与消息内容
    • 说明无法自动恢复的信息

    练到的知识点PEL · XACK 时机

  6. 作业 09编程实践

    在隔离本地 Redis 创建两个消费组,实施受限数量的读取、裁剪和确认操作,再编写报告器关联各组 PEL 与本地业务账本。

    提交内容
    脚本及状态差异报告
    怎样算完成
    • 资源独立命名且可清理
    • 记录 Redis 版本和实际命令语义

    练到的知识点PEL · XACK 时机

进阶挑战 · 掌握前置后选做

  1. 作业 10面试场景

    某条事件总在接管后继续失败,并占用消费者大量时间。制定隔离和人工重放协议,说明原工作者恢复时还需检查哪些约束。

    提交内容
    运维处理单与验收条件
    怎样算完成
    • 保留原事件身份和失败证据
    • 区分锁保护、幂等与接管职责

    练到的知识点锁持有者令牌 · 租约窗口 · PEL · 幂等落库 · XAUTOCLAIM

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 跨域迁移

    把消费组确认为“作业批改完成”信号,学生可能撤回文件,批改者可能离线。设计此迁移场景中确认应代表的业务事实。

    提交内容
    流程与确认契约
    怎样算完成
    • 区分已接收、已批改和已反馈
    • 考虑原文不可恢复

    练到的知识点PEL · XACK 时机

  2. 作业 12思维拓展 · 条件变化

    若只能长期保存订单事实,原消息最多保留两天,而最长故障可能持续一周,应怎样定义恢复承诺并设计可检验的补充证据?

    提交内容
    保留期取舍与验证计划
    怎样算完成
    • 将保留期和故障期限量化
    • 明确不能保证的恢复范围

    练到的知识点PEL · XACK 时机

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗