PHP + Redis实现订单超时
针对秒杀场景下的订单超时未支付自动取消,这是一个典型需求。由于秒杀需要快速释放被占用的库存,所以超时机制的设计非常关键。
一、核心思路
- 订单创建时:记录超时时间(如 30 分钟),将订单 ID 推入 Redis 的 Sorted Set(有序集合),score 为超时时间戳。
- 定时轮询:启动一个 PHP 常驻进程,每隔 1-2 秒轮询 Sorted Set,取出已超时的订单 ID。
- 超时处理:更新 MySQL 订单状态为“已取消”,并恢复 Redis 中的秒杀库存(如果业务要求释放库存)。
- 无需额外组件:纯 Redis + PHP,不依赖 RabbitMQ 的延迟队列或 RocketMQ。
二、详细设计
1. 订单表增加字段
ALTER TABLE orders ADD COLUMN status TINYINT DEFAULT 0 COMMENT '0-待支付 1-已支付 2-已取消 3-已超时';
ALTER TABLE orders ADD COLUMN expire_time DATETIME COMMENT '订单超时时间';
2. 创建订单时:加入超时监控
在秒杀成功、订单写入 MySQL 后(或写入 Redis 队列时),同时将订单 ID 加入 Redis Sorted Set:
$orderId = 123456; // 假设已生成
$expireSeconds = 1800; // 30分钟超时
$expireTimestamp = time() + $expireSeconds;
// 将订单ID加入超时监控队列,score为超时时间戳
$redis->zAdd('order:timeout', $expireTimestamp, $orderId);
同时记录订单的 expire_time 字段到 MySQL。
3. 常驻进程:扫描超时订单
<?php
// timeout_consumer.php 用 Supervisor 管理
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', 'password');
while (true) {
$now = time();
// 获取所有超时的订单ID(score <= 当前时间戳)
// ZRANGEBYSCORE key -inf (now 按顺序取出,每次最多取100个
$timeoutOrderIds = $redis->zRangeByScore('order:timeout', '-inf', $now, ['limit' => [0, 100]]);
foreach ($timeoutOrderIds as $orderId) {
// 开启事务,避免并发问题
$pdo->beginTransaction();
try {
// 使用悲观锁锁定订单行,防止重复处理
$stmt = $pdo->prepare("SELECT status FROM orders WHERE id = ? FOR UPDATE");
$stmt->execute([$orderId]);
$order = $stmt->fetch();
if ($order && $order['status'] == 0) { // 仅处理待支付订单
// 更新订单状态为超时取消
$pdo->prepare("UPDATE orders SET status = 3 WHERE id = ?")->execute([$orderId]);
// 【关键】恢复库存:根据订单的商品ID,增加Redis中的秒杀库存
$goodsId = $pdo->prepare("SELECT goods_id FROM orders WHERE id = ?")->execute([$orderId]);
$redis->incr("seckill:stock:$goodsId");
// 注意:也要移除用户抢购记录,让该用户可以再次抢购
$redis->sRem("seckill:users:$goodsId", $userId);
$pdo->commit();
// 从超时队列中移除
$redis->zRem('order:timeout', $orderId);
} else {
$pdo->rollBack();
// 订单已被处理,直接移除队列记录
$redis->zRem('order:timeout', $orderId);
}
} catch (Exception $e) {
$pdo->rollBack();
// 记录错误日志,稍后重试(不移除队列,下次扫描还会处理)
}
}
// 避免空转消耗CPU,没有任务时休眠1秒
if (empty($timeoutOrderIds)) {
sleep(1);
}
}
4. 用户支付成功后:移除超时监控
当用户完成支付时,需要主动从 Sorted Set 中删除该订单,避免后续再次超时处理:
// 支付成功回调
function paySuccess($orderId) {
$redis->zRem('order:timeout', $orderId);
// 更新订单状态为已支付...
}
三、优化与增强策略
1. 避免重复处理(幂等性)
- 使用 数据库乐观锁 或 订单状态机:只有当订单状态为“待支付”时才更新为“已取消”,否则跳过。
- 上面的例子中已经用
FOR UPDATE+ 状态判断实现了这一点。
2. 分片扫描(大规模订单)
如果订单量巨大(每天千万级),单个 Sorted Set 可能成为性能瓶颈。可以按商品 ID 或用户 ID 哈希分片:
$shard = $orderId % 10;
$redis->zAdd("order:timeout:$shard", $expireTimestamp, $orderId);
扫描时轮询所有分片。
3. 时间精度与性能平衡
- 扫描间隔:1-2 秒可接受(用户对几十秒的取消延迟不敏感)。如果需要秒级精度,可缩短到 0.5 秒,但会增加 CPU 负载。
- 批量处理:每次处理 100 条,避免单次事务过大。
4. 恢复库存的注意事项
- 是否必须恢复库存?
秒杀场景通常需要释放库存,让其他用户有机会购买。但注意:释放库存可能导致恶意用户锁单(先下单不支付,然后抢购)。解决方案:限制用户未支付订单数量 + 较短超时时间(如 5 分钟)。 - 原子性:恢复库存和更新订单状态最好在同一个 MySQL 事务中,但 Redis 操作无法回滚。所以先更新 MySQL,再操作 Redis;即使 Redis 失败(如网络抖动),也可以通过定时对账脚本修复。
5. 定时对账兜底
即使常驻进程意外崩溃,也要有兜底方案:每小时执行一次 SQL,批量取消超时订单并恢复库存。
-- 查找超过30分钟未支付的订单
UPDATE orders o
JOIN goods g ON o.goods_id = g.id
SET o.status = 3, g.seckill_stock = g.seckill_stock + 1
WHERE o.status = 0 AND o.expire_time < NOW();
四、为什么不推荐使用 Redis 的过期键通知?
Redis 的 keyspace notifications 虽然可以在 Key 过期时发布事件,但存在缺陷:
- 不可靠:过期事件可能丢失(Redis 不保证送达)。
- 延迟:过期扫描不是实时的(每秒 10 次左右)。
- 无法携带业务数据:只能获取到 Key,还需回查数据库。
所以不推荐用于订单超时这种需要精确控制的场景。
五、完整时序图
用户秒杀成功
↓
订单写入 MySQL(status=0, expire_time=now+30min)
↓
redis.zAdd('order:timeout', expire_timestamp, order_id)
↓
[常驻进程] 每1秒扫描 zRangeByScore(-inf, now)
↓
发现超时订单
↓
MySQL事务:UPDATE orders SET status=3 WHERE id=? AND status=0
↓
redis.incr('seckill:stock:goods_id') # 恢复库存
↓
redis.sRem('seckill:users:goods_id', user_id) # 移除用户记录
↓
redis.zRem('order:timeout', order_id)
↓
提交事务
六、 其他方案
上面提到的 轮询 Sorted Set 方案虽然简单可靠,但存在 轮询延迟(至少1秒)和 扫描开销(每次可能取到大量未超时订单)。更高效的方案主要有两种
一、 Redis 6.0+ 的 Redis Time Series + Consumer Group 方案
这是目前较优的方案,利用 Redis Stream 的 消费者组 和 阻塞读取 特性,实现订单超时的 准实时 处理。
实现原理
- 订单创建时,向 Redis Stream 添加一条消息,消息内容包含订单ID、超时时间戳。
- 启动多个 PHP 消费者进程,使用
XREADGROUP BLOCK 0阻塞等待新消息。 - 消费者收到消息后,如果当前时间 < 超时时间,则调用
XPENDING将消息暂挂,设置一个定时器(如usleep)等待到超时时刻再处理;如果已超时则直接取消订单。 - 利用 Stream 的 消费者组 实现多个消费者并行处理,且支持 ACK 机制 防止消息丢失。
代码示例(简化)
// 订单创建时:添加 Stream 消息
$streamKey = "order:stream";
$orderId = 123456;
$expireAt = time() + 1800;
$redis->xAdd($streamKey, '*', [
'order_id' => $orderId,
'expire_at' => $expireAt
]);
// 消费者进程(可启动多个)
$groupName = "order_timeout_group";
$consumerName = "consumer_1";
$redis->xGroup('CREATE', $streamKey, $groupName, 0, true); // 创建消费者组
while (true) {
// 阻塞读取新消息,超时1秒
$messages = $redis->xReadGroup($groupName, $consumerName, [$streamKey => '>'], 1, 1000);
foreach ($messages[$streamKey] as $msgId => $data) {
$expireAt = $data['expire_at'];
$now = time();
if ($now < $expireAt) {
// 未到超时时间,等待
$sleep = $expireAt - $now;
usleep($sleep * 1000000);
}
// 执行超时取消逻辑(加锁、更新订单、恢复库存)
$this->cancelOrder($data['order_id']);
$redis->xAck($streamKey, $groupName, $msgId);
}
}
优点:
- 无轮询开销,消息驱动,实时性好。
- 支持多消费者并行,吞吐量高。
- ACK机制保证消息不丢失。
缺点:
- 消费者需要
usleep阻塞,占用 PHP 进程(但每个进程可同时处理多个消息?实际一个消费者同时只能处理一条,因为usleep会阻塞)。 - 需要 PHP 8.1+ 对 Redis Stream 的良好支持。
二、时间轮(Timing Wheel)算法
时间轮是一种经典的高效定时器实现,适用于海量定时任务。可以用 Redis 的 Sorted Set 模拟多级时间轮。
原理
- 将超时时间按粒度分桶(如秒级),每个桶是一个 Sorted Set。
- 一个独立的 指针 随时间推进,每次处理当前时间戳对应的桶。
- 桶内订单批量处理,无需扫描所有未超时订单。
简化实现(单级时间轮)
// 订单创建时,计算桶位置(假设每10秒一个桶)
$bucketInterval = 10; // 秒
$bucketIndex = floor(time() / $bucketInterval);
$redis->sAdd("order🪣$bucketIndex", $orderId);
// 独立的定时器进程(每10秒触发一次)
$currentBucket = floor(time() / $bucketInterval);
while (true) {
$bucketKey = "order🪣$currentBucket";
$orderIds = $redis->sMembers($bucketKey);
foreach ($orderIds as $orderId) {
// 检查订单是否超时(实际时间可能已过桶边界)
if (订单未支付) {
取消订单 + 恢复库存;
}
}
$redis->del($bucketKey);
sleep($bucketInterval);
$currentBucket++;
}
优点:
- 批量处理,大幅减少 Redis 操作次数。
- 无锁,无竞争。
缺点:
- 时间精度受桶大小限制(10秒桶意味着最多10秒延迟)。
- 需要处理桶边界溢出(跨天等)。
三、基于 Redis Keyspace Notifications + Lua 脚本(折中方案)
虽然之前提到过期通知不可靠,但配合 持久化队列 和 Lua 脚本 可以提升可靠性。
实现
- 订单创建时,设置一个带过期时间的 Key:order:timeout:{orderId},值存储订单信息,过期时间 = 超时时间。
- 开启 Keyspace Notifications 的过期事件(EX 事件)。
- 订阅 Redis 的 keyevent@0:expired 频道,收到事件时,用 Lua 脚本原子地检查订单状态并执行取消(避免并发)。
// 订阅脚本(PHP 使用 Redis 的 subscribe)
$redis->subscribe(['__keyevent@0__:expired'], function ($redis, $channel, $message) {
if (strpos($message, 'order:timeout:') === 0) {
$orderId = substr($message, strlen('order:timeout:'));
// 调用取消逻辑(需保证幂等)
cancelOrder($orderId);
}
});
可靠性增强:
- 事件可能丢失,因此仍需要一个 定时扫表兜底(如每5分钟检查一次超时订单)。
- 事件处理必须幂等,防止重复取消。
适用场景:对实时性要求不高(允许几秒延迟),且希望代码简单的场景。
七、方案对比与选型建议
| 方案 | 实时性 | 吞吐量 | 可靠性 | 复杂度 | 额外依赖 |
|---|---|---|---|---|---|
| 轮询 Sorted Set | 秒级 | 中 | 高(有兜底) | 低 | 无 |
| Redis Stream + Group | 毫秒级 | 高 | 高(ACK) | 中 | Redis 5.0+ |
| 时间轮 | 桶粒度(如10s) | 极高 | 高 | 中 | 无 |
| Keyspace Notifications | 秒级(可能丢) | 高 | 低(需兜底) | 低 | 开启事件通知 |
参考
- 中小规模(日均订单 < 100万):使用 轮询 Sorted Set 方案,简单稳定,1秒延迟用户无感知。
- 大规模(日均订单 > 1000万):使用 Redis Stream + 消费者组,配合 定时兜底,既高效又可靠。
- 超大规模(亿级):考虑专业消息队列(如 RocketMQ 的延迟消息、Kafka 的时间轮),或自研基于 时间轮 的组件。