refactor: Workerman 消费改用 Laravel Queue Worker,database/redis 统一支持(实测 redis 消费通过);默认消费 default,ai 队列;WORKERMAN_QUEUES/MAX_TRIES/TIMEOUT 配置;2 个队列消费测试

This commit is contained in:
ak
2026-08-11 19:27:43 +08:00
parent 34ea046906
commit 1c8a801238
16 changed files with 164 additions and 54 deletions
+11
View File
@@ -63,3 +63,14 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# ---- Workerman 常驻服务 ----
WORKERMAN_QUEUE_WORKERS=2
WORKERMAN_WS_ENABLED=true
WORKERMAN_WS_PORT=8787
# 队列连接:database / redisredis 需要先配置好下方 REDIS_* 与 QUEUE_CONNECTION=redis
WORKERMAN_QUEUE_CONNECTION=database
# 消费的队列名,逗号分隔;ai 队列为 AI 审核/润色任务
WORKERMAN_QUEUES=default,ai
WORKERMAN_MAX_TRIES=3
WORKERMAN_TIMEOUT=60
+7 -2
View File
@@ -92,8 +92,13 @@ php artisan workerman:serve start # 队列消费者 × N + WebSocket :8787
php artisan workerman:serve stop php artisan workerman:serve stop
``` ```
- 队列消费者在常驻进程内复用 Laravel 容器,省去每任务的框架启动开销LLM 调用主路径 - 消费者复用 Laravel `Queue\Worker`**database / redis 队列统一支持**,常驻进程内框架只启动一次(省去每任务的启动开销)
- 任务统一实现 `App\Blog\Jobs\AiJob` 接口(`handle(LlmClient)`),Workerman 反序列化执行 - 任务统一实现 `App\Blog\Jobs\AiJob` 接口(`handle(LlmClient)`),依赖由容器自动注入
- 队列选择:
- database(默认):`WORKERMAN_QUEUE_CONNECTION=database`
- redis`WORKERMAN_QUEUE_CONNECTION=redis` 并确保 `QUEUE_CONNECTION=redis``REDIS_*` 配置正确;`redis` 连接的 `block_for` 建议设为 `0`(配合 1s 轮询,避免阻塞 Workerman event loop
- 消费队列:`WORKERMAN_QUEUES=default,ai`ai 队列为 AI 审核/润色任务)
- 失败重试:`WORKERMAN_MAX_TRIES`(默认 3),超时 `WORKERMAN_TIMEOUT`(默认 60s),重试耗尽进 `failed_jobs`
- 降级路径:`php artisan queue:work` 照常可用(同一队列) - 降级路径:`php artisan queue:work` 照常可用(同一队列)
## 测试 ## 测试
+32 -51
View File
@@ -2,10 +2,10 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Blog\Services\LlmClient;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan; use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Facades\DB; use Illuminate\Queue\Worker as QueueWorker;
use Illuminate\Queue\WorkerOptions;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Workerman\Connection\TcpConnection; use Workerman\Connection\TcpConnection;
use Workerman\Timer; use Workerman\Timer;
@@ -15,7 +15,7 @@ class WorkermanServe extends Command
{ {
protected $signature = 'workerman:serve {action=start : start/stop/restart/reload}'; protected $signature = 'workerman:serve {action=start : start/stop/restart/reload}';
protected $description = '启动 Workerman 常驻服务:队列消费者(LLM 异步任务+ WebSocket 进度推送'; protected $description = '启动 Workerman 常驻服务:队列消费者(database/redis+ WebSocket 进度推送';
public function handle(): int public function handle(): int
{ {
@@ -23,12 +23,13 @@ class WorkermanServe extends Command
$this->info('Workerman 启动中('.config('workerman.name').'...'); $this->info('Workerman 启动中('.config('workerman.name').'...');
// 队列消费者:常驻进程内复用 Laravel 容器,避免每次任务重新启动框架 // 队列消费者:复用 Laravel 队列 Workerdatabase / redis 统一支持),
// 常驻进程内框架只启动一次,省去每个任务的启动开销
$queueWorker = new Worker(); $queueWorker = new Worker();
$queueWorker->name = 'laralog-queue'; $queueWorker->name = 'laralog-queue';
$queueWorker->count = (int) config('workerman.queue_workers', 2); $queueWorker->count = (int) config('workerman.queue_workers', 2);
$queueWorker->onWorkerStart = function ($worker) { $queueWorker->onWorkerStart = function ($worker) {
$this->info("队列消费者 {$worker->id} 启动"); $this->info("队列消费者 {$worker->id} 启动(连接:".config('workerman.queue_connection').',队列:'.config('workerman.queues').'');
Timer::add(1, function () { Timer::add(1, function () {
try { try {
$this->consumeNextJob(); $this->consumeNextJob();
@@ -52,55 +53,35 @@ class WorkermanServe extends Command
return self::SUCCESS; return self::SUCCESS;
} }
/**
* 消费一个任务。使用 Laravel 队列 Worker
* - 支持 database / redis 等所有驱动(redis reserved/重试/超时语义由驱动处理)
* - sleep=0 避免阻塞 Workerman event loop(由 1s Timer 驱动轮询)
* - 任务 handle() 的依赖(如 LlmClient)由容器自动注入
*/
private function consumeNextJob(): void private function consumeNextJob(): void
{ {
$queue = config('workerman.queue_connection', 'database'); $connection = (string) config('workerman.queue_connection', config('queue.default'));
// Worker 内部按逗号分隔解析多队列(如 "default,ai"
$queues = (string) config('workerman.queues', 'default');
$queues = $queues === '' ? 'default' : $queues;
if ($queue === 'database') { $worker = new QueueWorker(
$job = DB::table('jobs') app('queue'),
->whereNull('reserved_at') app('events'),
->orderBy('id') app(ExceptionHandler::class),
->lockForUpdate() fn () => app()->isDownForMaintenance(),
->first(); );
if (! $job) { $options = new WorkerOptions(
return; name: 'laralog',
} backoff: 0,
memory: 128,
timeout: (int) config('workerman.timeout', 60),
sleep: 0,
maxTries: (int) config('workerman.max_tries', 3),
);
DB::table('jobs')->where('id', $job->id)->update([ $worker->runNextJob($connection, $queues, $options);
'reserved_at' => now()->getTimestamp(),
'attempts' => $job->attempts + 1,
]);
$payload = json_decode($job->payload, true);
try {
$this->runJob($payload);
DB::table('jobs')->where('id', $job->id)->delete();
} catch (\Throwable $e) {
Log::error('任务失败', ['job' => $job->id, 'error' => $e->getMessage()]);
$attempts = $job->attempts + 1;
if ($attempts >= 3) {
DB::table('jobs')->where('id', $job->id)->update(['reserved_at' => null, 'attempts' => $attempts]);
} else {
DB::table('jobs')->where('id', $job->id)->delete();
}
}
return;
}
// Redis 队列:降级到 artisan queue:work
Artisan::call('queue:work', ['--once' => true, '--stop-when-empty' => true]);
}
private function runJob(array $payload): void
{
$command = unserialize($payload['data']['command'] ?? '');
if ($command instanceof \App\Blog\Jobs\AiJob) {
$command->handle(app(LlmClient::class));
}
} }
} }
+13 -1
View File
@@ -22,12 +22,24 @@ return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| 队列:Workerman 消费者使用的队列连接 | 队列:Workerman 消费者使用的队列连接database / redis 均可)
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
'queue_connection' => env('WORKERMAN_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'database')), 'queue_connection' => env('WORKERMAN_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'database')),
/*
|--------------------------------------------------------------------------
| 消费的队列名(逗号分隔;ai 队列为 AI 审核/润色任务)
|--------------------------------------------------------------------------
*/
'queues' => env('WORKERMAN_QUEUES', 'default,ai'),
'max_tries' => env('WORKERMAN_MAX_TRIES', 3),
'timeout' => env('WORKERMAN_TIMEOUT', 60),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| 进程名称(ps 可见) | 进程名称(ps 可见)
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature;
use Tests\Fixtures\PingJob;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Worker as QueueWorker;
use Illuminate\Queue\WorkerOptions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class WorkermanQueueTest extends TestCase
{
use RefreshDatabase;
/**
* Workerman 消费者复用 Laravel Queue Worker:验证能真实消费 database 队列任务。
*/
public function test_worker_consumes_database_job(): void
{
config(['queue.default' => 'database']);
Log::spy();
dispatch(new PingJob);
$this->assertSame(1, DB::table('jobs')->count());
$this->runWorkerOnce('database', 'default');
$this->assertSame(0, DB::table('jobs')->count());
Log::shouldHaveReceived('info')->withArgs(fn ($message) => str_contains($message, 'WM_PING_CONSUMED'));
}
/**
* 多队列(default,ai)支持:ai 队列任务也能被消费。
*/
public function test_worker_consumes_multiple_queues(): void
{
config(['queue.default' => 'database']);
Log::spy();
dispatch((new PingJob)->onQueue('ai'));
$this->runWorkerOnce('database', 'default,ai');
$this->assertSame(0, DB::table('jobs')->count());
Log::shouldHaveReceived('info')->withArgs(fn ($message) => str_contains($message, 'WM_PING_CONSUMED'));
}
private function runWorkerOnce(string $connection, string $queues): void
{
$worker = new QueueWorker(
app('queue'),
app('events'),
app(ExceptionHandler::class),
fn () => app()->isDownForMaintenance(),
);
$options = new WorkerOptions(
name: 'laralog-test',
backoff: 0,
memory: 128,
timeout: 30,
sleep: 0,
maxTries: 2,
);
$worker->runNextJob($connection, $queues, $options);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Tests\Fixtures;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class PingJob implements ShouldQueue
{
use Queueable;
public function handle(): void
{
Log::info('WM_PING_CONSUMED');
}
}