zhaojie.tech/me
项目实战复盘 2026-10-05T00:00:00.000Z

一段 Lua 扣四类 Key:促销防超卖的原子设计

促销上线第一周就被运营找上门:限时购活动设了 100 件库存,卖完后台一看,成交记录加起来是 104 件。复盘日志,两笔订单几乎同时进来,各自读到 stock=1,各自判断”够扣”,各自扣减——经典的读-判-写竞态。收银链路的库存扣减我早已用数据库 CAS 兜住,但促销活动有四样东西要同时扣:活动预算、活动库存、用户限购次数,外加一个防重复提交的幂等键,全是 Redis 里的 Key。四样东西要么全扣成功、要么全不动,这个”要么”没法用四条独立命令表达。

为什么不能拆成多条命令

先看拆开写会发生什么。假设按”先扣预算、再扣库存、再校验限次”三条命令执行:预算扣完、库存扣到一半时,另一个请求在同一活动上并发执行,读到的预算/库存是中间态;更糟的是第三步发现限次已满要回滚前两步,回滚期间又可能有新请求读到了回滚前的值。Redis 单命令是原子的,命令与命令之间不是——任何把”检查 + 扣减”拆进多条命令的写法,都给并发留了窗口。四类 Key、带回滚、带幂等,最终只有两种选择:分布式锁把整个序列串行化,或者一段 Lua 脚本在 Redis 内原子执行。我选了 Lua:锁要额外一次网络往返和一个可能泄漏的持有者,而 Lua 把逻辑搬到数据旁边,一次往返完成。

promotion_deduct.lua 的执行序

脚本约定四类 Key:budget:{promotionId}、stock:{promotionId}、userLimit:{promotionId}:{customerId}、幂等键,核心顺序是幂等先行、逐项检查扣减、失败回滚已扣项:

-- 1. 幂等检查:处理过直接成功返回
local existing = redis.call('GET', 'idempotency:' .. idempotencyKeyVal)
if existing then return {1, 'idempotent_skip'} end

-- 2. NX 抢占幂等键(EX 3600)
local ok = redis.call('SET', 'idempotency:' .. idempotencyKeyVal, '1', 'NX', 'EX', expireSeconds)
if not ok then return {0, 'idempotent_conflict'} end

-- 3. 预算:Key 不存在 = 活动未设预算,跳过;不足则删幂等键返回
if currentBudget < budgetAmount then
    redis.call('DEL', 'idempotency:' .. idempotencyKeyVal)
    return {0, 'budget_exceeded'}
end
redis.call('DECRBY', budgetKey, budgetAmount)

-- 4. 库存不足 → INCRBY 回滚预算 + DEL 幂等键 → stock_insufficient
-- 5. 限次已满 → 回滚预算库存 + DEL 幂等键 → user_limit_reached
return {1, 'ok'}

两个设计点值得展开。幂等键放在最前面:同一订单重试进来时(幂等键是 orderId:promotionId),第一步就短路返回成功,后面四类 Key 一概不碰——重试成本最低。失败必须删幂等键:扣减失败的业务请求不算”处理过”,不删的话用户改完购物篮重试会被幂等键挡住,白白丢单。回滚只回滚”已扣的项”:库存检查失败时预算已扣所以要加回去,限次失败时预算库存都要加回——顺序上的后项失败,前项全部还原,等效于一个 Redis 内的迷你事务。

Java 侧加载走 Spring 的标准通道,@PostConstruct 里一次读入,之后复用:

deductScript = new DefaultRedisScript<>();
deductScript.setScriptSource(
    new ResourceScriptSource(new ClassPathResource("lua/promotion_deduct.lua")));
deductScript.setResultType(List.class);

脚本放 resources/lua/ 下随 jar 发布,不拼字符串——改脚本就是改代码,走评审和版本管理。

配套的 rollback 脚本

订单取消、退款要把已扣的量还回去,配套一段 promotion_rollback.lua。它同样先查自己的回滚幂等键(TTL 86400 秒)防重复回滚,再校验原始幂等键仍然存在——原扣减都没发生过,回滚无从谈起;然后 INCRBY 还预算库存、DECR 还限次,最后删掉原始幂等键。删原键的意义是:用户退款后再下同一订单(同 orderId 重放场景之外的新请求有新键),额度是干净的;而同 orderId 的重放不会再吃到已退回的名额。

一个脚本底座,撑起一排玩法

这套四 Key 原子扣减是促销中台的底座:满减、限时购、买赠、套餐等 8 类以上玩法全部复用同一段脚本——差异只在调用方组装的参数:纯满减不设库存 Key(脚本里 stockQty=0 自动跳过),限购玩法传 userLimit,套餐按子活动分别调用。扣减入口挂了慢调用监控(阈值 100ms),上线以来脚本本身的耗时基本在 1ms 量级,出问题的大多是上游把 Redis 连接池占满——那是另一个故事了。

一句原则

多 Key 一致性要么一把锁串行、要么一段脚本原子,选后者时记住失败路径要精确还原到”已执行的步骤”,并且幂等键的生命周期要和业务成败绑定。

← 全部文章 赵杰 · me.zhaojie.tech