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

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

适合谁看:正在用Redis做幂等性设计的后端开发者,或者订单状态机频繁出问题的技术负责人。如果只关心业务逻辑可以跳过代码部分直接看思路。

事故现场:凌晨三点,订单状态机卡死在"采购中"

有次客户反馈,某个用户的订单从"已支付"直接跳到了"已发货",跳过了"采购中"和"集运中"两个状态。当时第一反应是代码逻辑写错了,但查日志发现------同一个幂等性key被两个不同状态的请求命中了

具体场景是这样的:用户提交代购订单后,系统会触发1688自动代采流程。代采引擎通过Node.js + Puppeteer模拟下单,然后回调更新订单状态。问题出在回调接口------1688那边因为网络抖动,同一个采购单回调了两次,第一次是"采购成功",第二次是"采购失败"。按幂等性设计,第二次请求应该被拦截,但实际结果是:第一次把状态更新为"采购中",第二次直接把状态覆盖成了"采购失败",然后系统自动触发了退款流程。

更离谱的是,退款流程又触发了另一个回调,把订单状态改成了"已发货"。一个订单,三次回调,走了四个状态,最后卡死在非法状态

根因分析:幂等性设计边界只覆盖了同一状态

查完代码发现,幂等性检查的逻辑是这样的:

php 复制代码
// 简化后的幂等性检查
public function checkIdempotency($orderId, $requestId)
{
    $key = "idempotent:{$orderId}:{$requestId}";
    $exists = Redis::get($key);
    if ($exists) {
        return true; // 重复请求,直接返回
    }
    Redis::setex($key, 3600, 1);
    return false;
}

这个逻辑看起来没问题,但实际场景是:同一个requestId对应了不同的状态变更请求。1688回调的requestId是固定的(采购单号),但回调内容可能不同(成功/失败)。幂等性key只校验了orderId + requestId,没校验状态变更的方向。

说白了,幂等性设计边界只覆盖了同一状态下的重复请求,没防跨状态的重复请求。当第一个请求把状态从"已支付"改成"采购中"后,第二个请求又把它从"采购中"改成了"采购失败"------两个请求的requestId一样,但状态变更方向不同,幂等性检查直接失效。

更隐蔽的问题是:状态机回滚场景下幂等性设计边界未覆盖。如果第一次回调是"采购失败",系统自动回滚到"已支付",然后第二次回调是"采购成功",按幂等性设计应该拦截,但实际业务上应该允许------因为状态已经回滚了,这是一个新的状态机起点。

修复方案:状态机 + 幂等性双重校验

修复思路很简单:幂等性key不仅要包含orderId和requestId,还要包含当前状态和期望的下一个状态。这样同一个requestId在不同状态下不会被误判为重复请求。

php 复制代码
// 修复后的幂等性检查
public function checkIdempotency($orderId, $requestId, $currentStatus, $expectedNextStatus)
{
    // 幂等性key包含当前状态和期望的下一个状态
    $key = "idempotent:{$orderId}:{$requestId}:{$currentStatus}:{$expectedNextStatus}";
    $exists = Redis::get($key);
    if ($exists) {
        return true;
    }

    // 状态机校验:检查当前状态是否允许转换到期望状态
    $allowedTransitions = $this->getAllowedTransitions($currentStatus);
    if (!in_array($expectedNextStatus, $allowedTransitions)) {
        throw new \Exception("非法状态转换: {$currentStatus} -> {$expectedNextStatus}");
    }

    Redis::setex($key, 3600, 1);
    return false;
}

// 状态机转换规则
private function getAllowedTransitions($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] ?? [];
}

关键改动有两个:

  1. 幂等性key增加了状态维度$currentStatus$expectedNextStatus 都参与key生成。这样同一个requestId在不同状态下不会被误拦截。比如第一次回调是 paid -> purchasing,第二次回调是 purchasing -> purchase_failed,两个key不同,幂等性检查正常通过。

  2. 状态机校验前置 :在幂等性检查之前先做状态转换合法性校验。如果当前状态不允许转换到期望状态,直接抛异常,不走幂等性缓存。这解决了状态机回滚场景下幂等性失效 的问题------比如订单回滚到 paid 后,paid -> purchasing 是合法转换,不会被拦截。

另外还加了一个补偿机制:幂等性key的TTL设置为1小时,但状态机转换成功后立即删除对应的key。这样如果状态机因为异常回滚了,新的请求不会因为旧的幂等性key被拦截。

php 复制代码
// 状态转换成功后清理幂等性key
public function onStatusTransitionSuccess($orderId, $requestId, $oldStatus, $newStatus)
{
    $key = "idempotent:{$orderId}:{$requestId}:{$oldStatus}:{$newStatus}";
    Redis::del($key);
}

效果:状态机非法转换从每周3-5次降到0

修复上线后,订单状态机非法转换的次数直接从每周3-5次降到了0。幂等性设计边界覆盖了跨状态请求后,状态机回滚场景下的幂等性失效问题也一并解决了

单机Redis每秒处理约5万次幂等性检查,加了个状态维度后性能几乎没变化------因为Redis的key查找复杂度是O(1),多几个字段拼接不影响性能。

说白了吧,幂等性设计不是简单的"同一个请求不处理两次",而是要搞清楚"什么算同一个请求"。在订单状态机这种场景下,同一个requestId在不同状态下应该算不同的请求。Taocarts的采购引擎在处理1688自动代采回调时,就是靠这套方案保证订单状态机不会进入非法状态。

相关推荐
考虑考虑2 小时前
数据库中的EXISTS
运维·数据库·后端
Wang's Blog3 小时前
Java框架快速入门: Spring Security+OAuth2之数据库和实体类的RBAC改造
java·数据库·spring
梦想平凡4 小时前
百游棋牌源代码开发搭建教程(十):隔离部署、备份恢复与双端验收
java·前端·javascript·数据库·源代码管理
xcLeigh4 小时前
让大模型长出手脚,自己写SQL查数据库(Function Calling初探)
数据库·人工智能·sql·时序数据库·timechoai
yume_sibai5 小时前
06-Rust Web 开发实战(Axum 框架 + 数据库 + JWT 认证 + 中间件 + 部署)
前端·数据库·rust
AugustRed6 小时前
Neo4j 图数据库原理 + 应用场景简单介绍
数据库·neo4j
Gl�ria6 小时前
Redis 高可用架构对比
数据库·redis
IpdataCloud7 小时前
高可用的IP数据接口平台有哪些?从可用性、P99延迟到离线部署的评估框架(含代码)
数据库·tcp/ip·ip
呆萌很7 小时前
MySQL 数据库和表的管理操作(命令行)
数据库·mysql
IvorySQL8 小时前
当PostgreSQL“听懂”MySQL——协议兼容层的设计与实战
数据库·人工智能·postgresql