读完这篇,你能做什么
能解释从获取消息到恢复挂起订单的完整状态变化,并识别锁不提供的保证。
学习理由、准备要求与知识点
为什么学它
锁只限制部分并发,异步下单还要保证消息能恢复、落库能去重且失败可处理。
先读这些内容
知识点清单
- 锁持有者令牌
- 租约窗口
- 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["有限重试或持久失败"]按顺序看,每一步做了什么
- 明确锁保护的资源与业务不变量。
- 区分随机锁所有者与可验证的单调代次。
- 在最终资源原子检查代次、幂等身份和业务条件。
- 数据库提交后确认消息,合理接管空闲Pending。
- 监控重试、永久失败和可靠性交接。
03对照实现代码
OpenJDK 17 GA,jdk-17+35 · AtomicLong.incrementAndGet / compareAndSet
下方为原创 Java 教学片段,调用已核验的 JDK API 说明局部机制;不是该类原样源码,不代表Redis或数据库内核实现。完整程序见算法模块。
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混用。
代码先调用谁,再调用谁
- 产生可靠所有权代次
- 请求携带代次与稳定操作ID
- 最终资源核对已接受代次
- 原子提交业务与幂等记录
- 持久结果确认后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成功后引入新的业务效果。
固定宽度数值处理常数,HashMap平均查找常数,n个操作结果保留用于重放;字符串哈希另计ID长度,生产记录需定义保留期限。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
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 时机
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
订单工作者处理一个任务需 80~800 毫秒,锁租约为 300 毫秒。分析正常执行、长暂停和响应丢失时“仍持锁”的证据各是什么。
- 提交内容
- 租约时间线与证据表
- 怎样算完成
- 区分本地判断和远端状态
- 覆盖租约到期边界
练到的知识点锁持有者令牌 · 租约窗口
- 作业 02代码推演
消费组 A 有两个消费者,依次出现读到未确认、消费者退出、重新启动三种事件。追踪消息在新消息、待确认和已完成记录中的归属。
- 提交内容
- 事件表与状态图
- 怎样算完成
- 注明消费组和消费者身份
- 不将消费位置等同于数据库完成状态
练到的知识点Stream 消费组 · PEL
- 作业 03原理问答
运维看到一个消费组 PEL 为零,拟据此宣布订单全部完成。列举作出判断前必须核对的 Stream、消费组和业务记录范围。
- 提交内容
- 完成声明的证据清单
- 怎样算完成
- 包括不同消费组与保留期
- 区分无待确认和无未处理业务
练到的知识点PEL
综合应用
- 作业 04设计评审
同一订单事件被两个工作者同时处理,其中一个更新订单金额后失败。审查业务写入与去重记录需要满足的事务契约。
- 提交内容
- 事务边界图与冲突规则
- 怎样算完成
- 覆盖回滚及提交结果未知
- 校验重复 ID 的负载一致性
练到的知识点数据库事务 · 幂等落库
- 作业 05排错实验
在本地 Redis Stream 中制造两条不同空闲时长的待确认消息,接管其中符合实验条件的消息,再在不同数据库处理阶段确认。
- 提交内容
- 命令记录与消息状态快照
- 怎样算完成
- 记录 idle 时间和领取条件
- 对每次 XACK 说明依据而非只列命令
练到的知识点XACK 时机 · XAUTOCLAIM
- 作业 06编程实践
实现一个本地异步订单消费者演示,注入业务异常和确认失败两类可控故障;运行恢复后按事件 ID 对照订单表、待确认列表和处理日志。
- 提交内容
- 源码、注入开关和对账报告
- 怎样算完成
- 实验可重置且仅访问本地资源
- 包含重放前后业务副作用数量
练到的知识点Stream 消费组 · PEL · 数据库事务 · 幂等落库 · XACK 时机 · XAUTOCLAIM
- 作业 07代码推演
一个工作者一次读取五条消息,前三条已落库,第四条失败,第五条尚未开始。设计逐条确认的状态记录,评审按批次全部确认的语义。
- 提交内容
- 批次状态表
- 怎样算完成
- 每条消息有独立处理证据
- 考虑确认请求结果未知
练到的知识点XACK 时机
- 作业 08面试场景
Stream 在消费前后持续裁剪,某些待确认条目对应的原始负载不再可用。制定调查字段与恢复资格,避免凭记录 ID 猜测订单内容。
- 提交内容
- 缺失消息处置单
- 怎样算完成
- 区别待确认元数据与消息内容
- 说明无法自动恢复的信息
练到的知识点PEL · XACK 时机
- 作业 09编程实践
在隔离本地 Redis 创建两个消费组,实施受限数量的读取、裁剪和确认操作,再编写报告器关联各组 PEL 与本地业务账本。
- 提交内容
- 脚本及状态差异报告
- 怎样算完成
- 资源独立命名且可清理
- 记录 Redis 版本和实际命令语义
练到的知识点PEL · XACK 时机
进阶挑战 · 掌握前置后选做
- 作业 10面试场景
某条事件总在接管后继续失败,并占用消费者大量时间。制定隔离和人工重放协议,说明原工作者恢复时还需检查哪些约束。
- 提交内容
- 运维处理单与验收条件
- 怎样算完成
- 保留原事件身份和失败证据
- 区分锁保护、幂等与接管职责
练到的知识点锁持有者令牌 · 租约窗口 · PEL · 幂等落库 · XAUTOCLAIM
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 跨域迁移
把消费组确认为“作业批改完成”信号,学生可能撤回文件,批改者可能离线。设计此迁移场景中确认应代表的业务事实。
- 提交内容
- 流程与确认契约
- 怎样算完成
- 区分已接收、已批改和已反馈
- 考虑原文不可恢复
练到的知识点PEL · XACK 时机
- 作业 12思维拓展 · 条件变化
若只能长期保存订单事实,原消息最多保留两天,而最长故障可能持续一周,应怎样定义恢复承诺并设计可检验的补充证据?
- 提交内容
- 保留期取舍与验证计划
- 怎样算完成
- 将保留期和故障期限量化
- 明确不能保证的恢复范围
练到的知识点PEL · XACK 时机
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
Redis:Distributed Locks (新标签页)↗
阅读租期、唯一令牌、安全释放和故障模型,识别锁过期后的并发风险。
- 实践参考文档英文
Redis:XREADGROUP (新标签页)↗
阅读消费组与 PEL,明确投递、处理和确认是不同阶段。
- 深入参考文档英文
Redis:XACK (新标签页)↗
核对确认只移出待确认列表的语义,把确认放在持久业务结果之后。
- 深入参考文档英文
Redis:XAUTOCLAIM (新标签页)↗
阅读空闲时间、游标与所有权转移,在 Redis 6.2 以上设计挂起消息恢复。
- 深入参考文档英文
OpenJDK 17 AtomicLong 源码 (新标签页)↗
按指定版本定位
AtomicLong.incrementAndGet/compareAndSet;对照源码分析理解实际调用边界。