线上事故复盘:Redis幂等性设计边界没覆盖跨状态请求,订单状态机直接崩了

发布时间:2026/7/7 3:13:55
线上事故复盘:Redis幂等性设计边界没覆盖跨状态请求,订单状态机直接崩了 线上事故复盘Redis幂等性设计边界没覆盖跨状态请求订单状态机直接崩了适合谁看正在用Redis做幂等性设计的后端开发者或者订单状态机频繁出问题的技术负责人。如果只关心业务逻辑可以跳过代码部分直接看思路。事故现场凌晨三点订单状态机卡死在“采购中”有次客户反馈某个用户的订单从“已支付”直接跳到了“已发货”跳过了“采购中”和“集运中”两个状态。当时第一反应是代码逻辑写错了但查日志发现——同一个幂等性key被两个不同状态的请求命中了。具体场景是这样的用户提交代购订单后系统会触发1688自动代采流程。代采引擎通过Node.js Puppeteer模拟下单然后回调更新订单状态。问题出在回调接口——1688那边因为网络抖动同一个采购单回调了两次第一次是“采购成功”第二次是“采购失败”。按幂等性设计第二次请求应该被拦截但实际结果是第一次把状态更新为“采购中”第二次直接把状态覆盖成了“采购失败”然后系统自动触发了退款流程。更离谱的是退款流程又触发了另一个回调把订单状态改成了“已发货”。一个订单三次回调走了四个状态最后卡死在非法状态。根因分析幂等性设计边界只覆盖了同一状态查完代码发现幂等性检查的逻辑是这样的// 简化后的幂等性检查publicfunctioncheckIdempotency($orderId,$requestId){$keyidempotent:{$orderId}:{$requestId};$existsRedis::get($key);if($exists){returntrue;// 重复请求直接返回}Redis::setex($key,3600,1);returnfalse;}这个逻辑看起来没问题但实际场景是同一个requestId对应了不同的状态变更请求。1688回调的requestId是固定的采购单号但回调内容可能不同成功/失败。幂等性key只校验了orderId requestId没校验状态变更的方向。说白了幂等性设计边界只覆盖了同一状态下的重复请求没防跨状态的重复请求。当第一个请求把状态从“已支付”改成“采购中”后第二个请求又把它从“采购中”改成了“采购失败”——两个请求的requestId一样但状态变更方向不同幂等性检查直接失效。更隐蔽的问题是状态机回滚场景下幂等性设计边界未覆盖。如果第一次回调是“采购失败”系统自动回滚到“已支付”然后第二次回调是“采购成功”按幂等性设计应该拦截但实际业务上应该允许——因为状态已经回滚了这是一个新的状态机起点。修复方案状态机 幂等性双重校验修复思路很简单幂等性key不仅要包含orderId和requestId还要包含当前状态和期望的下一个状态。这样同一个requestId在不同状态下不会被误判为重复请求。// 修复后的幂等性检查publicfunctioncheckIdempotency($orderId,$requestId,$currentStatus,$expectedNextStatus){// 幂等性key包含当前状态和期望的下一个状态$keyidempotent:{$orderId}:{$requestId}:{$currentStatus}:{$expectedNextStatus};$existsRedis::get($key);if($exists){returntrue;}// 状态机校验检查当前状态是否允许转换到期望状态$allowedTransitions$this-getAllowedTransitions($currentStatus);if(!in_array($expectedNextStatus,$allowedTransitions)){thrownew\Exception(非法状态转换:{$currentStatus}-{$expectedNextStatus});}Redis::setex($key,3600,1);returnfalse;}// 状态机转换规则privatefunctiongetAllowedTransitions($currentStatus){$transitions[pending[paid],paid[purchasing,refunding],purchasing[purchased,purchase_failed],purchased[collecting],collecting[collected],collected[shipping],shipping[shipped],shipped[delivered],purchase_failed[paid,refunding],// 允许回滚到已支付refunding[refunded],];return$transitions[$currentStatus]??[];}关键改动有两个幂等性key增加了状态维度$currentStatus和$expectedNextStatus都参与key生成。这样同一个requestId在不同状态下不会被误拦截。比如第一次回调是paid - purchasing第二次回调是purchasing - purchase_failed两个key不同幂等性检查正常通过。状态机校验前置在幂等性检查之前先做状态转换合法性校验。如果当前状态不允许转换到期望状态直接抛异常不走幂等性缓存。这解决了状态机回滚场景下幂等性失效的问题——比如订单回滚到paid后paid - purchasing是合法转换不会被拦截。另外还加了一个补偿机制幂等性key的TTL设置为1小时但状态机转换成功后立即删除对应的key。这样如果状态机因为异常回滚了新的请求不会因为旧的幂等性key被拦截。// 状态转换成功后清理幂等性keypublicfunctiononStatusTransitionSuccess($orderId,$requestId,$oldStatus,$newStatus){$keyidempotent:{$orderId}:{$requestId}:{$oldStatus}:{$newStatus};Redis::del($key);}效果状态机非法转换从每周3-5次降到0修复上线后订单状态机非法转换的次数直接从每周3-5次降到了0。幂等性设计边界覆盖了跨状态请求后状态机回滚场景下的幂等性失效问题也一并解决了。单机Redis每秒处理约5万次幂等性检查加了个状态维度后性能几乎没变化——因为Redis的key查找复杂度是O(1)多几个字段拼接不影响性能。说白了吧幂等性设计不是简单的“同一个请求不处理两次”而是要搞清楚“什么算同一个请求”。在订单状态机这种场景下同一个requestId在不同状态下应该算不同的请求。Taocarts的采购引擎在处理1688自动代采回调时就是靠这套方案保证订单状态机不会进入非法状态。