在分布式系统中,接口调用可能因网络延迟、重试机制、前端误操作等原因出现重复请求——如用户连续点击支付按钮导致重复扣款,或分布式事务中因重试引发的数据重复插入。幂等性设计通过“让接口在重复调用时产生相同的效果”,避免重复请求导致的数据不一致、业务逻辑混乱等问题,是保障接口可靠性的核心原则。
幂等性的核心价值与适用场景
为什么需要幂等性?
- 防止重复数据:避免因重复请求导致的订单重复创建、商品重复下单
- 保障数据一致性:确保多次调用接口后,业务状态和数据结果保持一致
- 支持安全重试:让接口调用失败时可以放心重试,无需担心副作用
- 适应分布式环境:在网络不稳定、服务间调用复杂的分布式系统中,抵御不确定性
需保证幂等性的场景
- 支付接口:防止重复扣款(如用户多次点击“确认支付”)
- 订单创建:避免同一笔业务生成多个订单
- 数据提交:如表单提交、信息注册,防止重复提交导致的数据重复
- 分布式事务:在TCC、SAGA等模式中,补偿操作需要幂等性支持
无需幂等性的场景:
- 纯查询接口(多次查询不改变数据状态)
- 一次性操作且无重试机制的接口(如日志上报)
幂等性的实现方案
1. 基于唯一标识的幂等(Token机制)
通过预生成的唯一Token标识请求,确保同一业务仅被处理一次:
// Token生成与验证服务
@Service
public class IdempotentTokenService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 生成幂等Token(用于后续请求验证)
*/
public String generateToken(String businessKey) {
// 生成唯一Token(如UUID)
String token = UUID.randomUUID().toString();
// 存储Token到Redis(键:业务标识+Token,值:未处理状态,过期时间30分钟)
String redisKey = "idempotent:" + businessKey + ":" + token;
redisTemplate.opsForValue().set(redisKey, "UNPROCESSED", 30, TimeUnit.MINUTES);
return token;
}
/**
* 验证Token并加锁(确保同一Token仅被处理一次)
*/
public boolean verifyToken(String businessKey, String token) {
String redisKey = "idempotent:" + businessKey + ":" + token;
// 使用Redis的setIfAbsent实现分布式锁(原子操作)
return redisTemplate.opsForValue().setIfAbsent(redisKey, "PROCESSING", 30, TimeUnit.MINUTES);
}
/**
* 标记Token处理完成
*/
public void markProcessed(String businessKey, String token) {
String redisKey = "idempotent:" + businessKey + ":" + token;
redisTemplate.opsForValue().set(redisKey, "PROCESSED", 24, TimeUnit.HOURS); // 保留24小时便于查询
}
/**
* 检查Token是否已处理
*/
public boolean isProcessed(String businessKey, String token) {
String redisKey = "idempotent:" + businessKey + ":" + token;
String status = redisTemplate.opsForValue().get(redisKey);
return "PROCESSED".equals(status);
}
}
// 接口调用流程
@RestController
@RequestMapping("/pay")
public class PaymentController {
@Autowired
private IdempotentTokenService tokenService;
@Autowired
private PaymentService paymentService;
/**
* 步骤1:预生成支付Token(如在订单确认页调用)
*/
@GetMapping("/token")
public Result generatePayToken(@RequestParam String orderNo) {
String token = tokenService.generateToken("pay:" + orderNo);
return Result.success(token);
}
/**
* 步骤2:使用Token调用支付接口(确保幂等)
*/
@PostMapping("/submit")
public Result submitPayment(@RequestParam String orderNo,
@RequestParam String token,
@RequestBody PaymentDTO dto) {
// 1. 验证Token是否有效且未被处理
if (!tokenService.verifyToken("pay:" + orderNo, token)) {
// Token已处理或无效,直接返回结果
if (tokenService.isProcessed("pay:" + orderNo, token)) {
return Result.success("支付已处理");
} else {
return Result.fail(400, "无效的Token");
}
}
try {
// 2. 执行支付逻辑
PaymentResult result = paymentService.doPayment(orderNo, dto);
// 3. 标记Token处理完成
tokenService.markProcessed("pay:" + orderNo, token);
return Result.success(result);
} catch (Exception e) {
// 4. 异常时可删除Token锁,允许重试(或根据业务决定是否保留)
// redisTemplate.delete("idempotent:pay:" + orderNo + ":" + token);
return Result.fail(500, "支付失败:" + e.getMessage());
}
}
}
Token机制优势:
- 通用性强:适用于各类需要幂等的接口
- 安全性高:Token一次性有效,避免被恶意复用
- 支持分布式:基于Redis实现,适配集群环境
局限性:
- 需额外请求:生成Token需要前置接口调用
- 增加复杂度:需要管理Token的生成、验证和状态
2. 基于业务唯一键的幂等
利用业务自身的唯一标识(如订单号、用户ID+业务类型)确保幂等:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 创建订单(基于订单号确保幂等)
*/
public Long createOrder(OrderDTO dto) {
String orderNo = dto.getOrderNo(); // 业务唯一标识(如前端生成或后端生成)
if (orderNo == null) {
throw new BusinessException("订单号不能为空");
}
// 1. 先查询订单是否已存在(快速判断)
Order existingOrder = orderMapper.selectByOrderNo(orderNo);
if (existingOrder != null) {
return existingOrder.getId(); // 已存在则返回原有ID
}
// 2. 使用分布式锁防止并发创建
String lockKey = "lock:order:" + orderNo;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
// 获取锁失败,说明有并发请求,等待后重试查询
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Order order = orderMapper.selectByOrderNo(orderNo);
return order != null ? order.getId() : null;
}
try {
// 3. 再次检查订单是否存在(防止锁等待期间已被创建)
Order order = orderMapper.selectByOrderNo(orderNo);
if (order != null) {
return order.getId();
}
// 4. 执行创建订单逻辑
order = new Order();
order.setOrderNo(orderNo);
order.setUserId(dto.getUserId());
order.setAmount(dto.getAmount());
// ... 其他字段设置
orderMapper.insert(order);
return order.getId();
} finally {
// 5. 释放锁
redisTemplate.delete(lockKey);
}
}
}
业务唯一键优势:
- 无需额外Token:利用业务自身标识,减少接口调用
- 实现简单:直接通过唯一键查询和判断
局限性:
- 依赖业务设计:需要业务有天然的唯一标识
- 并发风险:需配合分布式锁防止并发创建
3. 基于数据库的幂等(唯一索引)
通过数据库唯一索引约束,防止重复数据插入:
-- 订单表添加唯一索引(确保order_no唯一)
ALTER TABLE `order` ADD UNIQUE INDEX `idx_order_no` (`order_no`);
@Service
public class OrderDbIdempotentService {
@Autowired
private OrderMapper orderMapper;
public Long createOrderWithUniqueIndex(OrderDTO dto) {
try {
// 尝试插入订单(若order_no重复,会触发唯一索引异常)
Order order = new Order();
order.setOrderNo(dto.getOrderNo());
// ... 其他字段设置
orderMapper.insert(order);
return order.getId();
} catch (DuplicateKeyException e) {
// 捕获唯一索引冲突异常,查询已有订单并返回
log.warn("订单号重复,orderNo={}", dto.getOrderNo(), e);
Order existingOrder = orderMapper.selectByOrderNo(dto.getOrderNo());
return existingOrder != null ? existingOrder.getId() : null;
}
}
}
数据库唯一索引优势:
- 最底层保障:即使应用层逻辑失效,数据库仍能防止重复
- 简单可靠:无需额外代码,仅通过数据库约束实现
局限性:
- 仅适用于插入场景:无法解决更新操作的幂等问题
- 异常处理成本:需要捕获数据库异常,可能影响性能
不同操作类型的幂等性实现
| 操作类型 | 幂等性实现方案 | 示例 |
|---|---|---|
| 新增(INSERT) | 唯一索引、业务唯一键 | 订单创建(order_no唯一) |
| 更新(UPDATE) | 基于版本号、条件更新 | 更新用户余额(UPDATE user SET balance = balance - 10 WHERE id = 1 AND balance >= 10) |
| 删除(DELETE) | 逻辑删除、条件删除 | 删除订单(DELETE FROM order WHERE id = 1 AND status = 'UNPAID') |
| 查询(SELECT) | 天然幂等 | 查询商品详情 |
版本号机制实现更新幂等
@Service
public class UserBalanceService {
@Autowired
private UserMapper userMapper;
/**
* 更新用户余额(基于版本号确保幂等)
*/
public boolean updateBalance(Long userId, BigDecimal amount, Integer version) {
// 带版本号条件更新(仅当版本号匹配时才更新)
int rows = userMapper.updateBalance(userId, amount, version);
// 更新行数为0说明版本号不匹配(已被其他请求更新)
return rows > 0;
}
}
// Mapper接口
public interface UserMapper {
@Update("UPDATE user SET balance = balance + #{amount}, version = version + 1 " +
"WHERE id = #{userId} AND version = #{version}")
int updateBalance(@Param("userId") Long userId,
@Param("amount") BigDecimal amount,
@Param("version") Integer version);
}
幂等性的最佳实践
1. 优先使用业务自身的唯一标识
尽量利用业务中天然的唯一键(如订单号、交易号),减少额外Token的使用,降低系统复杂度。
2. 结合多种方案保障幂等
例如:“业务唯一键 + 分布式锁 + 唯一索引”三重保障,即使某一层失效,其他层仍能发挥作用。
3. 明确幂等接口的返回值
对重复请求,应返回与首次请求相同的结果(如相同的订单ID、支付状态),避免调用方困惑。
4. 记录幂等处理日志
详细记录重复请求的处理过程(如“检测到重复支付,订单号xxx,返回已有结果”),便于问题排查。
避坑指南
- 不要过度设计:纯查询接口无需实现幂等性
- 注意锁的粒度:分布式锁的键应尽量具体(如
lock:order:123而非lock:order),避免锁竞争 - 处理锁超时:确保业务逻辑执行时间短于锁的过期时间,避免锁提前释放导致的并发问题
- 考虑异常恢复:当处理过程中发生异常,需明确Token或锁的状态如何处理(释放还是保留)
幂等性设计的核心是“让重复请求无害”——通过技术手段确保多次调用接口的效果与一次调用一致。在分布式系统中,网络抖动、服务重试等情况不可避免,幂等性不是可有可无的优化,而是保障系统可靠性的“底线要求”。一个具备完善幂等性的接口,才能在复杂的生产环境中“稳如泰山”。