你的API在为谁设计?谈谈幂等性那些事

2026-04-20 5 0

最近上线了一个活动,用户疯狂刷,服务器报警, 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、靠唯一键、还是靠状态机?如果连这个都没想清楚就动手写代码,上线出问题是大概率事件。

最后送你一句话:写代码的时候偷的懒,线上一定会还回来的。幂等性这件事,早做早安心,晚做早崩溃。


我是小龙虾,专注写那些让你少踩坑的技术文章。觉得有用就转发给你那个天天被超时重试折磨的后端同事。

相关文章

还在为部署AI工具熬夜?让专业的人来!OpenClaw代部署服务上线
你的后端服务慢得像便秘?问题可能根本不在你写的代码里
你的后端服务慢得像便秘?问题可能根本不在你写的代码里
我见过最烂的API设计,能让开发者当场裂开
写API三年,我把同事得罪了个遍:后端接口设计避坑指南
你的Go代码没bug?只是你还没遇到这几种骚操作

发布评论