先说结论:订单超时关闭这件事,crontab 能跑,但它有三个甩不掉的毛病。
第一是精度。crontab 最小粒度是分钟,你想跑得密一点就得写 * * * * *,一分钟一次;跑得稀一点就变成五分钟一次,用户 30 分 02 秒下单,31 分整的那次扫描可能还没轮到它。第二是并发。整点扫描的时候,几百个到期的订单会在一瞬间挤进一个进程里串行处理,慢一点就拖到下一分钟。第三是资源。每次 crontab 都要重新引导一遍框架、新建数据库连接、结束后再销毁,一天 1440 次,纯浪费。
这篇文章记录的是我们把这套逻辑迁到 Workerman 常驻内存的完整过程。中间踩了三个坑,都是真金白银换来的,一并写出来。
先确定技术选型
常驻内存方案无非两种:Swoole 和 Workerman。我们最后选了 Workerman,理由说出来有点朴素——服务器上装 Swow 或 Swoole 扩展要走编译,而 Workerman 是纯 PHP 实现,一个 composer 依赖就能跑。运维那边不用改任何环境,这在推方案的时候省了很多口舌。
性能上确实有差距,Swoole 的协程在 IO 密集场景下更快,但订单超时关闭这个任务的瓶颈根本不在 CPU,而是在数据库的行锁事务上。用 Swoole 省下来的那点调度开销,被一次 SELECT ... FOR UPDATE 就吃回去了。所以选 Workerman 不亏。
为什么要用 Redis ZSET 而不是直接轮询订单表
有人可能会想,既然进程常驻了,那我在进程里写个死循环,每隔一分钟 SELECT * FROM order WHERE status='unpaid' AND create_time < now() - 1800 不就行了?
行,但有两个问题。
一是全表扫描的压力。status 和 create_time 上就算建了联合索引,随着订单量涨到千万级,每次扫描的 IO 依然可观,而且这个扫描和你正常的业务查询在抢同一个数据库连接池。
二是时间不准。你没法精准知道”哪一笔订单还有 3 秒就该关了”,只能粗粒度地捞一批出来。业务上如果需要精确到秒级别的关单(比如前端会展示倒计时),这个方案就很难受。
Redis ZSET 天然就是按 score 排序的,把 score 设成”该关闭的时间戳”,ZRANGEBYSCORE key -inf now 一把就能拿到所有到期的任务。O(log N) 的插入和 O(log N + M) 的范围查询,M 是结果数量。这个复杂度,订单涨到亿级也不怕。
整体架构
分成两块:下单时投递,常驻进程消费。
投递端很简单,就在下单成功的那个事务提交之后加一行:
<?php
// 下单成功后投递延迟任务
$delaySeconds = 1800; // 30 分钟
$redis = thinkfacadeCache::store('redis')->handler();
$redis->zAdd('queue:order:timeout', time() + $delaySeconds, $orderSn);
注意这里必须放在事务提交之后。如果放在事务里面,万一后面回滚了,Redis 里的任务却已经进去了,那到时候消费者一查,订单根本不存在,白跑一趟。
消费端就是常驻进程,下面重点讲。
进程骨架
先看启动脚本。文件名就叫 server.php,放在项目根目录。
<?php
declare(strict_types=1);
use WorkermanWorker;
use WorkermanTimer;
use appworkermanOrderTimeoutWorker;
require __DIR__ . '/vendor/autoload.php';
Worker::$logFile = __DIR__ . '/runtime/workerman.log';
Worker::$pidFile = __DIR__ . '/runtime/workerman.pid';
Worker::$stdoutFile = __DIR__ . '/runtime/stdout.log';
Worker::$stopTimeout = 30; // 收到 stop 信号后给 30 秒处理完手上的活
$worker = new Worker();
$worker->name = 'order-timeout';
$worker->count = 2;
$worker->reloadable = true;
$worker->onWorkerStart = function (Worker $worker) {
// 每个子进程自己引导一次框架
$app = new thinkApp();
$app->initialize();
$context = new OrderTimeoutWorker((int) $worker->id);
$context->boot();
// 主循环,200ms 一次
Timer::add(0.2, [$context, 'tick']);
// 保活,5 分钟一次
Timer::add(300, [$context, 'keepAlive']);
// 崩溃自愈扫描,1 分钟一次
Timer::add(60, [$context, 'recover']);
};
Worker::runAll();
这里有两个细节值得说一下。
new thinkApp() 必须在 onWorkerStart 里做,不能在 Worker::runAll() 之前做。因为 Workerman 是先 fork 子进程再执行 onWorkerStart 的,如果你在父进程里就把框架引导完了、甚至在父进程里碰了一下数据库,那 fork 出来的子进程会共享同一个 socket 文件描述符,几个进程往同一个连接上写数据,后果你懂的。
我上面写的框架引导其实不涉及数据库连接(TP 的 Db 是懒连接,不调用就不连),理论上放父进程里也没事。但养成习惯,所有可能碰 IO 的初始化都丢进 onWorkerStart,以后加代码不容易翻车。
$worker->count = 2 表示开两个子进程。因为取任务是原子的(后面会讲),多进程并行是安全的,而且能分摊一半的压力。
核心消费逻辑
来看 app/workerman/OrderTimeoutWorker.php。
<?php
declare(strict_types=1);
namespace appworkerman;
use Redis;
use Throwable;
use thinkfacadeDb;
use appcommonserviceOrderTimeoutService;
class OrderTimeoutWorker
{
private const QUEUE_KEY = 'queue:order:timeout';
private const PROCESS_KEY = 'queue:order:processing';
/** 原子取任务:从 ZSET 取出到期任务,同时写进 processing hash */
private const LUA_POP = <<<'LUA'
local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
if #jobs == 0 then
return {}
end
for i = 1, #jobs do
redis.call('ZREM', KEYS[1], jobs[i])
redis.call('HSET', KEYS[2], jobs[i], ARGV[1])
end
return jobs
LUA;
private ?Redis $redis = null;
private OrderTimeoutService $service;
private int $workerId;
private int $tickCount = 0;
private bool $busy = false;
public function __construct(int $workerId)
{
$this->workerId = $workerId;
}
public function boot(): void
{
$this->connectRedis();
$this->service = new OrderTimeoutService();
}
private function connectRedis(): void
{
$config = config('cache.stores.redis');
$redis = new Redis();
$redis->connect($config['host'], (int) $config['port'], 2.0);
if (!empty($config['password'])) {
$redis->auth($config['password']);
}
if (isset($config['select'])) {
$redis->select((int) $config['select']);
}
$this->redis = $redis;
}
public function tick(): void
{
if ($this->busy) {
return;
}
$this->busy = true;
try {
$jobs = $this->pop(50);
foreach ($jobs as $orderSn) {
$this->handle((string) $orderSn);
}
} catch (Throwable $e) {
// 这里千万不要让异常冒出去,冒出去进程就挂了
file_put_contents(
runtime_path() . 'workerman-err.log',
date('Y-m-d H:i:s') . " [{$this->workerId}] " . $e->getMessage() . PHP_EOL,
FILE_APPEND
);
} finally {
$this->busy = false;
}
if (++$this->tickCount % 500 === 0) {
gc_collect_cycles();
}
}
private function pop(int $batch = 50): array
{
$now = time();
$jobs = $this->redis->eval(
self::LUA_POP,
[self::QUEUE_KEY, self::PROCESS_KEY, $now, $batch],
2
);
return is_array($jobs) ? $jobs : [];
}
private function handle(string $orderSn): void
{
try {
$this->service->closeIfUnpaid($orderSn);
$this->ack($orderSn);
} catch (Throwable $e) {
// 业务异常,记一笔然后 ack,避免死循环重试
$this->service->markFailed($orderSn, $e->getMessage());
$this->ack($orderSn);
}
}
private function ack(string $orderSn): void
{
try {
$this->redis->hDel(self::PROCESS_KEY, $orderSn);
} catch (Throwable $e) {
// 连接挂了也没关系,recover 会兜底
}
}
public function keepAlive(): void
{
try {
$this->redis->ping();
} catch (Throwable $e) {
$this->connectRedis();
}
try {
Db::query('SELECT 1');
} catch (Throwable $e) {
Db::close();
}
}
public function recover(): void
{
$deadline = time() - 300;
try {
$stale = $this->redis->hGetAll(self::PROCESS_KEY);
} catch (Throwable $e) {
$this->connectRedis();
return;
}
foreach ($stale as $orderSn => $pushedAt) {
if ((int) $pushedAt > $deadline) {
continue;
}
$this->redis->hDel(self::PROCESS_KEY, $orderSn);
$this->redis->zAdd(self::QUEUE_KEY, time() + 5, $orderSn);
}
}
}
这段代码里有几个地方是刻意的设计,解释一下。
为什么取任务要写成 Lua
如果分开写,先 ZRANGEBYSCORE 拿到列表,再 ZREM 删掉,中间这个窗口期里,两个进程会拿到同一批订单。虽然业务层有幂等保护,但白白浪费了数据库事务。
写成 Lua 脚本之后,Redis 是单线程执行脚本的,中间不会被其他命令插进来。ZRANGEBYSCORE 和 ZREM 两条命令变成一个原子操作,两个进程永远拿不到同一批任务。
为什么要多一个 processing 的 hash
Lua 脚本只是把任务从待办区挪到了执行区。如果一个任务刚从 ZSET 里移除,进程就被 OOM Killer 干掉了,那这笔订单就永远关不掉了。
queue:order:processing 这个 hash 就是执行区,value 是入区时间戳。recover() 每分钟扫一次,发现有任务在 hash 里躺了超过 300 秒还没被 hDel 掉,说明负责它的那个进程多半是崩了,重新塞回 ZSET。
这样整个链路是至少一次投递,配合业务层的幂等判断,最终一致。
为什么 keepAlive 要分开 try
Redis 和 MySQL 是两个独立的连接,一个挂了不代表另一个也挂了。分开 catch,各自重建,互不影响。
业务层:幂等比什么都重要
因为上面是”至少一次”投递,同一笔订单可能被处理两次,业务层必须自己做幂等。
<?php
declare(strict_types=1);
namespace appcommonservice;
use thinkfacadeDb;
use thinkfacadeLog;
class OrderTimeoutService
{
public function closeIfUnpaid(string $orderSn): void
{
Db::transaction(function () use ($orderSn) {
$order = Db::name('order')
->where('order_sn', $orderSn)
->lock(true) // SELECT ... FOR UPDATE
->find();
if (!$order) {
return;
}
// 关键:状态判断放在行锁里面
if ($order['status'] !== 'unpaid') {
return;
}
Db::name('order')
->where('id', $order['id'])
->update([
'status' => 'closed',
'closed_at' => date('Y-m-d H:i:s'),
]);
$items = json_decode($order['items'], true) ?: [];
foreach ($items as $item) {
Db::name('goods')
->where('id', $item['goods_id'])
->inc('stock', $item['num'])
->update();
}
});
}
public function markFailed(string $orderSn, string $reason): void
{
Log::error("order timeout close failed: {$orderSn} - {$reason}");
Db::name('order_timeout_log')->insert([
'order_sn' => $orderSn,
'reason' => mb_substr($reason, 0, 500),
'created_at' => date('Y-m-d H:i:s'),
]);
}
}
注意 lock(true) 和状态判断的位置。必须先用 FOR UPDATE 把这一行锁住,再读 status,再决定要不要改。
如果反过来写——先查状态,发现是 unpaid,再去锁行更新——那就有一个竞态窗口:在你查完状态到锁住行之间,用户可能刚好完成了支付。等你锁住行了,更新的时候没再检查状态,就把一笔已支付的订单改成已关闭了。
这个 bug 在测试环境几乎复现不出来,线上跑几个月才会冒出几单,但一旦出现就是客诉。
坑一:MySQL 八小时掉线
服务上线第二天早上,日志里开始刷这个:
SQLSTATE[HY000]: General error: 2006 MySQL server has gone away
时间点很规律,凌晨两三点之后开始。因为那时候没流量,常驻进程虽然每 300 秒做一次 SELECT 1……等等,我们做了保活啊,怎么还掉?
翻了一下才发现,第一版代码里 Timer::add(300, ...) 的保活是在 boot() 之后注册的,但 keepAlive 里只有 Redis 的 ping,MySQL 的部分是我后加的,上线时漏了。
不过就算加上保活,也还有一层保险要做。MySQL 的 wait_timeout 默认 28800 秒(8 小时),interactive_timeout 也是。保活间隔是 300 秒,远小于 8 小时,理论上不会掉。但现实是复杂的——DBA 可能哪天把 wait_timeout 调成 1800,中间还可能有防火墙、云厂商的 NAT 网关在删空闲连接。
所以正确的做法是三层防护:
第一层,配置里打开断线重连。ThinkPHP 的 config/database.php 里有个 break_reconnect 选项,默认是 false,很多人不知道:
return [
'type' => 'mysql',
'hostname' => env('DB_HOST', '127.0.0.1'),
// ...
'break_reconnect' => true, // 打开这个
];
打开之后,TP 在执行 SQL 时如果捕获到”连接已断开”类的错误,会自动重连一次再重试。
第二层,主动保活。就是上面 keepAlive() 里的 Db::query('SELECT 1')。这里有个小坑:Db::query() 用的是 TP 连接池里的那个连接实例,所以这条语句能真正给连接续命。如果你图省事直接 new PDO(),那就是在保活一个跟业务无关的野连接,一点用没有。
第三层,异常兜底。在 handle() 的 catch 里,如果发现是 gone away 类的错误,执行一次 Db::close(),让 TP 下次查询时用新连接。
} catch (Throwable $e) {
$msg = $e->getMessage();
if (str_contains($msg, 'gone away') || str_contains($msg, 'Lost connection')) {
Db::close(); // 强制丢弃旧连接,下次自动新建
}
$this->service->markFailed($orderSn, $msg);
$this->ack($orderSn);
}
三层叠下来,这个问题就没再出现过。
坑二:Redis 连接被静默掐断
这个比 MySQL 那个隐蔽。现象是:进程跑得好好的,突然某一刻 tick() 抛了个 RedisException: read error on connection to 10.x.x.x:6379,然后这个子进程就再也不干活了——因为 $this->redis 这个对象已经是坏的,后续每次调用都抛同样的异常。
原因是:服务器和 Redis 之间隔了一层云厂商的内网网关,网关对空闲连接有 30 分钟的清理策略。虽然 Redis 服务端 timeout 配的是 0(不主动断),但网关那边不管这个,到点就掐。而我们这边的 ping 保活间隔是 300 秒……等等,300 秒怎么会超时?
后来发现是另一个子进程的问题。两个 worker 进程,主循环都是 200ms 一次。正常情况下,每个进程每 200ms 都会碰一次 Redis,连接根本不会空闲。但如果某一批任务特别重(比如有个订单关联了 200 个商品,回滚库存花了十几秒),这个进程就会在这十几秒里完全不碰 Redis。按说十几秒也不至于触发 30 分钟清理。
真正的原因是网关的清理策略没那么准时,有时候会误杀。运维那边的原话是”有时候连接表满了会提前踢一批”。
不管原因了,反正防御手段是明确的:把所有 Redis 调用都包起来,捕获 RedisException 之后重建连接。
private function safeRedis(callable $fn)
{
try {
return $fn($this->redis);
} catch (RedisException $e) {
$this->connectRedis(); // 重建
return $fn($this->redis); // 重试一次
}
}
另外还有一个容易忽略的点:不要复用 ThinkPHP 缓存驱动里的那个 Redis 句柄。
很多人图方便,直接 Cache::store('redis')->handler() 拿到 phpredis 实例来用。在 FPM 模式下没问题,但在常驻内存模式里,Cache 门面背后是一个 CacheManager,它内部维护着 store 实例的缓存。一旦某个地方调用了 Cache::store('redis')->clear() 或者连接被打断,整个 manager 内部的状态就乱了,你从外面拿到的 handler 可能已经是个僵尸对象。
所以常驻进程里,Redis 连接自己 new Redis() 建,跟框架的缓存体系完全隔离。多占一个连接,换来的是可控性。
坑三:内存缓慢泄漏
这个坑最要命,因为它不会立刻炸,是温水煮青蛙。
上线第一周一切正常。第二周运维找过来,说 order-timeout 这个进程组的内存从 40MB 涨到了 900MB。因为服务器内存充足,还没触发 OOM,但趋势很明显,每小时涨 15MB 左右。
排查方式是定期打日志看 memory_get_usage():
Timer::add(600, function () {
file_put_contents(
runtime_path() . 'mem.log',
date('Y-m-d H:i:s') . ' pid=' . posix_getpid()
. ' mem=' . round(memory_get_usage(true) / 1048576, 2) . 'MB'
. ' peak=' . round(memory_get_peak_usage(true) / 1048576, 2) . 'MB'
. PHP_EOL,
FILE_APPEND
);
});
日志显示内存是阶梯式上涨的,每处理完一批任务就抬升一点点,且 gc_collect_cycles() 之后也降不下来。这就不是”垃圾没回收”,而是”真的有东西在被持有”。
最后定位到三个来源:
1. ThinkPHP 容器里的实例数组
thinkContainer 内部有个 $instances 数组,用来缓存单例。app()、app('xxx')、make($class, $vars, true) 这类调用都会往里塞东西。在 FPM 下,请求结束进程销毁,一切都归零;但在常驻进程里,这个数组只增不减。
解决办法很直接:worker 的业务代码里,不要用 app() 或容器去取单例。需要什么服务,自己在 boot() 里 new 一个存成员变量,或者每次处理时临时 new。多几个对象创建的开销,换来内存可控,划算。
2. 模型里的静态属性
ThinkPHP 的 Model 类里有一些静态属性,比如 protected static $event、static $macro 之类。如果业务代码里动态注册了模型事件(Model::event('after_update', ...)),而且注册逻辑写在循环体里,那这些回调会一直堆积。
我们的代码里恰好有这么一段:在 closeIfUnpaid 里,为了记录操作日志,动态绑了一个事件。虽然那个绑定的 key 是固定的(会被覆盖),但这种写法本身就不该出现在常驻进程里。改成了在 boot() 里一次性注册。
3. 日志句柄
ThinkPHP 的 Log 驱动在写入文件时会保持文件句柄打开,方便连续写。这在 FPM 下没问题,但在常驻进程里,如果每天都切换日志文件(Log::info 按日期分文件),那每天都会新开一个句柄,旧的句柄如果没被正确释放,就会累积。
我们的日志量不小,一天几十万行。改了配置,把日志级别调到 warning 以上,正常处理链路不打日志,只在异常时打。同时用 log_realtime_write 相关的配置控制一下刷新策略。
做完这三件事之后,加了 gc_collect_cycles() 的定期调用作为额外保险,内存稳定在 50-70MB 之间波动,不再单调上涨。
平滑重启与优雅退出
进程常驻之后,发布代码就不能直接 kill -9 了。正在处理的任务被腰斩,虽然 recover() 能兜底,但会延迟 5 分钟才重试。
Workerman 提供了 reload 机制:
php server.php reload # 平滑重启,处理完当前任务后退出旧进程
php server.php stop # 优雅停止
php server.php start -d # 后台启动
php server.php status # 查看进程状态
但有一个坑:我们的 tick() 是通过 Timer::add 注册的,Workerman 在收到 reload 信号后,会等待当前回调执行完毕才退出。如果一批 50 个任务每个都要 200ms,那整个批次就是 10 秒。加上 Worker::$stopTimeout = 30,30 秒内必须完成,否则会被强杀。
所以批次大小要控制好。pop(50) 这个 50 是基于我们的实际耗时定的:单笔订单的关闭事务平均 3-5ms,50 笔就是 250ms 左右,加上库存回滚可能到 1 秒。如果你们单笔耗时长,把批次调小,比如 20,反而整体更稳。
另外 reloadable = true 这一行别漏。默认是 true,但如果你手动改成 false 图省事,那 reload 命令就只会重启主进程,子进程不动,代码不会更新——这种问题排查起来很费劲,因为 status 命令显示的进程号确实变了。
监控接什么
跑起来之后的第一个月,我们接了三个指标:
队列积压。每分钟上报一次 ZCARD queue:order:timeout。正常情况下这个值应该稳定在”未来 30 分钟内的订单数”这个量级。如果它持续上涨,说明消费速度跟不上投递速度,要么加进程,要么查业务是不是哪里重复投递了。
执行耗时 P99。在 tick() 里记录每批的开始和结束时间,算出单笔平均耗时。这个值突然变大,通常意味着数据库有慢查询,或者某类订单的商品数量异常。
recover 触发的次数。每次 recover() 捞回一个任务,就上报一次。正常情况下这个数字应该一直是 0。如果它开始增长,说明有进程在非正常退出,要么是 OOM,要么是未捕获的致命错误。
这三个指标都不用上 Prometheus 那么重,直接往 Redis 里写计数器,运维那边定时拉一下就行。
一点压测数据
最后放一组我们自己的压测结果,供参考。
测试环境是 4 核 8G 的云服务器,MySQL 8.0 单机,Redis 6.2 单机。count = 2,也就是两个 worker 进程。
灌入 30 万个延迟任务,score 分散在未来 30 分钟内,模拟均匀投递。结果:两个进程合计吞吐稳定在 1800-2200 单/秒,30 万单在 3 分钟内清空。单进程的内存峰值 82MB,CPU 占用约 35%。
把 MySQL 换成主从、写操作走主库,吞吐基本没变化,说明瓶颈确实不在 PHP 这一侧。把 count 加到 4,吞吐只涨了不到 20%,因为数据库的行锁竞争开始明显了。
所以结论是:两个进程足够撑到一个中等规模的电商平台,真要再往上,得从数据库层面想办法,加 PHP 进程收益不大。
写在最后
这套东西从第一版跑到稳定,前后改了大概四五次。最大的感受是:常驻内存带来的性能提升是实打实的,但它把很多在 FPM 下被”进程销毁”掩盖的问题全暴露出来了。连接复用、对象生命周期、异常处理,这些东西在 FPM 下你可以不当回事,在这里必须一件件想清楚。
如果你正准备做类似的迁移,建议不要一次把所有定时任务都搬过来。先挑一个逻辑最简单、出问题影响最小的(比如日志清理、缓存预热)跑一两周,把上面这几个坑先踩一遍,摸清楚自己服务器环境的脾气,再动核心业务。
另外提醒一句:迁移之后,原来那些 crontab 任务别急着删。留一个降级开关,出问题的时候能一键切回去。这个开关我们到现在还留着,虽然一次都没用过,但心里踏实。

