Skip to content

第 15 章 实测笔记:队列 + Job + Mailable 完整复盘

实测时间:2026-05-08(约 1.5 小时,含 1 次失败诊断) 项目:playground/(在第 13 章博客系统上扩展) 关联大纲:第 15 章 队列 Queue + 事件 Event + Job

本章定位:把"为什么队列、怎么写 Job、怎么调试失败、怎么测"全过一遍——含 1 次真实生产里也会遇到的 SQLite 并发锁踩坑。


0. 战果速览

数字看战果

维度数字
总耗时约 1.5 小时(含 30 分钟失败诊断)
Sprint 数7 个(Sprint 1-7)
新增/修改文件5 个
PHP 代码行~150 行
Blade 视图(邮件模板)22 行
测试用例9 个(PostQueueTest)
测试断言12 条
测试结果40 passed (79 assertions) in 0.84s
真实失败次数1 次(SQLite is locked)
调试用工具数4(log 文件 / database-query / queue:failed / Tinker)

时间线

T+0:00   "想给文章发布加邮件通知" → AI 拆 7 个 Sprint
T+0:05   .env 检查 → QUEUE_CONNECTION=database 已默认配置 ⭐
T+0:08   Schema::hasTable('jobs') = true → jobs 表已存在 ⭐
T+0:15   php artisan make:mail + make:job → 3 个文件生成
T+0:25   填充 Mailable + Job + Markdown 模板
T+0:30   PostController@store 加 SendPostPublishedEmailJob::dispatch
T+0:35   开 3 个窗口(serve / queue:work / tail log)→ 浏览器创建文章
T+0:38   ❌ Worker 报 FAIL → "database is locked" ⭐ 踩坑
T+0:48   诊断完毕 → 切 QUEUE_CONNECTION=sync → 邮件成功输出
T+1:05   写 7 个 Pest 测试 → 38 passed
T+1:20   加 2 个失败链路测试(failed 钩子 + 异常传播)→ 40 passed
T+1:30   写本笔记

T+0:38 那次失败是这次最有教学价值的瞬间——真实生产里也常发生。把"诊断 → 修复"完整经历了一次。

9 个测试覆盖的"业务规则地图"

                    ┌────────────────────────────────┐
                    │ PostController@store           │
                    └───────────────┬────────────────┘

                  ┌─────────────────┼─────────────────┐
                  ▼                 ▼                 ▼
         [test 1] published    [test 2] draft   [test 3] scheduled
            assertPushed         assertNotPushed   assertNotPushed


         ┌────────────────────────────────┐
         │ SendPostPublishedEmailJob      │
         │ ::handle()                     │
         └───────────────┬────────────────┘

       ┌─────────────────┼─────────────────────────┐
       ▼                 ▼                         ▼
[test 4] 真发邮件   [test 6] 空 email         [test 9] Mail 抛异常
Mail::assertSent      Mail::assertNothing       toThrow → worker 重试


                                              [test 8] failed() 钩子
                                              Log::shouldHaveReceived

         ┌────────────────────────────────┐
         │ Mailable / Config              │
         └───────────────┬────────────────┘

              ┌──────────┼──────────┐
              ▼                     ▼
      [test 5] subject 含 title  [test 7] tries=3, backoff=5

1. 起点:一句话需求

给 AI 的原始 prompt

我想给第 13 章的博客系统加一个功能:
作者发布"已发布"状态的文章后,给作者本人异步发一封"文章已发布"的邮件通知。

要求:
- 用 Laravel 队列(database 驱动),不要直接同步发邮件
- 邮件用 Markdown 模板(不要手写 HTML 邮件)
- 邮件驱动用 log(写到 storage/logs/laravel.log),不要装 SMTP
- 如果发邮件失败,要重试 3 次,每次间隔 5 秒
- 失败超过 3 次后写一条 error 日志(含 post_id 和错误信息)
- 草稿和定时未来发布的文章不应该触发邮件
- 写 Pest 测试覆盖以上所有规则

完成后告诉我访问 /posts/create 创建文章后应该看到什么。

