qinghuan
← 黑马点评 / 全部笔记

单元 80 / 黑马点评

P0 · 必会主线L2 · 进阶

黑马点评:秒杀 Lua 与数据库唯一约束

用 Lua 保护 Redis 内的检查和预扣,再由数据库约束、条件更新和事务确保最终订单与库存一致。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能区分 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["失败状态与受控补偿"]

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

  1. 定义活动、库存、重复购买及取消规则。
  2. 写前校验脚本键类型、参数和同槽范围。
  3. 把预扣结果与稳定订单事件可靠关联。
  4. 数据库事务用条件库存更新和唯一约束落单。
  5. 按订单状态执行幂等补偿并验证库存守恒。

03对照实现代码

Spring Data Redis 3.5.0 DefaultScriptExecutor 源码 (新标签页)↗

Spring Data Redis 3.5.0;Redis脚本概念基线6.2+;算法Java17 · DefaultScriptExecutor.execute / eval / keysAndArgs

原创Java脚本客户端片段,需要StringRedisTemplate与经过审查的Lua正文。它说明客户端参数和返回值边界,不是Redis内核Java实现,不是完整秒杀服务。

核心机制代码 · 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 合成一笔事务;把订单交给后续处理时,还要保留稳定的事件身份和故障后能找回的处理记录。

代码先调用谁,再调用谁

  1. 校验活动与可信用户
  2. 准备显式同槽KEYS和ARGV
  3. DefaultScriptExecutor序列化并执行脚本
  4. 区分预扣结果与调用未知
  5. 可靠交接后由数据库事务落单

04动手写一个例子

带确认和取消的预扣库存账本

单券初始库存为非负整数,支持reserve、confirm、cancel。同订单同用户重试幂等,同订单不同用户拒绝;取消仅用于未确认预扣。单线程Java17模型。

输入 1
库存1,A预扣o1两次
输出 1
库存0,仍只有一份预扣
输入 2
o1取消两次
输出 2
库存只恢复到1

订单表负责稳定请求身份,用户占用表负责业务唯一性。两张映射承担不同约束,不能只保留其中一张就宣称同时解决重复请求与重复购买。

预扣时先执行所有可能的业务拒绝,再更新库存和映射。确认只改变状态不再次扣库存,取消只从保留状态释放一次,并保留历史订单防止旧请求复活。

每个转换后检查守恒式和用户占用数。自测通过证明给定串行转换的模型性质,真实并发原子性与持久性需数据库或Redis适配提供。

为什么这样写是对的available始终非负,初始库存等于available加非取消订单数;每个用户最多拥有一个非取消订单,同订单ID的用户不可改变。

时间 每次业务操作平均O(1),审计O(n)额外空间 O(n)

HashMap业务查找平均常数,历史订单保留n条;教学审计逐条计数,所以若每次都全量审计总成本会增加,生产可按窗口对账。

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

Main.java · 完整可运行程序 · Java
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 内部原子性与订单持久化,并为重复请求和落库失败定义结果。

本篇重点加练

数据库约束和补偿共同守住最终库存事实,重点检验失败分类与恢复中的再次失败。

  • 数据库唯一约束
  • 失败补偿

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

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

基础必做

  1. 作业 01代码推演

    脚本接收库存键、用户标识和数量,数量可能为 0、负数或非整数。逐项定义输入契约并审查校验过程中可出现的部分写入风险。

    提交内容
    契约表与脚本审查记录
    怎样算完成
    • 覆盖类型及数值范围
    • 区分原子执行和业务合法性

    练到的知识点库存校验 · Lua 原子执行

  2. 作业 02设计评审

    两场活动都出售同一种券,活动规则分别为每人一单和每人两单。设计购买状态的逻辑键与 Cluster 键布局,说明如何验证参与键的路由关系。

    提交内容
    键清单与路由检查记录
    怎样算完成
    • 区分活动身份与券身份
    • 覆盖花括号和空标识输入

    练到的知识点重复购买集合 · 同槽 KEYS

  3. 作业 03原理问答

    数据库存在订单主键、用户券唯一键和业务请求唯一键。为每个约束各构造一种独立冲突,列出冲突后仍需核对的业务字段。

    提交内容
    约束冲突矩阵
    怎样算完成
    • 不能把所有冲突当同一结果
    • 记录冲突对象与请求身份

    练到的知识点数据库唯一约束

