qinghuan
← 黑马点评 / 全部笔记

单元 111 / 黑马点评

P1 · 重点提升L3 · 深入

黑马点评演练:程序中断后,异步订单怎样恢复

分别模拟提交前中断、提交后未确认和永久失败,再核对订单、库存与消息记录能否正确恢复。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能用三个故障注入点验证不重复下单、可恢复挂起消息和可追踪失败订单。

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

为什么学它

秒杀链路存在多个成功与失败窗口,演练必须覆盖数据库已提交但消息未确认的情况。

先读这些内容

知识点清单

  • 固定业务事件 ID
  • 状态转移
  • 提交前崩溃
  • 提交后未确认
  • PEL 恢复
  • 唯一约束
  • 补偿与对账
  • 持久失败出口

运行前先确认Redis消费组与XAUTOCLAIM基线6.2+,JDBC源码OpenJDK17。完整Java17算法枚举有限内存状态,不启动消费者、不执行数据库事务或故障注入。

01先弄懂它是什么

验证异步订单能否恢复,要分别核对三件事:消息还有没有机会被处理,数据库是否保存了结果,以及库存是否与订单对应。Redis 预扣成功只能说明进入处理中,最终下单成功还要有数据库记录。Pending 数量变成 0 也不一定是成功,可能只是过早确认,或已被转到失败处理流程。

每个事件要有稳定的事件 ID、订单 ID 和业务内容。消费者在数据库事务里核对是否已完成,再条件扣库存、写入唯一订单,成功提交后才确认消息。确定无法继续的失败也要保存可查原因并交给可靠的后续处理,不能无限重试,更不能只为了清空队列就丢掉没有结论的订单。

演练分别注入提交前中断、提交后ACK前中断和永久失败,使用隔离消费者与测试数据。恢复后核对订单、去重记录、库存、Pending和失败记录,重复执行恢复步骤结果应保持不变。每次只注入一个边界,才能解释差异来源。

文中这几个词,先认一认

崩溃点
在两个能分别观察的完整步骤之间故意停止程序,用来检查这时崩溃会留下什么状态。
幂等重放
同一个业务请求再次处理时沿用已完成的结果,不再次扣库存或创建订单。
状态空间检查
列出一个有限模型可能到达的状态,寻找哪条最短执行过程会破坏业务规则。

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

02看看它怎么工作

本题把单消息消费简化为READ、COMMIT和ACK三个步骤,并允许在READ或COMMIT之后崩溃,重启后再次读取未确认消息。数据库提交作为一个原子步骤,状态持久保留;模型不模拟Redis丢数据、消息裁剪和数据库部分提交。

程序用广度优先搜索,在限定的崩溃次数内,逐层列出可能发生的步骤和状态,再检查两件事:消息已确认时必须有持久订单,同一消息不能创建超过一笔订单。按正确顺序执行,并让重复提交沿用已有结果时,本模型没有找到违反规则的情况。把 ACK 移到 COMMIT 前,就会出现 READ、ACK 之后已经确认却还没存订单的过程;去掉去重处理,则会在提交后崩溃、恢复重放时再建一单。

模型检查给出具体反例比“可能有问题”更可验收,但没有反例只证明有限模型。真实Redis和数据库之间没有共同本地事务,连接结果未知、重试并发和永久失败转交都需要额外状态与集成测试,不能把小状态图证明扩展成生产永不丢单。

正在加载流程图…

图中状态只描述本篇可执行机制;业务调用仍需由外层应用负责。
查看流程图源码(Mermaid)
flowchart TD
  R["READ或Pending接管"] --> T["幂等数据库事务"]
  T --> C["COMMIT"]
  C --> A["XACK"]
  R --> F["崩溃恢复"]
  C --> F
  F --> R
  T --> E["持久失败出口与对账"]

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

  1. 定义稳定事件与订单身份及库存不变量。
  2. 在隔离环境列出提交前后和确认前后的注入点。
  3. 先用有限状态模型验证顺序与幂等设计。
  4. 实际恢复时读取Pending并按空闲阈值接管。
  5. 核对持久效果、失败出口与重复恢复结果。

03对照实现代码

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

OpenJDK 17 GA,jdk-17+35 · Connection.setAutoCommit / commit / rollback

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

核心机制代码 · java
connection.setAutoCommit(false); // 后续本库写入进入显式事务。
try {
    persistIdempotentOrder(connection, event); // 应用逻辑检查稳定身份、唯一约束和条件库存。
    connection.commit(); // 提交完成后才进入确认投递阶段。
} catch (SQLException failure) {
    rollbackAndPreserveOriginalFailure(connection, failure); // 应用辅助函数,需保留回滚失败与原异常。
    throw failure;
}
acknowledgeOnOriginalStreamGroup(event); // 另一个系统的操作,提交与ACK之间允许崩溃。
// 示例假定专用事务连接;连接池归还和提交结果未知需要外层策略。

