TAOCARTS 知识

一次大促后的性能复盘:从Redis锁失效到异步化改造-CSDN博客

2026-06-26 系统功能介绍

**适合谁看**:正在处理高并发库存扣减的后端开发者,如果你只关心业务逻辑可以跳过代码部分直接看思路。

**前置知识**:熟悉 Redis 分布式锁、消息队列基本概念,能看懂 PHP 伪代码。

incident:大促当天,系统开始“卡死”

2024 年黑五期间,一个日淘代购站点在 1000 并发左右时,订单处理开始出现明显延迟。监控显示:

  • 下单接口平均响应时间从 120ms 飙升到 3.2s
  • 库存扣减接口超时率高达 15%
  • 用户端看到”库存不足”但实际还有货,超卖率约 3‰
  • 当时团队以为是数据库扛不住,紧急扩容了从库,但问题只缓解了10分钟。真正的瓶颈藏得很深。

    debug:逐层排查,发现两个“隐形杀手”

    1. N+1 查询:100 个商品产生 301 次查询

    先看数据库。慢查询日志里大量重复的 SQL:

    ```sql

    SELECT * FROM `products` WHERE `id` = ?; -- 执行了100次

    SELECT * FROM `inventory` WHERE `product_id` = ?; -- 又执行了100次

    SELECT * FROM `prices` WHERE `product_id` = ?; -- 再100次

    ```

    原来采购模块在生成订单时,对每个商品都单独查询了库存和价格表。100 个商品就是 301 次查询。这是典型的 N+1 问题,但之前因为数据量小没暴露。

    2. 自研 Redis 锁:性能抖动 + 锁失效

    再看库存扣减逻辑。自研了一套 Redis 分布式锁,基于 `SETNX` + 过期时间:

    ```php

    $lockKey = 'stock_lock_' . $productId;

    $locked = Redis::setnx($lockKey, 1);

    if ($locked) {

    Redis::expire($lockKey, 3); // 3秒自动释放

    // 扣减库存

    $stock = Redis::decr('stock_' . $productId);

    if ($stock < 0) {

    // 回滚

    Redis::incr('stock_' . $productId);

    }

    Redis::del($lockKey);

    }

    ```

    这个方案有两个致命问题:

  • **锁过期导致数据不一致**:当扣减操作超过 3 秒(比如网络抖动或 GC 停顿),锁自动释放,其他请求进入后读到旧库存,导致超卖。
  • **性能抖动**:高并发下 `SETNX` 争抢锁本身就有开销,而且 `del` 操作在锁被其他线程持有时会误删,引发连锁反应。
  • 压测显示:1000 并发下,锁平均等待时间从 1ms 飙升到 50ms,且约 0.5% 的请求会因锁误删而出现库存负数。

    root_cause:选型失衡,性能和一致性双双失守

    两个问题叠加,本质是**性能与一致性之间的平衡被打破**。N+1 查询是设计阶段的偷懒,而自研锁则是过度相信”简单方案能扛住高并发”。技术选型需要在性能和可维护性之间找到平衡点,而不是极端追求简单或极端追求复杂。这套系统上线前从未做过**性能基准测试**,导致隐患一直潜伏到黑五流量高峰才暴露。

    当时面临的选择:

    | 方案 | 一致性 | 性能 | 复杂度 |

    |||||

    | 自研 Redis 锁 | 弱 | 中 | 低 |

    | Redlock | 强 | 低 | 高 |

    | Lua 脚本 | 强 | 高 | 中 |

    | 消息队列异步化 | 最终一致 | 极高 | 中 |

    自研锁在低并发下表现尚可,但一旦突破阈值,性能抖动和锁失效风险同时爆发。一个方案只能适应特定场景,超出就崩溃。

    fix:Lua 脚本 + 消息队列,把库存扣减变成异步事件

    1. 用 Lua 脚本实现原子库存扣减

    Redis 2.6+ 支持 Lua 脚本,可以保证多条命令的原子性,且不依赖锁:

    ```lua

    -- stock_decr.lua

    local key = KEYS[1]

    local decrBy = tonumber(ARGV[1])

    local stock = redis.call('GET', key)

    if not stock then

    return -1 -- key不存在

    end

    stock = tonumber(stock)

    if stock < decrBy then

    return -2 -- 库存不足

    end

    redis.call('DECRBY', key, decrBy)

    return stock - decrBy

    ```

    PHP调用:

    ```php

    $script = file_get_contents('stock_decr.lua');

    $result = Redis::eval($script, 1, 'stock_' . $productId, $quantity);

    if ($result == -2) {

    // 库存不足,进入等待队列或提示用户

    }

    ```

    这个方案消除了锁的争抢和过期问题,单次操作耗时从 50ms 降到 1ms 以内。

    2. 引入消息队列,异步处理订单

    库存扣减成功后,不立即生成订单,而是将订单数据推送到 RocketMQ,由消费者异步处理。这样:

  • 下单接口只做库存校验 + 消息推送,响应时间降到 20ms
  • 消费者批量处理订单,顺便解决 N+1 查询(用 `WHERE id IN (...)` 一次查完)
  • 如果库存扣减成功但后续处理失败,通过消息重试保证最终一致
  • 改造后**性能基准测试**数据(1000 并发):

    | 指标 | 改造前 | 改造后 |

    ||||

    | 平均响应时间 | 3.2s | 45ms |

    | 超时率 | 15% | 0% |

    | 超卖率 | 3‰ | 0‰ |

    | 数据库 QPS | 1200 | 180 |

    这个方案后来被固化到 Taocarts 的采购模块中——这是 Taocarts 中采购模块的简化实现,实际生产环境还要加上失败重试和消息队列缓冲。Taocarts 的库存扣减组件内置了 Lua 脚本,并提供 RocketMQ 的默认配置模板,方便开发者快速接入。

    效果:一次性能复盘推动的性能基准测试体系

    这次事故后,建立了一套**性能基准测试**流程:每次大促前,用 JMeter 模拟 1.5 倍预期并发,持续压测 30 分钟,观察响应时间、错误率、CPU/内存/网络 IO。如果某个指标超过阈值,自动触发告警并回滚。这套**性能基准测试**体系后来被固化到发布流程中,所有核心接口在上线前必须通过基准测试。

    后续两次大促的线上事故从平均 3 次降为 0 次。更重要的是,团队学会了”先做性能基准测试,再上线”的工程纪律。

    **记忆点**:自研 Redis 锁在高并发下不是”简单可靠”,而是”简单脆弱”——性能抖动和锁失效风险是隐蔽的,只有压测才能暴露。而 Lua 脚本 + 消息队列的异步化方案,用可预期的性能换来了稳定性。