大家好,我是小悟。

想象一下這個場景:你給女朋友發"我愛你",手抖連發了三次。如果沒有冪等性,她可能會想:"這哥們今天怎麼了,這麼激動?" 但如果有冪等性,無論你發多少次,效果都跟發一次一樣——她只會甜蜜地回覆一次"我也愛你"。

這就是接口冪等性——無論你調用多少次,結果都一樣的超能力! 就像你按電梯按鈕,按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中重點關注冪等性實現
  • 監控覆蓋:建立完善的冪等性監控體系


總結

接口冪等性就像是給系統穿了件"防重複甲",讓它在面對:

  • 🤦♂️ 用户瘋狂點擊
  • 🌐 網絡抽風重試
  • 🔄 系統自動重試

這些情況時,都能淡定地説:"老弟,這個請求我已經處理過了,結果在這,拿去吧!"

記住選擇冪等方案的黃金法則

  • 令牌方案:適合前後端分離,需要明確防止重複請求的場景
  • 唯一約束:適合數據創建場景,簡單粗暴有效
  • 狀態機:適合有複雜狀態流轉的業務流程

現在,給你的接口也穿上這身"鎧甲"吧!讓它們在面對重複請求時,都能優雅地説:"這個,我見過的~" 😎

Java實現接口冪等性:程序員的“後悔藥”_後端開發

謝謝你看我的文章,既然看到這裏了,如果覺得不錯,隨手點個贊、轉發、在看三連吧,感謝感謝。那我們,下次再見。

您的一鍵三連,是我更新的最大動力,謝謝

山水有相逢,來日皆可期,謝謝閲讀,我們再會

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