Connection定义自动提交、提交与回滚的客户端契约,具体事务由数据库驱动和服务端执行。把两个方法放在相邻行不会获得跨Redis与MySQL原子性。

commit抛异常时结果可能未知,例如数据库已经提交但响应丢失;随后尝试rollback也不能证明之前没提交。必须用稳定订单身份查询或幂等重放解决不确定结果。

片段中的持久化、回滚辅助和ACK都是应用函数,不是JDBC内置能力。事务连接的生命周期、异常保留和池状态恢复需要完整工程实现,不能把片段当作全部消费代码。

完整算法没有连接数据库,而是在有限模型里把 COMMIT 当成一次不可拆开的状态改变,再逐项检查可能执行过程。它用于验证先后顺序和重复处理规则,不模拟数据库引擎、网络故障或 Stream 的磁盘保存。

代码先调用谁,再调用谁

  1. 读取或接管稳定事件
  2. 开启本库事务并核对身份
  3. 提交订单与库存效果
  4. 若结果未知先查询或幂等重放
  5. 确认已持久结果后ACK

04动手写一个例子

消费崩溃窗口的有限状态反例搜索

输入是否ACK优先、是否幂等以及0到3的最大崩溃次数,BFS寻找提前确认或重复订单的最短轨迹。没有反例返回空Optional,仅针对本模型。Java17自测。

输入 1
ACK优先
输出 1
[READ,ACK]
输入 2
提交优先且幂等,最多2次崩溃
输出 2
无模型反例

状态记录程序阶段、订单数、是否确认及已发生崩溃次数。READ后可能COMMIT或错误地ACK,COMMIT后可能ACK;崩溃只重置程序阶段,保留持久订单和确认事实。

BFS每次推进一个动作,保存到达状态的路径。状态第一次出现时路径最短,因此发现违反不变量时即可返回具体最短反例。

正确幂等提交将订单数保持一,错误提交每次重放都加一。确认后不再读取消息,清楚展示提前确认为什么会失去恢复机会。状态和崩溃上限使搜索有限。

为什么这样写是对的所有队列状态都由声明动作产生,持久订单和确认在崩溃中保留;正确目标要求ack时订单已存在且订单总数不超过一。

时间 O(S·d)含路径复制,图搜索本身O(S+E)额外空间 O(S·d)

S为有限可达状态,d为最短轨迹最大长度,每状态最多两个后继;本题直接复制路径便于教学,生产模型检查可用前驱表降为O(S+E)存储与重建。

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

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    record State(int phase,int orders,boolean ack,int crashes){}
    record Node(State state,List<String> path){}
    static Node next(Node from,State state,String action){List<String> path=new ArrayList<>(from.path());path.add(action);return new Node(state,List.copyOf(path));}
    static Optional<List<String>> counterexample(boolean ackFirst,boolean idempotent,int maxCrashes){
        if(maxCrashes<0||maxCrashes>3)throw new IllegalArgumentException("bound");
        Deque<Node> q=new ArrayDeque<>();Set<State> seen=new HashSet<>();q.add(new Node(new State(0,0,false,0),List.of()));
        while(!q.isEmpty()){
            Node node=q.removeFirst();State s=node.state();if(!seen.add(s))continue;
            if(s.orders()>1||s.ack()&&s.orders()==0)return Optional.of(node.path()); // 核对持久效果与确认顺序。
            if(s.phase()==0&&!s.ack())q.addLast(next(node,new State(1,s.orders(),false,s.crashes()),"READ"));
            else if(s.phase()==1){
                if(ackFirst)q.addLast(next(node,new State(2,s.orders(),true,s.crashes()),"ACK"));
                else q.addLast(next(node,new State(2,idempotent?1:s.orders()+1,false,s.crashes()),"COMMIT")); // 重放是否增加第二单由幂等策略决定。
            }else if(s.phase()==2){
                if(ackFirst)q.addLast(next(node,new State(3,idempotent?1:s.orders()+1,true,s.crashes()),"COMMIT"));
                else q.addLast(next(node,new State(3,s.orders(),true,s.crashes()),"ACK"));
            }
            if(s.phase()>0&&s.phase()<3&&s.crashes()<maxCrashes)q.addLast(next(node,new State(0,s.orders(),s.ack(),s.crashes()+1),"CRASH")); // 崩溃丢程序位置,不丢本模型持久事实。
        }return Optional.empty();
    }
    public static void main(String[] args){
        if(counterexample(false,true,2).isPresent())throw new AssertionError();
        if(!counterexample(true,true,1).equals(Optional.of(List.of("READ","ACK"))))throw new AssertionError();
        if(!counterexample(false,false,1).equals(Optional.of(List.of("READ","COMMIT","CRASH","READ","COMMIT"))))throw new AssertionError();
        if(counterexample(false,false,0).isPresent())throw new AssertionError();
        if(counterexample(false,true,3).isPresent())throw new AssertionError();
        try{counterexample(false,true,-1);throw new AssertionError();}catch(IllegalArgumentException expected){}
        System.out.println("PASS 6 tests");
    }
}

