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

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





评论前必须登录!
注册