读完这篇,你能做什么
能区分 Redis 内部原子性与订单持久化,并为重复请求和落库失败定义结果。
学习理由、准备要求与知识点
为什么学它
Redis 预扣成功不等于数据库下单成功,正确性需要脚本、唯一约束和事务共同约束。
先读这些内容
知识点清单
- 库存校验
- Lua 原子执行
- 重复购买集合
- 同槽 KEYS
- 业务订单 ID
- 数据库唯一约束
- 条件扣减
- 失败补偿
运行前先确认Redis机制按6.2+基础能力讲解,Java脚本客户端源码固定Spring Data Redis 3.5.0;完整算法Java17单线程内存模型。未运行Lua或数据库。
01先弄懂它是什么
秒杀首先要保证:库存不能扣成负数,同一用户同一张券不能同时拥有多个有效订单,重试不能再扣一次。若检查库存和扣库存是两个独立命令,其他请求可能插在中间。Redis Lua 可以把 Redis 内的检查与修改合成一段不被插入的短操作,但这个保证不自动延伸到 MySQL。
Redis 预扣只表示先占用了名额,不能直接宣布 MySQL 订单已创建。客户端应能查询处理中状态,消费者再按稳定订单号写入数据库。数据库还要用用户与券的非空联合唯一约束防重复购买,用 stock 大于零的条件更新及影响行数防超扣,并让订单插入和库存修改在同一事务中提交。
需要明确取消与补偿政策。本文模型允许未确认预扣取消后再次申请,但同一订单号永不复用,已确认订单不在这里自动取消。真实退款、退券和活动结束有独立规则,不能对任何异常都无条件增加库存,否则可能给成功订单重复补回资源。
文中这几个词,先认一认
- 预扣
- 先占住库存等资源,并记录对应请求,等后续确认成功或按规则释放。
- 业务唯一键
- 业务上不允许重复的一组字段,例如购买用户与优惠券的组合。
- 幂等补偿
- 同一次释放库存的请求即使重复执行,也只把资源恢复一次。
02看看它怎么工作
本题以单券库存账本模拟预扣、确认和取消。订单ID映射到用户与状态,用户映射到当前有效订单;申请先检查同订单内容,再检查用户占用和库存,全部验证后才扣减与登记。所有操作在单线程内执行,不声称HashMap天然支持并发事务。
库存一时用户A预扣订单一,库存变零;同订单重试返回原状态,其他用户被拒绝。取消尚未确认的订单后库存恢复一,重复取消保持一;确认后的订单不能通过预扣取消接口释放,这使补偿是否允许由明确状态决定。
守恒式为初始库存等于当前可用库存加保留订单数加已确认订单数。取消订单留作历史但不再占库存,旧订单号重试仍返回取消状态。数据库与Redis真实账本可能暂时不一致,对账要比较稳定业务标识和状态,不能只比较两个库存数字。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
R["可信请求与稳定订单号"] --> P["Redis预扣"]
P --> E["可靠事件交接"]
E --> D["数据库条件库存与唯一订单事务"]
D --> C["确认结果"]
E --> F["失败状态与受控补偿"]按顺序看,每一步做了什么
- 定义活动、库存、重复购买及取消规则。
- 写前校验脚本键类型、参数和同槽范围。
- 把预扣结果与稳定订单事件可靠关联。
- 数据库事务用条件库存更新和唯一约束落单。
- 按订单状态执行幂等补偿并验证库存守恒。
03对照实现代码
Spring Data Redis 3.5.0;Redis脚本概念基线6.2+;算法Java17 · DefaultScriptExecutor.execute / eval / keysAndArgs
原创Java脚本客户端片段,需要StringRedisTemplate与经过审查的Lua正文。它说明客户端参数和返回值边界,不是Redis内核Java实现,不是完整秒杀服务。
RedisScript<Long> script = RedisScript.of(luaText, Long.class);
List<String> keys = List.of("stock:{v1}", "buyers:{v1}"); // 所有脚本访问键显式传入,同活动键使用相同hash tag。
Long result = template.execute(script, keys, authenticatedUserId); // 身份来自认证上下文,参数序列化要与Lua约定一致。
if (result == null) throw new IllegalStateException("no direct script result"); // 流水线或事务模式返回语义不能当普通同步调用。
recordReservationOutcome(result); // 这里只记录预扣结果,不能标记MySQL订单已提交。DefaultScriptExecutor 先把键和参数转换成 Redis 能接收的数据,并确定如何读取返回值。普通同步调用会先用 EVALSHA,按脚本摘要请求执行服务器已有的脚本;只有明确收到 NOSCRIPT、确认服务器找不到该脚本时,才改用 EVAL 发送脚本内容执行。遇到其他异常,不能据此认定脚本没有执行,也不能随意再执行一次。
在流水线或排队事务中,这条路径直接发送 EVAL,并先用 null 占位,真正结果要在之后相应执行阶段获取。调用方要知道当前模式,不能把这个 null 解释成库存不足,也不能当成脚本已成功。
Redis脚本保证执行期间不被其他命令穿插,但不提供数据库式运行时错误回滚;已经执行的写入可能保留。因此应在写前校验键类型、参数和业务前提,保持脚本短小并有错误恢复策略。
在 Redis 集群中,脚本要访问的键应通过 KEYS 参数明确传入,并落在同一个哈希槽,也就是键空间的同一分区。可以合理设置键名中的 hash tag,让同一活动的相关键分到同一个槽。这个脚本仍不能把 Redis 与 MySQL 合成一笔事务;把订单交给后续处理时,还要保留稳定的事件身份和故障后能找回的处理记录。
代码先调用谁,再调用谁
- 校验活动与可信用户
- 准备显式同槽KEYS和ARGV
DefaultScriptExecutor序列化并执行脚本- 区分预扣结果与调用未知
- 可靠交接后由数据库事务落单
04动手写一个例子
带确认和取消的预扣库存账本
单券初始库存为非负整数,支持reserve、confirm、cancel。同订单同用户重试幂等,同订单不同用户拒绝;取消仅用于未确认预扣。单线程Java17模型。
- 输入 1
库存1,A预扣o1两次- 输出 1
库存0,仍只有一份预扣
- 输入 2
o1取消两次- 输出 2
库存只恢复到1
订单表负责稳定请求身份,用户占用表负责业务唯一性。两张映射承担不同约束,不能只保留其中一张就宣称同时解决重复请求与重复购买。
预扣时先执行所有可能的业务拒绝,再更新库存和映射。确认只改变状态不再次扣库存,取消只从保留状态释放一次,并保留历史订单防止旧请求复活。
每个转换后检查守恒式和用户占用数。自测通过证明给定串行转换的模型性质,真实并发原子性与持久性需数据库或Redis适配提供。
为什么这样写是对的available始终非负,初始库存等于available加非取消订单数;每个用户最多拥有一个非取消订单,同订单ID的用户不可改变。
HashMap业务查找平均常数,历史订单保留n条;教学审计逐条计数,所以若每次都全量审计总成本会增加,生产可按窗口对账。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
import java.util.*;
public class Main {
enum State {RESERVED,CONFIRMED,CANCELED}
record Order(String user,State state){}
static class Ledger {
final int initial;int available;Map<String,Order> orders=new HashMap<>();Map<String,String> active=new HashMap<>();
Ledger(int stock){if(stock<0)throw new IllegalArgumentException("stock");initial=available=stock;}
State reserve(String id,String user){
if(id==null||id.isBlank()||user==null||user.isBlank())throw new IllegalArgumentException("identity");Order old=orders.get(id);
if(old!=null){if(!old.user().equals(user))throw new IllegalArgumentException("id conflict");return old.state();} // 稳定订单号重放不再次扣库存。
if(active.containsKey(user)||available==0)throw new IllegalStateException("not eligible");
available--;orders.put(id,new Order(user,State.RESERVED));active.put(user,id);return State.RESERVED;
}
void confirm(String id){Order o=required(id);if(o.state()==State.CANCELED)throw new IllegalStateException("canceled");orders.put(id,new Order(o.user(),State.CONFIRMED));} // 确认不再扣减。
void cancel(String id){Order o=required(id);if(o.state()==State.CONFIRMED)throw new IllegalStateException("confirmed");if(o.state()==State.CANCELED)return;
orders.put(id,new Order(o.user(),State.CANCELED));active.remove(o.user());available++; // 只释放一次未确认预扣。
}
Order required(String id){Order o=orders.get(id);if(o==null)throw new IllegalArgumentException("unknown");return o;}
void audit(){long held=orders.values().stream().filter(o->o.state()!=State.CANCELED).count();if(available<0||available+held!=initial||active.size()!=held)throw new AssertionError("conservation");}
}
public static void main(String[] args){
Ledger l=new Ledger(1);if(l.reserve("o1","A")!=State.RESERVED||l.reserve("o1","A")!=State.RESERVED||l.available!=0)throw new AssertionError();l.audit();
try{l.reserve("o2","B");throw new AssertionError();}catch(IllegalStateException expected){}
l.cancel("o1");l.cancel("o1");if(l.available!=1||l.reserve("o1","A")!=State.CANCELED)throw new AssertionError();l.audit();
l.reserve("o2","A");l.confirm("o2");l.confirm("o2");if(l.available!=0)throw new AssertionError();l.audit();
try{l.cancel("o2");throw new AssertionError();}catch(IllegalStateException expected){}
try{l.reserve("o2","B");throw new AssertionError();}catch(IllegalArgumentException expected){}
Ledger empty=new Ledger(0);empty.audit();try{empty.reserve("x","X");throw new AssertionError();}catch(IllegalStateException expected){}
System.out.println("PASS 7 scenarios");
}
}这些特殊情况也要试一试
- 模型为单线程,不提供跨系统事务。
- 已确认订单退款不属于本题预扣取消。
- 历史订单号保留防止旧请求重新占库。
进阶选读 · 后续再读:多资源预占与可靠交接
场景一个订单需要同时预占多种券或跨分片库存。
做法先明确原子范围,可使用同库事务或显式预占状态机,跨系统采用可靠事件与补偿;将每个资源操作绑定稳定订单和版本。
复杂度跨分片不能直接套用单Redis脚本的局部原子性;协调成本和可用性受分布式故障与业务约束共同影响。
好处与代价更强同步协调增加延迟,最终一致方案需要可解释的处理中状态、超时和补偿出口。
05项目里怎么用
Redis脚本尽量短,在写前检查键类型和参数,库存采用明确整数范围。脚本错误不会自动撤销先前写入,超时或连接断开也不必然代表没有执行;重试前应凭稳定订单身份查询或按幂等契约恢复,不能直接再扣一次。
数据库事务中先核对业务身份,再执行带条件的库存更新和唯一订单写入。唯一键冲突需要查明是同一业务已完成还是不同请求冲突,不能捕获所有异常都返回成功;事务隔离、死锁重试和连接故障也要分别处理。
预扣完成后如何交给消费者,必须留下故障后可找回的记录;只放在进程内队列,重启就可能丢失。对账要同时查订单、预扣、取消、补偿和事件状态,明确区分处理中与最终成功。单机模型只验证操作逻辑,没有验证真实 Redis 持久化、复制或 MySQL 并发。
容易踩的坑
- Lua原子执行被解释为跨Redis与
MySQL事务。 - 运行时脚本报错就假设之前写入自动回滚。
- 只查询重复而没有数据库唯一约束。
- 任何落库异常都立即无条件补回库存。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| Redis预扣 | 快速挡住大量不合格请求 | 增加与数据库对账的状态 | 热点抢购入口 |
| 数据库最终约束 | 守住持久业务不变量 | 可能出现竞争与重试 | 订单最终落库 |
| 显式预占状态 | 补偿和查询可解释 | 状态与历史存储增加 | 异步确认链路 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01脚本原子性具体是什么?
展开参考答案与推导
执行期间不被其他命令穿插,不能推导跨系统提交或运行时错误自动回滚。
02订单ID与用户券唯一键为何都需要?
展开参考答案与推导
前者识别同次请求,后者约束不同请求不能造成同一业务重复购买。
03库存守恒如何写?
展开参考答案与推导
初始库存等于可用、保留和已确认占用之和,取消历史不再占用但仍保留身份。
换个情况,还会处理吗
01脚本成功后进程崩溃,如何展示?
展开参考答案与推导
保持可查询的处理中状态,依据持久事件或预占记录恢复,不能宣称订单已完成。
02唯一冲突后能直接成功吗?
展开参考答案与推导
需要核对冲突订单的稳定身份与内容,相同业务可幂等返回,不同业务应明确冲突。
03补偿与确认同时发生怎么办?
展开参考答案与推导
以状态和版本条件决定唯一合法转换,已确认后不能走预扣取消,真实适配要原子执行条件更新。
写代码试一试
01重复取消后库存是多少?
- 输入 / 条件
初始1,预扣一次,取消两次- 输出 / 预期
1
提示 取消状态幂等。
展开参考答案与推导
只有RESERVED到CANCELED的首次转换增加库存,第二次直接返回。
02旧订单号取消后重试会怎样?
- 输入 / 条件
o1已取消,再reserve o1- 输出 / 预期
CANCELED
提示 保留历史身份。
展开参考答案与推导
同ID重放返回原状态,不重新占库,重新申请需要新的业务操作身份。
03确认为什么不扣库存?
- 输入 / 条件
预扣已扣一次- 输出 / 预期
库存不变
提示 状态转换与资源变化分开。
展开参考答案与推导
确认将保留占用转为最终占用,总占用不变,再扣一次会破坏守恒。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能区分 Redis 内部原子性与订单持久化,并为重复请求和落库失败定义结果。
本篇重点加练
数据库约束和补偿共同守住最终库存事实,重点检验失败分类与恢复中的再次失败。
- 数据库唯一约束
- 失败补偿
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
脚本接收库存键、用户标识和数量,数量可能为 0、负数或非整数。逐项定义输入契约并审查校验过程中可出现的部分写入风险。
- 提交内容
- 契约表与脚本审查记录
- 怎样算完成
- 覆盖类型及数值范围
- 区分原子执行和业务合法性
练到的知识点库存校验 · Lua 原子执行
- 作业 02设计评审
两场活动都出售同一种券,活动规则分别为每人一单和每人两单。设计购买状态的逻辑键与 Cluster 键布局,说明如何验证参与键的路由关系。
- 提交内容
- 键清单与路由检查记录
- 怎样算完成
- 区分活动身份与券身份
- 覆盖花括号和空标识输入
练到的知识点重复购买集合 · 同槽 KEYS
- 作业 03原理问答
数据库存在订单主键、用户券唯一键和业务请求唯一键。为每个约束各构造一种独立冲突,列出冲突后仍需核对的业务字段。
- 提交内容
- 约束冲突矩阵
- 怎样算完成
- 不能把所有冲突当同一结果
- 记录冲突对象与请求身份
练到的知识点数据库唯一约束
综合应用
- 作业 04面试场景
客户端超时重试时可能沿用或更换订单号,但用户与券不变。为每种情况定义服务端判定所需证据,避免把不同订单混为一次购买。
- 提交内容
- 请求对照表与约束方案
- 怎样算完成
- 同时记录业务订单 ID 和购买限制
- 包含同 ID 不同负载
练到的知识点业务订单 ID · 数据库唯一约束
- 作业 05原理问答
本地库存条件更新影响行数为零,但 Redis 预留已成功。列出至少三种业务解释,并描述决定补偿前必须核对的状态。
- 提交内容
- 决策表
- 怎样算完成
- 区分未提交、拒绝与结果未知
- 考虑补偿请求自身重复
练到的知识点条件扣减 · 失败补偿
- 作业 06编程实践
搭建仅供本机实验的 Redis 与
MySQL,或明确标注的内存模拟器;以 4 份库存执行并发购买、重复订单和非法数量,保留全过程账本。- 提交内容
- 代码、初始化脚本和事件账本
- 怎样算完成
- 同时核验库存与订单约束
- 标注真实中间件或模拟器的证据边界
练到的知识点库存校验 · Lua 原子执行 · 重复购买集合 · 业务订单 ID · 数据库唯一约束 · 条件扣减 · 失败补偿
- 作业 07代码推演
取消预留请求先发出,随后原下单结果才到达,取消响应又丢失。为每个参与方画出其局部认知,说明哪些动作需要知道最终状态。
- 提交内容
- 局部状态与时间线
- 怎样算完成
- 区别事实状态和节点认知
- 覆盖结果未知
练到的知识点失败补偿
- 作业 08设计评审
活动支持部分退券,但原设计一人一券且补偿按整单归还库存。审查约束和补偿单位,写出新业务的不变量。
- 提交内容
- 规则差异与补偿契约
- 怎样算完成
- 区分订单、购买份数和退回份数
- 包含多次部分退款
练到的知识点数据库唯一约束 · 失败补偿
- 作业 09编程实践
在本地数据库或声明边界的 Java 模型中制作补偿账本,注入补偿写入失败、重复提交及旧状态判断,检验每项决策是否有持久证据。
- 提交内容
- 程序、故障脚本及账本
- 怎样算完成
- 每次补偿关联原业务身份
- 模拟器不宣称验证真实事务持久性
练到的知识点数据库唯一约束 · 失败补偿
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
活动结束时仍有未决预留,运营要求导出可审计结算结果。定义未决记录、人工处理入口及再次运行结算的规则。
- 提交内容
- 结算流程与失败验收表
- 怎样算完成
- 覆盖晚到成功和晚到补偿
- 所有处理可追溯到稳定订单身份
练到的知识点同槽 KEYS · 业务订单 ID · 数据库唯一约束 · 失败补偿
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造“Redis 和数据库各自库存都非负,但全局仍不符合销售规则”的反例,说明检查局部下界还缺哪些跨系统约束。
- 提交内容
- 反例状态与缺失约束
- 怎样算完成
- 涉及至少两类业务事实
- 反例可按时间顺序重放
练到的知识点数据库唯一约束 · 失败补偿
- 作业 12思维拓展 · 跨域迁移
将抢券改为预约座位:每个座位不可替换,用户付款可能晚到。评审原数量补偿模型中哪些规则可以保留、哪些必须重新定义。
- 提交内容
- 座位模型与迁移评审
- 怎样算完成
- 包含座位身份与租约期限
- 明确晚到付款处理的产品前提
练到的知识点数据库唯一约束 · 失败补偿
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读教程英文
Redis:Lua 脚本入门 (新标签页)↗
阅读脚本原子执行、阻塞与显式 KEYS,控制脚本复杂度并验证写入前提。
- 实践规范英文
Redis Cluster specification (新标签页)↗
阅读哈希槽、重定向、多键操作和失效窗口,解释集群的能力边界。
- 深入参考文档英文
MySQL 8.4:CREATE TABLE (新标签页)↗
阅读列类型、空值、主键与唯一约束,把业务不变量落实到表结构。
- 深入参考文档英文
MySQL 8.4:事务隔离级别 (新标签页)↗
比较 RC、RR 与锁定读行为,明确并发实验采用的事务前提。
- 深入参考文档英文
Redis:EVAL 与键参数 (新标签页)↗
阅读本文对应命令、数据模型和失败边界,核对实际Redis或数据库版本后设计隔离验证。
- 深入参考文档英文
Spring Data Redis 3.5.0 DefaultScriptExecutor 源码 (新标签页)↗
按指定版本定位
DefaultScriptExecutor.execute/ eval /keysAndArgs;对照源码分析理解实际调用边界。 - 深入参考文档英文
Redis SCRIPT KILL (新标签页)↗
阅读脚本已写数据后的终止限制,理解脚本执行与回滚边界。
- 深入参考文档英文
Redis官方:原子执行与回滚边界 (新标签页)↗
阅读Lua与事务错误处理部分,区分命令不可穿插和运行时错误回滚,理解先校验再写入的原因。