幂等落地:去重表与令牌机制
上一章讲了幂等的思路,本章把它落到表结构与代码流程上。三种最常用的落地方式:去重表(唯一索引兜底)、幂等令牌(先领票后核销)、状态机幂等(条件更新)。三者可以叠加使用,其中唯一索引是并发场景下最后一道防线。
方案一:去重表
思路:把业务唯一标识单独存一张表并加唯一索引。插入成功说明是第一次处理;插入冲突说明已经处理过,直接返回上次的结果。
CREATE TABLE idempotent_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,如 PAY',
idem_key VARCHAR(64) NOT NULL COMMENT '幂等键,如商户订单号',
biz_result VARCHAR(512) DEFAULT NULL COMMENT '首次执行结果快照,JSON',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_biz_key (biz_type, idem_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等去重表';
UNIQUE KEY (biz_type, idem_key) 是关键:即使应用层判断全部失效,数据库也只会让一行插入成功。两种常见写法:
-- 写法一:INSERT IGNORE,冲突时静默忽略(会把其它可忽略错误一并吞掉)
INSERT IGNORE INTO idempotent_record (biz_type, idem_key, status)
VALUES ('PAY', 'M20240101001', 0);
-- 写法二:ON DUPLICATE KEY UPDATE,行为更可预期,实践中更推荐
INSERT INTO idempotent_record (biz_type, idem_key, status)
VALUES ('PAY', 'M20240101001', 0)
ON DUPLICATE KEY UPDATE updated_at = NOW();
判断依据:受影响行数为 1 表示首次插入;为 0(部分驱动返回 2)表示已存在。
去重表 + 业务流程的伪代码
把去重记录与业务操作放在同一个事务里,才能保证"记了账就一定处理了":
function pay(orderNo, amount):
# 1. 抢占幂等记录(依赖唯一索引,不要先 SELECT 再 INSERT)
rows = INSERT INTO idempotent_record(biz_type, idem_key, status)
VALUES ('PAY', orderNo, 0) ON DUPLICATE KEY UPDATE updated_at = NOW()
if rows == 0: # 已经处理过
rec = SELECT * FROM idempotent_record WHERE biz_type='PAY' AND idem_key=orderNo
if rec.status == 1: return rec.biz_result # 直接返回首次结果
if rec.status == 0: return 处理中, 请稍后重试 # 首次请求还在执行
try:
# 2. 执行真正的业务:扣款、写流水,与上面的插入在同一事务内
UPDATE account SET balance = balance - amount WHERE user_id = ? AND balance >= amount
INSERT INTO pay_flow(order_no, amount) VALUES (orderNo, amount)
# 3. 回填结果快照,供后续重复请求直接返回
UPDATE idempotent_record SET status = 1, biz_result = '{...}' WHERE idem_key = orderNo
COMMIT
return 成功
catch e:
ROLLBACK # 回滚后幂等记录也一并撤销,允许重试
throw e
关键点:先生成幂等记录再执行业务,失败一起回滚。若先执行业务再记录,中间崩溃就会漏记;若记录成功而业务失败却不回滚,重试将永远拿不到执行机会。
方案二:幂等令牌
适用场景:面向用户提交的表单、支付入口,专门防连点与前端重试,流程是"先领票、后核销"。
# 第一步:进入页面时申请令牌(服务端生成并写入 Redis,设置过期时间)
GET /api/idem-token
服务端: token = UUID()
SET idem:token:{token} 1 EX 600 NX
返回: { "token": "..." }
# 第二步:提交业务请求时带上该 token
POST /api/pay Header: X-Idem-Token: {token}
服务端: count = DEL idem:token:{token}
count == 1 → 令牌有效且未被使用,继续处理业务
count == 0 → 令牌不存在或已被核销,判定为重复提交,直接返回成功提示
SET idem:token:8f3a... 1 EX 600 NX # NX 保证同一令牌只写入一次;输出:OK
DEL idem:token:8f3a... # 输出:1(首次核销);再次执行输出 0
要点:
DEL是原子的,天然适合做核销,不需要额外加锁;- 令牌必须设过期时间,否则 Redis 里会不断堆积废弃令牌;
- 核销成功但业务失败时若允许重试,需要把令牌补回,否则用户只能刷新页面重新领票;
- 令牌只能防"同一张票用两次",防不了"领了两张票提交两次",前端要保证一次提交只领一张票。
方案三:状态机幂等
有状态的业务(订单、退款、工单)靠状态流转本身实现幂等:把校验状态与更新状态写在同一条 SQL 的 WHERE 里。
-- 订单支付回调:只有仍是「待支付」时才推进到「已支付」
UPDATE orders
SET status = 'PAID', paid_at = NOW(), trade_no = 'T20240101001'
WHERE order_no = '20240101001'
AND status = 'WAIT_PAY';
受影响行数为 1 表示首次处理;为 0 说明订单已是已支付或状态不允许流转。对应流程:
function onPayCallback(orderNo, tradeNo):
affected = UPDATE orders SET status='PAID', trade_no=tradeNo
WHERE order_no=orderNo AND status='WAIT_PAY'
if affected == 1:
发券(orderNo) # 只在首次成功时触发后续动作
return "success" # 回执成功,让对方停止重发
order = SELECT * FROM orders WHERE order_no = orderNo
if order.status == 'PAID' and order.trade_no == tradeNo:
return "success" # 重复回调,同样回执成功
return "fail" # 状态异常,记录告警人工介入
状态机的优势是不引入额外表,判断逻辑即业务语义;缺点是对状态设计有要求:状态必须单向推进,终态不接受任何更新(订单一般约定 WAIT_PAY → PAID → SHIPPED → FINISHED)。
注意事项
- 唯一索引是最后防线:应用层"先查再插"在并发下必定失效(两个线程都查到不存在),必须靠唯一索引或条件更新兜底;
- 幂等键由调用方生成:服务端生成的键在请求超时后无法复用,重试会拿到新键,幂等失效;
- 幂等表与业务表同库:跨库无法用同一个本地事务保证一致,会出现"记录了但没处理"的脏数据;
- 处理中状态要有超时兜底:状态长期停留在 0 时,应由补偿任务回查真实业务结果并回填;
- 历史数据定期清理:幂等表增长很快,一般只保留 7~30 天,按时间分区或归档删除;
- 不要用分布式锁代替幂等:锁只保证串行,锁释放后重复请求照样会重新执行业务。
小结:创建类接口用"去重表 + 唯一索引"兜底,用户入口加"幂等令牌"防连点,有状态业务用"条件更新 + 状态机";共同原则是把唯一性判断下沉到数据库的一次原子写操作上,靠应用层先查后写判断并发是不可靠的。