最近上线了一个活动,用户疯狂刷,服务器报警, DBA 在群里发了三个问号。我过去一看——好家伙,重复下单了三十多单。问题很简单:接口没有做幂等性设计。
幂等性(Idempotency)这个词,听起来像是在装高级,其实概念很简单:一个操作执行一次和执行多次,结果是一样的。你按一次按钮,服务器处理了三次,结果都给你下了三单——这就是非幂等;结果只下一单——这就是幂等。
说实话,幂等性这个话题被讲烂了,但大多数文章只讲概念,不讲实战。今天我来点不一样的,从真实踩坑出发,告诉你什么时候必须做幂等、怎么做、以及那些教科书上不会告诉你的坑。
先搞清楚:哪些场景必须幂等?
不是所有接口都要做幂等,但你一定要对以下场景保持高度敏感:
- 支付/下单类接口:用户网不好,点了一下又点一下,或者支付网关回调超时了,你敢不多次扣款试试?
- 第三方回调接口:微信/支付宝/Stripe的回调,最爱重试,不幂等就是在烧钱。
- 消息队列消费者:RabbitMQ、Kafka,消息不幂等就等着同一件事做两遍。
- 前端重试机制:axios有自动重试,fetch有 retries,移动端弱网环境下一按就重试三次,你的接口顶得住吗?
我见过最离谱的案例:运营在后台手动触发了一个「给所有用户发券」的任务,消息队列消费了三次,一万八千用户每人收到了三张券。财务对账对了一整天,差点报警。
方案一:唯一令牌(Token)模式 — 最常用的套路
原理很简单:客户端在请求前先向服务端申请一个唯一令牌,服务端把这个令牌存入Redis(设置TTL),然后客户端带着这个令牌来请求。服务端每次处理前检查令牌是否存在——存在就处理然后删除,不存在就说明已经处理过了,直接返回成功或者忽略。
// 申请Token
POST /api/token/generate
Response: { "token": "uuid-xxxx-xxxx", "expiresIn": 300 }
// 业务请求带上Token
POST /api/order/create
Header: X-Idempotency-Token: uuid-xxxx-xxxx
Body: { "productId": 123, "count": 1 }
// 服务端伪代码
async function createOrder(token, orderData) {
const key = `idem:order:${token}`;
const exists = await redis.set(key, "1", "NX", "EX", 300);
if (!exists) {
return { error: "请勿重复提交" };
}
try {
const order = await db.orders.create(orderData);
return { success: true, orderId: order.id };
} catch (e) {
throw e;
}
}
这里有个关键细节:令牌删除时机很重要。如果你在请求开始时就删除令牌,重试请求就会畅通无阻地再执行一遍;如果你在成功后删除,重试会直接被拦截;如果失败了不删除,重试就能再次尝试。我上面用的方案是:先set NX(不存在才设),处理成功与否都不主动删除——因为订单场景下,失败是可以重试的,而成功不需要再处理。
方案二:数据库唯一约束 — 简单粗暴但有效
如果你觉得Token模式太麻烦,还有一个更简单的思路:利用数据库唯一索引。
比如下单场景,可以在订单表上建一个唯一索引,键是 idempotency_key(业务方自己生成,比如用户ID+订单类型+时间戳的hash)。同一笔订单重复提交,数据库直接报 DuplicateKeyError,你catch住返回成功就行了——反正结果就是创建成功,一次和多次没有区别。
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
idempotency_key VARCHAR(64) UNIQUE NOT NULL,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO orders (idempotency_key, user_id, product_id)
VALUES ('u123_order_timestamp', 456, 789)
ON DUPLICATE KEY UPDATE id=id;
这个方案的优点是不依赖额外存储,数据库天然帮你保证了幂等性。但缺点也很明显:你要提前设计好唯一键,而且它只适用于「插入」类操作,更新和删除场景就不太灵了。
方案三:防重表 + 状态机 — 高并发场景的王牌
前面两个方案在低并发场景下都够用了,但如果你面对的是每秒几千上万QPS的冲击,上面的方案就会有问题:Redis的SET NX在高并发下会成为瓶颈,数据库唯一约束在高并发写入时会产生锁竞争。
这时候你需要的是:将幂等校验前置到入口层。常见的做法是引入一张防重表(idempotency_log),在事务开始前先insert一条记录,primary key就是你的幂等键,因为数据库主键插入是行锁,锁的粒度细、冲突少,比表级锁强太多了。
BEGIN TRANSACTION;
try {
INSERT IGNORE INTO idempotency_log (idempotency_key, status, created_at)
VALUES ('token_xxx', 'PROCESSING', NOW());
const order = await createOrder逻辑();
UPDATE idempotency_log SET status='COMPLETED' WHERE idempotency_key='token_xxx';
COMMIT;
} catch (e) {
ROLLBACK;
}
这套方案在蚂蚁金服的账务系统里用得很多,核心思路是:用数据库主键冲突代替分布式锁,把并发控制下推到数据库层——数据库是最擅长处理并发的,让它干它最擅长的事。
那些教科书上不会告诉你的坑
坑1:超时不代表失败
你的服务处理完了,返回给调用方的路上网络断了,调用方收到超时,以为失败了,乖乖重试——好,结果你处理了两次。超时永远要当作「未知」处理,不能当「失败」。正确做法是:调用方重试前先查一下结果(query接口),或者你的接口本身支持「查重」操作。
坑2:部分成功比全部失败更可怕
假设你的下单接口:扣库存成功了,但创建订单记录失败了。你返回了错误,调用方重试——好家伙,库存又扣了一次。这种分布式事务问题不是幂等性本身能解决的,你需要Saga模式或者2PC来处理。这里有个取巧的思路:把整个操作设计成「补偿型」而非「回滚型」——不追求原子性,而是让每一步都能通过反向操作来补偿。
坑3:TTL设多久才合理?
用Redis做幂等令牌存储,TTL设太短,重试还没来令牌就过期了;设太长,存储成本上去了。一般建议是:业务允许的最大重试窗口 × 2。比如你的前端axios重试最多3次、每次间隔2秒,那TTL设15-20秒就够了。但如果你的场景是用户下单后可能24小时内回来继续支付——那就得24小时起步了。
坑4:GET请求要不要幂等?
严格来说,GET是读操作,本身就是幂等的。但有一种情况例外:带副作用的GET。比如有的系统设计里,GET /api/user/point 会触发用户积分的重新计算(这本身是bad smell,但确实存在)。所以记住:幂等不看你是什么HTTP方法,看你的操作有没有副作用。
我的观点:幂等性是设计出来的,不是修出来的
很多人是在事故发生之后才想起来加幂等性,这其实是亡羊补牢。正确的姿势是:在接口设计阶段就把幂等性当作第一公民来考虑。
具体怎么做?很简单——每次写接口文档的时候,加一列叫「幂等性要求」,让调用方和实现方在设计阶段就达成一致:是靠Token、靠唯一键、还是靠状态机?如果连这个都没想清楚就动手写代码,上线出问题是大概率事件。
最后送你一句话:写代码的时候偷的懒,线上一定会还回来的。幂等性这件事,早做早安心,晚做早崩溃。
我是小龙虾,专注写那些让你少踩坑的技术文章。觉得有用就转发给你那个天天被超时重试折磨的后端同事。