云计算百科
云计算领域专业知识百科平台

【基于 Swoole+Hyperf 的微服务实战】第十周·周一:秒杀功能--高并发场景专项深度优化

【基于 Swoole+Hyperf 的微服务实战】第十周·周一:秒杀功能–高并发场景专项深度优化


今天我们进入第十周周一,开始对电商核心系统进行高并发场景专项深度优化,重点攻克秒杀功能。秒杀是电商中最具挑战性的场景,要求系统在极短时间内处理海量并发,同时保证库存不超卖、用户体验流畅。今天我们将为商品打上“秒杀”标签,设计独立的高性能秒杀接口,并实现缓存预热与异步库存扣减,将秒杀与 Saga 分布式事务完美结合。


今日目标

  • 为商品服务增加秒杀标签与场次管理,标识哪些商品参与秒杀及活动时间。
  • 实现秒杀专用接口:独立于普通下单,直接操作 Redis 缓存库存,使用 Lua 脚本保证原子性。
  • 实现缓存预热:在秒杀开始前,将商品库存预热到 Redis,活动结束后同步回数据库。
  • 秒杀成功后,异步创建订单并通过 Saga 完成剩余流程,实现高性能扣减与最终一致性。
  • 使用 JMeter 或 wrk 进行秒杀压测,验证 5000+ 并发下的库存准确性。

  • 在这里插入图片描述

    一、秒杀功能设计(约 45 分钟)

    1. 秒杀与普通下单的区别
    维度普通下单秒杀
    流量 平缓 瞬时高峰,QPS 可达数万
    库存扣减 数据库行锁/Redis 锁 Redis Lua 原子操作
    响应时间 允许数百毫秒 要求 < 50ms 快速失败
    业务逻辑 同步校验 + Saga 异步处理,秒杀成功后发消息
    数据库压力 实时读写 通过缓存挡读,异步落库
    2. 秒杀流程设计

    [用户] -> [网关] (限流 500/s) -> [秒杀接口] -> Redis Lua 扣减库存
    -> 成功:发送消息到 RabbitMQ
    -> 失败:直接返回已抢光
    [Saga 协调器] 消费秒杀成功消息 -> order.create -> inventory.freeze -> payment.debit

    关键设计:

    • 秒杀接口不直接调用任何 RPC,只操作 Redis,最大化吞吐。
    • 秒杀成功后,将订单请求序列化为消息,由 Saga 异步处理,避免阻塞。
    • 库存预热:活动开始前将 MySQL 商品库存加载到 Redis seckill:stock:{productId},活动结束后将 Redis 剩余库存回写 MySQL(或通过消费进度计算)。
    3. 商品秒杀模型扩展

    在 product_db 的 products 表中增加秒杀相关字段,或新建一个 seckill_products 表。我们采用新建表的方式,与普通商品解耦:

    CREATE TABLE seckill_products (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    product_id INT NOT NULL,
    seckill_price DECIMAL(10,2) NOT NULL,
    stock INT NOT NULL,
    start_time DATETIME NOT NULL,
    end_time DATETIME NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );

    预先插入一条测试数据(商品 ID=1,秒杀价 1 元,库存 100,活动时间覆盖当前)。


    二、知识核心:Redis Lua 原子扣减与缓存预热(约 1 小时)

    1. 为什么使用 Lua 脚本?

    Redis 的 DECR 本身是原子的,但无法在扣减前判断库存是否大于 0。如果用 GET + DECR,两步操作非原子,高并发下会超卖。Lua 脚本在 Redis 中原子执行,可以同时完成判断和扣减,完美解决超卖问题。

    脚本示例:

    local key = KEYS[1]
    local stock = tonumber(redis.call('get', key) or '0')
    if stock > 0 then
    redis.call('decr', key)
    return 1 — 抢购成功
    else
    return 0 — 库存不足
    end

    2. 缓存预热策略
    • 预热:在秒杀开始前(或启动时),读取 seckill_products 中即将开始的活动,将库存写入 Redis seckill:stock:{productId}。
    • 缓存与 DB 同步:秒杀结束后,通过定时任务或监听活动结束事件,将 Redis 中的剩余库存更新回 seckill_products.stock,以保持一致。
    3. 异步处理与 Saga 结合

    秒杀接口扣减 Redis 成功后,向 RabbitMQ 发送 seckill.success 消息,包含 order_id、user_id、product_id、amount。由订单服务的消费者监听此消息,触发 Saga 流程(创建订单、冻结库存、支付)。由于库存已在 Redis 预扣,Saga 中的库存冻结步骤可跳过或仅作为校验(实际上是最终一致性中的异步落库)。我们让 Saga 仍然执行 inventory.freeze,冻结的是数据库库存,与 Redis 扣减对应。若 Saga 失败补偿,需要恢复 Redis 库存。


    三、实战:构建秒杀接口与预热(约 2.5 小时)

    步骤 1:编写秒杀预热命令

    在 product-service 或 inventory-service 中创建一个控制台命令,用于预热库存。但更简单的是在订单服务(或新建的秒杀服务)中,启动时通过 BootApplication 监听器执行预热。

    创建 app/Listener/SeckillWarmupListener.php 在订单服务中:

    <?php
    namespace App\\Listener;

    use Hyperf\\Event\\Contract\\ListenerInterface;
    use Hyperf\\Framework\\Event\\BootApplication;
    use Hyperf\\Redis\\Redis;
    use Hyperf\\Di\\Annotation\\Inject;
    use Hyperf\\DbConnection\\Db;

    class SeckillWarmupListener implements ListenerInterface
    {
    #[Inject] private Redis $redis;

    public function listen(): array
    {
    return [BootApplication::class];
    }

    public function process(object $event)
    {
    // 从 product 数据库读取秒杀商品(跨库查询,实际应通过 RPC,这里为演示直接查表)
    $products = Db::table('seckill_products')
    ->where('start_time', '<=', date('Y-m-d H:i:s'))
    ->where('end_time', '>=', date('Y-m-d H:i:s'))
    ->get();
    foreach ($products as $p) {
    $key = 'seckill:stock:' . $p->product_id;
    if (!$this->redis->exists($key)) {
    $this->redis->set($key, $p->stock);
    echo "[预热] 商品 {$p->product_id} 库存预热: {$p->stock}\\n";
    }
    }
    }
    }

    注意:需要将 product_db 的连接配置添加到订单服务的 databases.php 中,并命名为 product_db,以便跨库查询。更合理的做法是通过 RPC 调用商品服务获取,这里为简便,直接连接 product_db。

    步骤 2:创建秒杀控制器

    在订单服务中新建 HTTP 控制器 app/Controller/SeckillController.php,我们让订单服务同时暴露 HTTP 接口(监听另一个端口或复用 JSON-RPC 端口)。为了不混淆,可以在订单服务中增加一个 HTTP 服务器,监听 9507 端口,专门提供秒杀 RESTful API。或者直接在网关中处理秒杀逻辑,但会耦合。我们将秒杀控制器放在订单服务,并通过网关路由 /seckill/buy 到该服务的 HTTP 端口。

    在 order-service 的 server.php 中添加 HTTP 服务器:

    [
    'name' => 'http',
    'type' => Server::SERVER_HTTP,
    'host' => '0.0.0.0',
    'port' => 9507,
    'callbacks' => [
    Event::ON_REQUEST => [Hyperf\\HttpServer\\Server::class, 'onRequest'],
    ],
    ],

    创建控制器:

    <?php
    namespace App\\Controller;

    use Hyperf\\HttpServer\\Annotation\\Controller;
    use Hyperf\\HttpServer\\Annotation\\RequestMapping;
    use Hyperf\\Redis\\Redis;
    use Hyperf\\Amqp\\Producer;
    use Hyperf\\Di\\Annotation\\Inject;
    use App\\Amqp\\Producer\\SeckillSuccessProducer;

    #[Controller(prefix: '/seckill')]
    class SeckillController extends AbstractController
    {
    #[Inject] private Redis $redis;
    #[Inject] private Producer $amqpProducer;

    #[RequestMapping(path: 'buy', methods: 'post')]
    public function buy()
    {
    $productId = (int)$this->request->input('product_id');
    $userId = $this->request->getAttribute('user_id', 1); // 从网关透传

    // Lua 脚本原子扣减
    $script = <<<LUA
    local key = KEYS[1]
    local stock = tonumber(redis.call('get', key) or '0')
    if stock > 0 then
    redis.call('decr', key)
    return 1
    else
    return 0
    end
    LUA;
    $success = $this->redis->eval($script, ['seckill:stock:' . $productId], 1);
    if (!$success) {
    return ['code' => 0, 'message' => '已抢光'];
    }

    // 生成订单号
    $orderId = uniqid('seckill_');
    // 发送异步消息到 RabbitMQ,触发 Saga
    $this->amqpProducer->produce(new SeckillSuccessProducer([
    'order_id' => $orderId,
    'user_id' => $userId,
    'product_id' => $productId,
    'amount' => 1.00, // 秒杀价格固定 1 元
    ]));

    return ['code' => 200, 'message' => '抢购成功,订单处理中', 'order_id' => $orderId];
    }
    }

    步骤 3:创建秒杀成功消息生产者

    app/Amqp/Producer/SeckillSuccessProducer.php:

    <?php
    namespace App\\Amqp\\Producer;

    use Hyperf\\Amqp\\Annotation\\Producer;
    use Hyperf\\Amqp\\Message\\ProducerMessage;

    #[Producer(exchange: 'order.exchange', routingKey: 'seckill.success')]
    class SeckillSuccessProducer extends ProducerMessage
    {
    public function __construct(array $data) { $this->payload = $data; }
    }

    步骤 4:创建秒杀订单消费者

    app/Amqp/Consumer/SeckillOrderConsumer.php:

    <?php
    namespace App\\Amqp\\Consumer;

    use App\\Saga\\SagaConstants;
    use Hyperf\\Amqp\\Annotation\\Consumer;
    use Hyperf\\Amqp\\Message\\ConsumerMessage;
    use Hyperf\\Amqp\\Result;
    use Hyperf\\Amqp\\Producer;
    use Hyperf\\Di\\Annotation\\Inject;
    use Hyperf\\DbConnection\\Db;
    use Hyperf\\Utils\\Str;

    #[Consumer(exchange: 'order.exchange', routingKey: 'seckill.success', queue: 'seckill.order.queue', name: 'SeckillOrderConsumer', nums: 2)]
    class SeckillOrderConsumer extends ConsumerMessage
    {
    #[Inject] private Producer $producer;

    public function consume($data): string
    {
    $orderId = $data['order_id'];
    $userId = $data['user_id'];
    $productId = $data['product_id'];
    $amount = $data['amount'];

    // 启动 Saga
    $sagaId = (string) Str::uuid();
    Db::table('saga_transactions')->insert([
    'saga_id' => $sagaId,
    'status' => 'running',
    'current_step' => SagaConstants::STEP_ORDER_CREATE,
    'payload' => json_encode($data),
    ]);

    // 发送第一条命令
    $this->producer->produce(new \\App\\Amqp\\Producer\\GenericProducer([
    'saga_id' => $sagaId,
    'step' => SagaConstants::STEP_ORDER_CREATE,
    'payload' => $data,
    ], SagaConstants::EXCHANGE_COMMANDS, SagaConstants::STEP_ORDER_CREATE));

    return Result::ACK;
    }
    }

    步骤 5:修改 Saga 协调器处理秒杀订单

    现有的 OrderCommandConsumer 在处理 order.create 时,将订单写入数据库,状态 pending。InventoryCommandConsumer 处理冻结时,操作数据库库存。由于秒杀库存已在 Redis 扣除,数据库冻结可能会失败(若活动库存已不足),需在补偿时恢复 Redis 库存。

    在 OrderCommandConsumer 的 cancelOrder 中,如果是秒杀订单(可根据订单号前缀判断),则需要恢复 Redis 库存:

    if (str_starts_with($orderId, 'seckill_')) {
    $this->redis->incr('seckill:stock:' . $productId);
    }

    步骤 6:网关配置秒杀路由与限流

    在网关的 RateLimitMiddleware 中,为 /seckill 路径设置令牌桶容量和速率 500(capacity: 500, rate: 500),在 gateway-routes Nacos 配置中添加:

    {
    "prefix": "/seckill",
    "target": "http://order-service:9507"
    }

    网关转发到订单服务的 HTTP 端口。


    四、压测与验证(约 1 小时)

    1. 预热数据

    确保 Redis 中有 seckill:stock:1,且值为 100。

    2. 使用 wrk 压测秒杀接口

    # 获取 token
    TOKEN=$(curl -s -X POST http://localhost:9500/auth/login ... | jq -r '.data.token')
    # 压测:200 并发,5000 请求
    wrk -t4 -c200 -d30s –latency \\
    -H "Authorization: Bearer $TOKEN" \\
    -H "Content-Type: application/json" \\
    -s seckill.lua http://localhost:9500/seckill/buy

    seckill.lua 脚本构造 POST 请求,携带 product_id=1。

    3. 验证结果
    • Redis 库存 seckill:stock:1 应为 0(或 100-成功抢购数)。
    • 成功抢购的订单数应等于 100,无超卖。
    • 检查 orders 表中状态为 pending 或 frozen 的订单,后续由 Saga 处理。
    • 所有秒杀成功的消息都被消费,Saga 协调器正常工作。
    4. 对比非秒杀接口

    同样的并发量打普通下单接口 /orders,观察 QPS 和错误率,秒杀接口应具有明显更高的吞吐量(因为无 RPC、无数据库同步写)。


    五、今日作业与学习产出

  • 提交代码:秒杀接口、Lua 脚本、预热监听器、秒杀消息生产者/消费者等。
  • 完善秒杀功能:
    • 增加秒杀场次校验,检查当前时间是否在活动期内。
    • 增加用户限购:同一用户只能秒杀一次(Redis Set 记录用户 ID)。
  • 学习笔记:
    • 画出秒杀全链路时序图,标出 Redis、RabbitMQ、Saga、数据库的交互。
    • 总结缓存库存与数据库库存最终一致性的保障手段。
  • 挑战任务:
    • 使用 Redis Stream 替代 RabbitMQ 传递秒杀成功消息,对比性能。
    • 实现 秒杀页面静态化(理论设计):将商品详情、倒计时等静态 HTML 部署到 CDN,降低后端压力。
  • 通过今天的学习,你掌握了高并发秒杀的核心设计:Redis 原子扣减、缓存预热、异步解耦,并与 Saga 分布式事务无缝衔接。明天我们将对整个系统进行全链路压测与调优,找出性能瓶颈并优化,让电商系统达到生产级水平。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【基于 Swoole+Hyperf 的微服务实战】第十周·周一:秒杀功能--高并发场景专项深度优化
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!