幂等性指同一操作执行多次与执行一次的效果相同。支付扣款、订单创建、库存扣减这类接口在网络超时重试、用户重复点击、MQ消息重投递的场景下,会被调用不止一次,没有幂等保障就会重复扣款、重复下单。这不是理论问题,是生产事故的直接来源。实现幂等的核心思路是给每个业务操作定义唯一标识,处理前先判断该标识是否已处理过。
唯一索引防重的数据库层兜底方案
最可靠的幂等手段放在数据库层,因为应用层缓存、Redis都可能异常,唯一约束是最后的防线。以用户参与一次秒杀活动为例,活动表加唯一索引限制单用户单活动仅一条记录:
CREATE TABLE activity_join_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_activity_user (activity_id, user_id)
);
重复插入时MySQL抛出DuplicateKeyException,应用捕获后按业务语义返回已参与:
@Transactional
public JoinResult joinActivity(Long activityId, Long userId) {
try {
joinRecordMapper.insert(new ActivityJoinRecord(activityId, userId));
// 首次参与,继续后续业务
awardService.grant(activityId, userId);
return JoinResult.success();
} catch (DuplicateKeyException e) {
// 唯一索引冲突 = 该用户已参与过,幂等返回
return JoinResult.alreadyJoined();
}
}
这个方案的前提是存在天然唯一的业务键(活动ID+用户ID)。支付场景的业务键是支付流水号,上游系统生成的out_trade_no加唯一索引即可。没有天然业务键的场景,需要应用层先生成操作凭证,也就是token机制。
Token机制与分布式锁的请求级防重实现
Token机制的流程是:客户端请求表单页时,服务端预生成一个token存入Redis并返回给页面;提交时请求带上token,服务端用Redis的原子删除操作抢占token,抢占成功才执行业务。关键在原子性,用Redis的GETDEL或LUA脚本:
-- 幂等检查LUA脚本:token存在则删除返回1,否则返回0
local val = redis.call('GET', KEYS[1])
if val then
redis.call('DEL', KEYS[1])
return 1
else
return 0
end
public IdempotentResult submit(String token, OrderRequest req) {
// 1. 原子抢占token,保证同一token只有一个请求能通过
Long grabbed = redisTemplate.execute(idempotentScript,
Collections.singletonList("idem:" + token));
if (grabbed == null || grabbed == 0) {
return IdempotentResult.duplicate(); // 重复请求直接拒绝
}
// 2. 抢到token后执行业务
return orderService.create(req);
}
GETDEL保证了检查与删除在一步完成,两个并发请求携带同一token时,只有一个能读到值。如果业务执行失败,token已被删,重试请求会被误判重复,需要业务侧在失败时重新获取token,这个代价与安全性是匹配的。
分布式锁方案与token的差异在于锁会过期释放,适合保护长耗时任务,比如MQ消费场景下的重复消息。消费逻辑:
public ConsumeResult onMessage(PayMessage msg) {
String key = "consume:" + msg.getMessageId();
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofHours(24));
if (Boolean.FALSE.equals(first)) {
return ConsumeResult.success(); // 已处理过,直接ACK
}
try {
payService.handle(msg); // 业务处理
} catch (Exception e) {
redisTemplate.delete(key); // 失败释放,允许重投递
throw e;
}
return ConsumeResult.success();
}
messageId作为幂等键,setIfAbsent即SETNX语义。24小时过期时间覆盖MQ最大重投递窗口。注意这个方案在Redis主从切换瞬间存在锁丢失的极小概率窗口,强一致场景用唯一索引兜底双保险。
状态机幂等与超时重试的边界处理
有状态的业务对象,状态机本身就能做幂等。订单支付流程中,只在待支付状态下扣款才生效:
@Transactional
public PayResult pay(String orderNo, String payId) {
Order order = orderMapper.selectByOrderNoForUpdate(orderNo); // 行锁
if (order.getStatus() != OrderStatus.WAIT_PAY) {
// 已支付/已取消,重复请求按当前状态返回
return PayResult.of(order.getStatus());
}
accountService.deduct(order.getUserId(), order.getAmount(), payId);
orderMapper.updateStatus(orderNo, OrderStatus.PAID);
return PayResult.success();
}
重复的支付请求进来时订单状态已是PAID,直接返回成功结果,客户端拿到一致响应不会误重试。行锁for update防止两个并发请求同时读到WAIT_PAY。扣款接口accountService.deduct内部再用payId唯一索引防重,形成两层保护。判断幂等边界的实践经验:读接口天然幂等不用处理;写接口中,创建类用唯一索引,状态变更类用状态机条件更新,累计类(如累加积分)把操作ID与增量一起落库防重。
最后是超时重试策略配合。设置合理的重试次数与退避间隔,指数退避公式interval = base * 2^n,把重试压力摊开。防重与重试是一体两面:接口幂等做到位,客户端与服务间重试才敢开启,系统整体的失败恢复能力才真正建立起来。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/gao-bing-fa-mi-deng-she-ji-shi-zhan-jie-kou-fang-zhong-yu/