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}) # 异步落库
必须配套的三件事:
- 落库幂等(消费端重复消息不能重复扣,见下节);
- 对账任务(定期比对 Redis 与 DB,差异告警);
- 宕机恢复(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)
③ 处置:差异自动修复 / 人工介入 / 只告警
实践建议:
- 高频资产类(余额、库存):准实时对账(分钟级),差异超阈值立即告警并停止削峰(切回同步路径);
- 计数类: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 设计成含时间戳 |
幂等是否失效(重复扣减) |
| 对账只查"少扣"不查"多扣" | 制造多扣差异,验证对账是否漏掉 |
自测题
- 判断"热点"的三条证据是什么?为什么不能凭感觉下结论?
- 子行拆分后为什么会出现"总库存够但扣不了"?怎么处理?
- Redis 预扣方案必须配套的三件事是什么?缺一件会怎样?
- 幂等三件套分别适用于什么场景?优先级怎么排?
- 为什么"业务上允许卖超再补偿"往往是正确的选择?这个决策应该由谁来做?