第 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=51. 起点:一句话需求
给 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 件事(按顺序)
- 调 application-info 工具确认版本 → 知道是 Laravel 12(决定用
Foundation\Queue\Queueable而不是Bus\Queueable) - 检查 .env 现状 → 发现
QUEUE_CONNECTION=database和MAIL_MAILER=log都已默认配置 - 拆成 7 个 Sprint → 每个 Sprint 明确"做什么 + 怎么验收"
- 预告失败重试细节 → 把
tries/backoff/failed()钩子提前讲清楚
→ 拆 Sprint 是这次任务最值的事。每完成一个 Sprint 立刻验收,避免"全做完才发现某一步错了"。
2. Sprint 1:队列驱动 + jobs 表(5 分钟)
Laravel 5 种队列驱动速查
| 驱动 | 特性 | 用途 |
|---|---|---|
sync | 同步执行(不入队) | 测试 / 教学简化 |
database | 用 DB 表当队列 | 0 外部依赖,开发首选 |
redis | Redis list | 生产高频场景 |
sqs | AWS 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 自检)
>>> Schema::getColumnListing('jobs')
=> ["id", "queue", "payload", "attempts", "reserved_at", "available_at", "created_at"]| 字段 | 含义 |
|---|---|
payload | 序列化的 Job 实例(JSON)—— worker 反序列化后执行 |
attempts | 已尝试次数(0 → tries) |
reserved_at | worker 取出时戳(避免重复处理) |
available_at | 可被 worker 取的时间(用于延迟 / backoff) |
→ payload 是关键——理解这个字段,就理解 Laravel 队列的本质:把 Job 实例序列化进 DB,跨进程传递。
Sprint 1 完成验收
✓ .env 含 QUEUE_CONNECTION=database
✓ jobs 表存在(含 7 个字段)
✓ failed_jobs 表存在3. Sprint 2:Mailable + Job(15 分钟)
3 个生成命令
php artisan make:mail PostPublishedMail --markdown=emails.posts.published
php artisan make:job SendPostPublishedEmailJob
# Markdown 邮件模板会被 make:mail 顺带生成Job 类的 5 个老手细节
文件:app/Jobs/SendPostPublishedEmailJob.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(),
]);
}
}| # | 细节 | 解释 |
|---|---|---|
| 1 | implements ShouldQueue | 关键标记——Laravel 看到这个接口,dispatch 时走队列而不是同步 |
| 2 | use Queueable | Laravel 11+ 简化 trait,聚合了 Bus\Queueable + InteractsWithQueue + SerializesModels + Dispatchable |
| 3 | public int $tries = 3 | 失败最多重试 3 次(默认 1 次) |
| 4 | public int $backoff = 5 | 失败后延迟 5 秒再重试。可以是数组 [5, 10, 30] 表示三次间隔递增 |
| 5 | __construct(public Post $post) | PHP 8 构造函数属性提升——一行同时声明属性 + 赋值 |
loadMissing 的精妙
$this->post->loadMissing('author');| 方法 | 行为 |
|---|---|
load('author') | 总是重新查询 author |
loadMissing('author') | 如果没加载过才查询 |
→ 在 Job 里你不知道 dispatch 时是否已经 eager-load 了——loadMissing 是防御性写法。
Mailable 类:让 public 属性"自动"传给视图
文件:app/Mail/PostPublishedMail.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
<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 完成验收
✓ 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() 方法)
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? |
|---|---|---|---|
| 草稿 | null | null(短路) | ❌ 不发邮件 |
| 定时发布(未来) | 未来时间 | false | ❌ 不发邮件 |
| 已发布 | 过去时间 | true | ✅ 发邮件 |
→ 业务正确性:只在"真的已发布"时才发邮件给作者。草稿和定时不打扰。
Job::dispatch($post) 是什么 magic?
3 种等价写法:
SendPostPublishedEmailJob::dispatch($post); // 最常用
dispatch(new SendPostPublishedEmailJob($post)); // 全局函数
(new SendPostPublishedEmailJob($post))->dispatch(); // 实例方法(很少见)魔法在 Dispatchable trait(被 Queueable 聚合)——它实现静态 dispatch() 方法,参数原封不动传给构造函数。
→ dispatch($post) 内部其实是 (new self($post))->dispatch() 的语法糖。
这一行的"轻量"
SendPostPublishedEmailJob::dispatch($post);只做一件事:往 jobs 表写一行(约 5ms)。
- ❌ 不会发邮件(那是 worker 的事)
- ❌ 不会阻塞 HTTP 请求
对比 ThinkPHP 老手习惯:
// 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 完成验收
✓ 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)
# 窗口 ① — 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:看日志
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 表
php artisan queue:failed输出:
+------------+--------------+------------+--------------------------------------+
| ID | Connection | Queue | Class |
+------------+--------------+------------+--------------------------------------+
| {uuid} | database | default | App\Jobs\SendPostPublishedEmailJob |
+------------+--------------+------------+--------------------------------------+或 Tinker 直接查:
>>> DB::table('failed_jobs')->get(['id', 'queue', 'exception'])->first()exception 字段里就是完整堆栈。
动作 3:重试 / 清空
php artisan queue:retry all # 修代码后重试所有失败 job
php artisan queue:retry {uuid} # 重试特定 job
php artisan queue:flush # 清空 failed_jobs 表→ 生产标准修复流:看 log → 修代码 → queue:retry all → 监控是否还失败。
修复:切到 sync 驱动
详见第 8 节深度分析。临时修复方法:
# .env
QUEUE_CONNECTION=syncphp 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):
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
chcp 65001 > $nullSprint 4 完成验收
✓ Job 抛出过 1 次(database is locked)→ 已诊断
✓ 切 sync 驱动后邮件成功输出
✓ 邮件含正确的中文标题、Markdown 编译为 HTML
✓ 学到 3 种调试动作6. Sprint 5:Pest 测试 — Queue::fake() + Mail::fake()(20 分钟)
7 个测试覆盖什么
文件:tests/Feature/PostQueueTest.php
| # | 测试名(it ...) | 验证 | 关键技巧 |
|---|---|---|---|
| 1 | dispatches the email job when a post is created as published | 已发布触发 dispatch | Queue::fake() + Queue::assertPushed |
| 2 | does not dispatch the email job when a post is created as draft | 草稿不触发 | Queue::assertNotPushed |
| 3 | does not dispatch the email job when a post is scheduled for the future | 定时不触发 | Queue::assertNotPushed |
| 4 | sends the published email to the author when the job handles | Job 真发邮件 | Mail::fake() + Mail::assertSent |
| 5 | renders the post title into the mail subject | 邮件主题正确 | 直接 new Mailable 读 envelope |
| 6 | does not send mail when the author has no email | 空 email 不发 | Mail::assertNothingSent |
| 7 | configures the job to retry up to 3 times with 5s backoff | 配置正确 | 直接读属性 |
6 个测试技巧(按价值)
① Queue::fake() —— 让 dispatch 不真的入队
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 内部数据
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 逻辑
(new SendPostPublishedEmailJob($post))->handle();
Mail::assertSent(PostPublishedMail::class, ...);绕开队列基础设施——直接调 handle() 当作普通 PHP 方法测。
④ Mail::assertSent 闭包断言
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
$mailable = new PostPublishedMail($post);
expect($mailable->envelope()->subject)->toContain('My Special Title');→ Mailable 的 envelope() / content() 是纯函数——不需要发邮件,直接断言返回对象的属性。
⑥ Pest 链式断言 expect()->and()->
expect($job->tries)->toBe(3)
->and($job->backoff)->toBe(5);→ Pest 比 PHPUnit 优雅的地方之一。
Sprint 5 完成验收
✓ tests/Feature/PostQueueTest.php 7 测试全 passed
✓ php artisan test 整套 38 passed (75 assertions) in 1.30s7. Sprint 6:失败链路测试 — failed() 钩子 + 异常传播(10 分钟)
这个 Sprint 解决的核心问题
如果 Job 把异常吞了(比如
try { ... } catch (\Throwable $e) { /* swallow */ }),worker 永远不会重试——因为 worker 看到handle()正常返回了。队列重试语义的关键:异常必须传播。
测试 8:failed() 钩子写正确日志
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 也抛异常
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 优雅得多:
| PHPUnit | Pest |
|---|---|
$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 完成验收
✓ 失败链路 2 个新测试 passed
✓ php artisan test 整套 40 passed (79 assertions) in 0.84s8. 重大踩坑: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 不释放共享锁那么快)
典型并发场景:
- Web 进程(
serve)处理 HTTP 请求时偶尔读 / 写 - Worker 进程(
queue:work)每秒轮询 jobs 表 - 测试 / Tinker / Boost MCP 也可能在用
任意两个进程同时尝试写 SQLite 文件 → BOOM。
5 个解决方向(按价值)
| # | 方案 | 优点 | 缺点 |
|---|---|---|---|
| 1 | 换 MySQL/PostgreSQL(行级锁) | 生产正解 | 装 / 配置成本 |
| 2 | 换 Redis 队列驱动 | 生产高性能正解 | Windows 装 Redis 烦 |
| 3 | QUEUE_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:
QUEUE_CONNECTION=sync→ 教学完成后所有测试用 Queue::fake() + Mail::fake(),完全绕开队列驱动——所以测试不依赖这个选择。
这个踩坑的元意义
这次失败的真正价值,不是修复它,而是让你完整经历"诊断 → 修复"链路:
- 看 worker FAIL 输出
- 看 storage/logs/laravel.log
- 看 failed_jobs 表
- 用 queue:failed / queue:retry 命令
- 分析根因
- 选解决方案
- 验证修复
这套调试流程是 Laravel 队列在生产环境的标准 SOP。教科书讲十遍不如自己跑一次。
9. ThinkPHP vs Laravel 队列对照
队列概念对照
| ThinkPHP 6 | Laravel 12 |
|---|---|
用 topthink/queue 包,扩展安装 | 内置 Illuminate\Queue\ 模块 |
Queue::push('App\Job\Foo', $data) | Foo::dispatch($data)(静态分发) |
Job 类继承 \think\queue\Job | Job 类 implements ShouldQueue + use Queueable |
配置在 config/queue.php | 配置在 config/queue.php(一致) |
Worker:php think queue:work | Worker:php artisan queue:work |
| 失败 job 自己手写 try/catch 处理 | 内置 failed_jobs 表 + failed() 钩子 |
| 重试:自己实现 | 内置 $tries / $backoff 属性 |
邮件概念对照
| ThinkPHP 6 | Laravel 12 |
|---|---|
| 自己装 PHPMailer | 内置 Illuminate\Mail\ |
| 自己写 SMTP 连接 | .env 一行 MAIL_MAILER=... |
| 没有"Mailable 类"概念 | Mailable 类 = "邮件就是对象" |
| 模板自己写 HTML | Markdown 邮件 + 自动包装响应式 HTML |
| 同步发邮件(阻塞) | dispatch 到队列,异步执行 |
| 单一格式 | 自动 HTML + 纯文本双版本 |
测试概念对照
| ThinkPHP 6 | Laravel 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 出场顺序)
# 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 test11. 下一步可玩的方向 + 教程总结
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 队列 + Horizon | search-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:
application-info——确认 Laravel 12,让 AI 用Foundation\Queue\Queueable(聚合 trait)而不是老Bus\Queueablesearch-docs——查到Mail::fake()/Queue::fake()/Mockery的最新 v12 用法database-query——验证failed_jobs表内容、Tinker 替代品
→ 详见 docs/12-Boost工具实战大全.md。
教程整体里程碑(截至本章)
截至本章,教程已经完成:
| 文档 | 行数 |
|---|---|
docs/Laravel_Boost_教程大纲.md | 326 |
docs/03-环境搭建实测.md | 383 |
docs/06-11-章节速查.md | 1485 |
docs/12-Boost工具实战大全.md | 530 |
docs/13-博客实战完整复盘.md | 1279 |
docs/15-队列实战.md | 本篇 |
docs/17-Prompt工程反例集.md | 1549 |
→ 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 监控失败。