幂等性设计:接口重复调用不是小问题,是架构问题
我见过太多团队在"幂等"这件事上翻车。不是不知道要做,是做了但没做对——要么做太重影响性能,要么做太轻线上出事。这篇文章把我这些年踩过的坑和判断框架整理一下。
一道面试题,暴露了一个架构盲区
先说一道我常拿来问候选人的题:
用户在支付页点击"确认支付",网络卡了,APP 超时重试。用户实际上只付了一次钱,但后端收到了两笔扣款请求。问:后端怎么保证不重复扣款?
大部分候选人会答:"加个锁。"再追问:"加什么锁?锁的粒度是订单还是用户?锁住了性能怎么保障?"十个人里有七个开始语塞。
这说明什么?幂等性设计不是加不加锁的问题,是分层的架构决策。
先说清楚,什么是幂等
HTTP 语义里,GET、PUT、DELETE 是天然幂等的——你发一次和发一百次,效果一样。但 POST(创建订单、发起支付)不是:发两次就是两个订单、两笔扣款。
所以幂等的本质是:同一个请求被重复执行,不产生副作用。
这个"副作用"在不同场景里有不同的破坏力:
- 支付接口被重复调用 → 钱扣多了,用户投诉,资损
- 库存扣减被重复执行 → 超卖,库存对不上
- 消息消费被重复执行 → 积分重复发放,数据错乱
幂等做不好,不是’有风险’,是’必然翻车’。 系统跑久了,重试必然发生,网络超时必然发生,并发重复必然发生。
第一层:业务层幂等——从源头减少重复
1. Token 机制:让服务端判断"这张单我见过没"
流程:
- 客户端在发起请求前,先从服务端拿一个全局唯一的 token
- 请求带上 token,服务端用 Redis 存
token → 已处理 - 第一次处理成功,第二次携带同一个 token 时直接返回成功(不重复处理)
java复制
public Result handleOrderCreate(OrderCreateRequest request) {
String idempotentKey = "order:create:" + request.getIdempotentToken();
// 第一次:SET NX 返回 true,正常处理
// 第二次:SET NX 返回 false,说明处理过了,直接返回
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "1", Duration.ofHours(2));
if (!Boolean.TRUE.equals(locked)) {
return Result.ok("订单已创建"); // 幂等返回
}
try {
return doCreateOrder(request);
} catch (Exception e) {
redisTemplate.delete(idempotentKey); // 失败时删除,允许重试
throw e;
}
}
我的判断:Token 机制适合"用户可感知"的操作——支付、订单创建。但 token 生成和传递本身就有复杂度,分布式环境下要保证 token 全局唯一。
2. 状态机:天然幂等的业务设计
java复制
public void handlePayCallback(PayCallbackRequest request) {
Order order = orderService.getByOrderNo(request.getOrderNo());
// 状态机校验:只有待支付才能切到已支付
if (order.getStatus() != OrderStatus.PENDING_PAY) {
log.info("订单状态非待支付,忽略重复回调");
return; // 幂等:重复回调直接忽略
}
orderService.updateStatus(order.getId(), OrderStatus.PAID);
}
状态机的好处是零额外存储、性能无损、业务语义自解释。但它只适用于状态流转是单向的场景。
我的观点:能用状态机解决的问题不用分布式锁,这是幂等设计的第一选择。
第二层:数据库层幂等——用约束兜底
3. 唯一索引:最后的防线
sql复制
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
idem_key VARCHAR(64) NOT NULL COMMENT '幂等键:用户ID+场景+时间戳',
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
UNIQUE KEY uk_idem_key (idem_key)
);
-- idem_key = user_id + "pay" + yyyyMMddHHmmss + order_no_hash
java复制
public void createOrder(Order order) {
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 唯一键冲突 → 查回已存在订单返回
Order exist = orderMapper.selectByIdemKey(order.getIdemKey());
}
}
我的踩坑经验:早期只用 order_no 做唯一键,微信同一个 out_trade_no 会出现两次回调(支付成功+退款成功),幂等键设计不区分场景就误杀正常请求。幂等键 = 业务场景 + 用户标识 + 业务单号。
4. 乐观锁:版本号防更新冲突
java复制
public boolean deductStock(Long productId, Integer quantity) {
int rows = productMapper.updateStockWithVersion(
productId, quantity, currentVersion
);
if (rows == 0) {
throw new OptimisticLockException("库存更新冲突,请重试");
}
return true;
}
xml复制
UPDATE t_product_stock
SET stock = stock - #{quantity}, version = version + 1
WHERE id = #{productId} AND stock >= #{quantity} AND version = #{currentVersion}
乐观锁只适合冲突概率不高的场景。秒杀系统用?那重试队列会爆炸。
第三层:消息队列层幂等——消费端不能依赖上游
上游做了幂等,但 MQ 的 At-Least-Once 投递决定了:消息一定会被重复投递。Broker 重发、Consumer 超时、Rebalance——任何一种都会导致重复消费。
5. 消息去重表
java复制
@KafkaListener(topics = "order-paid")
public void handleOrderPaid(OrderPaidEvent event) {
String msgId = event.getMessageId();
// 先查去重表
if (msgLogMapper.exists(msgId) > 0) return; // 已处理过
// 事务:处理业务 + 记录消息ID
doProcessOrderPaid(event);
msgLogMapper.insert(new MsgLog().setMsgId(msgId));
}
去重表用数据库唯一键约束代替内存判断,解决了集群多实例的幂等问题。代价:每次查库 + 表持续膨胀需归档。
6. Redis 去重
java复制
Boolean first = redisTemplate.opsForValue().setIfAbsent(
"msg:dedup:" + msgId, "1", Duration.ofDays(7)
);
if (!Boolean.TRUE.equals(first)) return; // 幂等返回
doChangePoints(event);
生产推荐:Redis + 去重表双保险——Redis 挡住 99%,数据库兜底 1%。
三层协作策略
| 层次 | 方案 | 作用 | 性能影响 |
|---|---|---|---|
| 业务层入口 | Token 去重 | 挡住用户重试 | 极低 |
| 业务层处理中 | 状态机校验 | 挡住重复状态流转 | 无 |
| 数据库层 | 唯一索引 | 兜底防护 | 低 |
| 数据库层 | 乐观锁 | 防并发冲突 | 中 |
| MQ消费层 | Redis 去重 | 挡重复投递 | 极低 |
| MQ消费层 | 去重表 | 集群级兜底 | 中 |
经验:入口 Token + DB 唯一索引覆盖 95% 场景。MQ 层按消息重要程度决定——钱相关的必须去重表,普通通知可去掉。
一个反直觉的观点
不是所有接口都需要幂等。
判断原则是:看这个接口失败后会不会被重试。
- 用户主动发起的操作(支付、下单)→ 必须幂等
- 下游系统回调(支付回调、发货回调)→ 必须幂等
- 内部定时任务、手动脚本 → 看场景
- 读接口 → 天然幂等,不用做
不要为了’保险’给所有接口加幂等,架构是做取舍,不是堆方案。
最后
真正考验架构师功力的,不是知道多少方案,而是知道在什么场景选什么方案,知道方案之间的配合与取舍。
面试时能讲清楚这个分层逻辑,比背一个"用 Redis SET NX 做幂等"要强得多。
附:幂等键设计参考公式
code复制
幂等键 = 业务场景 + 用户标识 + 业务单号 + 请求时间戳(可选)
支付幂等键 = "pay:" + userId + ":" + orderNo
库存幂等键 = "stock:" + productId + ":" + orderId
积分幂等键 = "points:" + userId + ":" + changeType + ":" + bizId
首发于「随生门户」公众号,转载须授权。