ThinkPHP 8 常驻内存任务实战:用 Workerman 重写订单超时关闭

2026-10-17 0 842

先说结论:订单超时关闭这件事,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 任务别急着删。留一个降级开关,出问题的时候能一键切回去。这个开关我们到现在还留着,虽然一次都没用过,但心里踏实。

ThinkPHP 8 常驻内存任务实战:用 Workerman 重写订单超时关闭
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 常驻内存任务实战:用 Workerman 重写订单超时关闭 https://www.taomawang.com/server/thinkphp/2950.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务