AI 拿到这个 prompt 后做的 4 件事(按顺序)

  1. 调 application-info 工具确认版本 → 知道是 Laravel 12(决定用 Foundation\Queue\Queueable 而不是 Bus\Queueable
  2. 检查 .env 现状 → 发现 QUEUE_CONNECTION=databaseMAIL_MAILER=log 都已默认配置
  3. 拆成 7 个 Sprint → 每个 Sprint 明确"做什么 + 怎么验收"
  4. 预告失败重试细节 → 把 tries / backoff / failed() 钩子提前讲清楚

拆 Sprint 是这次任务最值的事。每完成一个 Sprint 立刻验收,避免"全做完才发现某一步错了"。


2. Sprint 1:队列驱动 + jobs 表(5 分钟)

Laravel 5 种队列驱动速查

驱动特性用途
sync同步执行(不入队)测试 / 教学简化
database用 DB 表当队列0 外部依赖,开发首选
redisRedis list生产高频场景
sqsAWS SQS云原生
null丢弃所有 job关闭队列功能

Laravel 12 的"零配置"惊喜 ⭐

migrate:status 时发现:

0001_01_01_000002_create_jobs_table ............................. [1] Ran
0001_01_01_000003_create_failed_jobs_table ...................... [1] Ran

Laravel 12 默认就把 jobs / failed_jobs 表的 migration 内置到框架里。 → 你不需要 php artisan make:queue-table(这是 Laravel ≤ 10 的旧做法)。

踩坑实测:跑 php artisan make:queue-failed-table 报:

ERROR  Migration already exists.

→ 印证上一条。

反模式提醒

网上 90% 的 Laravel 队列教程让你跑 make:queue-table——但 v12 已经过时。 AI 训练数据停在 Laravel 10 的人会吃这亏。Boost Guidelines 应该在第 12 章 application-info 工具引导下避开。

jobs 表结构(用 Tinker 自检)

php
>>> Schema::getColumnListing('jobs')
=> ["id", "queue", "payload", "attempts", "reserved_at", "available_at", "created_at"]
字段含义
payload序列化的 Job 实例(JSON)—— worker 反序列化后执行
attempts已尝试次数(0 → tries)
reserved_atworker 取出时戳(避免重复处理)
available_at可被 worker 取的时间(用于延迟 / backoff)

payload 是关键——理解这个字段,就理解 Laravel 队列的本质:把 Job 实例序列化进 DB,跨进程传递。

Sprint 1 完成验收

bash
 .env QUEUE_CONNECTION=database
 jobs 表存在(含 7 个字段)
 failed_jobs 表存在

3. Sprint 2:Mailable + Job(15 分钟)

3 个生成命令

bash
php artisan make:mail PostPublishedMail --markdown=emails.posts.published
php artisan make:job SendPostPublishedEmailJob
# Markdown 邮件模板会被 make:mail 顺带生成

Job 类的 5 个老手细节

文件:app/Jobs/SendPostPublishedEmailJob.php

php
class SendPostPublishedEmailJob implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $backoff = 5;

    public function __construct(public Post $post) {}

    public function handle(): void
    {
        $this->post->loadMissing('author');

        if (! $this->post->author?->email) {
            return;
        }

        Mail::to($this->post->author->email)
            ->send(new PostPublishedMail($this->post));
    }

    public function failed(\Throwable $e): void
    {
        logger()->error('SendPostPublishedEmailJob failed', [
            'post_id' => $this->post->id,
            'error' => $e->getMessage(),
        ]);
    }
}
#细节解释
1implements ShouldQueue关键标记——Laravel 看到这个接口,dispatch 时走队列而不是同步
2use QueueableLaravel 11+ 简化 trait,聚合Bus\Queueable + InteractsWithQueue + SerializesModels + Dispatchable
3public int $tries = 3失败最多重试 3 次(默认 1 次)
4public int $backoff = 5失败后延迟 5 秒再重试。可以是数组 [5, 10, 30] 表示三次间隔递增
5__construct(public Post $post)PHP 8 构造函数属性提升——一行同时声明属性 + 赋值

loadMissing 的精妙

php
$this->post->loadMissing('author');
方法行为
load('author')总是重新查询 author
loadMissing('author')如果没加载过才查询

→ 在 Job 里你不知道 dispatch 时是否已经 eager-load 了——loadMissing 是防御性写法。

Mailable 类:让 public 属性"自动"传给视图

文件:app/Mail/PostPublishedMail.php

php
class PostPublishedMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(public Post $post) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: '新文章已发布:'.$this->post->title,
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.posts.published',
            with: [
                'post' => $this->post,
                'author' => $this->post->author,
                'url' => url('/posts/'.$this->post->slug),
            ],
        );
    }
}

