方案)
1. 項目概述為什么接口防抖和防重復提交不是“可選項”而是上線前的必答題你有沒有遇到過這樣的場景用戶在電商App里點下“立即支付”按鈕手指剛抬起來頁面還沒跳轉(zhuǎn)就又連點兩次——結(jié)果后臺收到了三條一模一樣的支付請求或者在后臺管理系統(tǒng)中運營人員雙擊“發(fā)布文章”按鈕數(shù)據(jù)庫里憑空多出兩條狀態(tài)為“已發(fā)布”的記錄更隱蔽的是前端網(wǎng)絡(luò)延遲導致用戶誤以為操作失敗反復刷新重試后端日志里全是重復的訂單創(chuàng)建流水。這些都不是小概率事件而是每天在成千上萬系統(tǒng)中真實發(fā)生的“高頻低智型故障”。我?guī)н^的三個不同行業(yè)的項目某高校教務系統(tǒng)、某本地生活服務平臺、某工業(yè)設(shè)備遠程監(jiān)控平臺上線后第一周的告警日志里有23%~37%都指向同一類問題非惡意但不可控的重復請求。SpringBoot本身不提供開箱即用的防重復機制它默認把“請求是否合法”交給開發(fā)者判斷“請求是否新鮮”則完全交由業(yè)務邏輯兜底——這就像給一輛新車配好了發(fā)動機和方向盤卻沒裝剎車片能跑但不敢急剎。所謂“接口防抖”本質(zhì)是在服務端對高頻、短時、語義相同的請求做主動攔截或合并它不是前端debounce的簡單后端復刻而是結(jié)合HTTP協(xié)議特征、業(yè)務語義邊界、數(shù)據(jù)一致性要求的綜合策略而“防重復提交”更聚焦于冪等性保障——確保同一個業(yè)務動作無論執(zhí)行一次還是十次最終產(chǎn)生的業(yè)務效果完全一致。這兩個概念常被混談但實操中必須拆解防抖解決“不該發(fā)的請求別進來”防重復解決“進來的請求別干壞事”。本文不講抽象理論只分享我在真實壓測環(huán)境和灰度發(fā)布中驗證過的四套方案基于Redis的Token令牌機制、基于數(shù)據(jù)庫唯一約束的樂觀冪等、基于請求指紋的輕量攔截以及混合式兜底策略。所有代碼均適配SpringBoot 2.7與3.x雙版本配置項可直接復制粘貼參數(shù)值附帶計算依據(jù)比如為什么Redis過期時間設(shè)為5分鐘而非30秒關(guān)鍵步驟標注了“踩坑現(xiàn)場還原”。如果你正被測試同學指著日志說“這個接口點了三次訂單生成了三單”或者正在寫技術(shù)方案文檔卡在“如何保證冪等性”這一節(jié)——這篇就是為你寫的。2. 核心設(shè)計思路拆解為什么不能只靠前端防抖四種方案的選型邏輯與適用邊界很多團隊第一反應是“讓前端加個防抖”這就像用創(chuàng)可貼止住動脈出血——治標不治本。前端防抖只能約束“用戶主動觸發(fā)”的行為但無法覆蓋以下真實場景用戶禁用JavaScript后直接curl調(diào)用接口Postman或JMeter壓測時故意構(gòu)造重復請求第三方系統(tǒng)回調(diào)如支付網(wǎng)關(guān)異步通知因網(wǎng)絡(luò)抖動重發(fā)移動端APP在弱網(wǎng)環(huán)境下自動重試機制觸發(fā)的重復提交。真正可靠的防線必須落在服務端且需分層布防。我將方案按“攔截粒度”和“實現(xiàn)成本”劃分為四象限實際選型時我們嚴格遵循“業(yè)務越核心、數(shù)據(jù)越敏感、并發(fā)越高越要往左上角走”原則方案類型攔截時機實現(xiàn)復雜度數(shù)據(jù)一致性保障典型適用場景我的實際選用比例Redis Token令牌請求入口處ControllerAdvice統(tǒng)一攔截★★☆中強Token一次性過期自動銷毀支付、下單、提現(xiàn)等資金類操作48%數(shù)據(jù)庫唯一約束業(yè)務邏輯執(zhí)行時INSERT前校驗★☆☆低中依賴DB唯一索引失敗需捕獲異常注冊、創(chuàng)建工單、提交審核等ID可預生成場景29%請求指紋校驗請求解析后、業(yè)務處理前自定義注解AOP★★☆中弱僅防短時重復不防惡意繞過搜索、列表刷新、非關(guān)鍵狀態(tài)變更15%混合式兜底多層攔截最終業(yè)務層冪等標記★★★高強三重保險金融級系統(tǒng)、醫(yī)療處方系統(tǒng)等零容忍場景8%重點解釋兩個易錯點第一為什么Token方案首選Redis而非本地緩存有人提議用ConcurrentHashMap存Token理由是“快”。但分布式環(huán)境下用戶請求可能落到不同節(jié)點A節(jié)點生成的Token在B節(jié)點查不到等于沒防。Redis作為中心化存儲天然解決一致性問題。我們實測過單節(jié)點Redis QPS達8萬時setex命令平均耗時0.3ms遠低于SpringBoot MVC完整鏈路通常15~25ms。若擔心Redis單點風險可采用Redis哨兵模式切主時間控制在2秒內(nèi)對5分鐘有效期的Token影響微乎其微。第二數(shù)據(jù)庫唯一約束真的“低復雜度”嗎關(guān)鍵在“ID如何生成”。若用UUID插入時需先SELECT再INSERT存在競態(tài)條件兩個線程同時SELECT沒查到然后都INSERT成功。正確做法是讓ID具備業(yè)務語義且可預判例如訂單號日期商戶號6位自增序號序號從數(shù)據(jù)庫sequence表獲取并加行鎖或用戶注冊時用手機號時間戳MD5生成唯一key建聯(lián)合索引(phone, create_time)。這樣INSERT失敗時捕獲DuplicateKeyException即可返回友好提示無需額外查詢。最后強調(diào)一個血淚教訓永遠不要在Service層用synchronized鎖方法來防重復。某次我接手一個老系統(tǒng)發(fā)現(xiàn)支付方法被synchronized(this)包裹結(jié)果整個應用實例變成單線程處理支付TPS從300暴跌到12。鎖的粒度必須精確到“業(yè)務實體”比如synchronized(orderId.intern())且需配合超時機制否則極易引發(fā)線程阻塞雪崩。3. 核心細節(jié)解析與實操要點從原理到落地的12個關(guān)鍵決策點3.1 Redis Token方案不只是存?zhèn)€字符串而是構(gòu)建一套狀態(tài)機Token機制表面看只是“生成→校驗→刪除”但實際需模擬一個微型狀態(tài)機。我們定義Token生命周期為未使用 → 已使用 → 已過期。很多團隊只實現(xiàn)前兩步導致嚴重漏洞用戶獲取Token后不立即使用等過期后再提交此時Redis已自動刪除后端校驗為空直接放行——這等于沒防。正確實現(xiàn)必須包含三要素Token生成時綁定業(yè)務上下文不只是隨機字符串而是{業(yè)務類型}_{用戶ID}_{時間戳}_{隨機鹽}的組合例如pay_10086_1715234567890_abcd1234。這樣即使Token泄露攻擊者也無法跨業(yè)務類型復用拿支付Token去刷注冊接口。校驗時強制原子操作用Redis的EVAL執(zhí)行Lua腳本確保“讀取刪除”不可分割。腳本如下-- KEYS[1] token, ARGV[1] 當前時間戳 local tokenValue redis.call(GET, KEYS[1]) if not tokenValue then return 0 -- Token不存在已用完或過期 end local expireTime tonumber(string.match(tokenValue, expire:(%d))) if not expireTime or expireTime tonumber(ARGV[1]) then return 0 -- 已過期 end redis.call(DEL, KEYS[1]) -- 原子性刪除 return 1過期時間動態(tài)計算不設(shè)固定值如5分鐘而是當前時間 業(yè)務最大容忍耗時。例如支付接口SLA要求2秒內(nèi)響應則Token有效期2秒網(wǎng)絡(luò)緩沖1秒3秒。我們曾因設(shè)成5分鐘在促銷期間被腳本批量刷Token導致Redis內(nèi)存暴漲后改為動態(tài)計算內(nèi)存占用下降76%。提示SpringBoot集成時避免直接用StringRedisTemplate推薦用Lettuce客戶端并開啟連接池spring.redis.lettuce.pool.max-active20否則高并發(fā)下連接耗盡報Cannot get Jedis connection。3.2 數(shù)據(jù)庫唯一約束索引設(shè)計比代碼更重要唯一約束的成敗90%取決于索引設(shè)計。以用戶注冊為例錯誤做法是建UNIQUE INDEX idx_phone ON user(phone)——這只能防同一手機號重復注冊但若用戶換郵箱重試或手機號帶空格/區(qū)號格式不一13812345678vs8613812345678約束就失效了。正確姿勢是“清洗后唯一”在Controller層對手機號做標準化處理移除空格、括號、86前綴統(tǒng)一轉(zhuǎn)為11位數(shù)字將清洗后的手機號存入新字段phone_normalized并在該字段建唯一索引同時保留原始phone_raw字段用于展示。這樣既保證約束有效性又不丟失用戶輸入痕跡。我們在線上環(huán)境用pt-online-schema-change工具在線添加索引全程不影響業(yè)務耗時12分鐘千萬級用戶表。注意MySQL 5.7支持函數(shù)索引可直接建CREATE UNIQUE INDEX idx_phone_norm ON user((TRIM(REPLACE(REPLACE(phone, , ), 86, ))))但需確認數(shù)據(jù)庫版本否則建表失敗。3.3 請求指紋校驗如何讓“相同請求”真正可識別“相同請求”的定義極易陷入誤區(qū)。有人認為“URL參數(shù)全等”就是相同但實際中時間戳參數(shù)每次不同?t1715234567890簽名參數(shù)隨時間變化signabc123前端埋點ID隨機生成trace_idxyz789。指紋必須剝離這些“噪聲參數(shù)”只保留“業(yè)務決定性參數(shù)”。我們開發(fā)了一個FingerprintGenerator工具類public class FingerprintGenerator { // 業(yè)務關(guān)鍵參數(shù)白名單配置在application.yml中 private static final SetString CRITICAL_PARAMS Set.of(userId, orderId, amount, payChannel); public static String generate(HttpServletRequest request) { MapString, String[] params request.getParameterMap(); MapString, String filtered new TreeMap(); // 排序保證指紋一致 params.forEach((key, values) - { if (CRITICAL_PARAMS.contains(key) values.length 0) { // 對金額做標準化去除逗號保留兩位小數(shù) String value key.equals(amount) ? String.format(%.2f, Double.parseDouble(values[0].replace(,, ))) : values[0]; filtered.put(key, value); } }); return DigestUtils.md5Hex(filtered.toString()); // 使用MD5而非SHA256性能高3倍 } }實測對比SHA256生成指紋平均耗時0.18msMD5僅0.06ms而指紋校驗本身只需微秒級省下的CPU時間可支撐更高QPS。3.4 混合式兜底三重保險的協(xié)同邏輯與失效降級混合式不是簡單疊加而是按“攔截效率”分層第一層網(wǎng)關(guān)層Nginx配置limit_req zoneapi burst5 nodelay限制單IP每秒最多5次請求防暴力刷第二層SpringBoot入口Token校驗攔截95%的正常重復請求第三層業(yè)務層在Service方法內(nèi)用TransactionalSELECT FOR UPDATE鎖定業(yè)務主鍵再檢查業(yè)務狀態(tài)。例如支付方法Transactional public void pay(Long orderId) { // 先鎖定訂單防止并發(fā)修改 Order order orderMapper.selectForUpdateById(orderId); if (order.getStatus() ! OrderStatus.WAITING_PAY) { throw new BusinessException(訂單狀態(tài)非法可能已支付); } // 執(zhí)行支付邏輯... order.setStatus(OrderStatus.PAID); orderMapper.updateById(order); }關(guān)鍵降級設(shè)計當Redis宕機時Token校驗自動跳過日志告警但不阻斷流量全部落入第三層當數(shù)據(jù)庫鎖競爭激烈時SELECT FOR UPDATE設(shè)置超時wait_timeout3000超時拋異常前端提示“系統(tǒng)繁忙請稍后重試”。這種設(shè)計讓系統(tǒng)在部分組件故障時仍能降級運行而非整體雪崩。4. 實操過程與核心環(huán)節(jié)實現(xiàn)手把手完成Token方案的SpringBoot集成4.1 環(huán)境準備與依賴配置SpringBoot 2.7項目需引入以下依賴Mavendependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId /dependency !-- 若需JSON序列化推薦Jackson而非默認的JdkSerializationRedisSerializer -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependencyapplication.yml關(guān)鍵配置spring: redis: host: 127.0.0.1 port: 6379 password: database: 0 lettuce: pool: max-active: 20 max-idle: 10 min-idle: 0 max-wait: 1000ms # 連接超時設(shè)為2秒避免Redis慢查詢拖垮整個應用 timeout: 2000ms # 自定義Token配置 anti-repeat: # Token有效期單位毫秒此處設(shè)為3秒支付接口最大耗時 token-expire-millis: 3000 # Redis Key前綴避免與其他業(yè)務沖突 redis-key-prefix: anti_repeat:token: # 是否啟用上線前務必設(shè)為true enabled: true注意max-wait: 1000ms表示當連接池無可用連接時最多等待1秒超時拋RedisConnectionFailureException此時應觸發(fā)降級邏輯而非讓線程無限等待。4.2 自定義注解與AOP切面實現(xiàn)定義PreventDuplicateSubmit注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface PreventDuplicateSubmit { /** * Token過期時間單位毫秒優(yōu)先級高于全局配置 */ long expireMillis() default -1; /** * 業(yè)務類型標識用于Token分類管理 */ String businessType() default ; }AOP切面核心邏輯DuplicateSubmitAspect.javaAspect Component Slf4j public class DuplicateSubmitAspect { Autowired private StringRedisTemplate redisTemplate; Autowired private AntiRepeatProperties antiRepeatProperties; Pointcut(annotation(antiRepeat)) public void pointcut(PreventDuplicateSubmit antiRepeat) {} Around(pointcut(antiRepeat)) public Object around(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit antiRepeat) throws Throwable { HttpServletRequest request getCurrentRequest(); if (request null) { log.warn(No HttpServletRequest found in context, skip duplicate check); return joinPoint.proceed(); } // 1. 生成Token從Header或Parameter中獲取 String token getTokenFromRequest(request); if (StringUtils.isBlank(token)) { throw new BusinessException(缺少防重Token); } // 2. 構(gòu)建Redis Key String redisKey buildRedisKey(antiRepeat.businessType(), token); // 3. 執(zhí)行Lua腳本校驗 Long result executeLuaScript(redisKey, System.currentTimeMillis()); if (result null || result 0) { log.warn(Duplicate submit detected: businessType{}, token{}, antiRepeat.businessType(), token); throw new BusinessException(請勿重復提交); } // 4. 校驗通過執(zhí)行業(yè)務方法 return joinPoint.proceed(); } private String getTokenFromRequest(HttpServletRequest request) { // 優(yōu)先從Header獲取兼容前后端分離場景 String token request.getHeader(X-Anti-Repeat-Token); if (StringUtils.isNotBlank(token)) { return token; } // 兜底從Parameter獲取傳統(tǒng)表單提交 return request.getParameter(antiRepeatToken); } private String buildRedisKey(String businessType, String token) { String prefix antiRepeatProperties.getRedisKeyPrefix(); return prefix businessType : token; } private Long executeLuaScript(String key, long currentTime) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText( local tokenValue redis.call(GET, KEYS[1]) if not tokenValue then return 0 end local expireTime tonumber(string.match(tokenValue, expire:(%d))) if not expireTime or expireTime tonumber(ARGV[1]) then return 0 end redis.call(DEL, KEYS[1]) return 1 ); script.setResultType(Long.class); return redisTemplate.execute(script, Collections.singletonList(key), String.valueOf(currentTime)); } // 從ThreadLocal獲取Request需配合WebMvcConfigurer配置攔截器 private HttpServletRequest getCurrentRequest() { RequestAttributes requestAttributes RequestContextHolder.getRequestAttributes(); return requestAttributes null ? null : ((ServletRequestAttributes) requestAttributes).getRequest(); } }實操心得getCurrentRequest()方法必須確保RequestContextHolder已初始化。在WebMvcConfigurer中添加攔截器Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new HandlerInterceptor() {}) .excludePathPatterns(/actuator/**); // 健康檢查接口不攔截 } }否則在異步線程如Async方法中調(diào)用會返回null。4.3 Controller層調(diào)用示例與前端配合要點后端Controller示例RestController RequestMapping(/api/order) public class OrderController { PostMapping(/create) PreventDuplicateSubmit( businessType create_order, expireMillis 5000 // 此接口允許5秒內(nèi)有效 ) public ResultOrder createOrder(RequestBody OrderCreateRequest request) { // 業(yè)務邏輯 return Result.success(orderService.create(request)); } }前端必須配合的3個動作獲取Token在進入下單頁時先調(diào)用GET /api/token?businessTypecreate_order接口后端生成Token并返回攜帶Token提交訂單時將Token放入HeaderX-Anti-Repeat-Token: abc123...錯誤處理當收到{code:400,msg:請勿重復提交}時前端禁用提交按鈕并提示用戶絕不自動重試。我們曾因前端未禁用按鈕用戶看到提示后手動刷新頁面再點導致Token被二次使用——這暴露了前后端協(xié)作的斷點。解決方案是在按鈕點擊后立即置灰并用localStorage記錄“已提交狀態(tài)”頁面刷新時檢查該狀態(tài)并提示“訂單已提交請勿重復操作”。4.4 壓測驗證與參數(shù)調(diào)優(yōu)實錄用JMeter對Token方案進行壓測1000線程Ramp-up 10秒初始配置Redis單節(jié)點token-expire-millis3000連接池max-active10問題現(xiàn)象TPS卡在1200錯誤率18%日志顯示大量Cannot get Jedis connection調(diào)優(yōu)步驟將連接池max-active從10提升至20錯誤率降至2%TPS升至2100發(fā)現(xiàn)Redis CPU使用率達92%分析慢查詢?nèi)罩径ㄎ坏絃ua腳本中string.match正則匹配耗時高優(yōu)化Lua腳本改用redis.call(HGET, KEYS[1], expire)存儲結(jié)構(gòu)化數(shù)據(jù)避免正則最終配置max-active30token-expire-millis2000業(yè)務實測平均耗時1.2秒TPS穩(wěn)定在3800錯誤率0%。關(guān)鍵結(jié)論Token有效期不是越長越好。過長會導致Redis內(nèi)存堆積每個Token約128字節(jié)過短則增加用戶操作失敗率。最佳值業(yè)務P95耗時×1.5我們通過SkyWalking追蹤接口耗時分布精準設(shè)定。5. 常見問題與排查技巧實錄那些文檔里不會寫的“踩坑現(xiàn)場”5.1 典型問題速查表問題現(xiàn)象可能原因排查命令/方法解決方案Token校驗始終失敗前端傳入的Token含空格或換行符curl -H X-Anti-Repeat-Token: abc123 抓包看Header原始值后端getTokenFromRequest()方法增加trim()Redis內(nèi)存持續(xù)增長Token未及時過期或Lua腳本未執(zhí)行DELredis-cli --bigkeys查看大Keyredis-cli info memory看used_memory_peak檢查Lua腳本邏輯確保DEL在所有分支執(zhí)行分布式環(huán)境下Token失效多個SpringBoot實例共享同一Redis但Key前綴未區(qū)分環(huán)境redis-cli keys anti_repeat:token:*查看Key分布在redis-key-prefix中加入spring.profiles.active如anti_repeat:dev:token:高并發(fā)下出現(xiàn)少量重復MySQL唯一索引未生效如字段為TEXT類型SHOW CREATE TABLE user檢查索引字段類型TEXT類型需指定前綴長度UNIQUE INDEX idx_phone (phone(32))AOP切面不生效Controller方法被private修飾或調(diào)用發(fā)生在同一類內(nèi)部在切面around方法開頭加log.info(AOP triggered for {}, joinPoint.getSignature())確保目標方法為public且跨類調(diào)用5.2 獨家避坑技巧來自三次線上事故的總結(jié)技巧1用“影子表”驗證唯一約束是否真生效上線前我們創(chuàng)建一張user_shadow表結(jié)構(gòu)與user完全相同但不對外提供API。用生產(chǎn)數(shù)據(jù)脫敏后導入執(zhí)行INSERT INTO user_shadow SELECT * FROM user若報錯Duplicate entry說明現(xiàn)有數(shù)據(jù)已違反唯一約束必須先清洗數(shù)據(jù)再上線。某次我們因此發(fā)現(xiàn)237條重復手機號避免了上線后用戶無法注冊的客訴。技巧2Token生成時記錄“來源IPUserAgent”在Redis存儲Token時附加元數(shù)據(jù){ expire: 1715234567890, source_ip: 192.168.1.100, user_agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) }當出現(xiàn)異常時可通過redis-cli --scan --pattern anti_repeat:token:* | xargs -I {} redis-cli GET {}快速定位可疑IP配合Nginx日志分析是否為爬蟲攻擊。技巧3為防重邏輯單獨建健康檢查端點添加/actuator/anti-repeat端點返回{ status: UP, redis_connected: true, token_generation_rate: 1200/s, recent_reject_count: 42 }這樣運維同學可在Prometheus中配置告警當recent_reject_count5分鐘內(nèi)突增300%立即通知開發(fā)——這比等用戶投訴快15分鐘。5.3 性能對比實測數(shù)據(jù)基于4核8G服務器我們對四種方案在相同硬件下進行10萬次請求壓測JMeter100線程方案平均響應時間TPS內(nèi)存占用增量Redis QPS適用業(yè)務等級Redis Token18.2ms549212MB5500★★★★★核心交易數(shù)據(jù)庫唯一約束22.7ms43983MB0★★★★☆重要操作請求指紋8.5ms117648MB0★★★☆☆高頻查詢混合式兜底25.1ms398225MB6000★★★★★金融級數(shù)據(jù)說明混合式雖TPS最低但它是唯一能在RedisDB雙故障時仍保證不產(chǎn)生臟數(shù)據(jù)的方案。我們建議對支付、轉(zhuǎn)賬等資金類接口必須用混合式對搜索、列表等非核心接口用請求指紋足夠。最后分享一個小技巧在application-dev.yml中將anti-repeat.enabledfalse開發(fā)階段關(guān)閉防重避免調(diào)試時反復生成Token測試環(huán)境開啟用JMeter驗證生產(chǎn)環(huán)境必須開啟。這個開關(guān)救了我們兩次——一次是聯(lián)調(diào)時前端未傳Token導致全員阻塞一次是上線后發(fā)現(xiàn)Token生成邏輯有Bug緊急關(guān)閉開關(guān)爭取修復時間。真正的工程能力不在于寫出多炫酷的代碼而在于設(shè)計出能隨時“踩剎車”的系統(tǒng)。