你的API够”幂等”吗?后端工程师踩坑实录

2026-10-09 3 0

你的API够"幂等"吗?后端工程师踩坑实录

凌晨两点,线上告警。支付系统提示:同一笔订单扣了用户两次钱。

你从床上爬起来,打开电脑,一通排查,发现是前端那个新来的实习生在重试逻辑里忘了个参数,接口被调了两次。再一看后端——好家伙,扣款接口压根没做幂等。

这不是故事,这是真实发生过的事,而且发生的频率比你想象中高得多。今天咱们就来聊聊幂等性,一个几乎所有后端教程都会提,但没几个人能讲明白的东西。

先说人话:幂等性到底是什么

幂等(Idempotent)这个概念来自数学,指的是一个操作无论执行多少次,结果都一样。你按一下灯开关,灯亮了;再按一下,灯灭了——这不是幂等。你按一下灯开关,灯亮了;再按一下,灯还是亮着——这才叫幂等。

映射到API世界,幂等性的意思是:这个接口你调一次和调一百次,对数据产生的影响是相同的。

GET请求天然幂等——你查一百次数据,数据不会变。
PUT请求应该幂等——你把用户姓名改成"张三",改一百次还是"张三"。
POST请求通常不幂等——你创建订单,调一次创建一条,调一百次创建一百条。

问题来了:为什么我们要在意这个?

为什么要做幂等?

因为网络是不可靠的。

用户点击支付按钮,网络超时了,前端重试了一次。支付请求发出去了,但服务器其实已经处理了,只是响应没回来。OK,你成功扣了用户两次钱。

再比如,消息队列消费时,由于处理失败导致消息被重复消费,如果没有幂等保护,同一个业务逻辑就会被执行多遍。

还有微服务架构里,接口调用链路过长,任何一个环节的超时重试都可能触发整条链路的重复执行。

总结一下触发幂等问题的场景:

  • 用户手抖多点了一下
  • 前端超时重试
  • 消息队列重复消费
  • 微服务调用链重试
  • 爬虫/脚本重复请求
  • CDN缓存失效导致的重复请求

任何一个面向用户的系统,这些场景你都会遇到。所以幂等性不是"可选项",是"必选项"。

实战方案:从入门到精通

方案一:唯一请求ID(最常用)

每次请求带一个唯一的token,服务器把这个token作为key,结果作为value存进Redis。处理之前先查Redis里有没有这个token,有就直接返回之前的结果,没有就处理并存进去。

func ProcessPayment(ctx context.Context, req PaymentRequest) (*PaymentResponse, error) {
    // 1. 检查这个请求是否已经处理过
    cached, err := redis.Get(ctx, "idempotent:"+req.RequestID).Result()
    if err == nil && cached != "" {
        return parseResponse(cached)
    }
    // 2. 加分布式锁防止并发重复提交
    lockKey := "lock:idempotent:" + req.RequestID
    locked, err := redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
    if err != nil || !locked {
        return nil, errors.New("请求正在处理中,请稍后")
    }
    defer redis.Del(ctx, lockKey)
    // 3. 执行业务逻辑
    result, err := doPayment(req)
    if err != nil {
        return nil, err
    }
    // 4. 存储结果
    resultJSON, _ := json.Marshal(result)
    redis.Set(ctx, "idempotent:"+req.RequestID, resultJSON, 7*24*time.Hour)
    return result, nil
}

这个方案的优点是实现简单、效果可靠。缺点是每次请求都要查一次Redis,有性能开销。不过这个开销在绝大多数场景下都是可以接受的。

一个关键细节:锁的过期时间要设得足够长,长到业务处理完成,否则可能出现锁过期了但业务还没处理完的情况。

方案二:数据库唯一索引(适合写入类场景)

如果你要把某个行为记录到数据库,最简单的方式就是加一个唯一索引。比如订单表,加一个 idempotent_key 字段,设置唯一索引。

-- 迁移SQL
ALTER TABLE orders ADD COLUMN idempotent_key VARCHAR(64) UNIQUE;
-- 插入时使用 INSERT IGNORE
INSERT IGNORE INTO orders (order_id, amount, idempotent_key, created_at)
VALUES ('ORDER123', 100, 'req_abc123', NOW());

如果重复插入,数据库会报 Duplicate key 错误,你 catch 住这个异常,返回之前的处理结果就行了。

这个方案的优点是不需要额外的缓存组件,数据库自己保证唯一性。缺点是你得改表结构,而且某些业务场景下不一定能找到合适的唯一键字段。