关键:构造函数 public Post $post —— public 属性

→ Laravel 自动把 public 属性传给 Blade 视图!邮件模板里能直接用 {{ $post->title }},不需要手动传。

显式 with 数组用于传衍生数据(author / url)——这些不是直接属性。

SerializesModels trait 的奥妙

Mailable 用了 SerializesModels trait(Job 的 Queueable 也聚合了它)。它做这件事:

你 dispatch 时把 $post(Post 模型对象)传进 Job 构造函数

SerializesModels 拦截序列化

存到 jobs 表 payload 时只存:
  { "class": "App\\Models\\Post", "id": 42 }

worker 反序列化时自动 Post::find(42) 重新查

为什么这么设计

  • 模型对象可能很大(几 KB 到几 MB),序列化进 DB 浪费空间
  • 模型对象可能在 dispatch 后被改了——执行时拿数据库最新状态更安全

这是"Laravel 大量使用约定"哲学的最佳例子——你不写一行序列化代码,框架替你处理好。

Markdown 邮件模板

文件:resources/views/emails/posts/published.blade.php

blade
<x-mail::message>
# 新文章已发布

你好 {{ $author->name }}

你的文章 **{{ $post->title }}** 已成功发布。

发布时间:{{ $post->published_at?->format('Y-m-d H:i') ?? '草稿' }}

---

## 摘要

{{ \Illuminate\Support\Str::limit(strip_tags($post->body), 200) }}

<x-mail::button :url="$url">
查看文章
</x-mail::button>

感谢使用 Boost Playground,
{{ config('app.name') }}
</x-mail::message>

Markdown 邮件的双输出

Laravel 把这个模板编译成 2 个版本

版本给谁看
HTML 版(带 <table> <style> 按钮)现代邮件客户端(Outlook、Gmail)
纯文本版老式客户端、命令行、反垃圾扫描器

你没写一行 HTML,Laravel 帮你包装了响应式 HTML 邮件。这是 2026 年 PHP 发邮件的标准模式。

Sprint 2 完成验收

bash
 app/Mail/PostPublishedMail.php 存在
 app/Jobs/SendPostPublishedEmailJob.php 存在
 resources/views/emails/posts/published.blade.php 存在
 MAIL_MAILER=log 已配置

4. Sprint 3:在 Controller 触发 dispatch(5 分钟)

改 1 个文件、加 5 行代码

文件:app/Http/Controllers/PostController.php(修改 store() 方法)

php
use App\Jobs\SendPostPublishedEmailJob;

public function store(StorePostRequest $request): RedirectResponse
{
    $this->authorize('create', Post::class);

    $post = $request->user()->posts()->create($request->validated());

    if ($post->published_at?->isPast()) {
        SendPostPublishedEmailJob::dispatch($post);
    }

    return redirect()
        ->route('posts.show', $post)
        ->with('status', 'Post created.');
}

业务判断:$post->published_at?->isPast()

3 种文章状态对应的判断结果:

状态$post->published_at?->isPast()dispatch?
草稿nullnull(短路)❌ 不发邮件
定时发布(未来)未来时间false❌ 不发邮件
已发布过去时间true✅ 发邮件

业务正确性:只在"真的已发布"时才发邮件给作者。草稿和定时不打扰。

Job::dispatch($post) 是什么 magic?

3 种等价写法:

php
SendPostPublishedEmailJob::dispatch($post);                  // 最常用
dispatch(new SendPostPublishedEmailJob($post));              // 全局函数
(new SendPostPublishedEmailJob($post))->dispatch();          // 实例方法(很少见)

魔法在 Dispatchable trait(被 Queueable 聚合)——它实现静态 dispatch() 方法,参数原封不动传给构造函数

dispatch($post) 内部其实是 (new self($post))->dispatch() 的语法糖。

这一行的"轻量"

php
SendPostPublishedEmailJob::dispatch($post);

只做一件事:往 jobs 表写一行(约 5ms)。

  • ❌ 不会发邮件(那是 worker 的事)
  • ❌ 不会阻塞 HTTP 请求

对比 ThinkPHP 老手习惯

php
// ThinkPHP / 朴素 PHP
$mail = new PHPMailer();
$mail->addAddress($post->author->email);
$mail->Subject = '...';
$mail->Body = view('emails.posts.published', ...);
$mail->send();   // ← 阻塞 1-3 秒