综合应用

  1. 作业 04面试场景

    客户端超时重试时可能沿用或更换订单号,但用户与券不变。为每种情况定义服务端判定所需证据,避免把不同订单混为一次购买。

    提交内容
    请求对照表与约束方案
    怎样算完成
    • 同时记录业务订单 ID 和购买限制
    • 包含同 ID 不同负载

    练到的知识点业务订单 ID · 数据库唯一约束

  2. 作业 05原理问答

    本地库存条件更新影响行数为零,但 Redis 预留已成功。列出至少三种业务解释,并描述决定补偿前必须核对的状态。

    提交内容
    决策表
    怎样算完成
    • 区分未提交、拒绝与结果未知
    • 考虑补偿请求自身重复

    练到的知识点条件扣减 · 失败补偿

  3. 作业 06编程实践

    搭建仅供本机实验的 Redis 与 MySQL,或明确标注的内存模拟器;以 4 份库存执行并发购买、重复订单和非法数量,保留全过程账本。

    提交内容
    代码、初始化脚本和事件账本
    怎样算完成
    • 同时核验库存与订单约束
    • 标注真实中间件或模拟器的证据边界

    练到的知识点库存校验 · Lua 原子执行 · 重复购买集合 · 业务订单 ID · 数据库唯一约束 · 条件扣减 · 失败补偿

  4. 作业 07代码推演

    取消预留请求先发出,随后原下单结果才到达,取消响应又丢失。为每个参与方画出其局部认知,说明哪些动作需要知道最终状态。

    提交内容
    局部状态与时间线
    怎样算完成
    • 区别事实状态和节点认知
    • 覆盖结果未知

    练到的知识点失败补偿

  5. 作业 08设计评审

    活动支持部分退券,但原设计一人一券且补偿按整单归还库存。审查约束和补偿单位,写出新业务的不变量。

    提交内容
    规则差异与补偿契约
    怎样算完成
    • 区分订单、购买份数和退回份数
    • 包含多次部分退款

    练到的知识点数据库唯一约束 · 失败补偿

  6. 作业 09编程实践

    在本地数据库或声明边界的 Java 模型中制作补偿账本,注入补偿写入失败、重复提交及旧状态判断,检验每项决策是否有持久证据。

    提交内容
    程序、故障脚本及账本
    怎样算完成
    • 每次补偿关联原业务身份
    • 模拟器不宣称验证真实事务持久性

    练到的知识点数据库唯一约束 · 失败补偿

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

  1. 作业 10设计评审

    活动结束时仍有未决预留,运营要求导出可审计结算结果。定义未决记录、人工处理入口及再次运行结算的规则。

    提交内容
    结算流程与失败验收表
    怎样算完成
    • 覆盖晚到成功和晚到补偿
    • 所有处理可追溯到稳定订单身份

    练到的知识点同槽 KEYS · 业务订单 ID · 数据库唯一约束 · 失败补偿

思维拓展

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

  1. 作业 11思维拓展 · 反例构造

    构造“Redis 和数据库各自库存都非负,但全局仍不符合销售规则”的反例,说明检查局部下界还缺哪些跨系统约束。

    提交内容
    反例状态与缺失约束
    怎样算完成
    • 涉及至少两类业务事实
    • 反例可按时间顺序重放

    练到的知识点数据库唯一约束 · 失败补偿

  2. 作业 12思维拓展 · 跨域迁移

    将抢券改为预约座位:每个座位不可替换,用户付款可能晚到。评审原数量补偿模型中哪些规则可以保留、哪些必须重新定义。

    提交内容
    座位模型与迁移评审
    怎样算完成
    • 包含座位身份与租约期限
    • 明确晚到付款处理的产品前提

    练到的知识点数据库唯一约束 · 失败补偿

完成后,再检验一次

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

继续阅读

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

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