你的接口真的是幂等的吗?我用三次线上事故换来的教训

2026-09-26 5 0

凌晨两点,客服群炸了。一堆用户反映:钱被扣了两次。

你打开日志一看,回调通知重复了——支付渠道重试了两次,你们系统处理了两次,用户余额少了两次。

然后你光荣地获得了一个新title:那个让公司赔钱的工程师。

别急着撇清责任。这种事情,我见过太多次了,而且它往往发生在你觉得自己代码写得特别"健壮"的时候。

先说清楚:什么是幂等性

幂等(Idempotent)这个概念,说白了就是:同一个操作,你执行一次和执行多次,效果必须一样。

注意,这里说的是"效果一样",不是"只执行一次"。

POST /api/order 创建订单——不是幂等的,每次调用都创建新订单
GET /api/order/123 查询订单——是幂等的,查多少次结果都一样
PUT /api/order/123/status?status=PAID 更新订单为已支付——可以是幂等的,如果设计得当

很多人以为幂等性是"高级货",只有微服务、分布式才需要考虑。但现实是:只要你写的接口会被重试(超时重试、手动重试、消息队列重试),幂等性就必须上场。

而在这个人人都在写HTTP接口、人人都在调用第三方服务的年代,不会设计幂等性的工程师,就是在给线上埋雷。

什么时候必须幂等,什么时候不需要

这是第一个容易搞错的点。

不需要额外幂等设计:

GET、HEAD、OPTIONS 这种只读操作,天然幂等。你爱重试多少次就重试多少次,结果不会变。

DELETE /api/order/123 删除订单——删除一次和删除多次,都是"没了"。但如果你设计的是软删除,每次重试会把 deleted_at 时间戳更新一次,这个操作本身就不再是幂等的了。

必须幂等:

所有涉及状态变更的操作,尤其是跟钱挂钩的。

支付回调:支付渠道告诉你"这笔订单已支付",你就要把用户账户余额加钱。重复通知?只加一次?这些问题想不清楚,线上就会有人薅你羊毛,或者你的用户会来骂你。

订单状态流转:待支付→已支付→已完成。每一步都必须严格幂等,否则就会出现状态乱跳、重复发货、结算出错。

库存扣减:超卖是电商的噩梦,而超卖的根本原因之一就是并发重复扣减——说到底就是幂等性没做好。

四种幂等方案,从入门到入土

方案一:数据库唯一约束(最稳)

这是最硬核、最靠谱的方案。数据库说一不二,你说不能重复,它就真的不会重复。

