大家好,我是小悟。
想象一下这个场景:你给女朋友发"我爱你",手抖连发了三次。如果没有幂等性,她可能会想:"这哥们今天怎么了,这么激动?" 但如果有幂等性,无论你发多少次,效果都跟发一次一样------她只会甜蜜地回复一次"我也爱你"。
这就是接口幂等性------无论你调用多少次,结果都一样的超能力! 就像你按电梯按钮,按100次也不会让电梯来得更快,但电梯还是会来。
为什么需要这个"后悔药"?
- 网络抽风:客户端等了半天没响应,心想"我再试一次吧",结果服务器其实已经处理完了
- 用户手抖:用户疯狂点击提交按钮,仿佛在玩节奏游戏
- 系统重试:微服务架构中,上游服务觉得你可能挂了,好心帮你重试几次
实战开始:给接口穿上"防重复甲"
第一步:令牌大法------领号排队
就像银行办业务先取号,办完业务号码就作废。
typescript
@Service
public class TokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String TOKEN_PREFIX = "IDEMPOTENT_TOKEN:";
/**
* 生成幂等令牌 - 就像发排队号码
*/
public String generateToken(String businessKey) {
String token = UUID.randomUUID().toString().replace("-", "");
String key = TOKEN_PREFIX + businessKey + ":" + token;
// 令牌有效期5分钟,足够你完成操作了
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
return token;
}
/**
* 检查并消耗令牌 - 就像叫号办理业务
*/
public boolean checkAndConsumeToken(String businessKey, String token) {
String key = TOKEN_PREFIX + businessKey + ":" + token;
// 用原子操作确保检查和使用是同步的
// 这就像确保叫号后立即把号码收走,防止别人再用
Boolean result = redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
byte[] keyBytes = key.getBytes();
// 开始事务监控
connection.multi();
// 检查令牌是否存在
Boolean exists = connection.exists(keyBytes);
// 如果存在就删除(消耗令牌)
if (Boolean.TRUE.equals(exists)) {
connection.del(keyBytes);
}
// 执行事务
List<Object> transactionResults = connection.exec();
// 第一个结果是exists检查,第二个是del操作
if (transactionResults != null && transactionResults.size() >= 1) {
return (Boolean) transactionResults.get(0);
}
return false;
}
});
return Boolean.TRUE.equals(result);
}
}
第二步:AOP切面------给接口加个"安检门"
scss
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 业务键,用于区分不同业务场景
* 比如:订单创建用"ORDER_CREATE",支付用"PAYMENT"
*/
String businessKey();
/**
* 令牌在什么位置
*/
TokenLocation tokenLocation() default TokenLocation.HEADER;
/**
* 如果令牌不存在或无效,是否抛出异常
*/
boolean throwException() default true;
}
/**
* 令牌位置枚举
*/
public enum TokenLocation {
HEADER, // 在HTTP头中
PARAM, // 在请求参数中
BODY // 在请求体中
}
/**
* 幂等性切面 - 接口的"安检官"
*/
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Autowired
private TokenService tokenService;
/**
* 环绕通知:在方法执行前后进行幂等性检查
*/
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 获取请求信息
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 2. 提取幂等令牌
String token = extractToken(request, idempotent.tokenLocation());
if (StringUtils.isEmpty(token)) {
log.warn("幂等令牌不存在,业务键: {}", idempotent.businessKey());
return handleTokenMissing(idempotent);
}
// 3. 检查并消耗令牌
boolean isValid = tokenService.checkAndConsumeToken(idempotent.businessKey(), token);
if (!isValid) {
log.warn("幂等令牌无效或已使用,业务键: {}, 令牌: {}", idempotent.businessKey(), token);
return handleTokenInvalid(idempotent);
}
log.info("幂等检查通过,执行业务逻辑,业务键: {}", idempotent.businessKey());
// 4. 令牌有效,执行业务逻辑
return joinPoint.proceed();
}
/**
* 从请求中提取令牌
*/
private String extractToken(HttpServletRequest request, TokenLocation location) {
switch (location) {
case HEADER:
return request.getHeader("Idempotent-Token");
case PARAM:
return request.getParameter("idempotentToken");
case BODY:
// 这里需要根据实际情况从请求体中提取
// 简单实现,实际项目中可能需要更复杂的逻辑
return extractTokenFromBody(request);
default:
return null;
}
}
/**
* 处理令牌不存在的情况
*/
private Object handleTokenMissing(Idempotent idempotent) {
if (idempotent.throwException()) {
throw new BusinessException("幂等令牌不存在");
}
// 如果不抛异常,可以返回特定的结果
return ApiResponse.error("请求重复,请勿重复提交");
}
/**
* 处理令牌无效的情况
*/
private Object handleTokenInvalid(Idempotent idempotent) {
if (idempotent.throwException()) {
throw new BusinessException("请求已处理,请勿重复提交");
}
return ApiResponse.error("请求已处理,请勿重复提交");
}
/**
* 从请求体中提取令牌(简化版)
*/
private String extractTokenFromBody(HttpServletRequest request) {
// 实际项目中可能需要读取请求体并解析JSON
// 这里返回null作为示例
return null;
}
}
第三步:业务异常类
typescript
/**
* 业务异常 - 专门用来抛出业务相关的异常
*/
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
}
}
/**
* 统一API响应格式
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private String code;
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "成功", data, "200");
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null, "500");
}
}
第四步:控制器使用示例
less
@RestController
@RequestMapping("/order")
@Slf4j
public class OrderController {
@Autowired
private TokenService tokenService;
/**
* 获取创建订单的幂等令牌
* 就像去银行先取个号
*/
@GetMapping("/token")
public ApiResponse<String> getOrderToken() {
String token = tokenService.generateToken("ORDER_CREATE");
log.info("生成订单创建令牌: {}", token);
return ApiResponse.success(token);
}
/**
* 创建订单 - 受幂等性保护
* 就像叫到号才能办理业务
*/
@PostMapping("/create")
@Idempotent(businessKey = "ORDER_CREATE", tokenLocation = TokenLocation.HEADER)
public ApiResponse<String> createOrder(@RequestBody OrderCreateRequest request) {
log.info("开始创建订单,订单信息: {}", request);
// 模拟业务处理
try {
// 这里应该是真实的订单创建逻辑
Thread.sleep(1000); // 模拟处理时间
String orderId = "ORDER_" + System.currentTimeMillis();
log.info("订单创建成功,订单ID: {}", orderId);
return ApiResponse.success(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return ApiResponse.error("订单创建失败");
}
}
/**
* 支付订单 - 同样受幂等性保护
*/
@PostMapping("/pay")
@Idempotent(businessKey = "ORDER_PAY", tokenLocation = TokenLocation.HEADER)
public ApiResponse<String> payOrder(@RequestBody OrderPayRequest request) {
log.info("开始处理支付,支付信息: {}", request);
// 模拟支付处理
String paymentId = "PAY_" + System.currentTimeMillis();
log.info("支付成功,支付ID: {}", paymentId);
return ApiResponse.success(paymentId);
}
}
/**
* 订单创建请求
*/
@Data
public class OrderCreateRequest {
private String productId;
private Integer quantity;
private BigDecimal amount;
private String address;
}
/**
* 订单支付请求
*/
@Data
public class OrderPayRequest {
private String orderId;
private BigDecimal payAmount;
private String payMethod;
}
第五步:全局异常处理
less
/**
* 全局异常处理器 - 系统的"和事佬"
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 处理业务异常
*/
@ExceptionHandler(BusinessException.class)
public ApiResponse<Object> handleBusinessException(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return ApiResponse.error(e.getMessage());
}
/**
* 处理其他异常
*/
@ExceptionHandler(Exception.class)
public ApiResponse<Object> handleException(Exception e) {
log.error("系统异常: ", e);
return ApiResponse.error("系统繁忙,请稍后重试");
}
}
使用流程详解
场景:用户创建订单
-
领号阶段:
sql// 前端先调用获取令牌 GET /order/token 响应: { "success": true, "data": "a1b2c3d4e5f6", ... } -
办理业务:
css// 带着令牌调用创建订单接口 POST /order/create Headers: { "Idempotent-Token": "a1b2c3d4e5f6" } Body: { "productId": "123", "quantity": 2, ... } -
可能的情况:
- 第一次调用:令牌有效 → 创建订单 → 返回成功
- 第二次调用:令牌已使用 → 直接返回"请求已处理" → 不会重复创建订单
其他幂等性方案(备选"武器")
方案一:数据库唯一约束
适合防止数据重复插入的场景。
scss
@Service
@Slf4j
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 使用数据库唯一约束防止重复订单
*/
@Transactional
public String createOrderWithUniqueConstraint(OrderCreateRequest request) {
// 生成唯一业务ID(比如:用户ID + 商品ID + 时间戳)
String uniqueBizId = generateUniqueBizId(request);
try {
// 尝试插入订单
Order order = convertToOrder(request);
order.setUniqueBizId(uniqueBizId);
orderMapper.insert(order);
log.info("订单创建成功,订单ID: {}", order.getId());
return order.getId();
} catch (DuplicateKeyException e) {
// 捕获唯一约束违反异常
log.warn("重复订单请求,业务ID: {}", uniqueBizId);
// 查询已存在的订单并返回
Order existingOrder = orderMapper.selectByUniqueBizId(uniqueBizId);
return existingOrder.getId();
}
}
private String generateUniqueBizId(OrderCreateRequest request) {
// 实际项目中这里应该有用户信息
return "USER_123_PRODUCT_" + request.getProductId() + "_" + System.currentTimeMillis();
}
}
方案二:状态机幂等
适合有状态流转的业务。
scss
@Service
@Slf4j
public class PaymentService {
@Autowired
private PaymentMapper paymentMapper;
/**
* 支付处理 - 通过状态机保证幂等
*/
@Transactional
public void processPayment(String orderId, BigDecimal amount) {
// 查询支付记录
Payment payment = paymentMapper.selectByOrderId(orderId);
if (payment == null) {
// 第一次支付,创建记录
payment = new Payment();
payment.setOrderId(orderId);
payment.setAmount(amount);
payment.setStatus(PaymentStatus.INIT);
paymentMapper.insert(payment);
}
// 基于当前状态决定操作
switch (payment.getStatus()) {
case INIT:
// 初始状态,执行支付
boolean payResult = executeRealPayment(orderId, amount);
if (payResult) {
payment.setStatus(PaymentStatus.SUCCESS);
paymentMapper.update(payment);
log.info("支付成功,订单ID: {}", orderId);
} else {
payment.setStatus(PaymentStatus.FAILED);
paymentMapper.update(payment);
log.error("支付失败,订单ID: {}", orderId);
}
break;
case SUCCESS:
// 已经是成功状态,直接返回
log.info("支付已完成,直接返回成功,订单ID: {}", orderId);
break;
case FAILED:
// 失败状态,可以重试或直接返回
log.warn("支付之前已失败,订单ID: {}", orderId);
break;
default:
log.error("未知支付状态: {}", payment.getStatus());
}
}
/**
* 支付状态枚举
*/
public enum PaymentStatus {
INIT, // 初始状态
PROCESSING, // 处理中
SUCCESS, // 成功
FAILED // 失败
}
}
1. 设计原则
- 默认幂等:在设计接口时,默认考虑幂等性需求
- 适度使用:不是所有接口都需要强幂等,根据业务重要性选择
- 明确语义:在API文档中明确说明接口的幂等特性
- 分层防护:从网关到数据库,多层防护确保可靠性
2. 实施要点
- 令牌生命周期:合理设置令牌有效期,避免存储无限增长
- 错误处理:幂等失败时给出明确错误信息,方便问题排查
- 性能考量:幂等检查不应该成为系统瓶颈
- 数据清理:定期清理过期的幂等记录,避免存储膨胀
3. 团队协作
- 统一规范:团队内统一幂等性实现标准
- 文档完善:详细记录每个接口的幂等特性和使用方式
- 代码审查:在CR中重点关注幂等性实现
- 监控覆盖:建立完善的幂等性监控体系
总结
接口幂等性就像是给系统穿了件"防重复甲",让它在面对:
- 🤦♂️ 用户疯狂点击
- 🌐 网络抽风重试
- 🔄 系统自动重试
这些情况时,都能淡定地说:"老弟,这个请求我已经处理过了,结果在这,拿去吧!"
记住选择幂等方案的黄金法则:
- 令牌方案:适合前后端分离,需要明确防止重复请求的场景
- 唯一约束:适合数据创建场景,简单粗暴有效
- 状态机:适合有复杂状态流转的业务流程
现在,给你的接口也穿上这身"铠甲"吧!让它们在面对重复请求时,都能优雅地说:"这个,我见过的~" 😎

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。
您的一键三连,是我更新的最大动力,谢谢
山水有相逢,来日皆可期,谢谢阅读,我们再会
我手中的金箍棒,上能通天,下能探海