PHP + Redis秒杀场景
PHP + Redis秒杀场景
在电商秒杀等高并发场景中,Redis 凭借其内存操作和原子性特性,成为库存扣减的核心组件。
下面以 PHP + Redis 为例,给出一个企业级秒杀库存设计方案示例,重点解决超卖、热点 Key、重复下单和流量冲击等问题。
一、核心思路
- 预加载库存:秒杀活动开始前,将商品库存从 MySQL 同步到 Redis(使用
SET或HMSET)。 - 原子扣减:使用 Redis Lua 脚本(或
DECR+ 校验)保证扣减与校验的原子性,杜绝超卖。 - 异步落库:扣减成功后,将用户 ID、商品 ID 推入消息队列(如 Redis Stream / List),后台进程消费并创建订单、扣减数据库库存。
- 限流 + 防重:通过令牌桶或滑动窗口限制用户访问频率,通过 Redis 记录用户抢购状态防止同一用户重复下单。
二、详细步骤与代码实现
1. 活动开始前:初始化库存
1// 假设商品ID=1001,秒杀库存=100
2$redis->set('seckill:stock:1001', 100);
3$redis->set('seckill:closed:1001', 0); // 0-未结束,1-已结束
2. 抢购接口核心:Lua 脚本原子扣减
1$lua = <<<LUA
2local stock_key = KEYS[1]
3local user_key = KEYS[2] -- 用于记录已抢购用户
4local user_id = ARGV[1]
5local closed_key = KEYS[3]
6
7-- 检查活动是否已结束
8if redis.call('get', closed_key) == '1' then
9 return -2 -- 活动结束
10end
11
12-- 检查用户是否已抢购过(防重复)
13if redis.call('sismember', user_key, user_id) == 1 then
14 return -1 -- 重复抢购
15end
16
17-- 扣减库存
18local stock = redis.call('decr', stock_key)
19if stock < 0 then
20 -- 库存不足,回滚
21 redis.call('incr', stock_key)
22 return 0 -- 无库存
23end
24
25-- 记录用户
26redis.call('sadd', user_key, user_id)
27-- 返回当前剩余库存(正数代表成功,0代表刚好扣完)
28return stock
29LUA;
30
31$result = $redis->eval($lua, [
32 'seckill:stock:1001',
33 'seckill:users:1001',
34 'seckill:closed:1001',
35 $userId
36], 3); // 3个KEYS
37
38if ($result === -2) {
39 // 活动已结束
40} elseif ($result === -1) {
41 // 用户已抢过
42} elseif ($result === 0) {
43 // 库存不足
44} else {
45 // 扣减成功,剩余库存 $result
46 // 将成功信息推入消息队列(异步创建订单)
47 $redis->lpush('seckill:orders', json_encode([
48 'user_id' => $userId,
49 'goods_id' => 1001,
50 'stock' => $result
51 ]));
52}
3. 异步消费队列(如使用 Redis List + PHP 常驻进程)
1// consumer.php 常驻进程(可用 Swoole / Supervisor 管理)
2while (true) {
3 $data = $redis->brpop('seckill:orders', 2); // 超时2秒
4 if ($data) {
5 $order = json_decode($data[1], true);
6 // 开启MySQL事务
7 $db->begin();
8 try {
9 // 再次检查数据库库存(防止Redis数据不一致)
10 $stock = $db->query("SELECT stock FROM goods WHERE id={$order['goods_id']} FOR UPDATE");
11 if ($stock > 0) {
12 $db->query("UPDATE goods SET stock = stock - 1 WHERE id={$order['goods_id']} AND stock>0");
13 // 创建订单...
14 $db->commit();
15 } else {
16 $db->rollback();
17 // 库存不足,需回滚Redis中的用户记录(补偿)
18 $redis->srem('seckill:users:1001', $order['user_id']);
19 $redis->incr('seckill:stock:1001');
20 }
21 } catch (Exception $e) {
22 $db->rollback();
23 // 记录异常,重试或人工介入
24 }
25 }
26}
4. 限流(防止刷单)
使用 Redis 的滑动窗口或令牌桶。示例:基于用户 IP 的 QPS 限制(每秒最多5次请求)
1$rateLimitKey = "rate:limit:user:{$userId}";
2$current = $redis->incr($rateLimitKey);
3if ($current == 1) {
4 $redis->expire($rateLimitKey, 1); // 1秒窗口
5}
6if ($current > 5) {
7 die('请求过快');
8}
5. 活动结束:关闭库存扣减
1// 后台脚本,到达活动结束时间后执行
2$redis->set('seckill:closed:1001', 1);
3// 同时将Redis剩余库存同步回MySQL(避免数据不一致)
4$left = $redis->get('seckill:stock:1001');
5$db->query("UPDATE goods SET stock = {$left} WHERE id=1001");
三、 增强策略
- 热点 Key 分片
如果单个商品库存过大(如 10 万),单个 Redis key 会成为热点。可以拆分库存:seckill:stock:1001:1到seckill:stock:1001:10,每个分片 1 万。
请求随机落到某个分片,最后汇总统计。Lua 脚本需依次尝试扣减,若全部分片无库存则失败。 - 本地缓存 + 快速失败
在 PHP 应用层(如 APCu)缓存一个标志位seckill:over:1001,一旦库存为 0 就立即拒绝请求,避免打到 Redis。 - 使用 Redis Cluster
将不同商品分散到不同节点,避免单节点压力过大。 - 防机器人刷单
结合图形验证码(如极验)、请求签名、设备指纹。 - 降级方案
当 Redis 或消息队列压力过大时,快速返回“排队中”或“活动火爆”,引导用户重试。
四、注意点
- Lua 脚本是保证原子性的不二选择,避免使用
GET + DECR这种非原子操作。 - 异步落库能防止数据库被瞬时流量打垮,但会带来短暂不一致(用户看到扣减成功但订单未生成)。可以通过前端轮询或 WebSocket 通知最终结果。
- 补偿机制:如果后台消费失败(如数据库库存不足),需要回滚 Redis 中的用户标记和已扣库存。
- 监控与告警:监控 Redis 命中率、队列堆积长度、数据库死锁情况。
五、完整流程图
1用户请求 → 限流过滤 → 图形验证码 → Lua脚本原子扣减Redis库存
2 ↓成功 ↓失败
3 记录用户抢购标记 → 推入消息队列 返回失败(已抢完/重复)
4 ↓
5 异步Worker消费 → MySQL事务(减库存+创建订单)
6 ↓
7 更新订单状态,通知用户
六、秒杀场景API设计,兼顾用户体验与最终一致性
在 Redis 秒杀 + 异步 MySQL 落库的架构下,兼顾用户体验与最终一致性的核心在于:同步返回受理状态,异步通知最终结果。让用户第一时间知道“我抢到了资格”,而不是长时间等待处理结果。
一、设计原则
| 原则 | 说明 |
|---|---|
| 快速响应 | 同步接口只做 Redis 扣减,毫秒级返回,避免用户等待超时 |
| 状态可查 | 提供轮询或主动推送接口,让用户知道最终结果 |
| 结果可达 | 无论成功还是失败,最终必须给用户一个明确的结果 |
| 失败可补偿 | 异步环节失败时,能回滚 Redis 状态,并通知用户 |
二、同步接口返回值设计
1. 抢购受理成功(Redis 扣减成功)
1{
2 "code": 200,
3 "msg": "抢购成功,订单处理中",
4 "data": {
5 "token": "uid123_gid1001_xxxxxx",
6 "status": "pending",
7 "estimated_time": 3
8 }
9}
token:本次抢购的唯一凭证,用于后续查询结果。status: pending:表示已获得资格,但订单尚未生成。estimated_time:预估处理时间(秒),给用户心理预期。
2. 库存不足
1{
2 "code": 4001,
3 "msg": "库存已抢完",
4 "data": null
5}
3. 重复抢购
1{
2 "code": 4002,
3 "msg": "您已参与过该活动",
4 "data": null
5}
4. 活动未开始/已结束
1{
2 "code": 4003,
3 "msg": "活动尚未开始或已结束",
4 "data": null
5}
5. 请求频率超限
1{
2 "code": 429,
3 "msg": "请求过于频繁,请稍后再试",
4 "data": null
5}
6. 系统繁忙(降级)
1{
2 "code": 503,
3 "msg": "系统繁忙,请重试",
4 "data": null
5}
三、异步结果查询接口
用户拿到 token 后,前端通过轮询或 WebSocket 查询最终结果。
接口如:GET /api/seckill/result?token=xxx
返回状态说明:
| status | 含义 | 用户侧展示 |
|---|---|---|
pending | 处理中 | “订单处理中,请稍后…” |
success | 订单已生成 | “抢购成功,正在准备发货” |
failed | 处理失败 | “抢购失败,可重试或联系客服” |
1. 轮询策略
建议前端采用指数退避轮询:
1let delay = 1000; // 初始 1 秒
2const maxDelay = 5000; // 最大 5 秒
3
4function pollResult(token) {
5 fetch(`/api/seckill/result?token=${token}`)
6 .then(res => res.json())
7 .then(data => {
8 if (data.data.status === 'pending') {
9 setTimeout(() => pollResult(token), delay);
10 delay = Math.min(delay * 1.5, maxDelay);
11 } else if (data.data.status === 'success') {
12 showSuccess(data.data);
13 } else {
14 showFailure(data.data);
15 }
16 });
17}
2. 主动推送方案(WebSocket/SSE)
轮询的替代方案,延迟更低、请求更少。
流程:
- 用户进入秒杀页面时,建立 WebSocket 连接,服务端记录
userId => connection。 - 抢购成功后,异步消费者完成订单写入后,通过 WebSocket 主动推送结果给对应用户。
- 前端收到推送后,直接展示结果,无需轮询。
推送消息格式:
1{
2 "type": "seckill_result",
3 "data": {
4 "token": "uid123_gid1001_1702713600",
5 "status": "success",
6 "order_id": "202504160001"
7 }
8}
3. 混合方案
实际生产中,通常采用轮询为主 + WebSocket 降级的组合,如
1// 优先尝试 WebSocket
2const ws = new WebSocket('wss://api.example.com/seckill');
3
4ws.onopen = () => {
5 ws.send(JSON.stringify({ token: 'xxx' }));
6};
7
8ws.onmessage = (event) => {
9 // 收到结果,展示
10 showResult(JSON.parse(event.data));
11};
12
13ws.onerror = () => {
14 // WebSocket 失败,降级为轮询
15 startPolling(token);
16};
17
18// 设置超时,如果 3 秒内 WebSocket 没返回结果,也降级轮询
19setTimeout(() => {
20 if (!resultReceived) {
21 ws.close();
22 startPolling(token);
23 }
24}, 3000);
决策矩阵
| 考量维度 | 轮询 (Polling) | 主动推送 (WebSocket/SSE) |
|---|---|---|
| 实时性要求 | 秒级延迟(取决于轮询间隔) | 毫秒级延迟 |
| 并发规模 | 适合中小规模(< 10 万同时在线) | 适合大规模(百万级连接) |
| 实现复杂度 | 简单,HTTP 标准接口 | 复杂,需要维护长连接 |
| 服务器资源 | 请求频繁,HTTP 开销大 | 连接常驻,内存占用高 |
| 网络环境 | 通用,穿透性好 | 某些防火墙/代理可能阻断 |
| 结果确定性 | 最终一定会查到(只要接口正常) | 连接断开可能收不到 |
四、前端交互流程
1用户点击“立即抢购”
2 ↓
3按钮置灰,显示“提交中...”
4 ↓
5收到同步响应
6 ├── code=200 → 显示“抢购成功,订单处理中”,开启轮询/建立WS
7 ├── code=4xx → 显示具体错误(库存不足/重复/活动结束)
8 └── code=429 → 显示“操作太快啦,稍后再试”,等待后恢复按钮
9 ↓
10轮询/推送收到最终结果
11 ├── success → 跳转订单页或显示“抢购成功”
12 └── failed → 显示失败原因,恢复按钮允许重试(如果活动还有库存)
发表评论