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
|
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 / 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
|
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` 照常可用(同一队列)
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|||||||
@@ -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 队列 Worker(database / 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
@@ -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 可见)
|
||||||
|
|||||||
+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