线上事故复盘: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自动代采回调时,就是靠这套方案保证订单状态机不会进入非法状态。

相关推荐
Minxinbb1 小时前
TDSQL for MySQL 服务器重启操作
数据库·mysql·dba
SelectDB1 小时前
Doris 实战:用 Colocate Join 消除 Shuffle,AB 指标计算性能提升 145 倍 🚀
数据库·性能优化·ab测试
PawSQL1 小时前
PawSQL 技术月报 | 2026年6月
数据库·sql·tdsql·pawsql·sql审核
SelectDB1 小时前
Apache Doris 在 AgentLogsBench 中领先,支撑 Agent 可观测性生产负载
运维·数据库·agent
聚美智数2 小时前
黄历查询-运势查询-国际法定节假日查询-API接口介绍
android·java·数据库
酷炫的水壶2 小时前
使用memc-nginx和srcache-nginx模块构建高效透明的缓存机制
运维·nginx·缓存
夜影风4 小时前
Redis集群创建时出现“Node xxx.xxx is not empty”问题处理
数据库·redis·缓存
utf8mb4安全女神4 小时前
【Redis数据库】哨兵集群/redis集群/安装配置/主从复制/数据持久化操作/数据结构/安全限制/PHP redis/
linux·服务器·数据结构·数据库·redis·缓存
lzz的编码时刻4 小时前
Liquibase 入门实战教程
数据库·运维开发