一、一次重复扣款让我差点赔了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é€šç”¨â­â­â­â­æœ€ä½³å®žè·µï¼šæ¯ä¸ªå†™æŽ¥å£éƒ½è€ƒè™‘å¹‚ç­‰å¤šç§æ–¹æ¡ˆç»„åˆä½¿ç”¨å¹‚ç­‰æ ‡è®°è¦æœ‰åˆç†çš„è¿‡æœŸæ—¶é—´å¤±è´¥æ—¶æ¸é™¤å¹‚ç­‰æ ‡è®°ï¼Œåè®¸é‡è¯•血的教训:幂等不是可选的,是å¿é¡»çš„ã€‚ä¸è¦ç­‰åˆ°é‡å¤æ‰£æ¬¾äº†æ‰åŽæ‚”ï¼Œæ¯ä¸ªå†™æŽ¥å£ä¸Šçº¿å‰éƒ½è¦åšå¹‚ç­‰æ£€æŸ¥ã€‚æ€è€ƒé¢˜ï¼šä½ çš„ç³»ç»Ÿæœ‰æ²¡æœ‰é‡åˆ°é‡å¤è¯·æ±‚çš„é—®é¢˜ï¼Ÿç”¨çš„å“ªç§å¹‚ç­‰æ–¹æ¡ˆï¼Ÿä¸ªäººè§‚ç‚¹ï¼Œä»ä¾›å‚考