跳转到主要内容
即使更新了包,也要确保更新前投递的任务仍能被处理。队列中保存的不是要执行的代码,而是任务类的名称和状态。除了新代码的单元测试之外,还需要用新代码恢复旧代码所保存状态的测试。
本页面向 Laravel 13,确认了 Framework v13.42.0 的实现。前提是已了解队列基础和包的测试。以下兼容性设计是包一方的方针,并不是 Laravel 自动迁移自定义载荷格式的功能。

存储与执行之间代码会发生变化

对于对象任务,Queue::createObjectPayload() 会执行 serialize(clone $job),并将结果放入载荷的 data.command。即使启用了加密,在此之前也会进行同样的序列化。 worker 一侧的 CallQueuedHandler::getCommand() 会对已保存的数据执行 unserialize(),并将恢复后的任务交给 Dispatcher。通常的 handle() 会使用此时 worker 正在运行的代码和服务容器来执行。 如果没有重启长时间运行的 worker,更新后旧代码仍会继续运行。在滚动部署中,不仅要考虑”旧投递方与新 worker”的组合,还要考虑”新投递方与旧 worker”的组合。 判断类的重命名或删除时,不能只看用户是否直接调用了该类。等待中、延迟中、等待重试的任务以及失败任务的保留期间,也都属于兼容性的范围。

SerializesModels 保存的内容

Illuminate\Foundation\Queue\Queueable 在内部使用了 SerializesModels。它不仅负责替换模型,还提供列举任务自身属性的 __serialize() 和还原值的 __unserialize()。

与声明默认值相等的值不会被保存

__serialize() 会排除 static、未初始化等属性,并且当属性的当前值与声明默认值严格相等时也会省略保存。
如果这个属性仍是 'csv',保存的数组中就不会包含 format。如果之后的版本把声明默认值改为 'json',旧任务也可能会使用 'json'。这不是单纯外观上的默认值变更,而是旧数据读取方式的变更。

恢复时不会重新调用构造函数

__unserialize() 会为当前类中存在、且保存数组中也有对应键的属性赋值。它不会执行通过构造函数进行的输入补全或验证。
这个例子中的 'csv' 是构造函数参数的默认值。它不是属性本身的声明默认值,因此在恢复不含 format 的旧载荷时不会起到补全作用。如果新增的类型化属性保持未初始化状态,之后访问时就会失败。 此外,public 属性的键就是名称本身,protected 属性的键带有表示可见性的前缀,private 属性的键还包含类名。将属性从 public 改为 private 的变更也要用旧载荷进行验证。
SerializesModels 列举的是任务的属性。不要认为任意 DTO 的内部或数组深处的模型也会被递归地进行同样的转换。传递模型以外的对象时,这些类及其存储格式也属于兼容性的范围。

将标识符与执行服务分开

将执行所需的标识符和选项保存在任务中,而 API 客户端或 Exporter 等执行服务则注入到 handle() 中。如果把服务传给构造函数并保留下来,其内部状态也可能成为保存对象。 下面的示例为旧版本中只持有 reportId 的同名任务添加了格式选择和载荷版本。ReportExporter 是包提供并绑定到容器的契约,假设它拥有 export(string $reportId, string $format): void。
这个设计将缺失值的含义与新投递的值分开。
  • 旧载荷中没有 format 时,使用声明默认值 'csv'。
  • 旧载荷中没有 payloadVersion 时,按声明默认值 1 处理。
  • 新建任务时,构造函数会把 payloadVersion 设为 2。由于与声明默认值不同,这个值会被保存。
  • 新任务的 format 为 'json' 时,该值也会被保存。为 'csv' 时会被省略,因此今后也要保持声明默认值 'csv'。
如果将来只想更改新任务的默认格式,可以修改构造函数参数的默认值和赋值,并与用于旧数据的属性默认值分开考虑。载荷版本不是包的发布版本号,而是作为存储格式及其解释的世代来管理。
仅仅添加版本号并不能获得兼容性。本例中的新版本也能处理旧格式,但无法保证旧版本的 worker 能理解新的 format 或 payloadVersion。还需要后文所述的投递方与 worker 的更新顺序。