方案三:乐观锁(适合更新类场景)

假设你要扣用户余额,最安全的做法是用乐观锁:

UPDATE user_balance
SET balance = balance - 100, version = version + 1
WHERE user_id = 123 AND version = 5 AND balance >= 100;

如果这条SQL影响行数为0,说明要么版本不对了,要么余额不够了。你再查一次最新数据,决定是重试还是返回失败。这个方案在并发量高的场景下表现很好,不会像悲观锁那样阻塞。

方案四:幂等表(适合复杂业务流程)

有些业务流程很复杂,涉及多个步骤和多个表。这个时候你可以建一张幂等记录表,专门记录每笔业务的状态和结果。

CREATE TABLE idempotent_records (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_id VARCHAR(64) NOT NULL,
    status TINYINT NOT NULL COMMENT '0=处理中 1=成功 2=失败',
    result JSON,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_biz_id (biz_id)
);

func ProcessBiz(req BizRequest) (*BizResponse, error) {
    record := getIdempotentRecord(req.BizID)
    if record != nil {
        if record.Status == 1 { return record.Result, nil }
        if record.Status == 0 { return nil, errors.New("业务正在处理中") }
    }
    updateIdempotentRecord(req.BizID, 0, nil)
    result, err := doBusiness(req)
    if err != nil {
        updateIdempotentRecord(req.BizID, 2, nil)
        return nil, err
    }
    updateIdempotentRecord(req.BizID, 1, result)
    return result, nil
}

那些年我踩过的幂等性坑

说几个真实案例,不是教科书里的那种,是真的半夜爬起来修的那种。

坑一:支付回调没做幂等,重复发货。 供应商的支付回调重试机制做得很好,好到每次网络抖动都会重试。我们接回调的接口只判断了"订单状态是否已更新",没判断"这次回调的流水号是否已处理"。结果用户付了一笔钱,货发了三份。血的教训:回调接口一定要用流水号做幂等。

坑二:消息消费没做幂等,积分重复发放。 消息队列消费时,ack时机没把握好,消息被重复消费了。业务逻辑里只判断了"用户ID+业务类型",但没做成唯一约束。结果用户做一次任务,积分加了三次。修复方案:把"用户ID+业务类型+业务ID"做成唯一键,消费前先查一下。

坑三:分布式锁设错了过期时间。 这个坑特别隐蔽。锁设了30秒过期,但业务逻辑要45秒。结果锁过期了,另一个请求进来了,两个请求同时在跑,业务数据乱了。所以锁的过期时间一定要大于最坏情况下的处理时间,或者用看门狗机制自动续期。

如何判断一个系统需不需要做幂等

很简单,问自己两个问题:

  1. 这个接口被重复调用,会不会产生副作用?
  2. 这个接口在分布式环境下被并发调用,会不会产生问题?

如果两个答案都是"会",那这个接口就必须做幂等。

一般来说,以下类型接口建议都做幂等:支付/退款/转账等资金相关接口、库存扣减类接口、订单创建类接口、消息发送/订阅类接口、状态变更类接口。

写在最后

幂等性这玩意儿,没出事的时候觉得可有可无,一出事就是大事。它不像性能优化,做不做差别只是"快一点"和"快很多";幂等性做不做差别是"正常运行"和"半夜两点爬起来修bug"。

很多开发者在设计API的时候,首先想到的是功能怎么实现、性能怎么优化,但很少有人把幂等性作为第一公民(First-Class Citizen)来对待。这是认知上的盲区,也是经验上的差距。

我的建议是:从你负责的下一个接口开始,把幂等性当作需求的一部分来设计,而不是事后再来打补丁。提前想清楚这个接口会被怎么调用、会被调用多少次、重复调用会有什么后果——这个思考过程本身就是一种工程师素养。

好的API不只是功能正确、性能达标,还要能扛得住这个混乱的真实世界。


有问题欢迎留言交流,或者你有踩过什么更离谱的幂等性坑,也可以说出来让大家乐一乐(不是)。

相关文章

🦞 AI探索|当小龙虾遇见AI:最近这些骚操作把我整不会了
你的API还在返回200状态码表示我错了?我看你是想被骂
为什么你的“优化”SQL比优化前还慢?一次线上事故的血泪教训
并发地狱:我代码里的那些幽灵死锁和玄学竞态
并发地狱:我代码里的那些幽灵死锁和玄学竞态
写了5年API,我踩过的那些坑够绕地球一圈了

发布评论