java后端接口(Java实现接口幂等性:程序员的“后悔药”)

java后端接口(Java实现接口幂等性:程序员的“后悔药”)
Java实现接口幂等性:程序员的“后悔药”

大家好,我是小悟。

想象一下这个场景:你给女朋友发"我爱你",手抖连发了三次。如果没有幂等性,她可能会想:"这哥们今天怎么了,这么激动?" 但如果有幂等性,无论你发多少次,效果都跟发一次一样——她只会甜蜜地回复一次"我也爱你"。

这就是接口幂等性—— 无论你调用多少次,结果都一样的超能力! 就像你按电梯按钮,按100次也不会让电梯来得更快,但电梯还是会来。

为什么需要这个"后悔药"?

  • 网络抽风 :客户端等了半天没响应,心想"我再试一次吧",结果服务器其实已经处理完了
  • 用户手抖 :用户疯狂点击提交按钮,仿佛在玩节奏游戏
  • 系统重试 :微服务架构中,上游服务觉得你可能挂了,好心帮你重试几次

实战开始:给接口穿上"防重复甲"

#后端 #Java第一步:令牌大法——领号排队

就像银行办业务先取号,办完业务号码就作废。

java后端接口(Java实现接口幂等性:程序员的“后悔药”)

@Servicepublic 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切面——给接口加个"安检门"

@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; } }

第三步:业务异常类

/** * 业务异常 - 专门用来抛出业务相关的异常 */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"); } }

第四步:控制器使用示例

@RestController@RequestMapping("/order")@Slf4jpublic 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; }

第五步:全局异常处理

/** * 全局异常处理器 - 系统的"和事佬" */@RestControllerAdvice@Slf4jpublic 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("系统繁忙,请稍后重试");    }}

使用流程详解

场景:用户创建订单

  1. 领号阶段
// 前端先调用获取令牌GET /order/token响应: { "success": true, "data": "a1b2c3d4e5f6", ... }
  1. 办理业务
// 带着令牌调用创建订单接口POST /order/createHeaders: { "Idempotent-Token": "a1b2c3d4e5f6" }Body: { "productId": "123", "quantity": 2, ... }
  1. 可能的情况
  2. 第一次调用 :令牌有效 → 创建订单 → 返回成功
  3. 第二次调用 :令牌已使用 → 直接返回"请求已处理" → 不会重复创建订单

其他幂等性方案(备选"武器")

方案一:数据库唯一约束

适合防止数据重复插入的场景。

@Service@Slf4jpublic 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();    }}

方案二:状态机幂等

适合有状态流转的业务。

@Service@Slf4jpublic 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中重点关注幂等性实现
  • 监控覆盖 :建立完善的幂等性监控体系

总结

接口幂等性就像是给系统穿了件"防重复甲",让它在面对:

  • ‍♂️ 用户疯狂点击
  • 网络抽风重试
  • 系统自动重试

这些情况时,都能淡定地说:"老弟,这个请求我已经处理过了,结果在这,拿去吧!"

记住选择幂等方案的 黄金法则

  • 令牌方案:适合前后端分离,需要明确防止重复请求的场景
  • 唯一约束:适合数据创建场景,简单粗暴有效
  • 状态机:适合有复杂状态流转的业务流程

现在,给你的接口也穿上这身"铠甲"吧!让它们在面对重复请求时,都能优雅地说:"这个,我见过的~"

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有

相关阅读