【架构实战】接口幂等设计:防止重复操作的终极方案
一ã€ä¸€æ¬¡é‡å¤æ‰£æ¬¾è®©æˆ‘差点赔了20万2019å¹´åŒå一,我们的支付系统出现了一个严é‡Bug。用户点击支付åŽï¼Œå› 为网络抖动,å‰ç«¯æ²¡æœ‰æ”¶åˆ°å“应,于是用户åˆç‚¹å‡»äº†ä¸€æ¬¡ã€‚ä¸¤ä¸ªæ”¯ä»˜è¯·æ±‚åŒæ—¶åˆ°è¾¾åŽç«¯ï¼Œç³»ç»Ÿæ‰£äº†ä¸¤æ¬¡æ¬¾ã€‚客æœé‚£è¾¹ä¸€ä¸‹åæ¶Œå¥äº†å‡ å个投诉电è¯ã€‚我们紧急排查,å‘çŽ°æ˜¯æ”¯ä»˜æŽ¥å£æ²¡æœ‰åšå¹‚ç‰æŽ§åˆ¶ã€‚æ›´å¯æ€•的是,这个问题在线上已ç»å˜åœ¨äº†3个月,累计多扣了20万。我们花了整整一周时间,一æ¡ä¸€æ¡å¯¹è´¦é€€æ¬¾ã€‚从那以åŽï¼Œå¹‚ç‰æ€§æˆäº†æˆ‘设计æ¯ä¸€ä¸ªæŽ¥å£çš„第一原则。二ã€å¹‚ç‰æ€§æ¦‚è¿°2.1 什么是幂ç‰å¹‚ç‰æ€§ï¼ˆIdempotency): 一个æ“ä½œæ‰§è¡Œä¸€æ¬¡å’Œæ‰§è¡Œå¤šæ¬¡ï¼Œäº§ç”Ÿçš„æ•ˆæžœæ˜¯ä¸€æ ·çš„ã€‚ HTTP方法: - GET: 幂ç‰ï¼ˆæŸ¥è¯¢ä¸ä¼šæ”¹å˜çжæ€ï¼‰ - PUT: 幂ç‰ï¼ˆå®Œæ•´æ›¿æ¢ï¼Œå¤šæ¬¡æ›¿æ¢ç»“果一致) - DELETE: 幂ç‰ï¼ˆåˆ é™¤å·²åˆ é™¤çš„èµ„æºï¼Œç»“果一致) - POST: éžå¹‚ç‰ï¼ˆæ¯æ¬¡åˆ›å»ºä¸€ä¸ªæ–°èµ„æºï¼‰ 需è¦å¹‚ç‰çš„场景: 1. 支付扣款 2. 订å•创建 3. åº“å˜æ‰£å‡ 4. ä¼˜æƒ åˆ¸é¢†å– 5. æ¶ˆæ¯æ¶ˆè´¹ 6. è¡¨å•æäº¤2.2 å¹‚ç‰æ–¹æ¡ˆå¯¹æ¯”┌─────────────────────────────────────────────────────────────────┠│ å¹‚ç‰æ–¹æ¡ˆå¯¹æ¯” │ │ │ │ 方案 │ 适用场景 │ 优点 │ 缺点 │ │ ───────────────────────────────────────────────────────────── │ │ Token机制 │ è¡¨å•æäº¤ │ ç®€å• â”‚ 需两次请求 │ │ æ•°æ®åº“å”¯ä¸€çº¦æŸ â”‚ æ’å ¥æ“作 │ å¯é │ 性能一般 │ │ çŠ¶æ€æœº │ 状æ€å˜æ›´ │ 业务è¯ä¹‰å¼º │ çŠ¶æ€æœ‰é™ │ │ ä¹è§‚é” â”‚ æ›´æ–°æ“作 │ 高并å‘å‹å¥½ │ éœ€ç‰ˆæœ¬å—æ®µ │ │ Redis SETNX │ 通用 │ 高性能 │ 需é¢å¤–å˜å‚¨ │ │ 分布å¼é” │ 夿‚æ“作 │ 强一致 │ 性能差 │ │ │ └──────────────────────────────────────────────────────────────────┘三ã€Token机制3.1 原ç†Token机制(防é‡Token): 1. 客户端请求Token GET /api/token → 返回唯一Token 2. 客户端æºå¸¦Tokenæäº¤è¯·æ±‚ POST /api/order {token: xxx, ...} 3. æœåŠ¡ç«¯éªŒè¯Token - Tokenå˜åœ¨ä¸”未使用 → æ‰§è¡Œä¸šåŠ¡ï¼Œåˆ é™¤Token - Tokenä¸å˜åœ¨æˆ–已使用 → 返回请勿é‡å¤æäº¤3.2 实现/** * Tokenå¹‚ç‰æœåŠ¡ */ServiceSlf4jpublicclassTokenIdempotentService{AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalStringTOKEN_PREFIXidempotent:token:;/** * 生æˆToken */publicStringcreateToken(){StringtokenUUID.randomUUID().toString().replace(-,);// å˜å¥Redis,10分钟有效 redisTemplate.opsForValue().set(TOKEN_PREFIXtoken,1,10,TimeUnit.MINUTES);returntoken;}/** * 验è¯Tokenï¼ˆåŽŸåæ“ä½œï¼‰ */publicbooleanverifyToken(Stringtoken){StringkeyTOKEN_PREFIXtoken;// Lua脚本:å˜åœ¨åˆ™åˆ 除,ä¿è¯åŽŸåæ€§Stringscriptif redis.call(get, KEYS[1]) then return redis.call(del, KEYS[1]) else return 0 end;LongresultredisTemplate.execute(newDefaultRedisScript(script,Long.class),Collections.singletonList(key));returnLong.valueOf(1).equals(result);}}/** * Token拦截器 */ComponentpublicclassIdempotentInterceptorimplementsHandlerInterceptor{AutowiredprivateTokenIdempotentServicetokenService;OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{// æ£€æŸ¥æ˜¯å¦æœ‰Idempotent注解if(handlerinstanceofHandlerMethod){HandlerMethodhandlerMethod(HandlerMethod)handler;IdempotentidempotenthandlerMethod.getMethodAnnotation(Idempotent.class);if(idempotent!null){Stringtokenrequest.getHeader(X-Idempotent-Token);if(StringUtils.isEmpty(token)){thrownewBusinessException(缺少幂ç‰Token);}if(!tokenService.verifyToken(token)){thrownewBusinessException(请勿é‡å¤æäº¤);}}}returntrue;}}/** * å¹‚ç‰æ³¨è§£ */Target(ElementType.METHOD)Retention(RetentionPolicy.RUNTIME)publicinterfaceIdempotent{/** * 幂ç‰ç±»åž‹ */IdempotentTypevalue()defaultIdempotentType.TOKEN;}å››ã€æ•°æ®åº“唯一约æŸ4.1 实现-- 订å•å·å”¯ä¸€çº¦æŸALTERTABLEt_orderADDUNIQUEINDEXuk_order_no(order_no);-- æ”¯ä»˜æµæ°´å·å”¯ä¸€çº¦æŸALTERTABLEt_payment_recordADDUNIQUEINDEXuk_payment_no(payment_no);-- ä¼˜æƒ åˆ¸é¢†å–记录唯一约æŸALTERTABLEt_coupon_recordADDUNIQUEINDEXuk_user_coupon(user_id,coupon_id);/** * æ•°æ®åº“唯一约æŸå®žçŽ°å¹‚ç‰ */ServiceSlf4jpublicclassPaymentIdempotentService{AutowiredprivatePaymentRecordMapperpaymentMapper;/** * 支付(幂ç‰ï¼‰ */TransactionalpublicPaymentResultpay(PaymentRequestrequest){StringpaymentNorequest.getPaymentNo();// 1. 查询是å¦å·²æ”¯ä»˜PaymentRecordexistingpaymentMapper.selectByPaymentNo(paymentNo);if(existing!null){log.info(支付记录已å˜åœ¨(幂ç‰): paymentNo{},paymentNo);returnPaymentResult.success(existing);}// 2. æ’奿”¯ä»˜è®°å½•(唯一约æŸä¿è¯å¹‚ç‰ï¼‰try{PaymentRecordrecordnewPaymentRecord();record.setPaymentNo(paymentNo);record.setOrderId(request.getOrderId());record.setAmount(request.getAmount());record.setStatus(PAID);record.setCreateTime(LocalDateTime.now());paymentMapper.insert(record);log.info(支付æˆåŠŸ: paymentNo{},paymentNo);returnPaymentResult.success(record);}catch(DuplicateKeyExceptione){// 唯一约æŸå†²çªï¼Œè¯´æ˜Žå·²ç»å¤„ç†è¿‡äº†log.warn(支付记录é‡å¤(幂ç‰): paymentNo{},paymentNo);PaymentRecordrecordpaymentMapper.selectByPaymentNo(paymentNo);returnPaymentResult.success(record);}}}五ã€çŠ¶æ€æœºå¹‚ç‰5.1 实现/** * 订å•çŠ¶æ€æœº */publicenumOrderStatus{CREATED(created,已创建),PAID(paid,已支付),SHIPPED(shipped,å·²å‘è´§),COMPLETED(completed,已完æˆ),CANCELLED(cancelled,已喿¶ˆ);privateStringcode;privateStringdesc;/** * 状æ€è½¬æ¢è§„则 */privatestaticfinalMapOrderStatus,SetOrderStatusTRANSITIONSnewHashMap();static{TRANSITIONS.put(CREATED,Set.of(PAID,CANCELLED));TRANSITIONS.put(PAID,Set.of(SHIPPED,CANCELLED));TRANSITIONS.put(SHIPPED,Set.of(COMPLETED));}/** * 是å¦å¯ä»¥è½¬æ¢åˆ°ç›®æ ‡çŠ¶æ€ */publicbooleancanTransitTo(OrderStatustarget){SetOrderStatusallowedTRANSITIONS.get(this);returnallowed!nullallowed.contains(target);}}/** * è®¢å•æœåŠ¡ï¼ˆçŠ¶æ€æœºå¹‚ç‰ï¼‰ */ServiceSlf4jpublicclassOrderStateMachineService{AutowiredprivateOrderMapperorderMapper;/** * 支付订å•ï¼ˆçŠ¶æ€æœºä¿è¯å¹‚ç‰ï¼‰ */TransactionalpublicvoidpayOrder(StringorderId){OrderorderorderMapper.selectById(orderId);if(ordernull){thrownewBusinessException(订å•ä¸å˜åœ¨);}// çŠ¶æ€æœºæ£€æŸ¥if(!order.getStatus().canTransitTo(OrderStatus.PAID)){if(order.getStatus()OrderStatus.PAID){// å·²ç»æ”¯ä»˜ï¼Œå¹‚ç‰è¿”回log.info(订å•已支付(幂ç‰): orderId{},orderId);return;}thrownewBusinessException(订å•状æ€ä¸å 许支付:order.getStatus());}// CAS更新状æ€introwsorderMapper.updateStatus(orderId,OrderStatus.PAID,OrderStatus.CREATED// 期望当å‰çжæ€);if(rows0){// CAS失败,说明已被å¶ä»–线程修改 log.warn(订å•çŠ¶æ€æ›´æ–°å¤±è´¥(å¹¶å‘): orderId{},orderId);thrownewBusinessException(订å•状æ€å·²å˜æ›´ï¼Œè¯·é‡è¯•);}log.info(è®¢å•æ”¯ä»˜æˆåŠŸ: orderId{},orderId);}}!-- Mapper: CASæ›´æ–°çŠ¶æ€ --updateidupdateStatusUPDATE t_order SET status #{newStatus}, update_time NOW() WHERE id #{orderId} AND status #{oldStatus}/updateåã€Redis SETNX幂ç‰/** * Redis SETNXå¹‚ç‰ */ServiceSlf4jpublicclassRedisIdempotentService{AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalStringIDEMPOTENT_PREFIXidempotent:;/** * æ£€æŸ¥å¹¶æ ‡è®°ï¼ˆåŽŸåæ“ä½œï¼‰ * param businessId 业务ID(如:订å•å·ã€æ”¯ä»˜æµæ°´å·ï¼‰ * param expireSeconds 过期时间 * return true首次执行 falseé‡å¤æ‰§è¡Œ */publicbooleancheckAndMark(StringbusinessId,longexpireSeconds){StringkeyIDEMPOTENT_PREFIXbusinessId;// SETNX è¿‡æœŸæ—¶é—´ï¼ˆåŽŸåæ“ä½œï¼‰BooleanresultredisTemplate.opsForValue().setIfAbsent(key,1,expireSeconds,TimeUnit.SECONDS);returnBoolean.TRUE.equals(result);}/** * 支付接å£ï¼ˆRedis幂ç‰ï¼‰ */publicPaymentResultpay(PaymentRequestrequest){StringbusinessIdPAY:request.getPaymentNo();// 检查幂ç‰if(!checkAndMark(businessId,3600)){log.warn(é‡å¤æ”¯ä»˜è¯·æ±‚(幂ç‰): paymentNo{},request.getPaymentNo());returnPaymentResult.duplicate();}try{// 执行支付逻辑returndoPay(request);}catch(Exceptione){// æ”¯ä»˜å¤±è´¥ï¼Œåˆ é™¤å¹‚ç‰æ ‡è®°ï¼Œå许é‡è¯• redisTemplate.delete(IDEMPOTENT_PREFIXbusinessId);throwe;}}}七ã€è¸©å‘实录å‘1:Token机制绕过攻击è€è·³è¿‡èŽ·å–Tokençš„æ¥éª¤ï¼Œç›´æŽ¥æäº¤è¯·æ±‚ï¼Œç»•è¿‡äº†å¹‚ç‰æ£€æŸ¥ã€‚解决:Tokenå¿é¡»å¼ºåˆ¶éªŒè¯ï¼ŒæŽ¥å£å±‚统一拦截,ä¸ç»™ç»•过机会。å‘2:唯一约æŸè¯¯æŠ¥å”¯ä¸€çº¦æŸå†²çªæ—¶ç›´æŽ¥æŠ›å¼‚常,没有区分é‡å¤è¯·æ±‚和系统错误。解决:æ•获DuplicateKeyException,查询已å˜åœ¨çš„记录并返回。å‘3ï¼šçŠ¶æ€æœºä¸å®Œæ•´åªåšäº†éƒ¨åˆ†çжæ€è½¬æ¢çš„å¹‚ç‰æ£€æŸ¥ï¼Œé—æ¼äº†æŸäº›çжæ€ã€‚解决:画出完整的状æ€è½¬æ¢å›¾ï¼Œé€ä¸€å®žçŽ°æ£€æŸ¥ã€‚å‘4:Redis SETNX过期时间太çŸå¹‚ç‰æ ‡è®°1å°æ—¶è¿‡æœŸï¼Œä½†ä¸šåŠ¡æ‰§è¡Œè¶è¿‡1å°æ—¶ï¼Œå¯¼è‡´é‡å¤æ‰§è¡Œã€‚解决:设置åˆç†çš„过期时间,或业务完æˆåŽæ‰‹åŠ¨åˆ é™¤ã€‚å‘5:分布å¼çŽ¯å¢ƒä¸‹è¯·æ±‚ä¹±åºè¯·æ±‚Båˆåˆ°è¾¾æ‰§è¡ŒæˆåŠŸï¼Œè¯·æ±‚AåŽåˆ°è¾¾åˆæ‰§è¡Œäº†ä¸€æ¬¡ã€‚è§£å†³ï¼šä½¿ç”¨çŠ¶æ€æœºæˆ–版本å·ï¼Œä¿è¯æ“ä½œçš„æœ‰åºæ€§ã€‚å«ã€æ€»ç»“å¹‚ç‰æ€§æ˜¯åˆ†å¸ƒå¼ç³»ç»Ÿçš„åŸºæœ¬è¦æ±‚ï¼šæ–¹æ¡ˆé€‚ç”¨åœºæ™¯æŽ¨èæŒ‡æ•°Tokenè¡¨å•æäº¤âââå”¯ä¸€çº¦æŸæ•°æ®æ’å¥âââââçŠ¶æ€æœºçжæ€å˜æ›´âââââä¹è§‚锿•°æ®æ›´æ–°ââââRedis SETNX通用ââââæœ€ä½³å®žè·µï¼šæ¯ä¸ªå†™æŽ¥å£éƒ½è€ƒè™‘幂ç‰å¤šç§æ–¹æ¡ˆç»„åˆä½¿ç”¨å¹‚ç‰æ ‡è®°è¦æœ‰åˆç†çš„过期时间失败时æ¸é™¤å¹‚ç‰æ ‡è®°ï¼Œå许é‡è¯•血的教è®ï¼šå¹‚ç‰ä¸æ˜¯å¯é€‰çš„,是å¿é¡»çš„。ä¸è¦ç‰åˆ°é‡å¤æ‰£æ¬¾äº†æ‰åŽæ‚”,æ¯ä¸ªå†™æŽ¥å£ä¸Šçº¿å‰éƒ½è¦åšå¹‚ç‰æ£€æŸ¥ã€‚æ€è€ƒé¢˜ï¼šä½ 的系统有没有é‡åˆ°é‡å¤è¯·æ±‚的问题?用的哪ç§å¹‚ç‰æ–¹æ¡ˆï¼Ÿä¸ªäººè§‚点,ä»ä¾›å‚考