ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot接口防抖实战:基于Redis与幂等表确保数据一致性

Spring Boot接口防抖实战:基于Redis与幂等表确保数据一致性 1. 接口防抖的前因后果先搞清楚我们在防什么干后端开发的朋友谁还没被“手抖”坑过几回。用户在前端下单页面多点了两下提交按钮结果后端收到了两条一模一样的请求订单表里瞬间多出两条重复订单或者前端网络超时后自动重试同一个写操作被幂等地执行了两次库存扣了两次账也算错了。这类问题在接口设计里一直都很烦人尤其现在前后端分离、客户端重试机制越来越普遍防抖已经不是一个“防御性优化”而是保障数据一致性的刚需。Spring Boot接口防抖说白了就是让后端在极短时间窗口内对同一个请求来源、同一个业务操作的重复提交做拦截保证只有第一次请求真正落库后续重复请求直接返回提示或旧结果。听起来简单但真要做得严谨得处理并发、分布式、缓存失效、业务超时等一系列细节。这篇文章会从零到一拆解接口防抖的实现方案重点讲基于Redis的分布式防抖、基于唯一请求号的幂等方案、以及和数据库唯一索引的配合使用。适合正在做电商、预约、办公系统、餐饮SaaS这类强交互项目的后端同学参考这些系统最容易踩“重复提交”的坑也是最需要防抖能力的地方。我会尽量把每个方案的原理讲透再给出可以直接抄作业的代码和配置同时把我在实际项目里踩过的坑一并写出来。看完这篇文章你再遇到“重复提交导致数据不一致”的问题应该能直接拿出一套靠谱的解决方案。2. 方案选型四种常见防抖策略的横向对比防抖的实现路径不止一条不同业务场景适合不同方案。选错方案要么防不住问题要么引入新的复杂度所以在动手写代码之前先把方案选型这件事理清楚。2.1 前端按钮禁用最原始但不可靠的方案前端按钮禁用是目前最简单的防抖方式就是在用户点击提交后立刻把按钮置灰等请求返回后再恢复可点击。我最早做项目就是这么干的一行JavaScript的事针对单页面的普通表单也确实有效。问题在于这个方案只防了“正常人”完全挡不住“异常人”和“异常机器”。举个例子用户在提交订单时网络突然抖动前端等了10秒没收到响应用户以为没提交成功刷新页面又重新点了一次。这时候按钮已经恢复了前端没有记住上一次请求的上下文重复请求照样打过来。再比如用户用Postman、脚本或者抓包工具直接构造请求前端的操作根本绕不过去这时候防抖就必须落到后端。前端禁用的定位应该是“体验优化”让正常用户少点几次真正的防抖保障必须放在后端。我见过不少团队把防抖全部押在前端结果上线后数据照样出问题原因就在这里。2.2 基于Redis的分布式防抖大多数场景的首选后端防抖的实现最主流的就是利用Redis的原子操作。核心原理不复杂把请求的唯一标识比如用户ID加接口路径或者业务请求号作为Redis的Key在请求进入处理逻辑之前尝试用SETNX或者SET key value NX EX timeout命令写入这个Key。如果写入成功说明是第一次请求放行执行业务逻辑如果写入失败说明同样标识的请求已经在处理中或者刚处理完直接拦截掉。这个方案能用的前提是Redis操作本身具备原子性不会出现两个并发请求同时写入成功的情况。Redis的SET NX操作在分布式环境下天然支持这个语义所以这也就是为什么很多防抖方案都基于Redis来实现。相比后端单机的内存缓存Redis方案还可以支撑多实例部署多个应用节点共用同一个Redis就能做到全局防抖。防抖的时间窗口该怎么设这是个很关键的问题。窗口太短用户多点几次就会漏过去窗口太长用户正常的二次请求会被误伤。我的经验是普通写接口设1秒到3秒足够下单、支付这种重量级操作可以设10秒如果是文件上传、复杂计算类接口可以根据实际耗时适当放宽到30秒甚至更长。下面的方案对比表可以先给你一个直观感受。防抖方案实现位置分布式支持防重强度适用场景风险评估前端按钮禁用浏览器端不涉及弱普通表单页面网络异常、恶意请求防护不住内存缓存单机后端单机不支持中单实例应用、内部系统多节点部署时失效Redis分布式锁/防抖后端支持强多实例部署的绝大多数场景需保证Redis可用性唯一请求号幂等表后端支持强下单、支付等资金类操作需要业务配合设计请求号2.3 数据库唯一索引内功业务层的最后一道防线不管前端怎么做防抖Redis防抖多严密数据库层面仍然可能出现重复数据。一个很典型的场景Redis因为网络分区短暂不可用或者防抖时间窗口刚好到期重复请求又进来了。如果数据库没有唯一约束重复数据就直接落库了。数据库唯一索引是防抖的兜底方案核心思路是给业务表加上唯一约束字段比如订单号、请求号、防重码。每次插入数据前先尝试插入如果触发了唯一索引冲突就说明这条记录已经存在直接当作重复请求处理。在MySQL里唯一索引冲突会抛出DuplicateKeyException配合Spring的事务处理可以在Service层统一捕获并转换为友好提示。这三个方案不是互相替代的关系而是可以叠加使用的。一个生产级接口防抖架构往往是这样组合的前端按钮禁用负责用户体验Redis防抖扛住绝大多数重复请求数据库唯一索引兜底最后一道防线。理解这个设计思路之后再看具体实现你就会明白为什么每一步都不能省。2.4 业务场景决定方案做设计之前先回答三个问题我在帮不同项目做防抖设计的时候一般会先问三个问题第一这个接口的重复提交概率高不高第二重复提交导致的后果有多严重第三系统的部署架构是单机还是多节点这三个问题的答案基本就决定了方案选型的走向。比如校园讲座预约系统高峰期同一个讲座可能有几千人同时抢重复提交概率非常高后果是讲座名额被重复占用这是典型需要Redis防抖加数据库唯一索引双重保障的场景。再比如企业办公用品管理系统低并发内部使用用户重复点击的危害相对可控用Redis防抖加前端禁用就足够了。餐饮SaaS系统涉及买单、会员充值这类资金操作防抖和幂等的要求就更高还得考虑请求号的设计和异常补偿机制。记住一个原则防抖方案没有绝对的最好只有最适配当前业务场景的。先回答问题再选方案这才是正确的设计顺序。3. 基于Redis实现接口防抖从顶层设计到落地代码Redis防抖是目前使用最广泛的方案所以我单独用一整章来讲。这一章会从顶层设计思路到注解定义、拦截器实现、工具类封装一步步带你写一个可复用的防抖组件。代码基于Spring Boot 3.x和RedisTemplate实现你用Spring Boot 2.x也可以直接迁移注意一下RedisTemplate的API差异就行。3.1 顶层设计注解驱动还是AOP切面防抖逻辑涉及横向拦截和重复请求判断最自然的实现方式是Spring AOP。设计思路是定义一个AntiDuplicateSubmit注解标注在Controller层的方法上然后在切面里对方法做拦截。每次请求进入方法前切面先从请求上下文中提取防抖标识再基于Redis判断是否放行。用注解驱动的好处第一是调用方使用成本极低只需要在接口方法上标一个注解、配几个参数就行第二是防抖逻辑和业务逻辑自然解耦业务代码里不会混入“判断是否重复”的代码第三是团队协作时一个注解就能统一规则不容易出现每个人各写各防抖代码的混乱局面。切面负责的事务就三件事生成防抖Key、执行Redis写入、拦截重复请求。生成防抖Key这一步最容易踩坑待会我会单独讲。执行Redis写入要选对命令语义建议使用SET key value NX EX time的组合原子操作而不是先EXISTS再SET避免检查与写入之间的竞态条件。3.2 自定义防抖注解参数就是规则先来看注解定义。我实际项目中用到的注解包含五个核心属性分别是防抖时间窗口、时间单位、Key前缀、业务标识策略、以及是否启用防抖。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface AntiDuplicateSubmit { /** * 防抖时间窗口默认1秒 */ int time() default 1; /** * 时间单位默认秒 */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** * Redis Key前缀默认用接口全路径 */ String keyPrefix() default ; /** * 防抖Key的生成策略 * DEFAULT - 基于用户ID 接口路径 * TOKEN - 基于请求头中的请求号 */ KeyStrategy keyStrategy() default KeyStrategy.DEFAULT; /** * 是否启用方便在测试环境临时关闭 */ boolean enabled() default true; } public enum KeyStrategy { DEFAULT, TOKEN }这个注解设计里最值得说的是keyPrefix。我见过很多团队直接拿接口路径做Key前缀但如果同一个用户同时操作同一种业务的不同数据比如订购不同商品那防抖Key就会互相冲突后面的请求全部被拦体验糟透了。所以我建议在Key里带上业务的唯一标识比如订单号、商品ID、讲座编号。怎么带最简单的做法是在方法的入参里加一个字段比如requestNo或者businessId然后在切面里用SpEL表达式从参数中提取。这个功能我放在切面实现里讲注解本身只需要提供一个SpEL表达式属性就够。/** * 从方法参数中提取业务ID的SpEL表达式 * 例如#request.requestNo 表示从参数request中取requestNo字段 */ String businessIdSpEl() default ;3.3 分布式锁还是SETNX防抖的原子性保证这个点非常重要值得单独拿出来讲。Redis里面判断重复提交最直观的写法是先EXISTS key判断是否存在不存在就SET key然后放行。但这种写法存在竞态条件两个并发请求同时在执行EXISTS都发现Key不存在然后都去SET两个请求全都放行了防抖失败。正确做法是用一条命令完成“判断不存在再写入”的操作。Redis从2.6.12版本开始SET命令支持NX和EX参数SET key value NX EX seconds的含义是“仅当Key不存在时写入并且设置过期时间”这个操作本身是原子的可以在并发条件下保证只有一个请求写入成功。在Spring Data Redis中对应的代码是这样写的private boolean tryAcquire(String key, long timeout, TimeUnit timeUnit) { // 使用SET NX EX原子命令防并发穿透 Boolean result redisTemplate.opsForValue().setIfAbsent(key, 1, timeout, timeUnit); return Boolean.TRUE.equals(result); }细心的人可能会问如果业务执行时间超过了防抖时间窗口防抖Key过期了这时候又一个重复请求进来怎么办这就得在业务执行完毕后主动删除防抖Key同时还要考虑业务超时和不删除Key的平衡。这个问题我放在第5章“常见问题与排查技巧”里详细说。3.4 拦截器核心代码一个完整的防抖切面下面给出切面的完整实现。我用的是Spring AOP的Around环绕通知这是最适合做防抖的方式因为可以在方法调用前后分别处理资源释放的逻辑。Aspect Component Slf4j public class AntiDuplicateSubmitAspect { private static final String DEFAULT_KEY_PREFIX anti:duplicate:; Autowired private RedisTemplateString, String redisTemplate; Around(annotation(antiDuplicateSubmit)) public Object around(ProceedingJoinPoint joinPoint, AntiDuplicateSubmit antiDuplicateSubmit) throws Throwable { // 未启用防抖直接放行 if (!antiDuplicateSubmit.enabled()) { return joinPoint.proceed(); } // 1. 生成防抖Key String key buildKey(joinPoint, antiDuplicateSubmit); long timeout antiDuplicateSubmit.time(); TimeUnit timeUnit antiDuplicateSubmit.timeUnit(); // 2. 尝试获取防抖锁 boolean acquired tryAcquire(key, timeout, timeUnit); if (!acquired) { log.warn(防抖拦截key{}, key); throw new DuplicateSubmitException(请求处理中请勿重复提交); } try { return joinPoint.proceed(); } finally { // 3. 业务执行完毕释放防抖Key // 注意这里要考虑是否立即删除还是等待自然过期 releaseKey(key); } } private String buildKey(ProceedingJoinPoint joinPoint, AntiDuplicateSubmit annotation) { // 核心Key前缀 String prefix StringUtils.hasText(annotation.keyPrefix()) ? annotation.keyPrefix() : DEFAULT_KEY_PREFIX; // 方法全限定名 用户标识 MethodSignature signature (MethodSignature) joinPoint.getSignature(); String methodName signature.getDeclaringTypeName() # signature.getName(); // 用户标识从RequestContextHolder获取 String userId getCurrentUserId(); // 业务ID通过SpEL表达式从参数中提取避免同一用户不同业务被重复拦截 String businessId extractBusinessId(joinPoint, annotation.businessIdSpEl(), signature); return prefix methodName : userId : businessId; } private String extractBusinessId(ProceedingJoinPoint joinPoint, String spEl, MethodSignature signature) { if (!StringUtils.hasText(spEl)) { return default; } try { StandardEvaluationContext context new StandardEvaluationContext(); Object[] args joinPoint.getArgs(); String[] paramNames signature.getParameterNames(); if (paramNames ! null) { for (int i 0; i paramNames.length; i) { context.setVariable(paramNames[i], args[i]); } } Object value new SpelExpressionParser().parseExpression(spEl).getValue(context); return value null ? null : String.valueOf(value); } catch (Exception e) { log.error(SpEL解析失败使用默认businessId, e); return default; } } private boolean tryAcquire(String key, long timeout, TimeUnit timeUnit) { Boolean result redisTemplate.opsForValue().setIfAbsent(key, 1, timeout, timeUnit); return Boolean.TRUE.equals(result); } private void releaseKey(String key) { redisTemplate.delete(key); } private String getCurrentUserId() { // 从Spring Security或自定义上下文获取当前用户ID // 示例中简单返回anonymous真实项目需要对接自身的用户体系 return anonymous; } }这段代码里有三个地方需要特别注意。第一tryAcquire使用setIfAbsent(key, 1, timeout, timeUnit)对应的是SET key 1 NX EX timeout这条命令同时完成了“不存在才写入”和“设置过期时间”两件事原子性有保障。第二releaseKey在finally里执行意味着无论业务方法执行成功还是抛出异常防抖Key都会被释放。这意味着业务一旦失败用户可以立刻重试。这个行为要结合具体场景看如果是业务校验失败用户改一下参数就能重试这很合理但如果业务执行了10秒才失败期间防抖Key已经释放后续重复请求在业务仍处理时就可能进来所以更稳妥的方式是记录业务执行状态在业务完成后统一判定。第三getCurrentUserId()我暂时返回的是anonymous真实项目里你需要改成从Spring Security的SecurityContextHolder或者自定义的RequestContext中获取用户标识。不同的用户应该使用不同的防抖Key否则A用户提交订单后被防抖挂起B用户提交相同接口也会被误伤。3.5 Key的设计为什么不能只用接口路径做Key关于Key的设计我见过太多翻车案例值得专门写一个小节。最错误的做法是只用接口路径做Key比如POST:/api/order/submit。这么设计的结果是同一时刻所有用户请求同一接口只有一个人能通过其他人全部收到“请勿重复提交”这已经不是防抖了这直接压垮了正常用户体验。正确的Key设计至少应该包含三个维度用户维度、接口维度、业务维度。用户维度用登录用户的ID来标识接口维度用请求方法加路径区分不同的操作业务维度用业务记录的ID来标识比如订单号、讲座编号、商品ID等。这样组合出的Key如anti:duplicate:POST:/api/order/submit:10086:orderNo20240101100001既做到了用户间隔离又做到了业务间隔离。顺带提一句如果接口不需要用户登录可以把用户维度换成客户端IP。不过用IP做维度要小心动态IP和代理IP的问题同一个用户频繁切换IP后防抖就失去作用了。更建议在这种场景下使用客户端生成的请求号RequestId这个方案我会在第4章详细讲。3.6 使用方式业务接口一行注解搞定切面和注解都准备好了业务接口的使用就非常简单了。下面是一个下单接口的示例RestController RequestMapping(/api/order) public class OrderController { PostMapping(/submit) AntiDuplicateSubmit(time 3, timeUnit TimeUnit.SECONDS, businessIdSpEl #request.requestNo) public ResultOrderVO submit(RequestBody OrderSubmitRequest request) { return Result.success(orderService.submit(request)); } }注意这里的businessIdSpEl取值是#request.requestNo意思是从参数对象request中取requestNo字段作为业务标识。这样同一个用户提交两笔不同订单它们的防抖Key是不同的不会互相影响。这里面有一个细节藏得很深requestNo到底从哪来我的建议是前端每次进入下单页面时通过一个后端接口生成全局唯一的请求号然后把请求号带在后续的提交请求里。请求号本身可以是一个UUID也可以是一次性编码。这个设计的意义在于防抖只在一个时间窗口内有效真正跨请求的重复请求拦截要靠请求号与幂等表配合这正好衔接到下一章的内容。4. 进阶方案唯一请求号与幂等表防抖的最终形态Redis防抖解决的是“短时间窗口内重复提交”的问题但如果业务本身比较重比如支付、对账、资金流水或者网络异常导致客户端多次重试间隔时间远超防抖窗口Redis方案就兜不住了。这时候就需要引入唯一请求号配合数据库幂等表这也是“接口防抖”在工程实践中更高阶的形态。4.1 幂等表的设计一张表守住所有重复请求幂等表的核心逻辑是在执行业务之前先把请求号插入到一张独立的幂等表中如果插入成功则继续执行业务如果插入失败请求号冲突说明这笔请求已经被处理过直接返回之前的结果。这张表的字段设计并不复杂关键是唯一约束要建立在请求号上。下面是幂等表的建表语句CREATE TABLE idempotent_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主键, request_no VARCHAR(64) NOT NULL COMMENT 请求号, biz_module VARCHAR(32) NOT NULL COMMENT 业务模块如ORDER、PAY, request_hash VARCHAR(64) NOT NULL COMMENT 请求参数摘要用于校验参数一致性, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-处理中1-成功2-失败, result TEXT COMMENT 处理结果的JSON序列化, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, finish_time DATETIME DEFAULT NULL COMMENT 完成时间, UNIQUE KEY uk_request_no_biz (request_no, biz_module) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT幂等记录表;这张表的uk_request_no_biz唯一索引是防重的核心。两个并发请求同时向这张表插入相同的request_noMySQL唯一索引保证只有一个插入成功。插入成功的那个继续执行业务插入失败的直接走重复请求处理逻辑。这个方案的可靠性不依赖Redis不依赖分布式锁数据库本身给出了最终一致性保障。4.2 幂等处理的完整流程从请求进入到结果返回有了表结构业务流程就可以设计成下面这样第一步接收请求后先从请求参数中提取requestNo。第二步向幂等表插入一条状态为“处理中”的记录这里要捕获数据库唯一索引冲突。第三步如果捕获到DuplicateKeyException说明请求重复了查询幂等表中已有记录的状态如果状态是“成功”直接返回保存的结果如果状态是“处理中”提示用户请勿重复提交如果状态是“失败”说明上一次处理失败了可以删除旧记录后允许重新处理。第四步执行真正的业务逻辑比如创建订单、扣减库存。第五步更新幂等表记录状态为“成功”并保存业务执行结果。这里最复杂的点是第三步中“处理中状态的递归问题”。两个并发请求同时插入都因为唯一索引冲突走重复分支如果代码没写好就可能出现无限查询的循环。我的建议是捕获到冲突后做一个短时间的自旋等待比如每次等待200毫秒最多3次等待第一个请求完成更新后再读取结果。如果3次后状态仍是“处理中”则认定系统繁忙直接返回“请稍后重试”。这种业务流程比单纯Redis防抖复杂不少它把所有状态都持久化到了数据库好处是防重能力不依赖缓存也不会因为时间窗口过期而失效。对于金融、支付这种不允许出现重复资金的场景这个方案是标配。4.3 Token机制的配合手动干预后的防抖设计另一个和请求号思路类似的是Token机制。用户在进入操作页面时先调用后端接口获取一个一次性Token后端把Token存到Redis并设置了较长的过期时间比如30分钟。用户在正式提交时需要把这个Token放在请求头或请求参数里。后端在执行业务前先校验Token是否存在存在则删除并放行不存在则说明Token已使用请求被拦截。Token机制的防抖强度在于“一次一密”一个Token只能被成功使用一次就算前端把同一个Token重复提交一百次只有第一次能通过。这个方案非常契合预约抢号、报名、优惠券领取这类业务因为这类业务天然适合“先领取凭证再凭证消费”的流程。不过Token机制也有一个麻烦的环节如果第一次请求执行业务失败但Token已经被删除了用户想要重试就不得不重新获取Token。这时候通常的做法是业务失败时不删除Token或者删除后重新生成一个新的Token返回给前端。这个取舍要看Token的生成成本和业务的重要性一般建议业务失败时保持Token不失效让用户可以原样重试。4.4 幂等方案和Redis方案怎么协同分层防抖模型回到实践的层面我是怎么把各种方案组合起来用的答案是分层协同。第一层是前端按钮禁用这一层解决的是用户的“手抖”问题体验影响最小。第二层是Redis防抖这一层解决的是“秒级重复请求”覆盖用户双击、前端误触、短时重试。第三层是请求号加幂等表这一层解决的是“跨时间窗口的重复请求”覆盖网络重试、客户端的多次提交。三层防御不是每层都必经的。比如普通查询接口只有第一层就够了普通新增或修改接口做到第二层性能最优、体验也好资金和订单类接口三层全开防止任何漏洞。记住这个分层思想你以后再做防抖设计时就不会纠结“该用哪个方案”了而是会想“这个接口需要几层防护”。5. 常见问题与排查技巧实录防抖方案看起来简单实际落地时坑很多。这一章整理了我自己在项目里遇到过的、以及帮别人排查过的高频问题每个问题都附上原因分析和解决思路。5.1 Redis防抖Key释放时机什么时候删什么时候不删我在3.4节的代码示例里releaseKey在finally里无条件删除Redis Key。这在业务执行时间很短、业务逻辑稳定可靠的前提下没有问题但如果业务执行耗时较长比如超过10秒执行期间防抖Key已经到时间自动过期了重复请求覆盖进来依然可能穿透防抖。换一个角度思考如果业务执行结束后立即删除Key也有问题业务虽然完成了但前端还没有收到成功响应用户看到页面仍在转圈又点了一下提交。这时候Key已经被删了重复请求再次进入执行业务又产生了一条重复数据。所以更严谨的做法是不立即删除Key而是设置一个比业务实际耗时略长的防抖窗口让Key自然过期。比如一个业务平均耗时2秒防抖窗口可以设5秒。窗口期内重复请求全部拦截窗口期后Key自然失效用户再做后续操作也不受影响。这个思路的核心是把“防抖窗口”从“请求入口防抖”扩展为“请求入口到结果反馈的时间区间防抖”更贴近用户的真实操作心理。那业务耗时的波动区间怎么评估可以通过监控观察接口的TP99响应时间然后用TP99乘以2到3倍作为防抖窗口。这个方法比拍脑袋定数值靠谱得多。5.2 Redis主从切换或故障时防抖失效怎么办Redis防抖方案有个绕不开的软肋Redis本身不可用时防抖逻辑也失效了。更隐蔽的一种情况是Redis主从切换的瞬间新的主节点上没有旧主节点上的防抖Key重复请求就会被放行。如果业务对一致性要求极高这个风险必须提前评估。通常我给出的处理建议是Redis防抖失败时做降级处理不能因此阻断全部请求也不能完全放行所有请求。降级策略是这样的如果Redis操作抛出异常就放弃防抖拦截让请求进入业务逻辑但同时在业务逻辑中增加数据库幂等校验由数据库兜底。也就是第4章说的幂等表方案作为降级后的安全保障。还有一个小经验给RedisTemplate的操作加上超时配置比如connectTimeout和readTimeout设置200毫秒。不要因为Redis卡顿把业务线程拖死防抖组件最忌讳成为系统的性能瓶颈。5.3 用户重复点击不同Tab或不同浏览器防抖是否有效有读者问过同一个用户在浏览器的两个Tab里分别提交同一个订单会出现两个不同的请求防抖Key的生成能否识别这是同一个操作答案是取决于防抖Key的粒度。如果防抖Key只用“用户ID 接口路径 请求号”而后端生成的请求号在两次提交中是不同的那防抖就拦截不了。如果业务标识直接选用“用户ID 接口路径”那两个Tab的请求都会被拦截第二个Tab收到“请勿重复提交”的提示。这里并没有绝对的正确答案要看产品设计。在讲座预约系统中一个用户确实可能同时打开两个Tab去抢同一个讲座的名额这时候用粗粒度Key是合理的后面打开的那个Tab应该被拦截。但在商品下单场景中同一个用户可能在不同Tab里分别提交两笔不同的商品订单这就不应该拦截所以Key粒度要细到业务ID这一层。业务设计决定了防抖Key的粒度不要想当然。5.4 加了防抖后接口被误伤响应时长、异常分支的排查清单如果生产环境出现“明明用户只提交了一次却提示请勿重复提交”的投诉大概率不是防抖本身出问题而是Key的生成范围太宽、时间窗口太长、或者业务ID提取失败。我给出一个排查步骤清单第一查看日志中被拦截请求的防抖Key确认Key中包含的用户ID、业务ID是否符合预期第二检查同一用户、同一业务ID的多次正常请求是否来自同一个来源比如前端是否在发起请求前重复调用了多次第三确认SpEL表达式是否解析正确尤其是嵌套对象属性这类写法解析失败时会默认返回default导致不同业务ID全部归为同一个Key第四检查防抖时间窗口是否设置过长比如一个业务本身耗时很短但防抖窗口设了30秒用户修改参数后再提交就被误拦截了第五检查业务异常发生后防抖Key是否被及时清理否则用户只能干等过期。我加过防抖的接口里误伤比例最高的问题就是SpEL解析失败和防抖窗口过长排查的时候先看这两个点命中率很高。6. 不止是防抖一些关于数据一致性的实操心得写到这里接口防抖的常见实现在前面几章基本覆盖了。最后这一章我在分享一些从项目中沉淀下来的经验它们不直接是代码但对做好防抖和数据一致性很有帮助。6.1 防抖与本地事务的配合先插幂等记录再执行业务很多写防抖的代码容易把“防抖记录插入”和“业务操作”放在同一个事务里这样做有个隐患防抖记录提交成功但业务操作失败回滚了防抖记录也会跟着回滚。此时重复请求进来防抖表里查不到记录再次执行业务依然有可能失败或者产生脏数据。我的建议是把防抖记录的插入和业务操作分离成两个步骤防抖记录先独立提交然后再执行业务。业务失败时防抖记录保留或者更新为失败状态重复请求进来时可以看到上一次失败的结果走补偿逻辑。隔离事务有很多写法比如用REQUIRES_NEW传播级别或者干脆把防抖表操作放在事务提交前完成。6.2 响应结果的一致性重复请求返回什么内容怎么设计才合理重复请求被拦截后直接抛出“请勿重复提交”的异常提示看起来简单但对用户体验并不友好。设想一个场景用户第一次下单成功了页面跳转白屏或者超时用户又点了一次这时候用户关心的是“我的订单有没有成功”而不是“请勿重复提交”。更好的做法是防抖拦截到重复请求后查询之前处理的结果把结果直接返回给前端。例如订单提交成功后生成的订单号可以通过请求号查到并返回给用户。这样用户不管点多少次看到的都是同一个成功结果。这个“查询后返回”的逻辑需要配合幂等表才能实现因为只有幂等表里保存了业务处理后的结果数据。6.3 防抖Key的清理机制别让Redis内存悄悄膨胀最后提醒一个很多人忽视的运维细节。防抖Key设置了过期时间看起来不会导致内存膨胀但如果某个接口被高频请求踩踏大量防抖Key在短时间内创建即便有过期时间Redis的内存也会经历一个大起大落的过程容易触发内存淘汰策略进而影响其他缓存数据的命中率。我的习惯是给防抖Key配置一个独立的Redis逻辑库或者独立的Redis实例和业务缓存分开部署。这样防抖Key的创建和删除不会干扰核心缓存的数据。如果公司基础设施没法拆分实例至少要在Redis监控面板上单独跟踪防抖Key的数量和内存占用避免无止境地扩容。6.4 写接口防抖组件前先想清楚团队所处的发展阶段最后讲一个定位层面的心得。防抖组件值不值得做成统一的公共组件和团队规模、系统复杂度、迭代速度有直接关系。小团队三五个人做一个内部后台系统直接在每个接口里用RedisTemplate封装一个防抖方法就够了不需要过度设计。但如果是十几个人维护一套SaaS服务多个业务线共用把防抖做成一个完整的注解组件统一技术规范、统一埋点、统一排查口径就很有必要。我见过一个团队为了做防抖引入了分布式锁框架、增加了复杂的扩展点、写了大量的抽象类结果一线开发根本不会用出了问题也没人愿意去排查最后这套组件被废弃了退回到简单方案。技术判断力的体现不在于写多牛的框架而在于知道在什么阶段做多复杂的事。防抖作为一个基础功能不要在“能用”的基础上叠加过度设计。接口防抖和具体业务形态相关没有一个方案可以一套代码通吃所有场景。我分享的这个思路和代码示例是基于常见单体和微服务项目整理出来的经验你可以拿去做参考模板然后根据自己系统的用户体量、并发规模和一致性要求去做调整。真正写出适合项目的防抖方案永远离不开对业务本身的思考和敬畏。
返回列表