电商交易类业务,「待支付订单超时自动取消」是个绕不开的需求。用户占着库存、优惠券、活动名额迟迟不付款,不及时释放就是实打实的资损。
很多技术人第一反应就是上延迟队列、分布式事务、各种中间件,好像不用点 "高大上" 的技术就不算做架构。结果折腾半天,开发成本翻倍,业务逻辑还没跑通,反而踩了一堆坑。
其实这个需求,有两套轻量级方案完全能覆盖从初创公司到日单十万甚至百万的体量,绝大多数电商系统都能用。今天我们把原理、代码、多进程并发、兜底机制一次性讲透,附可直接复用的 PHP 实现。
方案一:定时任务扫表 ------ 性价比最高的方案
适用场景:日订单量千级以内、业务初期、对超时精度要求不高的平台
这是所有订单系统的「基础款」方案,无需额外中间件,纯数据库 + 定时任务即可落地,同时也是所有方案的最终兜底手段------ 数据库里的订单数据是最权威的,任何中间件故障都可以靠扫表补全兜底。
设计思路:
- 下单时,订单表写入
pay_deadline字段,标记支付截止时间(例:下单后 30 分钟)。 - 高频定时任务(通过 Linux crontab 配置每分钟执行一次):扫描「过去10分钟内到期」的【超时未支付、未取消】订单(保留重叠窗口避免任务抖动漏单),执行取消订单业务;可根据实际业务调整窗口大小与执行频率。
- 兜底定时任务(每天凌晨低峰期执行 1 次):扫描「近 7 天内」所有【超时未支付、未取消】订单,处理高频任务漏处理的数据。
- 务必给订单表建立订单状态和支付截止日期 `status + pay_deadline` 的联合索引,SQL如下: ALTER TABLE orders ADD INDEX idx_status_pay_deadline (status, pay_deadline); 否则扫表会演变为全表扫描,订单量上来后数据库性能会急剧下降。
PHP 代码demo举例(此处只是提供思路,具体需要根据项目实际情况调整)
1. 通用幂等取消方法(两套方案通用)
先抽离统一的取消逻辑,核心是状态校验保证幂等,避免重复回滚库存、重复返还优惠券等:
/**
* 幂等取消订单
*/
function cancelOrder(int $orderId): bool
{
//防并发:lock锁订单同时只能有一个进程处理
//幂等性:只有待支付状态的订单才能取消,通过影响行数判断是否执行成功
//执行后续业务:回滚库存、退还优惠券、释放活动名额等
}
2. 下单时写入支付截止时间
//假设30分钟支付有效期
$payDeadline = time() + 1800;
// 订单插入SQL(仅展示核心字段)
$sql = "INSERT INTO orders (order_no, status, pay_deadline, created_at)
VALUES (?, 'pending', ?, ?)";
$stmt = $db->prepare($sql);
$stmt->execute([$orderNo, $payDeadline, time()]);
3. 每分钟执行一次的高频超时处理任务
// 时间窗口:往前推10分钟(容错重叠窗口),避免任务延迟导致漏单
$endTime = time();
$startTime = $endTime - 600; // 10分钟 = 600秒,按需调整
// 批量查询待支付订单,限制单次处理数量
$sql = "SELECT id FROM orders
WHERE status = 'pending'
AND pay_deadline BETWEEN ? AND ?
LIMIT 100";
$stmt = $db->prepare($sql);
$stmt->execute([$startTime, $endTime]);
$orders = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($orders as $order) {
cancelOrder($order['id']);
}
优化提示:如果单次查询返回的订单数等于 LIMIT 值(如100条),说明当前到期订单有堆积,可临时提高执行频率(如30秒一次)或增大批量数,避免订单超时取消延迟。
-
每日凌晨的兜底扫表任务
// 扫描近7天内所有超时未支付订单
sevenDaysAgo = time() - 86400 * 7; now = time();
batchSize = 100; lastId = 0;do {
sql = "SELECT id FROM orders WHERE status = 'pending' AND pay_deadline BETWEEN ? AND ? AND id > ? ORDER BY id ASC LIMIT ?"; stmt = db->prepare(sql);
stmt->execute([sevenDaysAgo, now, lastId, batchSize]); orders = stmt->fetchAll(PDO::FETCH_ASSOC); if (empty(orders)) {
break;
}foreach ($orders as $order) { cancelOrder($order['id']); $lastId = $order['id']; }} while (count(orders) === batchSize);
方案优缺点
✅ 优点:零额外中间件依赖,实现成本极低,排查问题简单,天然保证数据最终一致性
❌ 缺点:超时精度低(分钟级误差,取决于定时任务执行频率),订单量增大后扫表压力会上升
方案二:Redis ZSET + Lua 延迟队列
适用场景:日订单量过万、对超时精度要求高(秒级)、不想让数据库承受扫表压力的场景
靠 Redis 扛并发,用 Lua 脚本保证原子性,不需要分布式锁,实现成本远低于 MQ 延迟队列。
1、设计思路
- 下单成功后,用
ZADD将订单 ID 写入 Redis 有序集合(ZSET),score= 支付截止时间戳。ZSET 的核心优势是元素按 score 排序,能高效按时间范围筛选到期订单,是轻量延迟队列的最优选择。 - 后台常驻进程持续轮询(默认间隔1秒),通过 Lua 脚本原子取出当前时间已到期的订单,同时从集合中删除。
- 业务层执行取消逻辑,保留每日兜底扫表作为最终保险。
2、疑问:为什么必须用 Lua 脚本?
如果不用 Lua,常规 PHP 逻辑是「先查询到期订单 → 再删除集合中的订单」,但在多进程并发消费时,会出现同一条订单被多个进程重复拿到的竞态问题。
Lua 脚本在 Redis 中是单线程原子执行的,查询 + 删除一步完成,中间不会插入任何其他客户端的命令,天然避免并发冲突,性能远高于加分布式锁的方案。
3、完整实现思路
a. 编写 Lua 原子脚本文件
先把 "查询 + 删除" 的逻辑封装成独立的 timeout_order.lua 文件,方便统一管理:
-- 入参说明:
-- KEYS[1]: ZSET的键名,例如 order:pay_timeout
-- ARGV[1]: 当前时间戳(判断是否到期的阈值)
-- ARGV[2]: 单次批量取出的数量(防止单次处理过多)
local key = KEYS[1]
local now_score = tonumber(ARGV[1])
local limit_count = tonumber(ARGV[2])
-- 1. 按分数查询所有到期的订单ID(分数 <= 当前时间)
local expired_members = redis.call('ZRANGEBYSCORE', key, 0, now_score, 'LIMIT', 0, limit_count)
-- 2. 如果有到期数据,从ZSET中批量删除(取出即删除,避免重复消费)
if #expired_members > 0 then
redis.call('ZREM', key, unpack(expired_members))
end
-- 3. 返回到期的订单ID列表,交给PHP处理业务逻辑
return expired_members
b. PHP 常驻消费端
**设计思路:Lua 脚本加载写在消费脚本内部、while 循环外面,进程启动时加载一次,整个进程生命周期内复用。**生产环境建议用 Supervisor、systemd 等进程管理工具守护。
原因:script('load') 会产生一次 Redis 网络请求,循环内重复调用完全没必要;只要脚本内容不变、Redis 不重启,加载得到的 SHA1 摘要就一直有效,全局复用即可。
<?php
/**
* 订单超时取消消费进程(CLI模式运行,建议用Supervisor守护)
*/
// 1. 初始化连接(进程启动时执行一次)
$redis = new Redis();
// 增加连接超时,避免网络问题导致进程卡死
$redis->connect('127.0.0.1', 6379, 2.5);
// 可选:设置Redis读写超时,根据业务调整
$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);
// 2. 加载Lua脚本(进程启动时执行一次,while循环外,全局复用)
// 脚本加载后Redis服务端会缓存,返回SHA1摘要,后续只用摘要调用即可
$luaScriptPath = __DIR__ . '/timeout_order.lua';
$scriptSha = $redis->script('load', file_get_contents($luaScriptPath));
// 3. 业务配置
$zsetKey = 'order:pay_timeout';
$batchSize = 100; // 单次批量处理数量,避免Redis/数据库抖动
// 4. 常驻消费循环
while (true) {
$now = time();
try {
// ==================== 核心修正:evalSha 传参方式 ====================
// PHP Redis扩展规范:
// 第2参数 = 所有参数合并数组(KEYS在前,ARGV在后,按顺序排列)
// 第3参数 = KEYS的数量(告诉Redis前N个元素是KEYS)
//$redis->evalSha(脚本SHA1, [键名, 参数1, 参数2], KEYS的数量);
$expiredOrders = $redis->evalSha(
$scriptSha, // 预加载得到的脚本SHA1
[$zsetKey, $now, $batchSize], // 全部参数:1个KEY + 2个ARGV,按顺序合并
1 // KEYS的数量:前1个元素属于KEYS
);
// ========== 调试代码:生产环境请删除 ==========
// var_dump($expiredOrders);
// exit;
// ============================================
} catch (RedisException $e) {
// 降级处理:Redis重启导致脚本缓存丢失时,重新加载并执行
$scriptSha = $redis->script('load', file_get_contents($luaScriptPath));
// catch 里同样修正传参方式
$expiredOrders = $redis->evalSha(
$scriptSha,
[$zsetKey, $now, $batchSize],
1
);
// 异常时休眠1秒,避免故障时空转打爆CPU
sleep(1);
}
// 无到期订单,休眠1秒,降低CPU占用
if (empty($expiredOrders)) {
sleep(1);
continue;
}
// 批量处理到期订单
// var_dump($expiredOrders);exit;
foreach ($expiredOrders as $orderId) {
// 调用你的取消订单函数
// cancelOrder((int)$orderId);
}
}
开发调试快捷写法:如果不想单独维护 lua 文件,可以直接用 heredoc 把脚本传入
eval(),代码更紧凑,适合快速迭代。
c. 下单写入延迟队列
这部分在 Web 下单逻辑里,只需要一行写入,不需要调用 Lua 脚本:
// 下单成功后,写入延迟队列,score为支付截止时间戳(30分钟后)
$deadlineTimestamp = time() + 1800;
$redis->zAdd('order:pay_timeout', $deadlineTimestamp, $orderId);
d.在支付成功回调 / 支付通知接口调用的统一处理支付成功里,增加一行代码:
// 第一步:先更新数据库订单状态为已支付(核心)
// 第二步:成功后再从超时队列中移除
$redis->zRem('order:pay_timeout', $orderId);
4、专题:开多进程消费,Lua 会出问题吗?
这是大家问得最多的问题,结论先说:多进程并发调用 Lua 脚本,天然安全,不会出现订单重复消费,还能提升整体处理效率。
为什么不会重复?
Redis 单线程 + Lua 原子性,决定了执行顺序是严格串行的:
- 进程 A 先调用脚本,Redis 一口气完成「查 100 条 → 删 100 条 → 返回」
- 进程 A 执行完毕,Redis 才会开始执行进程 B 的脚本
- 此时前 100 条已经被删掉,进程 B 拿到的就是接下来到期的下一批 100 条
两个进程拿到的批次完全不重叠,不会出现重复消费。
多进程提升的是什么效率?
先明确:Redis 取数据的速度不会因为多进程而变快,因为 Redis 是单线程,多少个请求都得排队。
多进程真正的收益,是并行处理 "取出订单之后的业务逻辑":
- 从 Redis 取 100 条只需要 1ms,但更新订单、回库存、退券这些数据库操作可能需要 200ms以上
- 单进程是「取一批 → 慢慢处理 → 再取下一批」,业务处理慢就会拖慢整体速度
- 多进程下,多个进程同时处理不同批次的业务逻辑,Redis 只负责快速 "发牌",整体吞吐量能线性提升
推荐进程数
-
推荐进程数:2~4 个,性价比最高。再多收益急剧递减,反而会占用过多数据库连接。
-
运维配置 :不用改代码,直接用 Supervisor 的
numprocs配置即可:[program:order_timeout_worker]
command=/usr/bin/php /path/to/timeout_worker.php
autostart=true
autorestart=true
user=www-data
numprocs=4
process_name=%(program_name)s_%(process_num)02d
stdout_logfile=/var/log/order_timeout.log
配套兜底脚本
无论 Redis 方案多稳定,都必须保留每日一次全量兜底扫表,解决 Redis 宕机、消费进程异常退出、Redis 数据丢失导致的漏单。这一步可以复用方案一的兜底思路,开发成本极低。
方案优缺点
✅ 优点:超时精度高(秒级),并发能力强,对数据库零扫表压力,Lua 脚本天然解决并发竞态,支持多进程横向扩展
❌ 缺点:依赖 Redis,需要维护常驻消费进程,极端场景需兜底补偿
两套方案怎么选?
| 对比维度 | 定时任务扫表方案 | Redis ZSET + Lua 方案 |
|---|---|---|
| 额外依赖 | 无 | Redis |
| 超时精度 | 分钟级 | 秒级 |
| 数据库压力 | 较高(定时扫表) | 极低(仅业务更新时操作 DB) |
| 承载量级 | 千~万级 / 日订单量 | 十万~百万级 / 日订单量 |
| 并发能力 | 一般 | 强(支持多进程) |
| 实现复杂度 | 极低 | 中等 |
| 上线速度 | 最快 | 较快 |
建议
- 项目初始阶段、小体量业务:直接用方案一,先跑通业务最重要。
- 日订单上升,开始出现不可忍受的未及时失效的情况、对超时精度有要求:切换方案二,性能提升明显,实现成本远低于 MQ。
- 无论用哪个方案:订单领域处理取消订单,必须保证支持幂等性、业务校验考虑周全,不可信任异步查询出订单,异步捞取的订单只是触发时机点,不保证订单的准确性;
你们项目里是怎么处理超时订单的?欢迎在评论区聊聊~
技术进阶没有捷径,但有高效方法。 本号专注分享实战干货、避坑指南、性能调优、面试重难点, 每一篇都是亲手落地测试,帮你少走弯路、快速提升核心竞争力。
❤️ 点赞、在看、收藏、关注一键安排
关注账户,第一时间获取硬核技术干货,下期不见不散!