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 的 消费者组 和 阻塞读取 特性,实现订单超时的 准实时 处理。

实现原理

  1. 订单创建时,向 Redis Stream 添加一条消息,消息内容包含订单ID、超时时间戳。
  2. 启动多个 PHP 消费者进程,使用 XREADGROUP BLOCK 0 阻塞等待新消息。
  3. 消费者收到消息后,如果当前时间 < 超时时间,则调用 XPENDING 将消息暂挂,设置一个定时器(如 usleep)等待到超时时刻再处理;如果已超时则直接取消订单。
  4. 利用 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 脚本 可以提升可靠性。

实现

  1. 订单创建时,设置一个带过期时间的 Key:order:timeout:{orderId},值存储订单信息,过期时间 = 超时时间。
  2. 开启 Keyspace Notifications 的过期事件(EX 事件)。
  3. 订阅 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 的时间轮),或自研基于 时间轮 的组件。