接口幂等性:那个让我在凌晨三点背锅的bug,终于被我说清楚了

2026-10-18 19 0

接口幂等性:那个让我在凌晨三点背锅的bug,终于被我说清楚了

先讲个故事。

那年我负责支付系统,有天晚上突然警报狂响——某个用户收到了两笔重复扣款。用户炸了,我也炸了。查了一圈,发现是客户端网络超时,用户重试了支付请求,结果后端处理了两次,扣了两次钱。

那一刻我意识到:我写的接口,不是「幂等」的。

这个问题,后来我发现,几乎每个后端开发都踩过。有人轻描淡写说「加个唯一键就好了」。说这话的人,大概没在生产环境里真正处理过复杂业务。

今天就把接口幂等性这个话题,彻底讲透。


什么是幂等性?说人话

幂等(Idempotent)这个概念来自数学,指的是 f(f(x)) = f(x)。套到接口上,意思是:同一个请求,你执行一次和执行多次,效果是一样的。

拿HTTP GET来说,它是天然幂等的——你请求一次用户信息,请求十次用户信息,不会改变任何东西。但POST呢?你提交一次订单,系统就多一笔订单;提交十次,就多了十笔。

所以「幂等性」本质上是在问:你的接口,是否能扛住重复调用?

在什么场景下会重复调用?

  • 用户手抖,多点了一下提交按钮
  • 网络超时,客户端自动重试
  • 消息队列消费失败,消息被重复投递
  • 前端 bug,导致请求发了两遍
  • 别人的服务调用你,重试了你的接口

以上场景,我在生产环境全部遇到过。你呢?


六种幂等策略,从入门到精通

1. 唯一键/唯一索引:最简单的防御

这是大多数人想到的第一个方案。给表加个唯一索引,或者在业务层面用唯一键约束。

-- 订单表用业务ID作为唯一键
CREATE UNIQUE INDEX idx_biz_id ON orders(biz_id);

-- 插入时
INSERT INTO orders (biz_id, amount, status) 
VALUES ('ORDER_20260101_001', 100, 'PAID')
ON DUPLICATE KEY UPDATE status = 'PAID';

优点:简单粗暴,数据库帮你兜底。
缺点:并发插入时可能抛异常,你需要捕获并正确处理。而且有些业务场景,根本没有天然的唯一键。

吐槽:很多人以为加了唯一索引就高枕无忧了,结果并发请求来了,数据库抛了个 DuplicateKeyException,他们又手忙脚乱地 catch 住——然后 catch 块里啥都没干,把错误吞了。这不叫处理,这叫埋雷。

2. 悲观锁:老老实实排队

在读数据之前加锁,把并发变成串行。

SELECT * FROM products WHERE id = ? FOR UPDATE;

这是Oracle/MySQL里常用的行锁语法。锁住了,别人就只能等着。

优点:简单,不会超卖。
缺点:性能差,高并发下数据库连接分分钟被打满。而且锁要尽量短,别在锁里面干 IO 操作。

我曾经见过一个接口,FOR UPDATE 之后去调第三方 API 等待响应,第三方接口慢了 3 秒——然后数据库连接池被耗光了,整个系统炸了。全公司开会到凌晨两点复盘。

3. 乐观锁 + 版本号:大多数场景的最优解

给表加个 version 字段,更新时检查版本号。

-- 读取
SELECT id, amount, version FROM account WHERE user_id = ?;

-- 更新时加条件
UPDATE account 
SET amount = amount - 100, version = version + 1 
WHERE user_id = ? AND version = 1;

如果 version 不匹配,说明有人改过了,更新 0 行,你自己去查最新数据重试。

优点:性能好,不阻塞读操作。
缺点:重试逻辑需要自己写,要控制好重试次数和熔断。

4. 分布式锁:跨服务的核武器

当请求分布在多台机器上,数据库锁就不够用了,你需要分布式锁。

// Redis 实现分布式锁
String lockKey = "pay:lock:" + orderId;
String lockValue = UUID.randomUUID().toString();

// 加锁,设置过期时间防止死锁
Boolean acquired = redis.set(lockKey, lockValue, 
    Duration.ofSeconds(30), SetOption.SET_IF_ABSENT);

