主题
幂等性设计
一句话:幂等不是「不执行」,而是「执行 N 次和执行 1 次的副作用相同」;它靠一个稳定的业务标识和一次原子抢占,把重复请求挡在副作用之外。
适用:MySQL 8.0+、Redis 7.x、RocketMQ 5.x;TTL 和超时值为经验起点,按业务重试窗口调整。
为什么需要
客户端超时后会重试,但服务端可能已经执行成功,只是响应没回去。MQ 通常是至少一次投递,消费者也可能重复收到同一条消息。用户重复点击、网关重放、定时任务重跑,都会制造重复请求。
如果业务是「扣 100 元」「创建订单」,重复执行的代价是真金白银。幂等要解决的就是:同一个业务动作,无论到达几次,最终只产生一次副作用。
核心机制
唯一索引:INSERT 场景的最后防线
给业务唯一键建唯一索引,重复插入直接报冲突:
sql
CREATE UNIQUE INDEX uk_order_no ON t_order(order_no);捕获 DuplicateKeyException 后返回已有结果,而不是报错给用户。它简单、原子、不依赖外部组件,是 INSERT 类业务最可靠的一层。
分库分表后要注意:唯一索引只在单个分片内生效。按 user_id 分片时,同一个 order_no 落到不同分片不会冲突。跨分片唯一性要么建全局映射表,要么把分片信息编进业务 ID。
Token:防重复提交
流程是:客户端先申请 token,服务端存 Redis(带 TTL),提交时用 Lua 原子地「存在则删除并放行」:
lua
if redis.call('EXISTS', KEYS[1]) == 1 then
redis.call('DEL', KEYS[1])
return 1
else
return 0
endEXISTS + DEL 必须在一个脚本里,否则两个请求可能同时判断为存在。
Token 过期但请求才到达时,DEL 返回 0,会被误判为重复请求。解决方式是在 token 值里带创建时间,或者用第二个 key 记录使用状态,区分「已使用」和「已过期」。
状态机:用条件更新抢占
sql
UPDATE t_order SET status = 'PAID'
WHERE order_id = 123 AND status = 'UNPAID';第一次影响 1 行,第二次 WHERE 不匹配,影响 0 行。状态流转必须单向,不能从 PROCESSING 回到 UNPAID,否则条件会再次匹配。
状态机抢占后崩溃怎么办
只用 UPDATE status='PROCESSING' WHERE status='UNPROCESSED' 有一个漏洞:抢到执行权的进程崩溃后,记录永远停在 PROCESSING,消息重试看到不是 UNPROCESSED 就跳过,业务再也不会被执行。
要补三样东西:
- 抢占时间:记录
processing_at和执行者owner。 - 超时重抢:
PROCESSING且超过阈值(如 5 分钟)允许其他消费者重新抢占。 - 补偿扫描:定时任务找出超时的
PROCESSING,查下游真实状态,决定补执行还是回滚。
sql
START TRANSACTION;
SELECT status, processing_at FROM idempotency_record
WHERE biz_type = 'order' AND biz_id = 'ORD001' FOR UPDATE;
-- 不存在:INSERT status=0, owner=当前节点, processing_at=NOW()
-- status=0 且 processing_at 超过 5 分钟:UPDATE 抢占
-- status=1:事务提交,直接返回已有结果
-- status=0 且未超时:说明别人在处理,返回「处理中」
COMMIT;FOR UPDATE 只锁这一条幂等记录,粒度很小;比「先查后改」可靠。抢占成功后执行业务,成功置 status=1,失败置 status=2 或回到 0 并累加重试次数。
幂等表的 TTL
幂等记录不能永久保留,否则表会无限增长;也不能删得太早,否则重试窗口内又会被当成新请求。
- TTL 要大于「最大重试窗口 + 业务可追溯期」。MQ 消费常见 7 天;支付、账务按对账周期可能保留 30 天到更久。
- 表上建
expire_at索引,用低峰期分批删除,避免大事务。 - 删除前确认没有悬挂的
PROCESSING记录。
乐观锁与悲观锁
乐观锁用版本号:UPDATE ... SET version=version+1 WHERE id=? AND version=?。重复请求版本不匹配,影响 0 行。它适合冲突概率低的 UPDATE 场景。
悲观锁 SELECT ... FOR UPDATE 解决的是并发冲突,不是重复请求:两个请求先后到达,锁释放后第二个照样执行。所以悲观锁不能单独当幂等方案。
分布式锁与幂等的关系
分布式锁能把并发请求串行化,但锁不等于幂等:请求 A 拿到锁执行完释放,重试的请求 B 再拿锁,照样执行第二次。
Redis 锁用 SET key value NX PX 30000,value 放唯一标识,释放时用 Lua 比对 value 再删,避免删掉别人的锁。Redisson 看门狗默认锁 30 秒、每 10 秒续期(lockWatchdogTimeout=30000,以所用版本为准)。主从切换可能丢锁,Redlock 也有时钟和 fencing token 的争议;强一致选主场景更适合 ZooKeeper/etcd 的临时顺序节点或租约。
锁适合保护「同一时刻只能一个执行」的资源,幂等要在锁之外再用业务键记录结果。
分层设计
- 前端:按钮 disabled、loading,只能挡误触。
- 网关:请求 ID 去重,挡短时间重放;多实例要共享状态。
- 业务:Token、状态机、业务唯一键。
- 数据库:唯一索引、条件更新,最后防线。
越往前拒绝,代价越小;但任何一层都可能失效,所以数据库层不能省。
最小上手
sql
-- 幂等记录表
CREATE TABLE idempotency_record (
biz_type VARCHAR(32) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
status TINYINT NOT NULL, -- 0 处理中, 1 成功, 2 失败
owner VARCHAR(64),
processing_at DATETIME,
expire_at DATETIME NOT NULL,
PRIMARY KEY (biz_type, biz_id),
KEY idx_expire (expire_at)
);
-- 业务唯一键兜底
CREATE UNIQUE INDEX uk_order_no ON t_order(order_no);验证:并发提交同一个 biz_id,只应有一个请求拿到执行权;人为让执行进程在 PROCESSING 后退出,超过阈值后重试应能重新抢占。
生产动作
- 幂等键:由客户端生成并在重试时保持不变,不能用「每次请求新生成的 UUID」;服务端要校验归属,防止拿别人的键重放。
- TTL:按重试窗口设置,监控表行数和清理延迟;不要用
DELETE大事务一次性清空。 - 悬挂监控:统计
PROCESSING超过阈值的记录数,超过告警;补偿任务要幂等。 - Redis 降级:Redis 不可用时,Token 层降级到本地缓存或直接放行,把压力交给数据库唯一索引;不能因为 Redis 挂了就拒绝所有业务。
- 分片:全局唯一表只存映射,不存业务数据;或者让业务 ID 自带分片信息,避免跨分片查重。
- MQ:消费端幂等键用消息里的业务 ID,不要用消息 ID;消息 ID 在重投时可能变化。
边界
- 幂等键必须代表「同一个业务动作」,不是「同一个 HTTP 请求」;否则用户改数量重试会被误判为重复。
- 唯一索引只适用于 INSERT;UPDATE 场景要靠状态机或版本号。
- 状态机要求流转严格单向;为了「灵活」允许回退,会把幂等条件破坏掉。
- 分布式锁只解决并发,不解决重试;两者要分开设计。
- 端到端 Exactly-Once 很难做到,工程上通常是「至少一次投递 + 消费端幂等 + 对账」。
讲清楚
幂等是让同一个业务动作执行多次只产生一次副作用。最可靠的是数据库唯一索引,适合 INSERT;UPDATE 用状态机条件更新或版本号;Token 适合防重复提交,但要用 Lua 原子删除,并区分「已使用」和「已过期」。状态机抢占有一个容易漏的坑:进程抢到 PROCESSING 后崩溃,记录会永远卡住,所以要有 processing_at、超时重抢和补偿扫描。幂等记录还要设 TTL,既不能永久膨胀,也不能短于重试窗口。分布式锁只能串行化并发,不能替代幂等;最后防线始终是数据库。