编程 秒杀库存回滚别用 INCR:扣减流水、同槽 key 与乱序补偿

2026-10-22 00:04:20

秒杀库存回滚别用 INCR:扣减流水、同槽 key 与乱序补偿

信源参考:

站内那套讲的是 DB 里 available/locked 的预占,靠 outbox 投递释放任务、再每日对账来闭环;这篇只谈 Redis 这一层——秒杀扣减写进缓存之后,一笔取消怎么把量还回去。

直接 INCR 为什么不行

回滚最省事的写法是 INCRBY hot_item_stock 2。问题在重复:扣减请求超时后会重试,取消消息又可能来一次,同一个订单号回滚两遍,库存就凭空多出来一份。更麻烦的是事后追溯——INCRBY 只留下一个数字,你查不到是谁在哪一刻加回去的,出了差异只能靠猜。

扣减侧先要有流水。增强版扣减脚本:

-- KEYS[1] = hot_{1001-SKU_A}_stock
-- KEYS[2] = hot_{1001-SKU_A}_deduction_history
-- ARGV[1] = 扣减数量, ARGV[2] = 扣减请求唯一 key(订单号/流水号)
local history = KEYS[2]
if redis.call('hexists', history, ARGV[2]) == 1 then
  return 0                          -- 已扣过,幂等返回
end
local raw = redis.call('get', KEYS[1])
if not raw then
  return -1                         -- 库存 key 不存在,回源,不能当 0 处理
end
local stock = tonumber(raw)
local qty   = tonumber(ARGV[1])
if stock < qty then return 1 end    -- 库存不足
stock = stock - qty
redis.call('set', KEYS[1], tostring(stock))
redis.call('hset', history, ARGV[2], qty)
if stock == 0 then return 2 end     -- 扣完
return 3                            -- 扣减成功

返回码约定成 0/1/2/3/-1,调用方才能区分「重复」「不足」「扣完」「正常」「异常」这几种情况,不要把它们混成一个 true/false。

回滚脚本反着来:

-- KEYS[1] = hot_{1001-SKU_A}_stock
-- KEYS[2] = hot_{1001-SKU_A}_deduction_history
-- ARGV[1] = 扣减请求唯一 key
local qty = redis.call('hget', KEYS[2], ARGV[1])
if not qty then
  return -1                         -- 查不到流水,视为未知,不是成功
end
redis.call('hdel', KEYS[2], ARGV[1])   -- 先删流水,重复回滚天然被挡住
redis.call('incrby', KEYS[1], tonumber(qty))
return 1

HDEL 放在 INCRBY 前面,第二次同样的回滚请求 HGET 就返回 nil,不会再改库存。value 存扣减数量,回滚加多少不用再从别处查。

Cluster 下 key 必须在同一个槽

Redis Cluster 的 Lua 要求脚本里所有 key 落在同一个 slot,否则 EVAL 直接报:

CROSSSLOT Keys in request don't hash to the same slot

解决办法是 hash tag——大括号里的内容参与 CRC16,其余部分不参与。stock 和 history 用同一个 tag:

hot_{1001-SKU_A}_stock
hot_{1001-SKU_A}_deduction_history

带 bucket 之后 tag 要挪到 bucket 这一层:

hot_{1001-SKU_A_B1}_stock
hot_{1001-SKU_A_B1}_deduction_history
hot_{1001-SKU_A_B2}_stock
...

这样同一个 bucket 内的 stock 与 history 同槽、能被一段 Lua 原子操作,不同 bucket 的 tag 不同,才会真正散到不同节点。如果把 tag 写成 {1001-SKU_A},五个 bucket 全挤同一个 slot,分段就白分了。

乱序消费:回滚先落、扣减后落

扣减超时重试和取消回滚是两条路径。回滚先到、重试的扣减后到,Redis 里就留下一条「已取消订单的扣减」——库存少了一份,history 里多一条记录,单看 Redis 是自洽的,只有对着业务单据才看得出来。

两条路:

同 topic 顺序消息。 用订单号做分区键,扣减和回滚进同一个 topic 的同一个队列,靠 MQ 的有序性保证回滚一定排在扣减之后。代价是串行:单分区吞吐有上限,秒杀峰值下扣减本来是最烫的一段,把回滚塞进去会一起被拖慢。另外必须保证两者真在同一个 topic,跨 topic 有序性就没有了。

对账补偿。 定期扫 hot_{*}_deduction_history,拿 field 里的唯一 key 回查单据状态,发现已取消的再投一次回滚请求。回滚脚本本身幂等,重投安全。代价是有脏数据窗口,以及扫描的计算开销。

取舍看回滚占比和业务容忍度。秒杀里扣减 QPS 高、取消比例低,把回滚并进扣减队列不划算,通常是扣减走正常 topic、回滚走独立 topic,再加对账兜底;库存极紧、窗口内超卖不可接受的场景,才值得用顺序消息换一部分吞吐。窗口能压到多短,需按自己环境的取消率和 MQ 参数验证。

「history 里没有就当回滚成功」错在哪

