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

简介: 接口幂等性就像是给系统穿了件"防重复甲",让它在面对:用户疯狂点击、网络抽风重试、系统自动重试等这些情况时,都能淡定地说:"老弟,这个请求我已经处理过了,结果在这,拿去吧!"

大家好,我是小悟。

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

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

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

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

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

第一步:令牌大法——领号排队

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

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

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

第五步:全局异常处理

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

使用流程详解

场景:用户创建订单

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


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

方案一:数据库唯一约束

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

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

方案二:状态机幂等

适合有状态流转的业务。

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

总结

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

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

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

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

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

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

image.png

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


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

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

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

相关文章
|
19天前
|
人工智能 自然语言处理 Shell
🦞 如何在 OpenClaw (Clawdbot/Moltbot) 配置阿里云百炼 API
本教程指导用户在开源AI助手Clawdbot中集成阿里云百炼API,涵盖安装Clawdbot、获取百炼API Key、配置环境变量与模型参数、验证调用等完整流程,支持Qwen3-max thinking (Qwen3-Max-2026-01-23)/Qwen - Plus等主流模型,助力本地化智能自动化。
32003 115
🦞 如何在 OpenClaw (Clawdbot/Moltbot) 配置阿里云百炼 API
|
8天前
|
应用服务中间件 API 网络安全
3分钟汉化OpenClaw,使用Docker快速部署启动OpenClaw(Clawdbot)教程
2026年全新推出的OpenClaw汉化版,是基于Claude API开发的智能对话系统本土化优化版本,解决了原版英文界面的使用壁垒,实现了界面、文档、指令的全中文适配。该版本采用Docker容器化部署方案,开箱即用,支持Linux、macOS、Windows全平台运行,适配个人、企业、生产等多种使用场景,同时具备灵活的配置选项和强大的扩展能力。本文将从项目简介、部署前准备、快速部署、详细配置、问题排查、监控维护等方面,提供完整的部署与使用指南,文中包含实操代码命令,确保不同技术水平的用户都能快速落地使用。
4683 4
|
14天前
|
人工智能 安全 机器人
OpenClaw(原 Clawdbot)钉钉对接保姆级教程 手把手教你打造自己的 AI 助手
OpenClaw(原Clawdbot)是一款开源本地AI助手,支持钉钉、飞书等多平台接入。本教程手把手指导Linux下部署与钉钉机器人对接,涵盖环境配置、模型选择(如Qwen)、权限设置及调试,助你快速打造私有、安全、高权限的专属AI助理。(239字)
6732 18
OpenClaw(原 Clawdbot)钉钉对接保姆级教程 手把手教你打造自己的 AI 助手
|
13天前
|
人工智能 机器人 Linux
OpenClaw(Clawdbot、Moltbot)汉化版部署教程指南(零门槛)
OpenClaw作为2026年GitHub上增长最快的开源项目之一,一周内Stars从7800飙升至12万+,其核心优势在于打破传统聊天机器人的局限,能真正执行读写文件、运行脚本、浏览器自动化等实操任务。但原版全英文界面对中文用户存在上手门槛,汉化版通过覆盖命令行(CLI)与网页控制台(Dashboard)核心模块,解决了语言障碍,同时保持与官方版本的实时同步,确保新功能最快1小时内可用。本文将详细拆解汉化版OpenClaw的搭建流程,涵盖本地安装、Docker部署、服务器远程访问等场景,同时提供环境适配、问题排查与国内应用集成方案,助力中文用户高效搭建专属AI助手。
4728 11
|
15天前
|
人工智能 机器人 Linux
保姆级 OpenClaw (原 Clawdbot)飞书对接教程 手把手教你搭建 AI 助手
OpenClaw(原Clawdbot)是一款开源本地AI智能体,支持飞书等多平台对接。本教程手把手教你Linux下部署,实现数据私有、系统控制、网页浏览与代码编写,全程保姆级操作,240字内搞定专属AI助手搭建!
5640 20
保姆级 OpenClaw (原 Clawdbot)飞书对接教程 手把手教你搭建 AI 助手
|
15天前
|
存储 人工智能 机器人
OpenClaw是什么?阿里云OpenClaw(原Clawdbot/Moltbot)一键部署官方教程参考
OpenClaw是什么?OpenClaw(原Clawdbot/Moltbot)是一款实用的个人AI助理,能够24小时响应指令并执行任务,如处理文件、查询信息、自动化协同等。阿里云推出的OpenClaw一键部署方案,简化了复杂配置流程,用户无需专业技术储备,即可快速在轻量应用服务器上启用该服务,打造专属AI助理。本文将详细拆解部署全流程、进阶功能配置及常见问题解决方案,确保不改变原意且无营销表述。
6189 5
|
11天前
|
人工智能 JavaScript 安全
Claude Code 安装指南
Claude Code 是 Anthropic 推出的本地 AI 编程助手,支持 Mac/Linux/WSL/Windows 多平台一键安装(Shell/PowerShell/Homebrew/NPM),提供 CLI 交互、代码生成、审查、Git 提交等能力,并内置丰富斜杠命令与自动更新机制。
4134 0
|
17天前
|
人工智能 JavaScript 应用服务中间件
零门槛部署本地AI助手:Windows系统Moltbot(Clawdbot)保姆级教程
Moltbot(原Clawdbot)是一款功能全面的智能体AI助手,不仅能通过聊天互动响应需求,还具备“动手”和“跑腿”能力——“手”可读写本地文件、执行代码、操控命令行,“脚”能联网搜索、访问网页并分析内容,“大脑”则可接入Qwen、OpenAI等云端API,或利用本地GPU运行模型。本教程专为Windows系统用户打造,从环境搭建到问题排查,详细拆解全流程,即使无技术基础也能顺利部署本地AI助理。
7745 17