→ ThinkPHP 用户经常抱怨"提交表单后转圈圈半天"——99% 是这种同步发邮件。Laravel 队列是这个问题的标准解

Sprint 3 完成验收

bash
 PostController@store 末尾加了 dispatch
 仅在 published_at 已是过去时间时触发
 php artisan test --filter PostManagement 19 passed(不破坏现有)

5. Sprint 4:双进程实战 — 看到队列真的"异步"(10 分钟 + 30 分钟踩坑)

双进程心智模型 ⭐

┌────────────────────────────────────────────────────────────┐
│  [Web 进程]                                                 │
│   PostController@store                                     │
│       │                                                     │
│       ├─▶ Post::create([...])         (同步,秒级)         │
│       │                                                     │
│       └─▶ SendPostPublishedEmailJob::dispatch($post)        │
│                              │                              │
│                              ▼                              │
│                   写入 jobs 表(约 5ms)                     │
│       │                                                     │
│       ▼                                                     │
│  立即 redirect()->route(...)(用户感受:飞快)                │
└────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────┐
│  [Worker 进程](独立窗口跑 php artisan queue:work)            │
│   每秒轮询 jobs 表                                           │
│       │                                                     │
│       ├─▶ 取出待办 job                                       │
│       ├─▶ 反序列化为 Job 实例                                │
│       ├─▶ 调用 handle() 方法                                 │
│       │       └─▶ Mail::to($author)->send(new PostPublishedMail($post)) │
│       │              │                                      │
│       │              ▼                                      │
│       │         (MAIL_MAILER=log)                          │
│       │              │                                      │
│       │              ▼                                      │
│       │         写到 storage/logs/laravel.log               │
│       │                                                     │
│       └─▶ 从 jobs 表删除该任务                              │
└────────────────────────────────────────────────────────────┘

实测操作步骤(Windows PowerShell)

powershell
# 窗口 ① — Web 进程
cd D:\workspace\cursor\laravel-boost\playground
php artisan serve

# 窗口 ② — Worker 进程
cd D:\workspace\cursor\laravel-boost\playground
php artisan queue:work --tries=3

# 窗口 ③ — 实时看日志(PowerShell 等价于 tail -f)
cd D:\workspace\cursor\laravel-boost\playground
Get-Content storage\logs\laravel.log -Wait -Tail 30

第一次跑:FAIL ⚠️

第一次创建文章后,窗口 ② 显示:

2026-05-08 07:39:33 App\Jobs\SendPostPublishedEmailJob ............ 61yrs 3mos 1d 7h 39m 33s FAIL

61yrs 3mos 1d 7h 39m 33s 这个诡异时间是 worker 计算 available_at - created_at 的显示 bug——SQLite 把时间戳当字符串差值算崩了。重点是 FAIL,不是这个数字。

失败诊断:3 个标准动作

动作 1:看日志

powershell
Get-Content storage\logs\laravel.log -Tail 100

找到关键错误:

SQLSTATE[HY000]: General error: 5 database is locked
SQL: update "jobs" set "reserved_at" = 1778225973, "attempts" = 1 where "id" = 1

→ 根因:Worker 想给 jobs 表加锁标记自己"正在处理",但 SQLite 文件已经被另一个进程锁住了

动作 2:看 failed_jobs

bash
php artisan queue:failed

输出:

+------------+--------------+------------+--------------------------------------+
| ID         | Connection   | Queue      | Class                                |
+------------+--------------+------------+--------------------------------------+
| {uuid}     | database     | default    | App\Jobs\SendPostPublishedEmailJob   |
+------------+--------------+------------+--------------------------------------+

或 Tinker 直接查:

php
>>> DB::table('failed_jobs')->get(['id', 'queue', 'exception'])->first()

exception 字段里就是完整堆栈。

动作 3:重试 / 清空

bash
php artisan queue:retry all      # 修代码后重试所有失败 job
php artisan queue:retry {uuid}    # 重试特定 job
php artisan queue:flush           # 清空 failed_jobs 表

生产标准修复流:看 log → 修代码 → queue:retry all → 监控是否还失败。

修复:切到 sync 驱动

详见第 8 节深度分析。临时修复方法:

bash
# .env
QUEUE_CONNECTION=sync
bash
php artisan queue:flush       # 清失败记录
php artisan config:clear      # 让 .env 改动生效