if (Boolean.TRUE.equals(acquired)) {
    try {
        // 执行业务逻辑
        doPayment(orderId);
    } finally {
        // 释放锁(只能释放自己的锁)
        if (lockValue.equals(redis.get(lockKey))) {
            redis.del(lockKey);
        }
    }
} else {
    // 没拿到锁,说明正在处理中
    throw new BusinessException("订单正在处理中");
}

这里有个坑:释放锁的时候,必须检查 value 是否匹配,否则会误删别人的锁。用 Redis 的话,建议用 Redisson,它封装好了。

我见过有人用 Redis 的 setnx 做分布式锁,然后压根没设置过期时间。结果服务挂了,锁永远不会被释放,整个系统就卡死了,直到运维手动 DEL。这事发生在凌晨三点。

5. 幂等Token:接口层面的防重放

这是最通用、最干净的方案,适合几乎所有场景。

流程是这样的:

  1. 客户端先申请一个幂等Token
  2. 服务端把这个 token 存进 Redis,过期时间设长一点
  3. 客户端带着这个 token 发业务请求
  4. 服务端检查 Redis 里这个 token 是否存在:
    • 不存在——存入 token,执行业务逻辑,返回成功
    • 存在——说明重复调用,直接返回上次的结果
// 第一步:申请 token
String idempotentKey = "idempotent:" + UUID.randomUUID().toString();
redis.setex(idempotentKey, 3600, "PROCESSING");

// 第二步:带着 token 请求业务接口
// 服务端伪代码
public Result pay(PayRequest request) {
    String key = "idempotent:" + request.getIdempotentToken();
    String status = redis.get(key);
    
    if ("PROCESSING".equals(status)) {
        return Result.retryLater(); // 正在处理,稍后重试
    }
    if ("SUCCESS".equals(status)) {
        String cachedResult = redis.get("result:" + key);
        return JSON.parse(cachedResult); // 直接返回缓存结果
    }
    
    // 标记处理中
    redis.setex(key, 3600, "PROCESSING");
    
    try {
        Result result = doPayment(request);
        // 存结果
        redis.setex("result:" + key, 3600, JSON.stringify(result));
        // 标记成功
        redis.setex(key, 3600, "SUCCESS");
        return result;
    } catch (Exception e) {
        // 失败了就删除 token,允许重试
        redis.del(key);
        throw e;
    }
}

这套方案牛在哪儿?业务逻辑和幂等逻辑完全解耦。不管你是什么业务,支付、退款、订单创建,都可以套用这个模式。

6. 天然幂等设计:从源头消灭问题

最高级的做法,是让业务逻辑本身就具备幂等性。

比如:扣款接口不传「扣多少钱」,而是传「扣到哪个目标值」。

// 反面例子:传金额,容易重复扣
updateBalance(userId, -100);

// 正面例子:传目标值,不会扣多
updateBalanceTo(userId, 9000); // 不管你扣了多少次,最终余额是 9000

再比如:不是「增加库存」,而是「设置库存为某值」。当然,这需要对业务逻辑做比较大的改造。


我的实战经验总结

说了这么多,给个实战清单:

  • 查询类接口:天然幂等,不用管
  • 单条数据新增:优先用唯一索引 + ON DUPLICATE KEY UPDATE
  • 更新操作:优先用乐观锁(版本号),并发不高可以用悲观锁
  • 删除操作:天然幂等,删一次和删十次都是删
  • 复杂业务(支付/订单):分布式锁 + 幂等Token双保险
  • MQ 消费者:消息去重 + 消费记录表,缺一不可

还有一个血泪教训:幂等性一定要在设计阶段就定好,不要等项目上线了再打补丁。上线后再补救,代价是十倍起步。


写在最后

回到开头那个故事。那个重复扣款的 bug,最后怎么修的?分布式锁 + 幂等Token,凌晨四点才上线。

第二天用户客服打电话来道歉,我心想:早干嘛去了。

幂等性这个东西,面试能背八股文的人很多,真正能在生产环境写对的很少。希望看完这篇,你能是那个少数人。

有问题欢迎留言,我不一定回,但一定会看。

相关文章

还在为部署AI工具熬夜?让小龙虾帮你搞定!
还在为部署AI工具熬夜?让小龙虾帮你搞定!
REST API 最容易翻车的 10 个地方,我都替你踩过了
流量洪峰来了,你的服务还活着吗?限流熔断完全求生指南
REST API设计翻车现场:那些年我们一起踩过的坑
你的API正在被薅羊毛,而你什么都没做

发布评论