
下单、提现、结算这种接口前端防抖只能挡住手快双击挡不住网络重试、客户端重复请求、后端超时重发。最典型的翻车现场用户提现点了两次钱包扣了两笔客服电话直接被打爆。qkl-boot 里用Idempotent注解 Redis 锁把重复提交拦截在业务逻辑之前这篇讲实现思路和踩坑。一个注解声明式幂等业务侧用法极简——方法上挂个注解就行不用侵入业务代码Idempotent(expireSeconds5,message请勿重复提交请稍后再试)publicvoidwithdraw(LonguserId,BigDecimalamount){// 业务逻辑}注解定义本身也很简单两个参数有效期和提示文案Target(ElementType.METHOD)Retention(RetentionPolicy.RUNTIME)DocumentedpublicinterfaceIdempotent{/** 幂等锁有效期单位秒同一请求在此窗口内视为重复 */intexpireSeconds()default5;/** 命中重复提交时的提示文案 */Stringmessage()default请勿重复提交请稍后再试;}切面核心setIfAbsent 抢锁IdempotentAspect用Around拦截核心就一行setIfAbsent——Redis 里键不存在才写入写进去就算抢到锁抢不到说明窗口期内有人提交过了Around(annotation(idempotent))publicObjectaround(ProceedingJoinPointpjp,Idempotentidempotent)throwsThrowable{StringterminalresolveTerminal();StringuserIdresolveUserId();StringfingerprintbuildFingerprint(pjp);StringlockKeyRedisKeys.idempotent(terminal,userId,fingerprint);BooleanacquiredredisTemplate.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(idempotent.expireSeconds()));if(Boolean.FALSE.equals(acquired)){thrownewQklBizException(ErrorCode.IDEMPOTENT_CONFLICT,idempotent.message());}returnpjp.proceed();}锁的维度是终端 用户 请求指纹指纹 HTTP 方法 URI 参数摘要MD5 压缩成定长字符串。这样同一个用户重复提交同一笔请求会被拦但不同用户、不同参数互不影响privateStringbuildFingerprint(ProceedingJoinPointpjp){MethodSignaturesignature(MethodSignature)pjp.getSignature();StringBuilderrawnewStringBuilder();ServletRequestAttributesattributes(ServletRequestAttributes)RequestContextHolder.getRequestAttributes();if(attributes!null){HttpServletRequestrequestattributes.getRequest();raw.append(request.getMethod()).append(:).append(request.getRequestURI()).append(:);}raw.append(serializeArgs(pjp.getArgs()));returnDigestUtils.md5DigestAsHex(raw.toString().getBytes(StandardCharsets.UTF_8));}序列化参数有讲究把不能序列化的排除掉参数里如果混进了MultipartFile或者HttpServletRequestobjectMapper.writeValueAsString直接抛异常幂等就废了。切面里先过滤privateStringserializeArgs(Object[]args){if(argsnull||args.length0)return;try{Object[]filteredArrays.stream(args).filter(a-!(ainstanceofMultipartFile)!(ainstanceofHttpServletRequest)!(ainstanceofHttpServletResponse)).toArray();returnobjectMapper.writeValueAsString(filtered);}catch(Exceptione){returnArrays.toString(args);// 兜底序列化失败就用 toString别让幂等自身挂掉}}未登录接口怎么幂等用 IP 兜底不是所有接口都要求登录匿名接口也要防重复。切面里拿不到 userId 时退回用 IP 作为用户维度privateStringresolveUserId(){LoginUserloginSecurityContextHolder.get();if(login!nulllogin.getUserId()!null){returnString.valueOf(login.getUserId());}ServletRequestAttributesattributes(ServletRequestAttributes)RequestContextHolder.getRequestAttributes();if(attributes!null){Stringipattributes.getRequest().getHeader(X-Forwarded-For);if(!StringUtils.hasText(ip)){ipattributes.getRequest().getRemoteAddr();}else{ipip.split(,)[0].trim();// 多级代理取第一个}if(StringUtils.hasText(ip)){returnip:ip;}}returnanonymous;}X-Forwarded-For 取第一个 IP这个细节踩过坑多个代理会拼接一串 IP直接用整个串做键同一个用户走不同代理链就变成不同用户幂等失效。踩坑记录同一个 IP 后面挂着一整个办公室现象办公室场景下A 同事提交了某个匿名接口的请求B 同事紧接着提交相同参数的请求居然被当成重复提交拦截了业务方来问是不是出 bug 了。排查过程看 Redis 里幂等键发现键的前缀是ip:xxx.xxx.xxx.xxx两个同事出口 IP 一样。再看接口确实挂了Idempotent参数也一致——指纹相同IP 相同于是被判定重复。定位思路IP 兜底本身就是粒度妥协。登录用户用 userId 是精准的匿名用户没有身份维度只能拿 IP 凑合。但办公室场景多用户共享出口 IP把 IP 当唯一键必然误伤。问题不在实现在选型——匿名 写操作 高频场景不该指望 IP 级别的幂等。最终解决分场景处理。第一能登录的接口一律走 userId 维度匿名兜底只是保底。第二真正的写操作提现、下单前端必须传业务单号后端用唯一键 数据库唯一索引做持久化幂等——Redis 锁有 5 秒窗口窗口外重复照样穿透数据库唯一索引才是最终防线ALTERTABLEwithdraw_recordADDUNIQUEKEYuk_biz_no(biz_no);-- 业务单号唯一重复插入直接报错Redis 拦窗口内、唯一索引兜底窗口外两层叠加提现重复问题才算真正闭环。可直接复用的清单注解 AOP 声明式幂等业务零侵入方法上挂Idempotent即可。锁键 终端 用户 请求指纹MD5setIfAbsent 过期时间窗口内重复直接拒绝。参数序列化先过滤 MultipartFile/Request/Response兜底 toString 别让切面自己炸。未登录接口用 IP 兜底X-Forwarded-For 只取第一个 IP。资金类写操作必须再加业务单号 数据库唯一索引Redis 窗口外靠它兜底。项目源码https://gitee.com/gzqkl/qkl-boot