再次创建文章 → 邮件立刻出现在 storage/logs/laravel.log

第二次跑:成功 ✓

storage/logs/laravel.log 看到(搜中文关键词):

# 新文章已发布

你好 Test User,

你的文章 **xxx** 已成功发布。
...
查看文章: http://127.0.0.1:8000/posts/test-queue

感谢使用 Boost Playground,

紧接着是 HTML 版(<table> <style> 按钮)—— Markdown 邮件双输出生效。

PowerShell 显示乱码?

如果 PowerShell 终端里看到 鏌ョ湅鏂囩珷 这种乱码——只是显示问题,文件本身是 UTF-8

永久解决(编辑 $PROFILE):

powershell
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
chcp 65001 > $null

Sprint 4 完成验收

bash
 Job 抛出过 1 次(database is locked)→ 已诊断
 sync 驱动后邮件成功输出
 邮件含正确的中文标题、Markdown 编译为 HTML
 学到 3 种调试动作

6. Sprint 5:Pest 测试 — Queue::fake() + Mail::fake()(20 分钟)

7 个测试覆盖什么

文件:tests/Feature/PostQueueTest.php

#测试名(it ...)验证关键技巧
1dispatches the email job when a post is created as published已发布触发 dispatchQueue::fake() + Queue::assertPushed
2does not dispatch the email job when a post is created as draft草稿不触发Queue::assertNotPushed
3does not dispatch the email job when a post is scheduled for the future定时不触发Queue::assertNotPushed
4sends the published email to the author when the job handlesJob 真发邮件Mail::fake() + Mail::assertSent
5renders the post title into the mail subject邮件主题正确直接 new Mailable 读 envelope
6does not send mail when the author has no email空 email 不发Mail::assertNothingSent
7configures the job to retry up to 3 times with 5s backoff配置正确直接读属性

6 个测试技巧(按价值)

Queue::fake() —— 让 dispatch 不真的入队

php
Queue::fake();
// ... 触发 dispatch ...
Queue::assertPushed(SendPostPublishedEmailJob::class);
Queue::fake()Queue::fake()
dispatch → 真的写 jobs 表dispatch → 写到 in-memory 数组
测试要用 sync 才能立刻执行dispatch 完什么都不发生
测试(要走 DB)测试(纯内存)
失败可能影响别的测试完全隔离

测试黄金法则:永远 fake 副作用Queue::fake() / Mail::fake() / Bus::fake() / Event::fake() 都是同一套思想。

② 闭包断言验证 job 内部数据

php
Queue::assertPushed(SendPostPublishedEmailJob::class, function ($job) use ($author) {
    return $job->post->slug === 'hello'
        && $job->post->user_id === $author->id;
});

→ 不只验证"有没 push 这个 job",还验证"push 的 job 携带的数据是不是对的"。

③ 直接 new Job(...)->handle() —— 最快测试 Job 逻辑

php
(new SendPostPublishedEmailJob($post))->handle();
Mail::assertSent(PostPublishedMail::class, ...);

绕开队列基础设施——直接调 handle() 当作普通 PHP 方法测。

Mail::assertSent 闭包断言

php
Mail::assertSent(PostPublishedMail::class, function ($mail) use ($post) {
    return $mail->hasTo($post->author->email)
        && $mail->post->is($post);
});
断言作用
hasTo($email)验证收件人
$mail->post->is($post)Eloquent is()——比较两个模型是不是同一条记录

⑤ 测试 Mailable 不需要 fake

php
$mailable = new PostPublishedMail($post);
expect($mailable->envelope()->subject)->toContain('My Special Title');

→ Mailable 的 envelope() / content()纯函数——不需要发邮件,直接断言返回对象的属性

⑥ Pest 链式断言 expect()->and()->

php
expect($job->tries)->toBe(3)
    ->and($job->backoff)->toBe(5);

→ Pest 比 PHPUnit 优雅的地方之一。

Sprint 5 完成验收

bash
 tests/Feature/PostQueueTest.php 7 测试全 passed
 php artisan test 整套 38 passed (75 assertions) in 1.30s

7. Sprint 6:失败链路测试 — failed() 钩子 + 异常传播(10 分钟)

这个 Sprint 解决的核心问题

如果 Job 把异常吞了(比如 try { ... } catch (\Throwable $e) { /* swallow */ }),worker 永远不会重试——因为 worker 看到 handle() 正常返回了。

