系列第 4 篇。上一篇我们聊了多租户和数据权限拦截器怎么共存,这篇兑现预告,来拆
forge-starter-idempotent。全文代码均来自真实仓库,行数、参数、默认值都是我一个个核过的。
一、先说那个让我熬夜的 Bug
去年给一个客户做支付回调,接口长这样:
java
@PostMapping("/pay/callback")
@Idempotent(key = "'pay:' + #request.orderNo", strategy = IdempotentStrategy.STRICT)
public PayResult callback(@RequestBody PayCallbackRequest request) {
return payService.handleCallback(request);
}
看着挺标准对吧?压测的时候 500 并发打过去,同一个订单号扣了 3 次款。
我当时第一反应是:Redis 挂了?Redisson 锁没生效?SpEL 写错了?
都不是。
查到最后我发现,问题出在一个我从来没想过的地方------注解上的 expire = 600 这个参数,在 STRICT 策略里压根没被用过。
下面从头拆。
二、模块全景
整个 starter 只有 1279 行主代码 + 824 行测试,结构很清晰:
csharp
forge-starter-idempotent/
├── annotation/
│ └── Idempotent.java # 幂等注解(10 个可配属性)
├── aop/
│ └── IdempotentAspect.java # 唯一入口切面
├── strategy/
│ ├── IdempotentStrategyHandler.java # 策略接口(单方法)
│ ├── StrictStrategyHandler.java # 策略①:严格拒绝
│ ├── ReturnCacheStrategyHandler.java # 策略②:返回缓存结果
│ └── TokenRequiredStrategyHandler.java # 策略③:Token 校验
├── generator/
│ ├── IdempotentKeyGenerator.java # 键生成接口
│ └── DefaultIdempotentKeyGenerator.java# 默认实现(SpEL + SHA-256)
├── lock/
│ ├── LockManager.java
│ └── RedissonLockManager.java # Redisson 分布式锁
├── service/
│ ├── TokenService.java / RedisTokenService.java
│ ├── ResultCacheService.java / RedisResultCacheService.java
│ └── IdempotentStorageService.java
├── util/SpelUtil.java
└── config/IdempotentAutoConfiguration.java
调用链路一句话说清楚:
kotlin
Controller 方法
↓ @Around("@annotation(idempotent)")
IdempotentAspect.around()
↓ keyGenerator.generate() 生成幂等键
↓ strategyHandlers.get(strategy)
┌───┴──────────────┬────────────────────┐
│ STRICT │ RETURN_CACHE │ TOKEN_REQUIRED
│ tryLock 抢锁 │ 先查缓存 → 抢锁 │ 校验 Token → 委派
│ 失败直接抛异常 │ 失败 sleep 100ms │ ↓
│ │ 再查缓存 → 抛异常 │ returnCache 处理器
└──────────────────┴────────────────────┘
↓
joinPoint.proceed() 执行业务
↓
成功:可选缓存结果 / 可选删键
失败:无条件 unlock
三、三种策略到底差在哪
先看注解定义,@Idempotent 有 10 个属性,日常真正会调的就这几个:
java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String prefix() default IdempotentConstant.DEFAULT_PREFIX; // "idempotent:"
int expire() default IdempotentConstant.DEFAULT_EXPIRE; // 600 秒
String key() default ""; // SpEL 表达式
String message() default IdempotentConstant.DEFAULT_MESSAGE; // "请勿重复提交"
boolean deleteKeyAfterSuccess() default false;
IdempotentStrategy strategy() default IdempotentStrategy.RETURN_CACHE;
int cacheExpire() default IdempotentConstant.CACHE_EXPIRE_DEFAULT; // 3600 秒
boolean cacheResult() default true;
boolean enableMetrics() default true;
}
三种策略的行为差异,我整理成表:
| STRICT | RETURN_CACHE(默认) | TOKEN_REQUIRED | |
|---|---|---|---|
| 重复请求 | 直接抛 IdempotentException |
返回上次缓存的结果 | 先校验 Token,再走 RETURN_CACHE |
| 首次请求 | 抢锁 → 执行 → 靠租期自然过期 | 抢锁 → 执行 → 写结果缓存 | 校验+消费 Token → 委派 |
| 并发请求 | 抢不到锁立刻失败 | sleep 100ms 后重读缓存,读不到抛"并发冲突" | 同 RETURN_CACHE |
| 业务失败 | unlock() 释放锁,允许重试 |
unlock() 释放锁,允许重试 |
同 RETURN_CACHE |
| 适用场景 | 支付、转账等"宁可报错也不能重复" | 表单提交、创建订单 | 前端显式申请 Token 的场景 |
注意 TOKEN_REQUIRED 不是独立实现,它是个装饰器:
java
public class TokenRequiredStrategyHandler implements IdempotentStrategyHandler {
private final TokenService tokenService;
private final TokenProperties tokenProperties;
private final IdempotentStrategyHandler delegateHandler; // ← 委派给 returnCache
@Override
public Object handle(ProceedingJoinPoint joinPoint, Idempotent annotation, String idempotentKey) throws Throwable {
String token = extractToken();
String prefix = annotation.prefix();
if (!tokenService.validateToken(token, prefix)) {
throw new TokenInvalidException("Token无效或已过期");
}
tokenService.consumeToken(token, prefix);
return delegateHandler.handle(joinPoint, annotation, idempotentKey);
}
}
这个设计是对的------Token 只负责"防重放的门票",真正的幂等语义交给 RETURN_CACHE。
四、键生成:一段被低估的代码
这段是整个模块里我最欣赏的部分,很多自研幂等组件都漏了:
java
@Override
public String generate(ProceedingJoinPoint joinPoint, String prefix, String key) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
String[] paramNames = PARAMETER_NAME_DISCOVERER.getParameterNames(method);
String keyValue;
if (key != null && !key.isEmpty()) {
Object spelResult = SpelUtil.parse(key, args, paramNames);
keyValue = spelResult != null ? spelResult.toString() : "";
} else {
String methodSign = method.getDeclaringClass().getName() + ":" + method.getName();
keyValue = sha256(methodSign + ":" + serializeArguments(args));
}
return prefix + keyValue;
}
private String serializeArguments(Object[] args) {
try {
JsonNode tree = stableObjectMapper.valueToTree(args == null ? new Object[0] : args);
return stableObjectMapper.writeValueAsString(canonicalize(tree));
} catch (IllegalArgumentException | JsonProcessingException e) {
throw new IllegalStateException("无法稳定序列化幂等参数,请通过 SpEL 显式指定幂等 key", e);
}
}
private JsonNode canonicalize(JsonNode node) {
if (node.isObject()) {
Map<String, JsonNode> fields = new TreeMap<>(); // ← 关键:排序
node.properties().forEach(entry -> fields.put(entry.getKey(), entry.getValue()));
ObjectNode result = stableObjectMapper.createObjectNode();
fields.forEach((name, value) -> result.set(name, canonicalize(value)));
return result;
}
if (node.isArray()) {
ArrayNode result = stableObjectMapper.createArrayNode();
node.forEach(value -> result.add(canonicalize(value)));
return result;
}
return node;
}
canonicalize 用 TreeMap 递归重排了 JSON 的所有字段。
为什么要这么干?因为 Jackson 序列化对象的字段顺序取决于类里 getter 的声明顺序------对同一个类来说顺序是固定的,但对同一个 DTO 的两个实例 ,如果一个是 Jackson 反序列化出来的、一个是 new 出来的,理论上顺序一致。真正会翻车的是 Map 参数:HashMap 的遍历顺序跟 hash 有关,同样的内容可能有不同的序列化结果。
排序之后,{"a":1,"b":2} 和 {"b":2,"a":1} 会得到同一个 SHA-256,幂等键才稳定。
另外注意异常抛出的信息:"无法稳定序列化幂等参数,请通过 SpEL 显式指定幂等 key" ------ 没写 SpEL 又序列化失败时,它选择 fail-fast 抛异常,而不是随便生成一个 key。这个取舍是对的。
五、5 个隐蔽的坑
坑 1:SpEL 写错不会报错,所有请求会共用一把锁
这是最容易中招的一个。看 SpelUtil:
java
public static Object parse(String expression, Object[] args, String[] paramNames) {
try {
EvaluationContext context = new StandardEvaluationContext();
if (args != null && paramNames != null) {
for (int i = 0; i < args.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
}
Expression exp = PARSER.parseExpression(expression);
return exp.getValue(context);
} catch (Exception e) {
log.warn("SpEL表达式解析失败: {}", expression, e);
return null; // ← 吞掉异常,返回 null
}
}
再看回 generate:
java
Object spelResult = SpelUtil.parse(key, args, paramNames);
keyValue = spelResult != null ? spelResult.toString() : ""; // ← null → ""
解析失败 → null → "" → 最终幂等键 = prefix = "idempotent:"。
后果:这个接口的所有请求,不管参数是什么,都挤在同一把锁上。 表现就是"接口莫名其妙串行了,QPS 上不去",或者"用户 A 提交完了,用户 B 提交报'请勿重复提交'"。
而且日志级别是 warn,生产环境日志量大时根本注意不到。
常见的 SpEL 翻车写法:
| 错误写法 | 原因 |
|---|---|
key = "#orderId" |
没有前缀,也没加引号;应该用 "'order:' + #orderId" |
key = "'order:' + #order.orderNo" |
参数名被编译成 arg0(没开 -parameters) |
key = "#request.getOrderNo()" |
应该写 #request.orderNo,SpEL 走 getter |
解法 :上线前用单元测试把 key 打出来看一眼。项目里 DefaultIdempotentKeyGeneratorTest 就是干这个的,别偷懒。
坑 2:STRICT 策略下 expire 是个死参数,锁租期只有 5 秒
回到开头那个扣款 3 次的问题。看 StrictStrategyHandler 的完整代码:
java
@Override
public Object handle(ProceedingJoinPoint joinPoint, Idempotent annotation, String idempotentKey) throws Throwable {
int expire = annotation.expire(); // ← 取出来了
if (!lockManager.tryLock(idempotentKey, lockProperties.getWaitTime(), lockProperties.getLeaseTime())) {
// ↑ waitTime ↑ leaseTime,都没用 expire
log.warn("严格模式: 拒绝重复请求, key={}", idempotentKey);
throw new IdempotentException(annotation.message());
}
try {
Object result = joinPoint.proceed();
if (annotation.deleteKeyAfterSuccess()) {
lockManager.unlock(idempotentKey);
}
return result;
} catch (Throwable e) {
lockManager.unlock(idempotentKey);
throw e;
}
}
int expire = annotation.expire(); 声明之后再也没被用过。真正的锁租期来自:
java
@ConfigurationProperties(prefix = "forge.idempotent.lock")
public class LockProperties {
private boolean enabled = true;
private long waitTime = 3000; // 抢锁最多等 3 秒
private long leaseTime = 5000; // 锁 5 秒后自动释放
}
这带来两个问题:
问题 A:业务执行超过 5 秒,幂等直接失效。
支付回调要调第三方、要发消息、要更新好几张表,5 秒很容易超。锁一到点自动释放,第二个请求顺利拿到锁,重复扣款就发生了------这就是我那 3 笔重复扣款的全部原因。
问题 B:Redisson 传了 leaseTime 就不会启动看门狗。
java
// RedissonLockManager
boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
Redisson 的 watchdog 续期机制只在 leaseTime = -1 时生效。一旦你显式传了 5000ms,它就是个死租期,没有任何续期。
解法(三选一):
java
// 方案一:调大 leaseTime(简单粗暴,但要估准业务耗时上限)
forge:
idempotent:
lock:
lease-time: 30000
// 方案二:改用 RETURN_CACHE + Redis 幂等记录,让"已执行"标记和"互斥"分离
@Idempotent(key = "'pay:' + #request.orderNo", strategy = IdempotentStrategy.RETURN_CACHE)
// 方案三(推荐):别用锁当去重标记。锁只做并发互斥,去重另用 Redis SETNX + 业务状态机
核心认知:分布式锁 ≠ 幂等标记。 锁的语义是"同一时刻只能有一个",幂等的语义是"同一个请求只能成功一次"。用锁兼职做去重,就得接受"锁过期 = 幂等失效"。
坑 3:RETURN_CACHE 反序列化出来的是 LinkedHashMap,不是你的返回值类型
先看缓存怎么写进去的:
java
// RedisResultCacheService
public void cacheResult(String key, Object result, int expireSeconds) {
String resultJson = objectMapper.writeValueAsString(result);
Map<String, String> cacheData = new HashMap<>();
cacheData.put("result", resultJson);
cacheData.put("status", IdempotentResult.STATUS_SUCCESS);
redisTemplate.opsForHash().putAll(cacheKey, cacheData);
redisTemplate.expire(cacheKey, expireSeconds, TimeUnit.SECONDS);
}
再看怎么读出来:
java
String resultJson = (String) entries.get("result");
if (resultJson != null && !resultJson.isEmpty()) {
result.setResult(objectMapper.readValue(resultJson, Object.class)); // ← 问题在这
}
readValue(json, Object.class) 的结果类型是 LinkedHashMap ,不是 Order、不是 PayResult。
然后切面直接把它 return 了:
java
// ReturnCacheStrategyHandler
return cachedResult.getResult();
后果 :方法签名声明返回 Order,实际返回 LinkedHashMap。JVM 在调用点插入的 checkcast 会抛 ClassCastException,而且这个异常发生在调用方,不是幂等组件里------排查时你会一脸懵:为什么我的订单服务报了个类型转换错误?
更阴的是:首次请求正常,重复请求才炸。开发环境你手动点两下才能复现,自动化压测如果每次都用新 key 也测不出来。
解法 :IdempotentResult.result 用 Object 存是有道理的(要兼容各种返回类型),但读的时候必须带上目标类型:
java
// 改进:把返回类型也存进 Redis
Class<?> returnType = ((MethodSignature) joinPoint.getSignature()).getMethod().getReturnType();
cacheData.put("returnType", returnType.getName());
// 读取时按真实类型还原
Class<?> type = Class.forName((String) entries.get("returnType"));
result.setResult(objectMapper.readValue(resultJson, type));
或者更省事:只缓存 RespInfo 这类统一包装类型,反序列化到具体类型。
坑 4:TOKEN_REQUIRED 模式下 Token 一定取不到
这段是我在拆源码时意外发现的,它让整个 Token 模式处于不可用状态 。看 extractToken:
java
private String extractToken() {
org.aspectj.lang.ProceedingJoinPoint jp = null;
try {
org.springframework.web.context.request.RequestContextHolder currentRequest =
(org.springframework.web.context.request.RequestContextHolder) // ← 强转目标类型错了
org.springframework.web.context.request.RequestContextHolder.getRequestAttributes(); // ← 返回的是 RequestAttributes
if (currentRequest != null) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
return request.getHeader(tokenProperties.getHeader());
}
} catch (Exception e) {
log.warn("提取Token失败: {}", e.getMessage()); // ← 异常被吞
}
return null;
}
getRequestAttributes() 的返回类型是 RequestAttributes,这里却强转成了 RequestContextHolder(一个工具类,跟 RequestAttributes 没有继承关系)。
只要有 HTTP 请求,这行必然抛 ClassCastException ,被 catch 吞掉后 return null。然后:
java
if (!tokenService.validateToken(token, prefix)) { // token = null
throw new TokenInvalidException("Token无效或已过期");
}
// RedisTokenService.validateToken
if (token == null || token.isEmpty()) {
log.warn("Token为空");
return false;
}
结果:任何带 TOKEN_REQUIRED 的接口都会 100% 抛 TokenInvalidException ,即使你请求头里规规矩矩带了 X-Idempotent-Token。
而且日志只有一行 提取Token失败: ...,是 warn 级别。
正确写法(去掉那个莫名其妙的强转就行):
java
private String extractToken() {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes sra) {
return sra.getRequest().getHeader(tokenProperties.getHeader());
}
log.warn("非 Servlet 请求上下文,无法提取幂等 Token");
return null;
}
顺带说一句,catch (Exception e) 吞异常这件事本身也要警惕:它把"代码写错了"伪装成了"Token 没传",两者处理方式完全不同。
坑 5:Token 的校验和消费不是原子操作,高并发下会双花
即使修好了坑 4,Token 模式还有一个并发问题:
java
// 第一步:校验
public boolean validateToken(String token, String prefix) {
Boolean exists = redisTemplate.hasKey(key);
if (!Boolean.TRUE.equals(exists)) return false;
String status = (String) redisTemplate.opsForHash().get(key, "status");
if (TOKEN_STATUS_CONSUMED.equals(status)) return false;
return true;
}
// 第二步:消费
public void consumeToken(String token, String prefix) {
redisTemplate.opsForHash().put(key, "status", TOKEN_STATUS_CONSUMED);
redisTemplate.expire(key, 60, TimeUnit.SECONDS);
}
hasKey + get status + put status 是三条独立的 Redis 命令 ,中间没有任何原子性保证。两个并发请求同时执行到第 6 行,都会读到 status = UNUSED,都会通过校验,都会往下走。
解法:用 Lua 脚本或者 Redis 原子命令把"检查+标记"合成一步:
lua
-- check_and_consume.lua
local key = KEYS[1]
local status = redis.call('HGET', key, 'status')
if status == false then
return 0 -- Token 不存在
end
if status == 'CONSUMED' then
return -1 -- 已消费
end
redis.call('HSET', key, 'status', 'CONSUMED')
redis.call('EXPIRE', key, 60)
return 1 -- 消费成功
java
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/check_and_consume.lua")));
script.setResultType(Long.class);
Long result = redisTemplate.execute(script, List.of(key));
顺带一提,consumeToken 里把过期时间从配置的 300 秒改成 60 秒,是为了让"已消费"痕迹多保留一会儿,防止 Token 过期后被重放------这个细节是对的。
补充:类上的 @Idempotent 不生效
注解声明了 ElementType.TYPE:
java
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Idempotent {
但切面的切点只有方法级:
java
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
写在类上的 @Idempotent 会被静默忽略,不报错、不打日志、就是不起作用。
这个坑我在上一篇拆 @IgnoreTenant 时也遇到过,当时项目里的解法是 @annotation + @within 双切点匹配。幂等这里如果想支持类级,得改成:
java
@Around("@annotation(idempotent) || (@within(idempotent) && execution(public * *(..)))")
六、可带走的 6 条诀窍
-
锁不是幂等标记。 分布式锁管"并发互斥",幂等管"只成功一次"。让锁兼职做去重,就要接受"锁过期即失效"。租期一定要大于业务最大耗时,或者干脆把两者拆开。
-
SpEL 一定要写单元测试。 解析失败是静默降级成空字符串,表现为"全站串行",比直接报错难查一百倍。
-
缓存结果必须连带类型信息。
readValue(json, Object.class)得到的永远是LinkedHashMap,返回给声明了具体类型的方法必然ClassCastException,而且在调用方炸。 -
别用
catch (Exception)吞掉强转和 ClassCast。 它会把"代码 bug"伪装成"业务校验失败",日志里只剩一行 warn。 -
"检查 + 标记"必须原子。
hasKey+get+put三步走,在高并发下等于没有幂等。Lua 脚本或者SETNX安排上。 -
注解的
@Target要和切点表达式对齐。 声明支持TYPE就得写@within,否则就是给人挖坑。
七、两个值得单独说的设计
失败了一定要放锁
三个策略的 catch 分支都做了 lockManager.unlock():
java
} catch (Throwable e) {
lockManager.unlock(idempotentKey);
throw e;
}
这意味着业务失败后同一个 key 可以立即重试 。这是个刻意的取舍:网络抖动、数据库超时这类瞬时失败,用户点第二次应该能成功。如果你的场景要求"失败了也不能重试"(比如某些金融场景),就要把 deleteKeyAfterSuccess 反过来用,或者自己改策略。
找不到策略处理器时是放行的
java
IdempotentStrategyHandler handler = strategyHandlers.get(strategy);
if (handler == null) {
log.warn("未找到策略处理器: strategy={}", strategy);
return joinPoint.proceed(); // ← fail-open
}
幂等组件找不到处理器时选择放行 而不是拒绝。这是"日志/审计类组件"的标准做法------旁路组件永远不应该阻断主流程。同样的思路在日志 starter、埋点 SDK 里都能看到。
反过来说,如果你的业务要求"幂等失效时必须拒绝请求",这里就得改成抛异常。
八、总结
幂等看起来是个"加个注解就完事"的小功能,但真要落地,你会发现它牵扯一堆决策:
| 决策点 | 这个 starter 的选择 |
|---|---|
| 去重标记放哪 | Redis 分布式锁(STRICT)/ Redis 结果缓存(RETURN_CACHE) |
| 并发冲突怎么办 | STRICT 立即失败;RETURN_CACHE sleep 100ms 重试一次 |
| 业务失败后能否重试 | 能(无条件 unlock) |
| 结果怎么缓存 | JSON 存 Hash,字段含 requestId / result / status / executeTime |
| 组件异常时的默认行为 | fail-open,放行 |
| key 怎么生成 | SpEL 优先,否则 SHA-256(方法签名 + 规范化参数 JSON) |
1279 行代码,29 个类,把这套东西讲清楚了。我扒完的收获是:幂等最难的不是实现,是边界情况的取舍------锁过期了怎么办、业务失败了怎么办、组件自己挂了怎么办、缓存的类型对不上怎么办。
这些取舍没有标准答案,但你必须明确地选一个,而不是让它静默地错下去。
这篇如果对你有启发,点个赞吧 🙏
extractToken 那个强转 bug 我赌很多人写过类似的------把 RequestContextHolder.getRequestAttributes() 的返回类型记混,然后被一个 catch (Exception) 完美隐藏。评论区聊聊你踩过的最阴的幂等坑。
下一篇我们拆 forge-starter-log(1383 行)------操作日志切面为什么拦的是所有 Controller 而不是 @OperationLog 标注的方法,以及那个只有 2 个核心线程的日志线程池是怎么把接口拖慢的。
项目地址
- Gitee:gitee.com/ForgeLab/fo...
- GitHub:github.com/yaomindong1...
系列回顾
- 数据权限拦截器:681 行是怎么改写 SQL 的
- 多租户 tenant Starter:数据源级 + 行级双隔离
- 多租户 × 数据权限共存:拦截器注册顺序的 4 个坑
- 幂等 Starter:1279 行里的 5 个隐蔽坑(本文)
标签 :#Java #Spring Boot #Redis #幂等 #源码拆解 #Redisson #架构设计