这些特殊情况也要试一试

  • 无反例仅限明确模型和崩溃上限。
  • 模型不包含消息保留丢失与永久失败分支。
  • 提交异常可能结果未知,不能只看异常类判断是否落库。
进阶选读 · 后续再读:并发恢复与故障模型扩展

场景旧消费者和接管者同时处理,消息可能裁剪,失败记录也可能写失败。

做法增加消费者身份、代次、事件保留和持久失败转交状态,用偏序约简等技术控制状态爆炸;将反例转换成隔离集成测试。

复杂度并发状态空间随参与者与步骤组合迅速增长,有限模型检查不能凭小样例声称覆盖全部分布式故障。

好处与代价更细模型更接近现实但维护成本高,先明确最危险的不变量与故障窗口,再逐步扩展。

05项目里怎么用

提交前崩溃时订单应不存在且事件仍能恢复;提交后确认前崩溃时应查询到同一订单,重放不得再次扣库存。永久失败应存在稳定原因与转交记录,之后才可按政策确认原投递。每个场景同时检查库存,不只断言订单行数。

XAUTOCLAIM 会按消息闲置时间,把 Pending 中尚未确认的消息交给新的消费者,但旧消费者可能仍在运行。接管阈值要结合正常处理时长设置,重试之间留出适当间隔,并让重复事务不重复产生结果;需要挡住旧持有者写入时,还要检查代次。Stream 的消息保留规则不能过早删去未完成订单仍需读取的内容。XACK 只结束这次待确认关联,不会自动删除消息正文。

补回库存前,先按稳定业务编号和版本查订单结果,避免已经下单成功却又把库存加回。保存失败记录本身也可能失败,所以原消息或其他可靠记录仍要保留。演练报告写清在哪一步中断、预期留下什么、怎样恢复、再次恢复是否重复,以及没测到哪些情况,不能把一次成功恢复扩大成所有故障都保证。

容易踩的坑

  • Pending清零被当作业务全部成功。
  • 提交异常后假设数据库一定回滚。
  • 消费者重启只读取新消息。
  • 补偿不检查订单状态导致重复加库存。

几种做法,怎么选

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

方案优势代价与限制适用条件
提交后ACK避免提前确认丢业务接受重复投递窗口有幂等的消费者
持久失败出口停止无限重试且可追踪转交本身也要可靠永久业务错误
有限状态模型快速给出具体反例无法覆盖所有真实环境设计顺序与恢复规则审查

06检验是否读懂了

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

先用自己的话解释

01为什么提交后ACK仍会重复?

展开参考答案与推导

进程可在两系统操作之间崩溃,数据库已提交而消息未确认,恢复会再次投递。

02commit异常说明什么?

展开参考答案与推导

说明客户端未获得确定成功结果,可能提交前失败也可能提交后响应丢失,需要查询或幂等恢复。

03模型没有反例意味着什么?

展开参考答案与推导

仅说明给定状态、动作和故障上限内未违反性质,不能推导无限故障或真实集成可靠。

换个情况,还会处理吗

01数据库已提交且消息仍Pending,怎么恢复?

展开参考答案与推导

核对同订单与内容后幂等返回已有结果,再确认消息,不能再次扣库存。

02永久失败记录也写失败怎么办?

展开参考答案与推导

保留原恢复依据并受限重试,不能先ACK再希望失败信息之后补上。

03旧消费者和接管者同时提交,依赖什么?

展开参考答案与推导

依赖数据库唯一约束、条件状态和事务幂等,接管或分布式锁本身不足以保证只执行一次。

写代码试一试

01提前确认最短反例是什么?

输入 / 条件
ACK优先策略
输出 / 预期
READ,ACK

提示 此时尚无订单。

展开参考答案与推导

确认已经发生而持久订单为零,后续崩溃会使消息无法正常恢复。

02无幂等的重放反例?

输入 / 条件
提交后崩溃一次
输出 / 预期
READ,COMMIT,CRASH,READ,COMMIT

提示 第二次提交增加订单。

展开参考答案与推导

第一次提交事实在崩溃中保留,第二次没有识别已有业务效果,订单数变二。