队列重试语义的关键:异常必须传播

测试 8:failed() 钩子写正确日志

php
it('writes a structured error log when the failed hook is called', function () {
    Log::spy();

    $post = Post::factory()->create();
    $exception = new RuntimeException('SMTP server unreachable');

    (new SendPostPublishedEmailJob($post))->failed($exception);

    Log::shouldHaveReceived('error')
        ->withArgs(function (string $message, array $context) use ($post) {
            return $message === 'SendPostPublishedEmailJob failed'
                && $context['post_id'] === $post->id
                && $context['error'] === 'SMTP server unreachable';
        })
        ->once();
});

Log::spy() vs Log::fake()

方法行为
Log::fake()替换 logger,所有 log 调用完全静默
Log::spy()替换 logger,记录所有调用但仍然透传(spy = 间谍)

→ 这里用 spy 因为我们只想断言 log 内容,不在乎日志真不真的写到文件。

shouldHaveReceived 是 Mockery 的 API

Laravel 测试默认集成 Mockery:

  • error = 被调用的方法名(log level)
  • withArgs(closure) = 断言传入的参数满足闭包条件
  • once() = 必须被调用一次

测试 9:Mail 抛异常 → handle 也抛异常

php
it('propagates mail exceptions so the queue worker can retry', function () {
    $post = Post::factory()->create();

    Mail::shouldReceive('to')
        ->once()
        ->andThrow(new RuntimeException('SMTP connection refused'));

    expect(fn () => (new SendPostPublishedEmailJob($post))->handle())
        ->toThrow(RuntimeException::class, 'SMTP connection refused');
});

Mail::shouldReceive('to')->andThrow(...) —— 模拟服务故障

→ 用 Mockery 把 Mail::to 改成"调用一次然后抛异常"。模拟"SMTP 服务器宕机"场景。

Pest 的 expect(closure)->toThrow()

比 PHPUnit 的 expectException 优雅得多:

PHPUnitPest
$this->expectException(...) 必须写在调用前expect(fn () => ...)->toThrow(...) 调用 + 断言绑在一起

完整的 Job 失败链路(终于钉死了)

链路环节在哪测的
dispatch() 是否被触发测试 1-3(assertPushed / assertNotPushed
handle() 是否调 Mail测试 4
handle() 异常时是否传播测试 9 ⭐
failed() 钩子被调用时写正确日志测试 8 ⭐
tries / backoff 配置正确测试 7

9 个测试组合,钉死了从 dispatch 到失败重试的全链路业务规则。任何后续修改如果破坏其中一条,立刻被测试发现。

Sprint 6 完成验收

bash
 失败链路 2 个新测试 passed
 php artisan test 整套 40 passed (79 assertions) in 0.84s

8. 重大踩坑:Windows + SQLite + 队列并发锁

这次最值的踩坑

整个第 15 章实战,最值的不是 9 个测试 passed,而是 Sprint 4 那次 database is locked 失败。真实生产里 90% 的 PHP 团队都会撞

现象

SQLSTATE[HY000]: General error: 5 database is locked
SQL: update "jobs" set "reserved_at" = ..., "attempts" = 1 where "id" = 1

根因(一图看懂)

┌──────────────────┐  读     ┌─────────────────────┐
│ Web 进程          │ ───────▶ │                     │
│ php artisan serve │         │  database.sqlite    │
└──────────────────┘  写     │  (单文件 SQLite)    │
                              │                     │
┌──────────────────┐         │  ⚠️ 整文件级锁 ⚠️    │
│ Worker 进程       │ 写——▶ │                     │
│ queue:work        │         └─────────────────────┘
└──────────────────┘

                    任何时刻只有 1 个进程能"独占写"
                    第二个想写的进程立刻报 "database is locked"

SQLite 锁机制

  • 整个文件级锁(不是表级 / 行级)
  • Linux 上用 OS advisory lock,能秒级释放
  • Windows 上文件锁更"重"——一旦撞上立刻报错(默认 timeout 5 秒,且 NTFS 不释放共享锁那么快)

典型并发场景

  1. Web 进程serve)处理 HTTP 请求时偶尔读 / 写
  2. Worker 进程queue:work每秒轮询 jobs 表
  3. 测试 / Tinker / Boost MCP 也可能在用

任意两个进程同时尝试写 SQLite 文件 → BOOM。

