refactor: Workerman 消费改用 Laravel Queue Worker,database/redis 统一支持(实测 redis 消费通过);默认消费 default,ai 队列;WORKERMAN_QUEUES/MAX_TRIES/TIMEOUT 配置;2 个队列消费测试
This commit is contained in:
@@ -63,3 +63,14 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# ---- Workerman 常驻服务 ----
|
||||
WORKERMAN_QUEUE_WORKERS=2
|
||||
WORKERMAN_WS_ENABLED=true
|
||||
WORKERMAN_WS_PORT=8787
|
||||
# 队列连接:database / redis(redis 需要先配置好下方 REDIS_* 与 QUEUE_CONNECTION=redis)
|
||||
WORKERMAN_QUEUE_CONNECTION=database
|
||||
# 消费的队列名,逗号分隔;ai 队列为 AI 审核/润色任务
|
||||
WORKERMAN_QUEUES=default,ai
|
||||
WORKERMAN_MAX_TRIES=3
|
||||
WORKERMAN_TIMEOUT=60
|
||||
|
||||
@@ -92,8 +92,13 @@ php artisan workerman:serve start # 队列消费者 × N + WebSocket :8787
|
||||
php artisan workerman:serve stop
|
||||
```
|
||||
|
||||
- 队列消费者在常驻进程内复用 Laravel 容器,省去每次任务的框架启动开销(LLM 调用主路径)
|
||||
- 任务统一实现 `App\Blog\Jobs\AiJob` 接口(`handle(LlmClient)`),Workerman 反序列化执行
|
||||
- 消费者复用 Laravel `Queue\Worker`,**database / redis 队列统一支持**,常驻进程内框架只启动一次(省去每个任务的启动开销)
|
||||
- 任务统一实现 `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` 照常可用(同一队列)
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Blog\Services\LlmClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
use Illuminate\Queue\Worker as QueueWorker;
|
||||
use Illuminate\Queue\WorkerOptions;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Workerman\Timer;
|
||||
@@ -15,7 +15,7 @@ class WorkermanServe extends Command
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -23,12 +23,13 @@ class WorkermanServe extends Command
|
||||
|
||||
$this->info('Workerman 启动中('.config('workerman.name').')...');
|
||||
|
||||
// 队列消费者:常驻进程内复用 Laravel 容器,避免每次任务重新启动框架
|
||||
// 队列消费者:复用 Laravel 队列 Worker(database / redis 统一支持),
|
||||
// 常驻进程内框架只启动一次,省去每个任务的启动开销
|
||||
$queueWorker = new Worker();
|
||||
$queueWorker->name = 'laralog-queue';
|
||||
$queueWorker->count = (int) config('workerman.queue_workers', 2);
|
||||
$queueWorker->onWorkerStart = function ($worker) {
|
||||
$this->info("队列消费者 {$worker->id} 启动");
|
||||
$this->info("队列消费者 {$worker->id} 启动(连接:".config('workerman.queue_connection').',队列:'.config('workerman.queues').')');
|
||||
Timer::add(1, function () {
|
||||
try {
|
||||
$this->consumeNextJob();
|
||||
@@ -52,55 +53,35 @@ class WorkermanServe extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费一个任务。使用 Laravel 队列 Worker:
|
||||
* - 支持 database / redis 等所有驱动(redis 的 reserved/重试/超时语义由驱动处理)
|
||||
* - sleep=0 避免阻塞 Workerman event loop(由 1s Timer 驱动轮询)
|
||||
* - 任务 handle() 的依赖(如 LlmClient)由容器自动注入
|
||||
*/
|
||||
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') {
|
||||
$job = DB::table('jobs')
|
||||
->whereNull('reserved_at')
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
$worker = new QueueWorker(
|
||||
app('queue'),
|
||||
app('events'),
|
||||
app(ExceptionHandler::class),
|
||||
fn () => app()->isDownForMaintenance(),
|
||||
);
|
||||
|
||||
if (! $job) {
|
||||
return;
|
||||
}
|
||||
$options = new WorkerOptions(
|
||||
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([
|
||||
'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));
|
||||
}
|
||||
$worker->runNextJob($connection, $queues, $options);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -22,12 +22,24 @@ return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 队列:Workerman 消费者使用的队列连接
|
||||
| 队列:Workerman 消费者使用的队列连接(database / redis 均可)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'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 可见)
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user