一、為什么需要冪等性?
核心定義:在分布式系統中,一個操作無論執行一次還是多次,最終結果都保持一致。
典型場景:
- 用戶重復點擊提交按鈕
- 網絡抖動導致的請求重試
- 消息隊列的重復消費
- 支付系統的回調通知
不處理冪等的風險:
- 重復創建訂單導致資金損失
- 庫存超賣引發資損風險
- 用戶數據重復插入破壞業務邏輯
二、實現步驟分解
1. 定義冪等注解
/*** 冪等注解** @author dyh*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {/*** 冪等的超時時間,默認為 1 秒** 注意,如果執行時間超過它,請求還是會進來*/int timeout() default 1;/*** 時間單位,默認為 SECONDS 秒*/TimeUnit timeUnit() default TimeUnit.SECONDS;/*** 提示信息,正在執行中的提示*/String message() default "重復請求,請稍后重試";/*** 使用的 Key 解析器** @see DefaultIdempotentKeyResolver 全局級別* @see UserIdempotentKeyResolver 用戶級別* @see ExpressionIdempotentKeyResolver 自定義表達式,通過 {@link #keyArg()} 計算*/Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;/*** 使用的 Key 參數*/String keyArg() default "";/*** 刪除 Key,當發生異常時候** 問題:為什么發生異常時,需要刪除 Key 呢?* 回答:發生異常時,說明業務發生錯誤,此時需要刪除 Key,避免下次請求無法正常執行。** 問題:為什么不搞 deleteWhenSuccess 執行成功時,需要刪除 Key 呢?* 回答:這種情況下,本質上是分布式鎖,推薦使用 @Lock4j 注解*/boolean deleteKeyWhenException() default true;}
2. 設計Key解析器接口
/*** 冪等 Key 解析器接口** @author dyh*/
public interface IdempotentKeyResolver {/*** 解析一個 Key** @param idempotent 冪等注解* @param joinPoint AOP 切面* @return Key*/String resolver(JoinPoint joinPoint, Idempotent idempotent);}
3. 實現三種核心策略
- 默認策略:方法簽名+參數MD5(防全局重復)
- 用戶策略:用戶ID+方法特征(防用戶重復)
- 表達式策略:SpEL動態解析參數(靈活定制)
3.1 默認策略
/*** 默認(全局級別)冪等 Key 解析器,使用方法名 + 方法參數,組裝成一個 Key** 為了避免 Key 過長,使用 MD5 進行“壓縮”** @author dyh*/
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {/*** 核心方法:生成冪等Key(基于方法特征+參數內容)* @param joinPoint AOP切入點對象,包含方法調用信息* @param idempotent 方法上的冪等注解對象* @return 生成的唯一冪等Key(32位MD5哈希值)*/@Overridepublic String resolver(JoinPoint joinPoint, Idempotent idempotent) {// 獲取方法完整簽名(格式:返回值類型 類名.方法名(參數類型列表))// 示例:String com.example.UserService.createUser(Long,String)String methodName = joinPoint.getSignature().toString();// 將方法參數數組拼接為字符串(用逗號分隔)// 示例:參數是 [123, "張三"] 將拼接為 "123,張三"String argsStr = StrUtil.join(",", joinPoint.getArgs());// 將方法簽名和參數字符串合并后計算MD5// 目的:將可能很長的字符串壓縮為固定長度,避免Redis Key過長return SecureUtil.md5(methodName + argsStr);}}
3.2 用戶策略
/*** 用戶級別的冪等 Key 解析器,使用方法名 + 方法參數 + userId + userType,組裝成一個 Key* <p>* 為了避免 Key 過長,使用 MD5 進行“壓縮”** @author dyh*/
public class UserIdempotentKeyResolver implements IdempotentKeyResolver {/*** 生成用戶級別的冪等Key** @param joinPoint AOP切入點對象(包含方法調用信息)* @param idempotent 方法上的冪等注解* @return 基于用戶維度的32位MD5哈希值* <p>* 生成邏輯分四步:* 1. 獲取方法簽名 -> 標識具體方法* 2. 拼接參數值 -> 標識操作數據* 3. 獲取用戶身份 -> 隔離用戶操作* 4. MD5哈希計算 -> 壓縮存儲空間*/@Overridepublic String resolver(JoinPoint joinPoint, Idempotent idempotent) {// 步驟1:獲取方法唯一標識(格式:返回類型 類名.方法名(參數類型列表))// 示例:"void com.service.UserService.updatePassword(Long,String)"String methodName = joinPoint.getSignature().toString();// 步驟2:將方法參數轉換為逗號分隔的字符串// 示例:參數是 [1001, "新密碼"] 會拼接成 "1001,新密碼"String argsStr = StrUtil.join(",", joinPoint.getArgs());// 步驟3:從請求上下文中獲取當前登錄用戶ID// 注意:需確保在Web請求環境中使用,未登錄時可能返回nullLong userId = WebFrameworkUtils.getLoginUserId();// 步驟4:獲取當前用戶類型(例如:0-普通用戶,1-管理員)// 作用:區分不同權限用戶的操作Integer userType = WebFrameworkUtils.getLoginUserType();// 步驟5:將所有要素拼接后生成MD5哈希值// 輸入示例:"void updatePassword()1001,新密碼1231"// 輸出示例:"d3d9446802a44259755d38e6d163e820"return SecureUtil.md5(methodName + argsStr + userId + userType);}
}
3.3 表達式策略
/*** 基于 Spring EL 表達式,** @author dyh*/
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {// 參數名發現器:用于獲取方法的參數名稱(如:userId, orderId)// 為什么用LocalVariableTable:因為編譯后默認不保留參數名,需要這個工具讀取調試信息private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();// 表達式解析器:專門解析Spring EL表達式// 為什么用Spel:Spring官方標準,支持復雜表達式語法private final ExpressionParser expressionParser = new SpelExpressionParser();/*** 核心方法:解析生成冪等Key** @param joinPoint AOP切入點(包含方法調用信息)* @param idempotent 方法上的冪等注解* @return 根據表達式生成的唯一Key*/@Overridepublic String resolver(JoinPoint joinPoint, Idempotent idempotent) {// 步驟1:獲取當前執行的方法對象Method method = getMethod(joinPoint);// 步驟2:獲取方法參數值數組(例如:[訂單對象, 用戶對象])Object[] args = joinPoint.getArgs();// 步驟3:獲取方法參數名數組(例如:["order", "user"])String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);// 步驟4:創建表達式上下文(相當于給表達式提供變量環境)StandardEvaluationContext evaluationContext = new StandardEvaluationContext();// 步驟5:將參數名和參數值綁定到上下文(讓表達式能識別#order這樣的變量)if (ArrayUtil.isNotEmpty(parameterNames)) {for (int i = 0; i < parameterNames.length; i++) {// 例如:將"order"參數名和實際的Order對象綁定evaluationContext.setVariable(parameterNames[i], args[i]);}}// 步驟6:解析注解中的表達式(例如:"#order.id")Expression expression = expressionParser.parseExpression(idempotent.keyArg());// 步驟7:執行表達式計算(例如:從order對象中取出id屬性值)return expression.getValue(evaluationContext, String.class);}/*** 輔助方法:獲取實際執行的方法對象* 為什么需要這個方法:處理Spring AOP代理接口的情況** @param point AOP切入點* @return 實際被調用的方法對象*/private static Method getMethod(JoinPoint point) {// 情況一:方法直接定義在類上(非接口方法)MethodSignature signature = (MethodSignature) point.getSignature();Method method = signature.getMethod();if (!method.getDeclaringClass().isInterface()) {return method; // 直接返回當前方法}// 情況二:方法定義在接口上(需要獲取實現類的方法)try {// 通過反射獲取目標類(實際實現類)的方法// 例如:UserService接口的create方法 -> UserServiceImpl的create方法return point.getTarget().getClass().getDeclaredMethod(point.getSignature().getName(), // 方法名method.getParameterTypes()); // 參數類型} catch (NoSuchMethodException e) {// 找不到方法時拋出運行時異常(通常意味著代碼結構有問題)throw new RuntimeException("方法不存在: " + method.getName(), e);}}
}
4. 編寫AOP切面
/*** 攔截聲明了 {@link Idempotent} 注解的方法,實現冪等操作* 冪等切面處理器** 功能:攔截被 @Idempotent 注解標記的方法,通過Redis實現請求冪等性控制* 流程:* 1. 根據配置的Key解析策略生成唯一標識* 2. 嘗試在Redis中設置該Key(SETNX操作)* 3. 若Key已存在 → 拋出重復請求異常* 4. 若Key不存在 → 執行業務邏輯* 5. 異常時根據配置決定是否刪除Key** @author dyh*/
@Aspect // 聲明為AOP切面類
@Slf4j // 自動生成日志對象public class IdempotentAspect {/*** Key解析器映射表(Key: 解析器類型,Value: 解析器實例)* 示例:* DefaultIdempotentKeyResolver.class → DefaultIdempotentKeyResolver實例* ExpressionIdempotentKeyResolver.class → ExpressionIdempotentKeyResolver實例*/private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;/*** Redis操作工具類(處理冪等Key的存儲)*/private final IdempotentRedisDAO idempotentRedisDAO;/*** 構造方法(依賴注入)* @param keyResolvers 所有Key解析器的Spring Bean集合* @param idempotentRedisDAO Redis操作DAO*/public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {// 將List轉換為Map,Key是解析器的Class類型this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass);this.idempotentRedisDAO = idempotentRedisDAO;}/*** 環繞通知:攔截被@Idempotent注解的方法* @param joinPoint 切入點(包含方法、參數等信息)* @param idempotent 方法上的@Idempotent注解實例* @return 方法執行結果* @throws Throwable 可能拋出的異常** 執行流程:* 1. 獲取Key解析器 → 2. 生成唯一Key → 3. 嘗試鎖定 → 4. 執行業務 → 5. 異常處理*/@Around(value = "@annotation(idempotent)") // 切入帶有@Idempotent注解的方法public Object aroundPointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {// 步驟1:根據注解配置獲取對應的Key解析器IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());// 斷言確保解析器存在(找不到說明Spring容器初始化有問題)Assert.notNull(keyResolver, "找不到對應的 IdempotentKeyResolver");// 步驟2:使用解析器生成唯一Key(例如:MD5(方法簽名+參數))String key = keyResolver.resolver(joinPoint, idempotent);// 步驟3:嘗試在Redis中設置Key(原子性操作)// 參數說明:// key: 唯一標識// timeout: 過期時間(通過注解配置)// timeUnit: 時間單位(通過注解配置)boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());// 步驟4:處理重復請求if (!success) {// 記錄重復請求日志(方法簽名 + 參數)log.info("[冪等攔截] 方法({}) 參數({}) 存在重復請求",joinPoint.getSignature().toString(),joinPoint.getArgs());// 拋出業務異常(攜帶注解中配置的錯誤提示信息)throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(),idempotent.message());}try {// 步驟5:執行原始業務方法return joinPoint.proceed();} catch (Throwable throwable) {// 步驟6:異常處理(參考美團GTIS設計)// 配置刪除策略:當deleteKeyWhenException=true時,刪除Key允許重試if (idempotent.deleteKeyWhenException()) {// 記錄刪除操作日志(實際生產可添加更詳細日志)log.debug("[冪等異常處理] 刪除Key: {}", key);idempotentRedisDAO.delete(key);}// 繼續拋出異常(由全局異常處理器處理)throw throwable;}}
}
5. 實現Redis原子操作
/*** 冪等 Redis DAO** @author dyh*/
@AllArgsConstructor
public class IdempotentRedisDAO {/*** 冪等操作** KEY 格式:idempotent:%s // 參數為 uuid* VALUE 格式:String* 過期時間:不固定*/private static final String IDEMPOTENT = "idempotent:%s";private final StringRedisTemplate redisTemplate;public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {String redisKey = formatKey(key);return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);}public void delete(String key) {String redisKey = formatKey(key);redisTemplate.delete(redisKey);}private static String formatKey(String key) {return String.format(IDEMPOTENT, key);}}
6. 自動裝配
/*** @author dyh* @date 2025/4/17 18:08*/
@AutoConfiguration(after = DyhRedisAutoConfiguration.class)
public class DyhIdempotentConfiguration {@Beanpublic IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {return new IdempotentAspect(keyResolvers, idempotentRedisDAO);}@Beanpublic IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) {return new IdempotentRedisDAO(stringRedisTemplate);}// ========== 各種 IdempotentKeyResolver Bean ==========@Beanpublic DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() {return new DefaultIdempotentKeyResolver();}@Beanpublic UserIdempotentKeyResolver userIdempotentKeyResolver() {return new UserIdempotentKeyResolver();}@Beanpublic ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() {return new ExpressionIdempotentKeyResolver();}}
三、核心設計模式解析
1. 策略模式(核心設計)
應用場景:多種冪等Key生成策略的動態切換
代碼體現:
// 策略接口
public interface IdempotentKeyResolver {String resolver(JoinPoint joinPoint, Idempotent idempotent);
}// 具體策略實現
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {@Overridepublic String resolver(...) { /* MD5(方法+參數) */ }
}public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {@Overridepublic String resolver(...) { /* SpEL解析 */ }
}
UML圖示:
2. 代理模式(AOP實現)
應用場景:通過動態代理實現無侵入的冪等控制
代碼體現:
@Aspect
public class IdempotentAspect {@Around("@annotation(idempotent)") // 切入點表達式public Object around(...) {// 通過代理對象控制原方法執行return joinPoint.proceed(); }
}
執行流程:
客戶端調用 → 代理對象攔截 → 執行冪等校驗 → 調用真實方法
四、這樣設計的好處
- 業務解耦
- 冪等邏輯與業務代碼完全分離
- 通過注解實現聲明式配置
- 靈活擴展
- 新增Key策略只需實現接口
- 支持自定義SpEL表達式
- 高可靠性
- Redis原子操作防并發問題
- 異常時自動清理Key(可配置)
- 性能優化
- MD5壓縮減少Redis存儲壓力
- 細粒度鎖控制(不同Key互不影響)
- 易用性
- 開箱即用的starter組件
- 三種內置策略覆蓋主流場景
五、使用示例
@Idempotent(keyResolver = UserIdempotentKeyResolver.class,timeout = 10,message = "請勿重復提交訂單"
)
public void createOrder(OrderDTO dto) {// 業務邏輯
}