03为什么零崩溃可能测不出幂等缺失?

输入 / 条件
提交优先且只执行一次
输出 / 预期
没有模型反例

提示 故障覆盖决定可观察问题。

展开参考答案与推导

正常路径只提交一次,需要在提交后确认前注入故障才能暴露重放错误。

读完之后,独立完成

课后作业:自己做一遍

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

这次练什么

能用三个故障注入点验证不重复下单、可恢复挂起消息和可追踪失败订单。

本篇重点加练

恢复演练应检查合法状态与对账证据,发现可恢复差异和不可判定结果同样重要。

  • 状态转移
  • 补偿与对账

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

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

基础必做

  1. 作业 01原理问答

    一笔优惠券订单在接口受理、库存预留、数据库完成和消息确认之间流转。设计事件身份与业务状态,声明用户何时可以看到哪种状态。

    提交内容
    状态机与身份表
    怎样算完成
    • 区分消息处理状态和订单状态
    • 所有转换有前置条件

    练到的知识点固定业务事件 ID · 状态转移

  2. 作业 02代码推演

    工作者在写订单事务内退出,与提交成功后、记录确认前退出。为两种实验预先列出恢复时需检查的独立证据。

    提交内容
    两份故障注入计划
    怎样算完成
    • 不依赖单条日志判断提交
    • 记录注入点和实际退出时刻

    练到的知识点提交前崩溃 · 提交后未确认

  3. 作业 03代码推演

    恢复脚本自身在读取状态后退出,重启时订单已由另一个工作者推进。逐步评审恢复动作依据的状态是否仍然成立。

    提交内容
    恢复任务与业务时间线
    怎样算完成
    • 区分观察版本和执行版本
    • 包括恢复动作重复

    练到的知识点状态转移

综合应用

  1. 作业 04排错实验

    在本地 Stream 与订单测试库中,将同一消息交给原消费者和接管者,使用固定屏障控制二者进入持久化阶段。

    提交内容
    运行脚本与竞争日志
    怎样算完成
    • 核对待确认记录和唯一约束
    • 实验有超时和清理程序

    练到的知识点PEL 恢复 · 唯一约束

  2. 作业 05设计评审

    一类事件因券已撤销而持续失败,另一类因数据库暂不可用失败。定义补偿、对账、人工处理和停止自动尝试的判据。

    提交内容
    失败处置表
    怎样算完成
    • 区分业务拒绝和基础设施故障
    • 永久失败仍可追溯原始事件

    练到的知识点补偿与对账 · 持久失败出口

  3. 作业 06编程实践

    制作本地恢复演练脚本,运行正常、提交前退出、提交后退出、重复接管四轮;每轮自动汇总订单、库存和消息处理证据。

    提交内容
    脚本、运行记录与对账报告
    怎样算完成
    • 固定事件集可重复
    • 明确真实组件与模拟故障的边界

    练到的知识点固定业务事件 ID · 状态转移 · 提交前崩溃 · 提交后未确认 · PEL 恢复 · 唯一约束 · 补偿与对账 · 持久失败出口

  4. 作业 07设计评审

    对账分别读取订单表、库存表和消息记录,各次读取时间不同。定义一次差异报告的时间口径,判断何时只能报告待复核。

    提交内容
    对账观察协议
    怎样算完成
    • 区分暂时差异和持续异常
    • 记录每份数据的范围与时间

    练到的知识点补偿与对账

  5. 作业 08编程实践

    写本地状态轨迹分析器读取多轮恢复账本,找出无合法前置状态的转换与长期未收敛项;测试账本缺事件的情况。

    提交内容
    程序及轨迹报告
    怎样算完成
    • 未知与非法分开输出
    • 不把日志完整性当作默认前提

    练到的知识点状态转移 · 补偿与对账

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

  1. 作业 09设计评审

    团队只验证过单次崩溃。扩展一份组合故障计划,包含恢复中再次退出、补偿失败及人工重复重放,并按风险给出执行顺序。

    提交内容
    组合故障矩阵
    怎样算完成
    • 每例列出不变量和停止条件
    • 不把有限覆盖声明为所有故障均安全

    练到的知识点固定业务事件 ID · 状态转移 · PEL 恢复 · 唯一约束 · 补偿与对账 · 持久失败出口

思维拓展

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

  1. 作业 10思维拓展 · 条件变化

    将演练目标从“最终订单正确”改成“任意时刻都不得向用户显示错误的成功状态”。设计会区分这两项目标的故障历史与观测。

    提交内容
    双目标验收及反例
    怎样算完成
    • 包含异步受理与最终完成
    • 事件历史能定位错误声明时刻

    练到的知识点状态转移 · 补偿与对账

完成后,再检验一次

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

继续阅读

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

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