凌晨两点,客服群炸了。一堆用户反映:钱被扣了两次。
你打开日志一看,回调通知重复了——支付渠道重试了两次,你们系统处理了两次,用户余额少了两次。
然后你光荣地获得了一个新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不知道你做了什么;出了事的时候,用户已经受损了。
所以它很难被当作功绩写进周报。但它是一个工程师是不是真的靠谱的分水岭。
下次写接口的时候,多问自己一句:这个接口被重试十次会怎样?如果答案让你心里一紧,那这篇文章就没白写。
(别问我怎么知道会心里一紧的。)