从“订单创建两次”到系统生命线支付、下单、回调系统中的幂等性设计实战在支付、下单、退款、发券、回调这类系统里有一个看起来很朴素、但极其关键的问题同一个请求如果被执行多次系统结果是否仍然正确这就是幂等性。很多系统事故不是因为代码完全不可用而是因为代码“太可用了”用户点了一次支付按钮前端重试了网关超时重发了消息队列重复投递了第三方支付平台多次回调了服务之间 RPC 超时后调用方又发了一遍。于是原本只应该创建一笔订单结果创建了两笔原本只应该扣一次库存结果扣了两次原本只应该给用户发一张优惠券结果发了三张。在普通业务里这可能只是一个 bug但在支付、交易、履约系统里这就是资金风险、库存风险、对账风险和信任风险。所以幂等性不是锦上添花而是支付、下单、回调这类系统的生命线。一、什么是幂等性幂等性简单说就是同一个操作执行一次和执行多次产生的最终业务结果一致。例如创建订单请求 request_id abc123 user_id 1001 sku_id 2001 amount 99.00如果这个请求因为网络抖动被提交了三次理想结果应该是系统中只有一笔订单 库存只扣一次 支付单只创建一次 用户只看到一个待支付订单而不是订单表多了三条记录 库存扣了三次 支付单创建了三笔 用户投诉我只点了一次怎么有三个订单幂等性的核心不是“不允许请求重复到达”因为在分布式系统里这几乎不可能彻底避免。它真正要解决的是请求可以重复到达但业务结果不能重复发生。二、为什么支付、下单、回调系统尤其需要幂等因为这些系统天然处在“不确定性”之中。1. 用户可能重复点击前端按钮没有及时置灰用户连续点击“提交订单”或“立即支付”。点击一次 POST /orders 连续点击三次 POST /orders POST /orders POST /orders如果后端没有幂等控制就可能创建多笔订单。2. 网络超时导致调用方重试调用方发起请求后服务端其实已经处理成功但响应在网络中丢失了。客户端创建订单 服务端订单已创建成功 网络响应超时 客户端以为失败再发一次这时如果服务端没有识别“这是同一个请求”就会重复创建。3. 消息队列天然可能重复投递大多数 MQ 只能保证“至少投递一次”不能保证“只投递一次”。比如订单创建成功后发送消息OrderCreatedEvent(order_id123)消费者处理完业务但还没来得及 ack 就宕机了。MQ 会重新投递消息消费者又处理一遍。如果消费逻辑不是幂等的库存、积分、优惠券、通知、账务都会出问题。4. 第三方支付回调一定要支持重复通知支付平台在没有收到业务系统成功响应时通常会反复回调。支付成功回调第 1 次 支付成功回调第 2 次 支付成功回调第 3 次业务系统必须保证支付单只从 unpaid 变成 paid 一次 订单只从 pending 变成 paid 一次 发货流程只触发一次否则用户只付了一笔钱系统却可能发两次货。三、事故场景订单被创建了两次假设我们有一个最简单的下单接口defcreate_order(user_id:int,sku_id:int,quantity:int):priceget_sku_price(sku_id)order{user_id:user_id,sku_id:sku_id,quantity:quantity,amount:price*quantity,status:pending_payment}order_idinsert_order(order)returnorder_id这个接口看起来没问题但它有一个致命缺陷每调用一次就创建一笔新订单。如果用户重复点击或者客户端超时重试就会产生两条订单记录order_id | user_id | sku_id | amount | status ---------|---------|--------|--------|---------------- 10001 | 1 | 200 | 99.00 | pending_payment 10002 | 1 | 200 | 99.00 | pending_payment业务上用户明明只想买一次系统却创建了两笔订单。要解决这个问题我们需要引入一个关键字段幂等键。四、幂等键识别“同一次业务请求”的身份证幂等键通常叫request_id idempotency_key biz_id trace_id client_token out_trade_no它的作用是标识一次唯一的业务请求。例如{request_id:order_20260517_abc123,user_id:1001,sku_id:2001,quantity:1}后端处理前先检查这个 request_id 是否已经处理过如果没有处理过就创建订单并记录结果。如果处理过就直接返回之前的结果。五、下单接口的幂等设计一个更可靠的订单创建流程应该是客户端生成 request_id ↓ 调用创建订单接口 ↓ 服务端检查 request_id 是否存在 ↓ 不存在创建订单保存 request_id 与 order_id ↓ 存在直接返回已有 order_id示意图┌──────────┐ │ Client │ └────┬─────┘ │ request_idabc123 ▼ ┌──────────────┐ │ Order API │ └────┬─────────┘ │ 查询幂等记录 ▼ ┌──────────────┐ │ Idempotency │ │ Table │ └────┬─────────┘ │ 不存在 ▼ ┌──────────────┐ │ Create Order │ └────┬─────────┘ │ 保存结果 ▼ ┌──────────────┐ │ Return │ └──────────────┘数据库可以设计一张幂等表CREATETABLEidempotency_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,idempotency_keyVARCHAR(128)NOTNULL,biz_typeVARCHAR(64)NOTNULL,biz_idVARCHAR(128),statusVARCHAR(32)NOTNULL,response_bodyTEXT,created_atDATETIMENOTNULL,updated_atDATETIMENOTNULL,UNIQUEKEYuk_biz_key(biz_type,idempotency_key));订单表也应该有业务唯一约束CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,sku_idBIGINTNOTNULL,amountDECIMAL(10,2)NOTNULL,statusVARCHAR(32)NOTNULL,request_idVARCHAR(128)NOTNULL,created_atDATETIMENOTNULL,UNIQUEKEYuk_request_id(request_id));Python 伪代码如下fromdatetimeimportdatetimefromtypingimportDict,Anydefcreate_order_with_idempotency(request_id:str,user_id:int,sku_id:int,quantity:int)-Dict[str,Any]:# 1. 先查幂等记录recordfind_idempotency_record(CREATE_ORDER,request_id)ifrecordandrecord[status]SUCCESS:return{order_id:record[biz_id],from_cache:True}# 2. 如果没有记录尝试插入幂等记录# 这里必须依赖数据库唯一索引兜底防止并发穿透insertedtry_insert_idempotency_record(biz_typeCREATE_ORDER,idempotency_keyrequest_id,statusPROCESSING,created_atdatetime.now())ifnotinserted:# 说明另一个并发请求已经抢先处理existingwait_and_get_result(CREATE_ORDER,request_id)return{order_id:existing[biz_id],from_cache:True}try:priceget_sku_price(sku_id)amountprice*quantity order_idinsert_order({user_id:user_id,sku_id:sku_id,quantity:quantity,amount:amount,status:pending_payment,request_id:request_id,created_at:datetime.now()})update_idempotency_record(biz_typeCREATE_ORDER,idempotency_keyrequest_id,statusSUCCESS,biz_idstr(order_id),response_body{order_id:order_id})return{order_id:order_id,from_cache:False}exceptException:update_idempotency_record(biz_typeCREATE_ORDER,idempotency_keyrequest_id,statusFAILED)raise这个设计的关键点是不能只靠先查再写。因为两个请求可能同时查到不存在然后同时创建订单。真正可靠的方案一定要有幂等键 唯一索引 事务控制六、你会在哪一层做幂等控制答案是不应该只在一层做而应该分层做。幂等性不是某个接口上的一行代码而是一套系统性防线。推荐从以下几层设计。七、第一层客户端防重复提交客户端可以做基础防护按钮置灰 加载状态 防抖节流 本地生成 request_id 失败后使用同一个 request_id 重试例如前端点击下单后第一次请求 request_id abc123 超时重试 request_id 仍然是 abc123而不是每次重试都生成一个新的 request_id。如果每次重试都是新 key服务端就无法判断它们属于同一次业务请求。客户端防护很有用但不能作为最终保障。因为用户可以刷新页面、绕过前端、使用脚本重复请求。所以客户端防重是体验优化不是安全边界。八、第二层网关层做基础拦截API 网关可以根据以下维度做限流或短期去重user_id uri idempotency_key ip uri token uri适合拦截高频重复请求例如同一用户 1 秒内提交 10 次下单请求网关可以直接拒绝明显异常的重复提交减轻后端压力。但网关也不应该承担最终业务幂等因为它通常不知道完整业务语义。例如同一个用户可以购买两个不同商品 同一个用户也可以为不同订单分别支付仅靠 URL 或 IP 去重容易误伤正常业务。所以网关层适合限流、防刷、短时间重复拦截不适合作为最终业务幂等依据。九、第三层业务服务层做核心幂等最核心的幂等控制应该放在业务服务层。例如订单服务保证同一个 request_id 只创建一笔订单 支付服务保证同一个 out_trade_no 只创建一笔支付单 退款服务保证同一个 refund_no 只发起一次退款 库存服务保证同一个 order_id 只扣一次库存 优惠券服务保证同一个 order_id 只核销一次优惠券业务服务层最理解业务语义因此也最适合定义幂等规则。比如下单场景幂等键可以是request_id支付场景幂等键可以是merchant_order_no out_trade_no退款场景幂等键可以是refund_no回调场景幂等键可以是payment_channel transaction_id核心原则是幂等键必须能唯一标识一次业务意图。十、第四层数据库唯一约束兜底无论应用层逻辑多么严密数据库唯一约束都必不可少。因为并发场景下应用层判断很容易被击穿。错误写法orderfind_order_by_request_id(request_id)ifnotorder:create_order(request_id)在高并发下两个线程都可能查到不存在然后同时插入。正确做法是让数据库参与约束UNIQUEKEYuk_request_id(request_id)然后代码处理唯一键冲突defsafe_create_order(request_id:str,user_id:int,sku_id:int):try:returninsert_order_with_request_id(request_id,user_id,sku_id)exceptDuplicateKeyError:returnfind_order_by_request_id(request_id)数据库唯一索引是幂等设计的最后防线。一句话应用层负责业务判断数据库负责强一致兜底。十一、第五层消息消费端幂等很多系统会在订单创建后发送消息订单创建成功 → 扣库存 订单支付成功 → 发货 支付成功 → 发积分 退款成功 → 退库存但 MQ 消息可能重复投递。所以消费者必须做幂等。例如库存扣减消息{event_id:evt_123,order_id:order_10001,sku_id:2001,quantity:1}消费者处理前先判断event_id 是否消费过 order_id 是否已经扣过库存可以设计消费记录表CREATETABLEmessage_consume_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,event_idVARCHAR(128)NOTNULL,consumer_nameVARCHAR(128)NOTNULL,statusVARCHAR(32)NOTNULL,created_atDATETIMENOTNULL,UNIQUEKEYuk_event_consumer(event_id,consumer_name));Python 伪代码defconsume_order_paid_event(event):insertedtry_insert_consume_record(event_idevent[event_id],consumer_namestock_service,statusPROCESSING)ifnotinserted:returnduplicate message ignoredtry:deduct_stock_once(order_idevent[order_id],sku_idevent[sku_id],quantityevent[quantity])mark_consume_success(event[event_id],stock_service)returnsuccessexceptException:mark_consume_failed(event[event_id],stock_service)raise注意真正扣库存时也要保证业务幂等CREATETABLEstock_deduct_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_idVARCHAR(128)NOTNULL,sku_idBIGINTNOTNULL,quantityINTNOTNULL,created_atDATETIMENOTNULL,UNIQUEKEYuk_order_sku(order_id,sku_id));因为消息消费记录只能说明“这条消息处理过”但业务上更应该保证“这个订单只扣一次库存”。十二、第六层第三方回调幂等支付回调是幂等设计的高危区域。典型流程用户支付成功 ↓ 支付平台通知业务系统 ↓ 业务系统更新支付单状态 ↓ 业务系统更新订单状态 ↓ 触发发货、积分、消息通知支付平台可能多次通知notify #1 notify #2 notify #3业务系统必须保证只有第一次有效处理。支付回调的关键判断通常是支付单是否已经成功 订单是否已经支付 交易号是否已经存在 金额是否一致 商户订单号是否一致 签名是否正确伪代码defhandle_payment_callback(payload):verify_signature(payload)out_trade_nopayload[out_trade_no]transaction_idpayload[transaction_id]paid_amountpayload[amount]paymentfind_payment_by_out_trade_no(out_trade_no)ifnotpayment:raiseValueError(payment not found)ifpayment[amount]!paid_amount:raiseValueError(amount mismatch)ifpayment[status]SUCCESS:returnsuccessupdatedupdate_payment_success_once(out_trade_noout_trade_no,transaction_idtransaction_id)ifnotupdated:returnsuccessmark_order_paid_once(payment[order_id])publish_order_paid_event(payment[order_id])returnsuccess其中update_payment_success_once应该使用状态条件更新UPDATEpayment_orderSETstatusSUCCESS,transaction_id:transaction_id,paid_atNOW()WHEREout_trade_no:out_trade_noANDstatusPENDING;这条 SQL 很关键。它表达的是只有处于 PENDING 的支付单才能被更新为 SUCCESS。 已经是 SUCCESS 的支付单不再重复处理。这就是状态机幂等。十三、幂等设计中的状态机思维很多幂等问题本质上是状态流转问题。订单状态可能是CREATED → PENDING_PAYMENT → PAID → SHIPPED → FINISHED退款状态可能是REFUND_CREATED → REFUND_PROCESSING → REFUND_SUCCESS支付状态可能是PENDING → SUCCESS PENDING → CLOSED PENDING → FAILED状态机设计要遵守一个原则状态只能按合法方向流转不能重复流转不能逆向流转。例如UPDATEordersSETstatusPAID,paid_atNOW()WHEREorder_id:order_idANDstatusPENDING_PAYMENT;如果第一次回调成功订单从PENDING_PAYMENT变成PAID。第二次回调再来时SQL 影响行数为 0因为状态已经不是PENDING_PAYMENT。这时直接返回成功即可。不要把重复回调当成异常因为对方重复通知是正常机制。你的系统应该优雅地告诉它我已经处理过了结果是成功。十四、幂等与分布式锁的关系很多人一提幂等就想到分布式锁。比如lock:order:create:{request_id}分布式锁有价值但它不是幂等的全部。它适合解决短时间并发进入的问题例如两个相同请求同时到达。但它有几个限制锁可能过期 锁可能释放失败 锁只能保护临界区 锁不能保存业务结果 锁不能替代数据库唯一约束更稳妥的组合是分布式锁减少并发冲突 幂等表记录处理状态和结果 唯一索引数据库强约束兜底 状态机保证业务流转正确不要把所有希望都押在锁上。真正可靠的幂等设计一定是多层防护。十五、一次完整的下单支付幂等方案假设用户购买商品完整链路如下创建订单 ↓ 创建支付单 ↓ 调用第三方支付 ↓ 接收支付回调 ↓ 更新订单为已支付 ↓ 扣库存 ↓ 发货或发放权益每一步都要有自己的幂等键。环节幂等键保护目标创建订单request_id防止重复创建订单创建支付单out_trade_no防止重复生成支付单支付回调transaction_id / out_trade_no防止重复处理支付成功扣库存order_id sku_id防止重复扣库存发权益order_id benefit_type防止重复发放消息消费event_id consumer_name防止重复消费消息退款refund_no防止重复退款核心链路可以这样设计客户端生成 request_id ↓ 订单服务用 request_id 做幂等 ↓ 支付服务用 out_trade_no 做幂等 ↓ 支付平台回调用 transaction_id 做幂等 ↓ 订单状态用条件更新防重复流转 ↓ MQ 消费者用 event_id 业务唯一键做幂等这才是一套完整的交易系统幂等设计。十六、常见错误设计错误一只靠前端按钮置灰前端可以提升体验但不能保证安全。用户刷新页面、接口重放、网络重试、移动端多端登录都可能绕过前端限制。错误二只用 Redis不落数据库Redis 可以做短期去重但如果 key 过期、缓存丢失、主从切换就可能失效。关键交易结果必须落数据库。错误三幂等 key 设计过粗比如只用user_id这会导致用户无法连续下两个订单。更合理的是user_id request_id或者直接使用全局唯一的业务请求号。错误四幂等 key 设计过细如果每次重试都生成新的 request_id幂等就失效了。重试必须使用同一个幂等键。错误五重复请求直接报错对于支付回调、消息消费这类场景重复请求通常不应该报错而应该返回之前的成功结果。因为调用方重试的目的就是确认你是否处理成功。十七、实践建议我会在哪些层做幂等如果是支付、下单、回调系统我会这样做第一客户端生成并复用 request_id。用户一次操作对应一个唯一请求号失败重试继续使用同一个请求号。第二网关层做限流和短期防刷。防止恶意高频提交但不在网关层承担最终业务判断。第三业务服务层做核心幂等逻辑。订单、支付、退款、库存、权益等服务各自定义业务幂等键。第四数据库唯一索引做最终兜底。所有核心幂等键都要有唯一约束不能只靠代码判断。第五状态机条件更新防止重复流转。例如订单只能从待支付变成已支付一次。第六消息消费者必须幂等。MQ 重复投递是常态消费端必须能安全重复执行。第七第三方回调必须幂等。支付成功、退款成功、发货通知等回调都要支持重复通知。简化成一句话前端防误触网关防高频服务做语义数据库做兜底消息和回调各自幂等。十八、幂等接口设计清单上线一个交易接口前可以用这份清单自查1. 是否有明确的幂等键 2. 重试时是否复用同一个幂等键 3. 幂等键是否有数据库唯一索引 4. 是否记录了请求处理状态 5. 是否能返回第一次成功处理的结果 6. 并发请求同时进入时是否安全 7. 失败状态是否可恢复 8. MQ 消费是否支持重复投递 9. 回调是否支持重复通知 10. 状态流转是否使用条件更新 11. 是否有日志、监控和告警 12. 是否有压测和并发测试用例测试用例也应该覆盖重复请求deftest_create_order_idempotency():request_idtest-request-001firstcreate_order_with_idempotency(request_idrequest_id,user_id1,sku_id100,quantity1)secondcreate_order_with_idempotency(request_idrequest_id,user_id1,sku_id100,quantity1)assertfirst[order_id]second[order_id]assertcount_orders_by_request_id(request_id)1还要覆盖并发场景fromconcurrent.futuresimportThreadPoolExecutordeftest_concurrent_create_order_idempotency():request_idconcurrent-request-001deftask():returncreate_order_with_idempotency(request_idrequest_id,user_id1,sku_id100,quantity1)withThreadPoolExecutor(max_workers10)asexecutor:resultslist(executor.map(lambda_:task(),range(10)))order_ids{item[order_id]foriteminresults}assertlen(order_ids)1没有经过并发测试的幂等设计通常只是“看起来幂等”。十九、总结幂等性守住的是系统的信任支付、下单、回调系统里的幂等性表面上是在防止重复请求实际上是在守住系统边界。它守住的是用户只下一单 库存只扣一次 钱只收一次 权益只发一次 状态只流转一次 消息只产生一次业务影响一个成熟的系统不是不会遇到重试、超时、重复投递、重复回调而是即使遇到了也能保持业务结果稳定。这就是幂等性的价值。面对“由于重试订单被创建两次”这个问题我的答案是幂等控制必须分层设计但核心应该放在业务服务层并由数据库唯一约束兜底同时在客户端、网关、消息消费端、第三方回调处理、状态机流转中建立完整防线。真正可靠的交易系统从来不是假设请求只来一次而是从第一天起就承认请求一定会重复。 网络一定会失败。 消息一定会重投。 回调一定会多次到达。然后依然保证业务结果只发生一次。这就是幂等性成为支付、下单、回调系统生命线的根本原因。
从“订单创建两次”到系统生命线:支付、下单、回调系统中的幂等性设计实战
从“订单创建两次”到系统生命线支付、下单、回调系统中的幂等性设计实战在支付、下单、退款、发券、回调这类系统里有一个看起来很朴素、但极其关键的问题同一个请求如果被执行多次系统结果是否仍然正确这就是幂等性。很多系统事故不是因为代码完全不可用而是因为代码“太可用了”用户点了一次支付按钮前端重试了网关超时重发了消息队列重复投递了第三方支付平台多次回调了服务之间 RPC 超时后调用方又发了一遍。于是原本只应该创建一笔订单结果创建了两笔原本只应该扣一次库存结果扣了两次原本只应该给用户发一张优惠券结果发了三张。在普通业务里这可能只是一个 bug但在支付、交易、履约系统里这就是资金风险、库存风险、对账风险和信任风险。所以幂等性不是锦上添花而是支付、下单、回调这类系统的生命线。一、什么是幂等性幂等性简单说就是同一个操作执行一次和执行多次产生的最终业务结果一致。例如创建订单请求 request_id abc123 user_id 1001 sku_id 2001 amount 99.00如果这个请求因为网络抖动被提交了三次理想结果应该是系统中只有一笔订单 库存只扣一次 支付单只创建一次 用户只看到一个待支付订单而不是订单表多了三条记录 库存扣了三次 支付单创建了三笔 用户投诉我只点了一次怎么有三个订单幂等性的核心不是“不允许请求重复到达”因为在分布式系统里这几乎不可能彻底避免。它真正要解决的是请求可以重复到达但业务结果不能重复发生。二、为什么支付、下单、回调系统尤其需要幂等因为这些系统天然处在“不确定性”之中。1. 用户可能重复点击前端按钮没有及时置灰用户连续点击“提交订单”或“立即支付”。点击一次 POST /orders 连续点击三次 POST /orders POST /orders POST /orders如果后端没有幂等控制就可能创建多笔订单。2. 网络超时导致调用方重试调用方发起请求后服务端其实已经处理成功但响应在网络中丢失了。客户端创建订单 服务端订单已创建成功 网络响应超时 客户端以为失败再发一次这时如果服务端没有识别“这是同一个请求”就会重复创建。3. 消息队列天然可能重复投递大多数 MQ 只能保证“至少投递一次”不能保证“只投递一次”。比如订单创建成功后发送消息OrderCreatedEvent(order_id123)消费者处理完业务但还没来得及 ack 就宕机了。MQ 会重新投递消息消费者又处理一遍。如果消费逻辑不是幂等的库存、积分、优惠券、通知、账务都会出问题。4. 第三方支付回调一定要支持重复通知支付平台在没有收到业务系统成功响应时通常会反复回调。支付成功回调第 1 次 支付成功回调第 2 次 支付成功回调第 3 次业务系统必须保证支付单只从 unpaid 变成 paid 一次 订单只从 pending 变成 paid 一次 发货流程只触发一次否则用户只付了一笔钱系统却可能发两次货。三、事故场景订单被创建了两次假设我们有一个最简单的下单接口defcreate_order(user_id:int,sku_id:int,quantity:int):priceget_sku_price(sku_id)order{user_id:user_id,sku_id:sku_id,quantity:quantity,amount:price*quantity,status:pending_payment}order_idinsert_order(order)returnorder_id这个接口看起来没问题但它有一个致命缺陷每调用一次就创建一笔新订单。如果用户重复点击或者客户端超时重试就会产生两条订单记录order_id | user_id | sku_id | amount | status ---------|---------|--------|--------|---------------- 10001 | 1 | 200 | 99.00 | pending_payment 10002 | 1 | 200 | 99.00 | pending_payment业务上用户明明只想买一次系统却创建了两笔订单。要解决这个问题我们需要引入一个关键字段幂等键。四、幂等键识别“同一次业务请求”的身份证幂等键通常叫request_id idempotency_key biz_id trace_id client_token out_trade_no它的作用是标识一次唯一的业务请求。例如{request_id:order_20260517_abc123,user_id:1001,sku_id:2001,quantity:1}后端处理前先检查这个 request_id 是否已经处理过如果没有处理过就创建订单并记录结果。如果处理过就直接返回之前的结果。五、下单接口的幂等设计一个更可靠的订单创建流程应该是客户端生成 request_id ↓ 调用创建订单接口 ↓ 服务端检查 request_id 是否存在 ↓ 不存在创建订单保存 request_id 与 order_id ↓ 存在直接返回已有 order_id示意图┌──────────┐ │ Client │ └────┬─────┘ │ request_idabc123 ▼ ┌──────────────┐ │ Order API │ └────┬─────────┘ │ 查询幂等记录 ▼ ┌──────────────┐ │ Idempotency │ │ Table │ └────┬─────────┘ │ 不存在 ▼ ┌──────────────┐ │ Create Order │ └────┬─────────┘ │ 保存结果 ▼ ┌──────────────┐ │ Return │ └──────────────┘数据库可以设计一张幂等表CREATETABLEidempotency_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,idempotency_keyVARCHAR(128)NOTNULL,biz_typeVARCHAR(64)NOTNULL,biz_idVARCHAR(128),statusVARCHAR(32)NOTNULL,response_bodyTEXT,created_atDATETIMENOTNULL,updated_atDATETIMENOTNULL,UNIQUEKEYuk_biz_key(biz_type,idempotency_key));订单表也应该有业务唯一约束CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,sku_idBIGINTNOTNULL,amountDECIMAL(10,2)NOTNULL,statusVARCHAR(32)NOTNULL,request_idVARCHAR(128)NOTNULL,created_atDATETIMENOTNULL,UNIQUEKEYuk_request_id(request_id));Python 伪代码如下fromdatetimeimportdatetimefromtypingimportDict,Anydefcreate_order_with_idempotency(request_id:str,user_id:int,sku_id:int,quantity:int)-Dict[str,Any]:# 1. 先查幂等记录recordfind_idempotency_record(CREATE_ORDER,request_id)ifrecordandrecord[status]SUCCESS:return{order_id:record[biz_id],from_cache:True}# 2. 如果没有记录尝试插入幂等记录# 这里必须依赖数据库唯一索引兜底防止并发穿透insertedtry_insert_idempotency_record(biz_typeCREATE_ORDER,idempotency_keyrequest_id,statusPROCESSING,created_atdatetime.now())ifnotinserted:# 说明另一个并发请求已经抢先处理existingwait_and_get_result(CREATE_ORDER,request_id)return{order_id:existing[biz_id],from_cache:True}try:priceget_sku_price(sku_id)amountprice*quantity order_idinsert_order({user_id:user_id,sku_id:sku_id,quantity:quantity,amount:amount,status:pending_payment,request_id:request_id,created_at:datetime.now()})update_idempotency_record(biz_typeCREATE_ORDER,idempotency_keyrequest_id,statusSUCCESS,biz_idstr(order_id),response_body{order_id:order_id})return{order_id:order_id,from_cache:False}exceptException:update_idempotency_record(biz_typeCREATE_ORDER,idempotency_keyrequest_id,statusFAILED)raise这个设计的关键点是不能只靠先查再写。因为两个请求可能同时查到不存在然后同时创建订单。真正可靠的方案一定要有幂等键 唯一索引 事务控制六、你会在哪一层做幂等控制答案是不应该只在一层做而应该分层做。幂等性不是某个接口上的一行代码而是一套系统性防线。推荐从以下几层设计。七、第一层客户端防重复提交客户端可以做基础防护按钮置灰 加载状态 防抖节流 本地生成 request_id 失败后使用同一个 request_id 重试例如前端点击下单后第一次请求 request_id abc123 超时重试 request_id 仍然是 abc123而不是每次重试都生成一个新的 request_id。如果每次重试都是新 key服务端就无法判断它们属于同一次业务请求。客户端防护很有用但不能作为最终保障。因为用户可以刷新页面、绕过前端、使用脚本重复请求。所以客户端防重是体验优化不是安全边界。八、第二层网关层做基础拦截API 网关可以根据以下维度做限流或短期去重user_id uri idempotency_key ip uri token uri适合拦截高频重复请求例如同一用户 1 秒内提交 10 次下单请求网关可以直接拒绝明显异常的重复提交减轻后端压力。但网关也不应该承担最终业务幂等因为它通常不知道完整业务语义。例如同一个用户可以购买两个不同商品 同一个用户也可以为不同订单分别支付仅靠 URL 或 IP 去重容易误伤正常业务。所以网关层适合限流、防刷、短时间重复拦截不适合作为最终业务幂等依据。九、第三层业务服务层做核心幂等最核心的幂等控制应该放在业务服务层。例如订单服务保证同一个 request_id 只创建一笔订单 支付服务保证同一个 out_trade_no 只创建一笔支付单 退款服务保证同一个 refund_no 只发起一次退款 库存服务保证同一个 order_id 只扣一次库存 优惠券服务保证同一个 order_id 只核销一次优惠券业务服务层最理解业务语义因此也最适合定义幂等规则。比如下单场景幂等键可以是request_id支付场景幂等键可以是merchant_order_no out_trade_no退款场景幂等键可以是refund_no回调场景幂等键可以是payment_channel transaction_id核心原则是幂等键必须能唯一标识一次业务意图。十、第四层数据库唯一约束兜底无论应用层逻辑多么严密数据库唯一约束都必不可少。因为并发场景下应用层判断很容易被击穿。错误写法orderfind_order_by_request_id(request_id)ifnotorder:create_order(request_id)在高并发下两个线程都可能查到不存在然后同时插入。正确做法是让数据库参与约束UNIQUEKEYuk_request_id(request_id)然后代码处理唯一键冲突defsafe_create_order(request_id:str,user_id:int,sku_id:int):try:returninsert_order_with_request_id(request_id,user_id,sku_id)exceptDuplicateKeyError:returnfind_order_by_request_id(request_id)数据库唯一索引是幂等设计的最后防线。一句话应用层负责业务判断数据库负责强一致兜底。十一、第五层消息消费端幂等很多系统会在订单创建后发送消息订单创建成功 → 扣库存 订单支付成功 → 发货 支付成功 → 发积分 退款成功 → 退库存但 MQ 消息可能重复投递。所以消费者必须做幂等。例如库存扣减消息{event_id:evt_123,order_id:order_10001,sku_id:2001,quantity:1}消费者处理前先判断event_id 是否消费过 order_id 是否已经扣过库存可以设计消费记录表CREATETABLEmessage_consume_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,event_idVARCHAR(128)NOTNULL,consumer_nameVARCHAR(128)NOTNULL,statusVARCHAR(32)NOTNULL,created_atDATETIMENOTNULL,UNIQUEKEYuk_event_consumer(event_id,consumer_name));Python 伪代码defconsume_order_paid_event(event):insertedtry_insert_consume_record(event_idevent[event_id],consumer_namestock_service,statusPROCESSING)ifnotinserted:returnduplicate message ignoredtry:deduct_stock_once(order_idevent[order_id],sku_idevent[sku_id],quantityevent[quantity])mark_consume_success(event[event_id],stock_service)returnsuccessexceptException:mark_consume_failed(event[event_id],stock_service)raise注意真正扣库存时也要保证业务幂等CREATETABLEstock_deduct_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_idVARCHAR(128)NOTNULL,sku_idBIGINTNOTNULL,quantityINTNOTNULL,created_atDATETIMENOTNULL,UNIQUEKEYuk_order_sku(order_id,sku_id));因为消息消费记录只能说明“这条消息处理过”但业务上更应该保证“这个订单只扣一次库存”。十二、第六层第三方回调幂等支付回调是幂等设计的高危区域。典型流程用户支付成功 ↓ 支付平台通知业务系统 ↓ 业务系统更新支付单状态 ↓ 业务系统更新订单状态 ↓ 触发发货、积分、消息通知支付平台可能多次通知notify #1 notify #2 notify #3业务系统必须保证只有第一次有效处理。支付回调的关键判断通常是支付单是否已经成功 订单是否已经支付 交易号是否已经存在 金额是否一致 商户订单号是否一致 签名是否正确伪代码defhandle_payment_callback(payload):verify_signature(payload)out_trade_nopayload[out_trade_no]transaction_idpayload[transaction_id]paid_amountpayload[amount]paymentfind_payment_by_out_trade_no(out_trade_no)ifnotpayment:raiseValueError(payment not found)ifpayment[amount]!paid_amount:raiseValueError(amount mismatch)ifpayment[status]SUCCESS:returnsuccessupdatedupdate_payment_success_once(out_trade_noout_trade_no,transaction_idtransaction_id)ifnotupdated:returnsuccessmark_order_paid_once(payment[order_id])publish_order_paid_event(payment[order_id])returnsuccess其中update_payment_success_once应该使用状态条件更新UPDATEpayment_orderSETstatusSUCCESS,transaction_id:transaction_id,paid_atNOW()WHEREout_trade_no:out_trade_noANDstatusPENDING;这条 SQL 很关键。它表达的是只有处于 PENDING 的支付单才能被更新为 SUCCESS。 已经是 SUCCESS 的支付单不再重复处理。这就是状态机幂等。十三、幂等设计中的状态机思维很多幂等问题本质上是状态流转问题。订单状态可能是CREATED → PENDING_PAYMENT → PAID → SHIPPED → FINISHED退款状态可能是REFUND_CREATED → REFUND_PROCESSING → REFUND_SUCCESS支付状态可能是PENDING → SUCCESS PENDING → CLOSED PENDING → FAILED状态机设计要遵守一个原则状态只能按合法方向流转不能重复流转不能逆向流转。例如UPDATEordersSETstatusPAID,paid_atNOW()WHEREorder_id:order_idANDstatusPENDING_PAYMENT;如果第一次回调成功订单从PENDING_PAYMENT变成PAID。第二次回调再来时SQL 影响行数为 0因为状态已经不是PENDING_PAYMENT。这时直接返回成功即可。不要把重复回调当成异常因为对方重复通知是正常机制。你的系统应该优雅地告诉它我已经处理过了结果是成功。十四、幂等与分布式锁的关系很多人一提幂等就想到分布式锁。比如lock:order:create:{request_id}分布式锁有价值但它不是幂等的全部。它适合解决短时间并发进入的问题例如两个相同请求同时到达。但它有几个限制锁可能过期 锁可能释放失败 锁只能保护临界区 锁不能保存业务结果 锁不能替代数据库唯一约束更稳妥的组合是分布式锁减少并发冲突 幂等表记录处理状态和结果 唯一索引数据库强约束兜底 状态机保证业务流转正确不要把所有希望都押在锁上。真正可靠的幂等设计一定是多层防护。十五、一次完整的下单支付幂等方案假设用户购买商品完整链路如下创建订单 ↓ 创建支付单 ↓ 调用第三方支付 ↓ 接收支付回调 ↓ 更新订单为已支付 ↓ 扣库存 ↓ 发货或发放权益每一步都要有自己的幂等键。环节幂等键保护目标创建订单request_id防止重复创建订单创建支付单out_trade_no防止重复生成支付单支付回调transaction_id / out_trade_no防止重复处理支付成功扣库存order_id sku_id防止重复扣库存发权益order_id benefit_type防止重复发放消息消费event_id consumer_name防止重复消费消息退款refund_no防止重复退款核心链路可以这样设计客户端生成 request_id ↓ 订单服务用 request_id 做幂等 ↓ 支付服务用 out_trade_no 做幂等 ↓ 支付平台回调用 transaction_id 做幂等 ↓ 订单状态用条件更新防重复流转 ↓ MQ 消费者用 event_id 业务唯一键做幂等这才是一套完整的交易系统幂等设计。十六、常见错误设计错误一只靠前端按钮置灰前端可以提升体验但不能保证安全。用户刷新页面、接口重放、网络重试、移动端多端登录都可能绕过前端限制。错误二只用 Redis不落数据库Redis 可以做短期去重但如果 key 过期、缓存丢失、主从切换就可能失效。关键交易结果必须落数据库。错误三幂等 key 设计过粗比如只用user_id这会导致用户无法连续下两个订单。更合理的是user_id request_id或者直接使用全局唯一的业务请求号。错误四幂等 key 设计过细如果每次重试都生成新的 request_id幂等就失效了。重试必须使用同一个幂等键。错误五重复请求直接报错对于支付回调、消息消费这类场景重复请求通常不应该报错而应该返回之前的成功结果。因为调用方重试的目的就是确认你是否处理成功。十七、实践建议我会在哪些层做幂等如果是支付、下单、回调系统我会这样做第一客户端生成并复用 request_id。用户一次操作对应一个唯一请求号失败重试继续使用同一个请求号。第二网关层做限流和短期防刷。防止恶意高频提交但不在网关层承担最终业务判断。第三业务服务层做核心幂等逻辑。订单、支付、退款、库存、权益等服务各自定义业务幂等键。第四数据库唯一索引做最终兜底。所有核心幂等键都要有唯一约束不能只靠代码判断。第五状态机条件更新防止重复流转。例如订单只能从待支付变成已支付一次。第六消息消费者必须幂等。MQ 重复投递是常态消费端必须能安全重复执行。第七第三方回调必须幂等。支付成功、退款成功、发货通知等回调都要支持重复通知。简化成一句话前端防误触网关防高频服务做语义数据库做兜底消息和回调各自幂等。十八、幂等接口设计清单上线一个交易接口前可以用这份清单自查1. 是否有明确的幂等键 2. 重试时是否复用同一个幂等键 3. 幂等键是否有数据库唯一索引 4. 是否记录了请求处理状态 5. 是否能返回第一次成功处理的结果 6. 并发请求同时进入时是否安全 7. 失败状态是否可恢复 8. MQ 消费是否支持重复投递 9. 回调是否支持重复通知 10. 状态流转是否使用条件更新 11. 是否有日志、监控和告警 12. 是否有压测和并发测试用例测试用例也应该覆盖重复请求deftest_create_order_idempotency():request_idtest-request-001firstcreate_order_with_idempotency(request_idrequest_id,user_id1,sku_id100,quantity1)secondcreate_order_with_idempotency(request_idrequest_id,user_id1,sku_id100,quantity1)assertfirst[order_id]second[order_id]assertcount_orders_by_request_id(request_id)1还要覆盖并发场景fromconcurrent.futuresimportThreadPoolExecutordeftest_concurrent_create_order_idempotency():request_idconcurrent-request-001deftask():returncreate_order_with_idempotency(request_idrequest_id,user_id1,sku_id100,quantity1)withThreadPoolExecutor(max_workers10)asexecutor:resultslist(executor.map(lambda_:task(),range(10)))order_ids{item[order_id]foriteminresults}assertlen(order_ids)1没有经过并发测试的幂等设计通常只是“看起来幂等”。十九、总结幂等性守住的是系统的信任支付、下单、回调系统里的幂等性表面上是在防止重复请求实际上是在守住系统边界。它守住的是用户只下一单 库存只扣一次 钱只收一次 权益只发一次 状态只流转一次 消息只产生一次业务影响一个成熟的系统不是不会遇到重试、超时、重复投递、重复回调而是即使遇到了也能保持业务结果稳定。这就是幂等性的价值。面对“由于重试订单被创建两次”这个问题我的答案是幂等控制必须分层设计但核心应该放在业务服务层并由数据库唯一约束兜底同时在客户端、网关、消息消费端、第三方回调处理、状态机流转中建立完整防线。真正可靠的交易系统从来不是假设请求只来一次而是从第一天起就承认请求一定会重复。 网络一定会失败。 消息一定会重投。 回调一定会多次到达。然后依然保证业务结果只发生一次。这就是幂等性成为支付、下单、回调系统生命线的根本原因。