KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

08 · 热点、削峰与一致性收口 — keel 龙骨

这一章回答:冲突率高到锁方案解决不了时怎么办,以及削峰之后怎么保证不错账。

这一章回答:冲突率高到锁方案解决不了时怎么办,以及削峰之后怎么保证不错账。

第 06 章给出的判据是:冲突率 > 50% 时不要在原粒度上竞争。这一章处理这种场景:热点(单行/单 key 被极高频率更新),以及所有削峰方案共同的前提——幂等与对账。

一、先确认真的是热点

不要凭感觉。三条例据:

-- ① 行锁等待集中在少数行
SELECT * FROM performance_schema.data_lock_waits;   -- 反复出现同一 index/行

-- ② 慢日志里同一类 UPDATE 的耗时随并发陡增
-- ③ 应用指标:该 SKU/账户的 conflict / attempt 比例持续 > 50%

确认之后再看可选方案。热点的本质是把"一个点的串行约束"暴露给了全部流量,解法只有两类:拆开这个点,或把流量削平。

二、四种方案与取舍

方案 做法 吞吐提升 代价
① 子行拆分 一行库存拆成 N 个子行,扣减时随机/轮询选一个,不足再合并扣 约 N 倍 需要聚合查询真实余额;扣减逻辑变复杂;单子行不足时要跨行扣
② Redis 预扣 + 异步落库 Redis 原子扣减,MQ 消费落库 极高 要处理一致性补偿;Redis 与 DB 的最终一致;宕机时的对账
③ 串行队列 同一 key 的请求进同一队列单线程处理 不稳定(无冲突收益) 延迟上升;队列积压即不可用
④ 合并提交 把 N 次扣减在内存里累加,批量落库(group commit) 高 有数据丢失窗口;需要持久化缓冲

① 子行拆分(优先推荐,改动最小)

-- 原来:sku_stock(sku_id, stock)
-- 改成:sku_stock_slot(sku_id, slot_no, stock),slot_no = 0..15
UPDATE sku_stock_slot SET stock = stock - 1
 WHERE sku_id = 1001 AND slot_no = ? AND stock >= 1;
slot = random.randrange(SLOT_COUNT)        # 或按用户 id 哈希,保证同一用户稳定
affected = await execute(UPDATE_SQL, sku_id, slot)
if affected == 0:
    # 该子行不足 → 尝试其它子行(最多遍历 SLOT_COUNT 次)
    ...

真实库存 = SELECT SUM(stock) FROM sku_stock_slot WHERE sku_id=?。
注意:拆分后"总库存够但单个子行不够"的情况必须处理(遍历其它子行)。

② Redis 预扣(吞吐最高,一致性成本最高)

# 原子扣减:Lua 脚本保证"判断 + 扣减"原子
DEDUCT = """
local cur = tonumber(redis.call('GET', KEYS[1]) or '-1')
if cur < tonumber(ARGV[1]) then return 0 end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
"""
ok = await redis.eval(DEDUCT, 1, f"stock:{sku_id}", n)
if not ok:
    raise InsufficientStock()
await mq.publish("stock_deduct", {"sku_id": sku_id, "n": n})   # 异步落库

必须配套的三件事:

  1. 落库幂等(消费端重复消息不能重复扣,见下节);
  2. 对账任务(定期比对 Redis 与 DB,差异告警);
  3. 宕机恢复(Redis 重启后要能重建库存,通常以 DB 为准 + 回放未落库消息)。

③ 串行队列(只适合"延迟换无冲突")

适合:任务本身耗时较长、且必须严格串行(如对同一账户的清算)。不适合秒杀——队列积压会让延迟不可接受。

④ 合并提交(对写入友好,对一致性有窗口)

适合:计数类(浏览量、点赞数),不适合余额/库存这类不能丢的数据。

三、削峰之后:幂等三件套

所有削峰方案都引入异步与重试,幂等是这个前提下的必要条件。三件套:

① 唯一键防重(最强)

-- 业务唯一键:同一订单只能有一条扣减流水
CREATE TABLE stock_flow (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  biz_key VARCHAR(64) NOT NULL,
  sku_id  BIGINT NOT NULL,
  amount  INT NOT NULL,
  UNIQUE KEY uk_biz (biz_key)
);
-- 重复消费时 INSERT 报 1062 → 忽略即可(天然幂等)
try:
    await execute("INSERT INTO stock_flow (biz_key, sku_id, amount) VALUES (?,?,?)", key, sku, n)
