从零实现一个简易的幂等设计
前几天线上遇到一个很烦的事,支付回调收到两次,订单竟然被创建了两遍。我翻了半天日志,发现上游重试时带了同一个
trade_no,但我们的接口压根没判断,直接insert。那会儿我就想,幂等这玩意儿到底要多“简易”才算真的能用?
下面是我后来在业务接口里加的一版做法,主要靠三个东西:唯一业务号、Redis 临时占位、MySQL 唯一索引。不算多高深,但确实能挡住大部分重复提交、网关重试和 MQ 重复消费。
先别急着加锁,想清楚“同一个请求”怎么定义
很多人一上来就说加锁,或者搞个分布式锁。但幂等最麻烦的不是锁,是你怎么知道这两个请求算“同一个”。
比如用户点了两次“创建订单”,前端可能真的发了两个请求。如果你只有一个用户 ID,那这两个请求没法区分。所以接口最好带一个幂等键,比如:
curl -X POST https://api.example.com/orders \
-H "Idempotency-Key: order_20260914001" \
-H "Content-Type: application/json" \
-d '{"user_id":123,"sku":"A-100","num":1}'
这个 key 可以叫 request_id、trade_no、message_id,名字无所谓,关键是它得由调用方决定,而且同一次业务重试必须一模一样。
如果调用方乱传,那后端也很难受。所以通常我会再记一个 request_hash,对请求参数做个哈希。参数变了,就不算同一个幂等请求。
一张幂等记录表,先把重复请求“钉住”
最简单可靠的做法,是专门有一张幂等记录表。它不负责业务,只负责回答:这个 key 来过没有?处理成功没有?处理结果是什么?
CREATE TABLE `idempotent_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`idem_key` VARCHAR(128) NOT NULL,
`biz_type` VARCHAR(32) NOT NULL DEFAULT '',
`request_hash` CHAR(64) NOT NULL DEFAULT '',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 processing 1 success 2 failed',
`result_json` TEXT NULL,
`create_time` DATETIME NOT NULL,
`update_time` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_idem_key` (`idem_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
重点就是这个:
UNIQUE KEY `uk_idem_key` (`idem_key`)
只要数据库唯一索引在,就算 Redis 挂了、应用实例重启了、两个请求同时进来,也能兜住底。
Redis 只用来挡一挡并发,别把它当唯一防线
接口进来后,可以先用 Redis SETNX 抢一个位置。
伪代码大概长这样:
key := "idem:lock:" + idempotencyKey
ok := redis.SetNX(key, reqID, 30*time.Second)
if !ok {
record := selectIdemRecord(idempotencyKey)
if record.Status == "success" {
return record.ResultJson
}
return "processing, retry later"
}
defer redis.Del(key)
result, err := doBusiness()
if err != nil {
return err
}
return result
这里 30s 是个很恶心的点。太短了,业务还没处理完锁就掉了;太长了,服务真挂了,后续重试全被卡住。所以我一般不把 Redis 当最终答案,只把它当成一个快速失败机制。
真正靠得住的还是数据库。
处理请求时,最好在一个事务里完成
比较稳的写法是:
BEGIN;
INSERT INTO idempotent_record (idem_key, biz_type, request_hash, status, create_time, update_time)
VALUES (?, ?, ?, 0, NOW(), NOW());
-- 这里执行业务逻辑,比如创建订单、扣库存、生成支付单
UPDATE idempotent_record
SET status = 1,
result_json = ?,
update_time = NOW()
WHERE idem_key = ?;
COMMIT;
如果第二个相同请求进来,INSERT 唯一键冲突,就可以直接查之前的记录:
SELECT status, result_json FROM idempotent_record WHERE idem_key = ?;
查到 success,直接返回缓存结果;查到 processing,告诉调用方还在处理;查到 failed,才考虑要不要允许重试。
这里有个细节:processing 状态一定要超时清理。不然服务中途崩了,记录停在 processing,后面所有重试都会被你挡住。
可以跑个定时任务:
UPDATE idempotent_record
SET status = 2
WHERE status = 0
AND create_time < NOW() - INTERVAL 10 MINUTE;
业务表自己也要有唯一约束
只做幂等记录还不够,业务表本身也得防止脏数据。
比如订单表,最好有:
UNIQUE KEY `uk_trade_no` (`trade_no`)
库存扣减,也可以做条件更新:
UPDATE stock
SET num = num - 1
WHERE sku = 'A-100'
AND num > 0;
如果 affected_rows == 0,就知道这次要么库存不够,要么重复执行被拦下了。
支付回调更明显,别只依赖 Redis:
UPDATE orders
SET status = 'PAID',
paid_time = NOW()
WHERE trade_no = ?
AND status = 'WAIT_PAY';
重复回调进来时,这条 SQL 更新不到行数。那就可以直接返回“订单已处理”。对支付渠道来说,回调成功和重复通知一样都是成功,它不关心你内部是不是真的改了状态。
对应需要替换的 订单服务 配置文件
idempotency:
enabled: true
key_header: Idempotency-Key
redis_ttl: 30s
db_timeout_clean_seconds: 600
return_original_result: true
不出问题的话,这套就够用了。ctrl+c 重启服务,再拿同一个 Idempotency-Key 连续请求两次,应该就能在 idempotent_record 里看到一条记录。第二次不会再生成订单,只会返回第一次结果。
当然,真到复杂场景里,比如跨服务事务、分库分表、MQ 重复消费,这个简易设计还得继续加东西。但至少从 0 开始把“重复请求别重复写库”这件事解决掉,不需要一上来就把系统设计得特别吓人。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。