常见写法是:HEXISTS 为 0 就 return 成功。这在下面几种情况下是错的:

  • history key 设了 TTL,过期了;
  • 内存淘汰策略是 allkeys-lru / volatile-lru,key 被淘汰;
  • 预热未覆盖、节点扩容后新 slot 没有历史数据;
  • 主从切换,从节点落后丢了最近的写入。

这些场景里「查不到」既可能是真没扣过,也可能是扣过但记录丢了,Redis 层面无法区分。默认成功等于把不确定当成确定,库存会凭空多出来。

正确做法是把 -1 当作未知,走回源校准:DB 的扣减流水表是权威,先查它再决定回不回补。

SELECT qty, status
FROM stock_deduct_flow
WHERE deduct_key = ?;

查到且状态为成功,就按流水里的数量和 bucket 定向回补;查不到才认定这笔扣减没发生过;查不到但 DB 也模糊的,进补偿队列排队处理,不要当场返回成功。

分段库存:回滚必须回到原 bucket

同一个 sku 的 key 经 CRC16 固定落在一个 slot,扣减 10 万 QPS 会把单节点打爆。把 10000 件拆成 5 份:SKU_1001_BUCKET_1=2000 一直到 BUCKET_5=2000,key 不同、slot 不同,扣减并发上限就上去了。

请求进来按 userId/TraceId 做一致性哈希选 bucket,扣减失败时从 startBucketIndex 往后轮询,全部失败才算物理售罄。这套在扣减侧没问题,回滚侧有个前提:必须回到当初扣减的那个 bucket。

按「当前 userId 重新哈希一次」去回滚是不行的——bucket 数变更、Hash 环重映射、路由算法改版,都会打到别的 bucket 上。结果是 BUCKET_1 被扣穿、BUCKET_2 又被回补,总量看着对,但 BUCKET_1 已经卖不出去,碎片化就是这么来的。

所以 bucket 号要跟扣减流水一起持久化,回滚按记录走,不重新路由:

CREATE TABLE stock_deduct_flow (
  deduct_key VARCHAR(64) NOT NULL,
  sku_id     BIGINT      NOT NULL,
  bucket_no  INT         NOT NULL,
  qty        INT         NOT NULL,
  status     VARCHAR(16) NOT NULL,
  PRIMARY KEY (deduct_key),
  KEY idx_sku_bucket (sku_id, bucket_no)
);

消息体里也带上 bucketNo,回滚消费时直接定位 hot_{1001-SKU_A_B3}_stock,不依赖哈希。bucket 下线或重置时,按 sku 总量守恒回滚到同 sku 的任一存活 bucket,并在流水里标记这只 bucket 已迁移——守恒的前提是绝不能跨 sku。

DB 与 Redis 的回滚顺序

DB 是主数据,Redis 是缓存和扛量层。顺序是:先写 DB 流水、改 DB 数量,成功后再回补 Redis。反过来先回 Redis 再改 DB,一旦 DB 失败,缓存里就多出一份库存,而 DB 那边其实扣着,直接变成超卖。

DB 成功、Redis 回补失败的情况:库存少一份,只会少卖,不会超卖,属于偏安全的一侧。重试三次、指数退避;仍失败就进补偿队列,由队列异步重试。补偿队列重试超过阈值,或者单位时间内回滚失败数大于 0,立即告警——回滚失败意味着用户已经取消、库存却没回来,是明确的资损方向问题。参数(重试次数、间隔、告警阈值)按自己环境的失败率和补偿队列积压定,需实测。

对账与自检的检查项

对账任务按 sku 抽样扫,检查这几条:

  1. 同一时刻快照下 初始库存 - HGETALL(history) 求和 = 当前 stock;
  2. Redis 可用量与 DB 可用量比对;
  3. history 里有记录、对应单据已是取消态的条目数(乱序留下的脏数据);
  4. 各 bucket 剩余量之和与该 sku 总剩余量是否一致(碎片检查)。
# 导出 Redis 侧可用量做 diff,抽样对账用
redis-cli --scan --pattern 'hot_{*}_stock' \
  | while read -r k; do echo "$k $(redis-cli get "$k")"; done \
  > /tmp/redis_stock.txt

阈值分两层:绝对差和相对差同时卡。库存 1 万件,差异超过 0.5% 或超过 50 件告警;库存只有 20 件的小 sku,差 1 件就足够说明问题。具体数值要按自己业务的库存量级和取消率调,不能照搬。

指标

  • 重复回滚拦截次数:回滚脚本走到 HGET 返回 nil 的次数,按订单维度看有没有异常放大;
  • 回滚失败数:DB 成功但 Redis 回补失败、进补偿队列的条数,非 0 就该看;
  • 对账差异 sku 数:每轮对账中差值超阈值的 sku 个数,这是最终兜底的信号;
  • 辅助看乱序补偿条数(对账追加的回滚次数)和bucket 碎片率(各 bucket 余量方差),前者反映顺序问题的规模,后者反映路由是否在漂。

推荐文章

程序员茄子在线接单