5 个解决方向(按价值)

#方案优点缺点
1换 MySQL/PostgreSQL(行级锁)生产正解装 / 配置成本
2换 Redis 队列驱动生产高性能正解Windows 装 Redis 烦
3QUEUE_CONNECTION=sync(开发期)0 配置失去"异步"演示
4独立 SQLite 文件给 jobs 表用仍用 SQLite + 不锁主库配置复杂
5关掉 web 进程,单独跑 worker临时调试 OK操作繁琐

推荐组合

阶段推荐
学习 / 演示方案 3(sync)
本地开发MySQL(任意 driver)+ database 队列
生产MySQL/PostgreSQL + Redis 队列 + Horizon 监控

Tutorial 选择 sync 的原因

为了让用户立刻看到邮件输出,本教程 Sprint 4 选了方案 3:

bash
QUEUE_CONNECTION=sync

→ 教学完成后所有测试用 Queue::fake() + Mail::fake()完全绕开队列驱动——所以测试不依赖这个选择。

这个踩坑的元意义

这次失败的真正价值,不是修复它,而是让你完整经历"诊断 → 修复"链路

  1. 看 worker FAIL 输出
  2. 看 storage/logs/laravel.log
  3. 看 failed_jobs 表
  4. 用 queue:failed / queue:retry 命令
  5. 分析根因
  6. 选解决方案
  7. 验证修复

这套调试流程是 Laravel 队列在生产环境的标准 SOP。教科书讲十遍不如自己跑一次。


9. ThinkPHP vs Laravel 队列对照

队列概念对照

ThinkPHP 6Laravel 12
topthink/queue 包,扩展安装内置 Illuminate\Queue\ 模块
Queue::push('App\Job\Foo', $data)Foo::dispatch($data)(静态分发)
Job 类继承 \think\queue\JobJob 类 implements ShouldQueue + use Queueable
配置在 config/queue.php配置在 config/queue.php(一致)
Worker:php think queue:workWorker:php artisan queue:work
失败 job 自己手写 try/catch 处理内置 failed_jobs 表 + failed() 钩子
重试:自己实现内置 $tries / $backoff 属性

邮件概念对照

ThinkPHP 6Laravel 12
自己装 PHPMailer内置 Illuminate\Mail\
自己写 SMTP 连接.env 一行 MAIL_MAILER=...
没有"Mailable 类"概念Mailable 类 = "邮件就是对象"
模板自己写 HTMLMarkdown 邮件 + 自动包装响应式 HTML
同步发邮件(阻塞)dispatch 到队列,异步执行
单一格式自动 HTML + 纯文本双版本

测试概念对照

ThinkPHP 6Laravel 12
测试基本不写Pest 默认装 + 队列默认 fake
没有 Queue::fake()Queue::fake() + Queue::assertPushed()
没有 Mail::fake()Mail::fake() + Mail::assertSent()
测试要真发邮件测试完全不发邮件也能验证逻辑

一句话总结差异

ThinkPHP 队列:你装个包,自己写 push/pull 协议,自己处理失败。

Laravel 队列:dispatch 一行,框架管所有。失败重试、串行/并行、监控、Horizon UI——开箱即用。

→ ThinkPHP 老手最容易吃惊的:测试时根本不需要真的入队、不需要真的发邮件——fake() 系列让"测试副作用"变成"测试调用图"。


10. 完整代码索引

5 个文件的索引

新增(4 个)

playground/app/Jobs/SendPostPublishedEmailJob.php          43 行
playground/app/Mail/PostPublishedMail.php                  44 行
playground/resources/views/emails/posts/published.blade.php  22 行
playground/tests/Feature/PostQueueTest.php                 130 行(9 测试)

修改(1 个)

playground/app/Http/Controllers/PostController.php   (store 方法 +6 行)

配置(2 个值)

.env QUEUE_CONNECTION=database  →  sync   ⭐ 因 SQLite 锁问题切换
.env MAIL_MAILER=log            (已默认)

关键命令清单(按 Sprint 出场顺序)

bash
# Sprint 1
Get-Content .env | Select-String "QUEUE"
php artisan migrate:status | Select-String -Pattern "jobs|failed"
php artisan tinker
>>> Schema::hasTable('jobs')
>>> Schema::getColumnListing('jobs')

# Sprint 2
php artisan make:mail PostPublishedMail --markdown=emails.posts.published
php artisan make:job SendPostPublishedEmailJob