传递模型时要与快照区分开

将模型直接保存在属性中时,SerializesAndRestoresModelIdentifiers 会将其替换为包含类、ID、连接名、已加载关联名等信息的 ModelIdentifier。恢复单个模型时,会通过 firstOrFail() 从数据库重新获取,并加载目标关联。 这并不是固定投递时属性或关联数据的机制。
  • 如果记录在执行前被更新,恢复后引用的是更新后的内容。
  • 事先应用于关联的筛选条件不会被保存,因此需要在执行时重新附加必要的条件。
  • 不需要的关联可以通过 withoutRelations() 或 #[WithoutRelations] 排除在保存对象之外。
  • 如果模型已被删除,任务可能会在 handle() 之前的恢复阶段失败。
如果可以丢弃已删除模型的任务,可以考虑使用官方的 #[DeleteWhenMissingModels]。不过,包不应擅自将丢弃设为默认行为,而应向用户公开失败方针。如果采用只传递 ID 并在 handle() 中查询的设计,则由自己控制该查询的失败或跳过。 如果需要投递时的内容,就明确保存所需的值,或者将不可变的报表请求等记录到数据库并传递其 ID。要区分单纯传递模型与保存业务数据快照这两件事。

提交后投递是另一个问题

处理在事务中创建的记录时,要确保 worker 不会在提交之前开始读取。
afterCommit() 会等到打开的父事务提交后再投递,回滚时则丢弃该投递。它并不是解决类名、属性兼容性或外部处理重复执行的功能。

将 fake 与旧载荷的测试分开

通常的 Queue::fake() 用于记录投递。仅仅 assertPushed() 通过,并不代表已经确认了存储、恢复,或新版本对旧载荷的处理。 当前类的往返可以这样验证。
serializeAndRestore() 会在 fake 内执行 serialize() 和 unserialize()。但它不会验证向真实后端的投递、worker 的执行以及旧版本代码的生成。

保留用旧版本生成的 fixture

在旧版本的 GenerateReport 只持有 reportId 的状态下,生成不包含真实数据的固定任务。以下是在旧版本的测试环境中执行一次的准备代码。tests/fixtures/ 需要事先准备好。
将 fixture 纳入版本管理,并在新版本的测试中读取。如果每次测试都用新版本重新生成,就不再是向后兼容性的验证,因此要保持固定。
本例中的 TestCase 是在包的测试环境中准备的基类。传给 unserialize() 的只能是自己管理的 fixture,不要传入外部输入。 此外,还要用测试用的 Exporter 执行恢复后旧任务的 handle(),验证到输出和保存结果为止。传递模型时,要另外验证更新、删除、连接和关联的重新获取;在实际使用的队列连接上,还要另外验证提交后投递和重试。

在投递新格式之前先更新读取方

1

准备能够读取旧格式的版本

先发布保持旧类名和旧值解释、同时也能读取新格式的代码。用 fixture 不仅确认恢复,也确认处理结果。
2

确认没有残留的旧 worker

在使用方应用的部署流程中加入 worker 的替换。对于通常的 queue:work,使用 php artisan queue:restart 请求在处理完当前任务后退出,并确认进程管理器启动了新版本。重启信号会使用缓存。
3

启用新格式的投递

在读取方完成支持之后,再通过功能开关等切换到新格式的投递。如果仅发布同一份代码就会开始生成新格式,就需要暂停投递,或将新旧队列与对应的 worker 分开等,确保不会交给旧 worker 的流程。
4

在重试和回滚期间也要持续维护

还要确认失败任务的重试以及在任务链中稍后投递的任务。如果在保存新格式后回退到旧代码,要验证旧代码也能读取。不要仅仅因为队列曾经清空过一次就删除对旧类或旧格式的支持。
queue:restart 并不是重写已保存载荷的命令。在包的更新说明中,要写明支持的格式、所需的 worker 替换以及废弃旧格式的条件。同样的变更也要记录在版本兼容性管理和 Changelog 的运用中。

参考的一手资料

最后修改于 2026年11月12日