CREATE TABLE payment_log (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_id VARCHAR(64) NOT NULL UNIQUE,
    amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 收到回调时
INSERT INTO payment_log (order_id, amount, status)
VALUES (#{orderId}, #{amount}, "PAID")
ON DUPLICATE KEY UPDATE status = "PAID";

核心逻辑:数据库唯一索引保证同一 order_id 只会有一条记录。ON DUPLICATE KEY UPDATE 让重复插入变成更新操作,数据库不会报错,只是静默完成幂等。

如果你不想只依赖 ON DUPLICATE KEY UPDATE 这种"静默成功"策略,更严谨的写法是用状态流转:

UPDATE payment_log 
SET status = "PAID", paid_at = NOW()
WHERE order_id = #{orderId} 
  AND status = "PENDING";

-- 判断影响行数
if (rowsAffected == 1) {
    // 真正处理了业务逻辑
    accountService.addBalance(orderId, amount);
} else {
    // 已经处理过了,跳过或返回成功
}

只有 PENDING→PAID 的流转才会真正执行。已经被支付过的订单,再来一次回调就是 0 行影响,直接 return。干净利落。

方案二:业务状态机(最优雅)

数据库唯一约束是"数据库替你做幂等",状态机是"业务逻辑本身保证幂等"。

// 订单状态枚举
enum OrderStatus {
    PENDING,    // 待支付
    PAID,       // 已支付
    SHIPPED,    // 已发货
    COMPLETED,  // 已完成
    CANCELLED   // 已取消
}

// 状态流转方法
public void pay(BigDecimal amount) {
    if (this.status != OrderStatus.PENDING) {
        throw new BizException("订单状态不允许支付,当前状态: " + this.status);
    }
    this.status = OrderStatus.PAID;
    this.paidAt = LocalDateTime.now();
}

// 调用方
Order order = orderRepository.findById(orderId);
try {
    order.pay(amount);
    orderRepository.save(order);
} catch (BizException e) {
    // 已知晓的非法状态转换,记录日志后可安全忽略
    log.warn("重复支付回调,已跳过: {}", orderId);
}

这个方案的好处是:业务规则和幂等性合为一体,代码读起来就是业务语言。而且状态机天然可以防并发——两个线程同时调用 pay(),只有一个能成功更新数据库。

代价是:每个业务都要设计一套完整的状态流转图,而且状态一多,流转规则就会变成屎山。

方案三:Redis去重(最快)

适合那些"我不想改数据库 schema"的场景。用幂等Key做标记。

public Result handleCallback(CallbackRequest request) {
    String idempotencyKey = "pay:callback:" + request.getOrderId();
    
    // SET key value NX EX 3600: 不存在才设置,过期时间1小时
    Boolean success = redisTemplate.opsForValue()
        .setIfAbsent(idempotencyKey, "1", Duration.ofHours(1));
    
    if (!success) {
        // 重复请求,直接返回
        return Result.ok("duplicate, ignored");
    }
    
    // 真正执行业务
    return doProcess(request);
}

看起来很美,但这里有个致命问题:setIfAbsent 和后续的业务操作不是原子的。如果 set 成功了(拿到了幂等锁),但 doProcess 的时候服务重启了——下一次重试来的时候,Key 还在(没过TTL),请求被拦截,但业务其实没处理。

所以 Redis 去重只适合那种"处理失败了也不需要重新处理"的场景。如果是核心支付流程,别贪这个快。

方案四:乐观锁(最灵活)

适合并发更新的场景,尤其是库存扣减。

UPDATE product_stock 
SET stock = stock - #{count}, version = version + 1
WHERE product_id = #{productId} 
  AND stock >= #{count}
  AND version = #{currentVersion};

if (rowsAffected == 0) {
    throw new StockException("库存不足或版本冲突");
}

通过 version 字段控制更新冲突。如果 version 被其他请求更新了,这一行就影响0个,抛出异常。重试?走正常流程再试。

分布式环境下的问题

以上都是单机场景。上了分布式,幂等性问题才真正露出獠牙。

支付回调可能在两台机器上同时到达。消息队列里的订单创建消息可能被两个消费者 group 各自处理一遍。数据库唯一键在主从切换时可能短暂失效。

分布式环境下的幂等性,本质上是要回答一个问题:

谁来定义"这一次操作"和"那一次操作"是同一个?

答案通常是:幂等Key。而幂等Key的生命周期管理才是真正复杂的部分。

存储在哪里?Redis?数据库?分布式锁服务?每种选择都有可用性、一致性、成本之间的权衡。

过期时间设多久?太短:业务处理还没完,Key就没了,下次重试会被当成新请求。太长:内存压力变大,如果Key存储的是业务数据,还要考虑数据一致性问题。

这些问题没有标准答案,只有tradeoff。理解透了业务场景,才能做出正确的选择。

实践建议

说了这么多,给几条实操建议:

第一,核心业务流程(支付、订单)用数据库唯一约束,不要偷懒。数据库是你最后一道防线。

第二,所有对外暴露的写接口,统一设计幂等Key机制。HTTP层做不到的话,就用消息队列的 messageId 做去重。

第三,幂等性测试要刻意写。不要只测正常流程,要模拟:并发请求同一笔订单、重复回调、消费者重启后消息重投。这三个场景能过的接口,才是真正幂等的接口。

第四,不要把"可重试"和"幂等"混为一谈。可重试是"失败了可以再试",幂等是"再试也不会有副作用"。很多团队的接口"支持重试",但重试之后发现多插了一条记录——这不是幂等,这是给自己埋雷。

最后

幂等性是那种"写的时候嫌麻烦,出事了才后悔"的东西。没出事的时候,PM不知道你做了什么;出了事的时候,用户已经受损了。

所以它很难被当作功绩写进周报。但它是一个工程师是不是真的靠谱的分水岭。

下次写接口的时候,多问自己一句:这个接口被重试十次会怎样?如果答案让你心里一紧,那这篇文章就没白写。

(别问我怎么知道会心里一紧的。)

相关文章

为什么不要写代码才是真正的程序员进阶之道
我和 OpenClaw 的相爱相杀:一只小龙虾的AI助手驯化笔记
我接了一个接口,差点和后端打起来
为什么你的API总被前端打回重做?一位后端老哥的血泪经验总结
为什么你的Go服务内存越来越肥:一个OOM当事人的自白
连接池:那个你天天用却从不伺候好的祖宗

发布评论