# Sprint 4 — 双进程实战
php artisan serve                                              # 窗口 ①
php artisan queue:work --tries=3                               # 窗口 ②
Get-Content storage\logs\laravel.log -Wait -Tail 30           # 窗口 ③

# Sprint 4 — 失败诊断
Get-Content storage\logs\laravel.log -Tail 100
php artisan queue:failed
php artisan queue:retry all
php artisan queue:retry {uuid}
php artisan queue:flush

# Sprint 4 — 修复
# 改 .env:QUEUE_CONNECTION=sync
php artisan config:clear

# Sprint 5 / 6 — 测试
php artisan test --filter PostQueueTest
php artisan test

11. 下一步可玩的方向 + 教程总结

6 个可以继续玩的方向(按难度递增)

#方向难度学到什么
1把 sync 切回 database 队列Windows + SQLite 调优
2改用 Redis 队列 + Horizon⭐⭐生产正解
3加"延迟发邮件"(用户发布后 1 小时再通知)dispatch()->delay()
4加"批处理"(一次发邮件给 100 个订阅者)⭐⭐Bus::batch() + Job 链
5加"事件驱动"(Event + Listener 替代直接 dispatch)⭐⭐解耦 Controller 和 Job
6加 Horizon UI 监控失败 / 重试 / throughput⭐⭐⭐Laravel Horizon 全套

推荐 Boost 演示场景(哪几个最适合用 Boost 工具协作)

推荐度方向Boost 工具组合
🔥 高2. Redis 队列 + Horizonsearch-docs(找最新 Horizon 配置) + database-query(看队列状态)
🔥 高4. 批处理search-docs(找 Bus::batch API) + read-log-entries(调试批处理)
5. 事件驱动tinker(测试 Event::fake)

复盘总结

这次实战回答了 3 个问题

Q1:Laravel 队列比 ThinkPHP 队列强多少?

A:从"你自己写 push/pull/重试"提升到"框架管所有"。具体收益:

  • dispatch() 一行代替 push 协议代码
  • failed_jobs 表 + failed() 钩子 = 自动失败处理
  • $tries / $backoff = 自动重试
  • Queue::fake() / Mail::fake() = 测试时不真入队不真发
  • Markdown 邮件 = 不写 HTML 也能发好邮件
  • Horizon = 可视化监控(生产用)

Q2:这次踩坑值得吗?

A值得。Sprint 4 那 30 分钟的诊断时间,比"一遍跑通"价值高得多——你现在掌握了真实生产里调试 Laravel 队列的标准 SOP(看 log → failed_jobs → queue:retry)。这套 SOP 复用到任何 Laravel 队列项目。

Q3:Boost 在这次起了什么作用?

TOP 3

  1. application-info——确认 Laravel 12,让 AI 用 Foundation\Queue\Queueable(聚合 trait)而不是老 Bus\Queueable
  2. search-docs——查到 Mail::fake() / Queue::fake() / Mockery 的最新 v12 用法
  3. database-query——验证 failed_jobs 表内容、Tinker 替代品

→ 详见 docs/12-Boost工具实战大全.md


教程整体里程碑(截至本章)

截至本章,教程已经完成

文档行数
docs/Laravel_Boost_教程大纲.md326
docs/03-环境搭建实测.md383
docs/06-11-章节速查.md1485
docs/12-Boost工具实战大全.md530
docs/13-博客实战完整复盘.md1279
docs/15-队列实战.md本篇
docs/17-Prompt工程反例集.md1549

5/18 主章节成稿 + 6 篇实测笔记。教程"实战篇 + 进阶篇"骨架基本完成。

剩余优先级:

  • 第 1-2 章(认知篇)—— 给教程一个像样的"为什么"开头
  • 第 14 章 API 化(Sanctum 已装好,只差 Resource + Controller + 测试)
  • 第 16 章 Filament 后台
  • 第 18 章 部署 + 性能 + 安全

最终战果:1.5 小时 → 一个完整的"Job + Mail + 测试 + 失败处理"队列实现,9 个测试覆盖业务规则。 Boost 的角色:让 AI 用 Laravel 12 最新 API 写代码,避开"网上 90% 教程已过时"的陷阱。 下一步:把 QUEUE_CONNECTION 切回 database(用 MySQL)+ 装 Horizon 监控失败。

基于 MIT 许可 发布