except DuplicateKey:
    return  # 已处理过,直接返回成功

biz_key 的设计是核心:通常是 业务类型:业务 id:操作序号,保证同一次业务操作永远得到同一个 key。

② 状态机(防止重复推进)

UPDATE orders SET status='PAID', paid_at=NOW()
 WHERE id=? AND status='PENDING';     -- 只有 PENDING 才能推进到 PAID
-- 影响行数 0 = 已处理或状态非法

③ 去重表 / 请求 id

对无法用唯一键表达的复杂操作,用一张去重表记录请求 id 与结果:

INSERT INTO idempotent (req_id, result) VALUES (?, ?);   -- req_id 唯一

优先级:能用唯一键就不要用去重表——唯一键由数据库保证,去重表还要自己处理并发插入。

四、对账:最后一道防线

削峰 + 异步必然产生不一致窗口。对账不是可选项。

对账三要素:
① 基准:以谁为准?(通常 DB 流水是权威,缓存是加速)
② 频率:实时(每次操作后校验)/ 准实时(分钟级)/ 离线(T+1)
③ 处置:差异自动修复 / 人工介入 / 只告警

实践建议:

-- 例:库存对账(缓存预扣总量 vs DB 流水总量)
SELECT s.sku_id,
       s.stock AS db_stock,
       COALESCE(SUM(f.amount), 0) AS flow_total
FROM sku_stock s LEFT JOIN stock_flow f ON f.sku_id = s.sku_id
GROUP BY s.sku_id
HAVING s.stock + COALESCE(SUM(f.amount),0) <> s.initial_stock;   -- 差异清单

五、方案选择决策表

冲突率 < 5%        → 条件更新(第 06 章),无需削峰
冲突率 5%~20%      → 乐观锁 + 有限重试
冲突率 20%~50%     → 悲观锁 或 先限流再乐观
冲突率 > 50%       → 热点:
                      能够拆分?      → 子行拆分(首选)
                      可接受最终一致? → Redis 预扣 + 异步落库 + 对账
                      必须强一致?     → 串行队列(接受延迟)或 重新设计业务粒度
无论哪种方案       → 幂等三件套 + 对账

一条容易被忽略的原则:削峰只是把压力搬走,不是消除。如果业务上允许"卖超一点再补偿",成本会低一个数量级——这往往是一个产品决策而不是技术决策,应该主动提出来讨论,而不是默认强一致。


动手:可观察结果

产出 判断标准
热点识别记录 三条证据(锁等待集中、慢日志、冲突率指标)
子行拆分对比 拆分前后:冲突率、QPS、P99 三组数据;并验证"总库存够但单子行不够"的处理
Redis 预扣实现 Lua 原子扣减 + MQ 异步落库 + 幂等消费(重复消息不重复扣)
一次对账演练 人为制造 Redis 与 DB 差异,对账任务能否发现并告警
幂等验证 同一 biz_key 重复提交 10 次,DB 只产生 1 条流水(1062 被正确处理)

完成标志:给你一个高并发写场景,你能给出:方案选择 + 冲突率判据 + 幂等实现 + 对账策略,并说明每一处失败时系统的表现(而不是只说"应该没问题")。

故障注入

注入方式 观察
1000 并发抢 10 个库存(单行) 冲突率、锁等待、吞吐;验证是否已到热点区间
拆成 16 个子行后重跑 冲突率与 QPS 的变化;是否出现单子行不足的分支
Redis 预扣后 kill 消费者 DB 与 Redis 的差异;对账任务是否发现
重复投递同一条 MQ 消息 幂等是否生效(流水条数、库存是否正确)
把 biz_key 设计成含时间戳 幂等是否失效(重复扣减)
对账只查"少扣"不查"多扣" 制造多扣差异,验证对账是否漏掉

自测题

  1. 判断"热点"的三条证据是什么?为什么不能凭感觉下结论?
  2. 子行拆分后为什么会出现"总库存够但扣不了"?怎么处理?
  3. Redis 预扣方案必须配套的三件事是什么?缺一件会怎样?
  4. 幂等三件套分别适用于什么场景?优先级怎么排?
  5. 为什么"业务上允许卖超再补偿"往往是正确的选择?这个决策应该由谁来做?

进入 keel 阅读