仅凭 Lua 和消息队列能否保证不超卖和一人一单?
未标记
考查重点:缓存原子性与持久化约束。Lua 管 Redis 原子检查,MQ 传事件,数据库条件扣减和用户唯一约束兜不超卖/一人一单;消费与补偿均幂等
本题目录
参考答案
核心回答
不能只凭“用了 Lua 和消息队列”就保证不超卖、一人一单。Lua 解决 Redis 内检查与预扣的并发原子性,MQ 负责异步传递和削峰;最终约束还需要数据库事务、唯一约束、消费幂等和失败恢复共同完成。
以优惠券秒杀为例,可以这样分工:
Lua在Redis内一起检查库存和用户领取标记,通过后预扣库存、登记本次请求,避免“检查后被其他请求抢先修改”。- 预占结果与待处理任务需要有可恢复记录,可靠投递订单事件,处理发送失败和结果不确定的窗口。
- 消费者在数据库事务中执行有库存条件的扣减、创建订单;用“活动、券、用户”等业务唯一约束保证同一用户的购买资格只成功一次,提交后再确认消息。
- 重复消息返回既有结果;失败和超时通过状态机重试、对账或释放预占,避免重复扣库存或重复补库存。
这里“不超卖”约束总库存,“一人一单”约束用户资格,是两个独立条件。数据库是最终订单与库存的权威来源时,所有写入路径都要遵守这些约束,不能只保护 Redis 入口。
Lua 的原子性覆盖到哪里
如果先查库存,再单独减库存,两个请求都可能看到最后一份库存。把检查库存、检查用户、预扣和登记放进同一个正确的短脚本,可以避免其他命令在这些步骤之间插入。
但脚本不能把 Redis、Kafka 和 MySQL 一起变成一个事务;执行中发生错误也不能假定此前写入自动回滚。因此应先验证参数、键类型与业务条件,再执行必要写入,并处理错误状态。Redis Cluster 下,多键脚本还要求相关键位于同一哈希槽,不能直接跨分片维护一个全局原子约束。
原子执行也不等于故障后状态一定保留。Redis 的持久化配置、异步复制和故障切换可能影响已确认预占的恢复;若预占状态丢失后重新放行请求,数据库仍应守住最终库存和用户资格,部分预占请求则可能需要返回失败或重新确认。
Redis 已扣库存,发消息失败怎么办
首先区分明确失败和结果未知:网络超时可能是 MQ 已接收、确认没到,不能立即无条件补库存,否则消息稍后消费成功,就可能同时出现订单和可再次出售的额度。
一种参考设计是在预占时登记稳定请求号和待发送事件,再由投递器重试;重复投递使用同一业务标识。若选择在同一 Lua 中写入 Redis Stream,可缩小“预扣成功却没有任务记录”的应用进程崩溃窗口,但仍需考虑 Redis 自身的持久化、复制、待处理任务恢复和积压,不会自动获得跨系统事务。
如果采用数据库 Outbox,业务记录和待发送事件必须在同一个数据库事务内提交;它能解决这一数据库事务与 MQ 发送之间的缺口,却不能顺带把前面的 Redis 预扣纳入原子提交。两种设计都要明确预占由谁恢复、如何重试,以及何时才向用户确认最终下单成功。异步入口可以先返回“已受理”,再查询订单结果。
数据库怎样同时守住两条约束
库存扣减应由一条带“库存足够”条件的更新完成,检查实际影响行数;或者在事务内锁住库存记录后检查和扣减,不能普通查询后在事务外盲目更新。库存不足时不创建成功订单。
同一事务还要插入带业务唯一约束的订单或购买资格记录。唯一键字段应完整且非空,范围按“同一活动的同一券、同一用户”等实际规则定义。只有请求号唯一,只能防同一请求重试,不能阻止一个用户换请求号再次购买。事务失败要整体回滚,不能已扣数据库库存却留下失败订单。
消息可能重复:订单事务已提交、确认消息前消费者崩溃,会再次投递。消费者应根据业务键识别已成功处理的订单,避免再扣一次,而不是只在内存中记录已消费。
释放预占也要按请求状态进行一次性的条件转换,成功订单不能被迟到的取消任务释放,重复取消不能重复加库存。如果业务允许取消后重买,需要单独设计购买资格状态与释放规则,不能简单删除全部历史去重记录。
参考资料
- 国内:JavaGuide:服务限流详解:借助 Redis 与 Lua 的组合说明条件检查和修改如何在服务端连续执行,以及脚本需要保持短小的原因。
- 国内:JavaGuide:接口幂等性设计详解:用于设计业务唯一键、数据库兜底及去重记录与业务同事务提交,区分锁、幂等和库存约束。
- 国内:阿里云:消息幂等:用于解释发送、确认和重投造成重复的窗口,以及为什么应使用稳定业务标识去重。
- 国外:Redis:Scripting with Lua:用于核对脚本原子执行、条件更新和显式传入访问键的要求,限定其作用范围在 Redis 内。
- 国外:MySQL 8.4:Locking Reads:通过并发更新计数器的例子说明普通查询为何不够,以及事务内锁定并更新共享记录的机制。

