PAYATHON 2026

Laravel Observer 派发 Job 为何抛出 ModelNotFoundException?

排障赵师傅

结论

只传入整数 ID,不会让 Laravel 自动查找或还原 Invoice。这段堆栈表明,执行过程中触发了 Eloquent 模型标识的还原,但没有找到对应的 Invoice 记录。

先检查 UpdateOrderParcelLithiumStatusJob 的构造函数、实例属性、父类和 traits,再核对失败任务的实际 payload。dispatch() 只接收一个整数,不代表 Job 序列化时也只保存了这个整数。

目前缺少 Job 类代码,还无法确定 Invoice 是在哪里进入序列化数据的,也不能仅凭这段堆栈认定 Observer 或事务时序有问题。

为什么传入 ID,还会还原 Invoice?

使用 SerializesModels 的 Job 在序列化模型属性时,会把模型转换成 ModelIdentifier,其中包含类名、主键、连接信息等。消费队列时,Laravel 再根据这些信息查询数据库,恢复模型对象。

还原单个模型时,如果查不到记录,SerializesAndRestoresModelIdentifiers::restoreModel() 就可能抛出 ModelNotFoundException。

例如,下面的 Job 只接收整数,却在构造函数中保存了一个 Invoice:

use App\Models\Invoice;
use App\Models\OrderDeliveryOption;
use Illuminate\Queue\SerializesModels;

class UpdateOrderParcelLithiumStatusJob
{
    use SerializesModels;

    public int $orderDeliveryOptionId;

    public Invoice $invoice;

    public function __construct(int $orderDeliveryOptionId)
    {
        $this->orderDeliveryOptionId = $orderDeliveryOptionId;

        // 假设项目中定义了 invoice 关联。
        $this->invoice = OrderDeliveryOption::findOrFail(
            $orderDeliveryOptionId
        )->invoice;
    }
}

模型通过 $this->invoice 进入 Job。构造函数在派发端执行,如果任务执行前这张发票被删除,或者 worker 使用的数据库连接查不到它,还原就会失败。

在常规队列流程中,Job 会先反序列化,再执行 handle()。因此,在 handle() 中加日志或 try/catch,可能捕获不到这次异常。

Observer 上已加载的关联会被带进 Job 吗?

如果派发代码确实只有:

UpdateOrderParcelLithiumStatusJob::dispatch($orderDeliveryOption->id);

那么,即使 Observer 持有 $orderDeliveryOption 对象,它已加载的关联也不会自动进入 Job。原代码中的日志记录同样不会把模型附加到任务上。

只有模型进入了 Job 的可序列化属性,或其他参与序列化的对象,才需要追查它的还原过程。

直接把 Eloquent 模型保存为 Job 属性时,SerializesModels 通常还会记录已加载关联的名称,并在还原时重新加载这些关联。不过,普通关联查询没有结果,不一定会触发单个模型还原时的 firstOrFail()。所以,错误中出现 Invoice,还不足以说明它来自 OrderDeliveryOption 的已加载关联。

确实需要传递模型时,可以用 withoutRelations() 减少关联的序列化信息。但模型本身仍需还原,记录不存在时依然可能失败。当前只传 ID 的代码无须为此调用 withoutRelations()。

Job 类应重点检查哪些内容?

1. 构造函数是否查询并保存了模型

查找构造函数中的模型查询和属性赋值,例如:

$this->invoice = Invoice::findOrFail($invoiceId);
$this->order = $orderDeliveryOption->order;
$this->model = $service->getInvoice();

也要检查 PHP 构造函数属性提升:

public function __construct(
    public int $orderDeliveryOptionId,
    public ?Invoice $invoice = null,
) {
}

构造函数最好只保存任务需要的输入。如果查询依赖数据库的当前状态,可以放到 handle() 中执行,这样更容易明确处理记录缺失的情况。

2. 所有实例属性及其赋值来源

除了 public 属性,还要检查 protected、private、父类属性,以及 trait 引入的属性。

重点查找:

  • Invoice 或其他 Eloquent 模型属性。
  • 构造函数调用的初始化方法、父类构造函数。
  • 保存到属性中的 DTO、服务对象及其自定义序列化逻辑。
  • 自定义的 __serialize()、__unserialize()、__sleep()、__wakeup()。

SerializesModels 是框架的正常机制。直接移除它会改变模型的序列化方式,却无法解释模型为何进入任务,也不能可靠地解决这个问题。

3. 失败任务是否确实来自这次派发

核对失败任务的 Job 类名、UUID、派发时间、队列名和完整堆栈,并在可信的本地环境中检查实际 payload。常见队列 payload 的任务对象保存在 data.command 中;如果任务经过加密,就不能直接按普通序列化文本查看。

检查 payload 中是否有指向 App\Models\Invoice 的 ModelIdentifier,并核对它的 ID 和连接信息。不要把完整业务 payload 直接写入生产日志,也不要对不可信输入执行 unserialize()。

如果修改后的 Job 属性已经只剩标量,失败任务中却仍然包含 Invoice,继续检查:

  • 队列中是否还有修改前入队的旧任务。
  • 是否有其他调用点给同一 Job 设置了模型属性。
  • 实际失败对象是否是另一个任务或 queued listener。
  • worker 是否仍在运行旧代码。

常驻的 queue:work worker 需要在部署后按部署流程重启,例如:

php artisan queue:restart

重启 worker 不会改写已经入队的旧 payload。

4. Invoice 在 worker 的数据库中是否存在

拿到实际模型 ID 后,核对派发端和 worker 的数据库配置、模型连接及租户上下文,同时检查记录是否在任务执行前被删除。

Web 请求使用的连接能查到记录,不代表 worker 也能访问同一条记录。多租户应用尤其要确认,租户上下文是否在模型反序列化之前就已建立。

Laravel 版本不同,或模型定制了 newQueryForRestoration(),都可能影响还原查询。涉及全局作用域、软删除时,要检查项目实际版本的实现,不能直接套用普通业务查询的行为。

推荐的 Observer 和 Job 写法

Observer 只负责轻量派发,Job 保存标量输入,在 handle() 中查询数据并处理记录缺失。以下示例假设主键是整数:

public function created(OrderDeliveryOption $orderDeliveryOption): void
{
    UpdateOrderParcelLithiumStatusJob::dispatch(
        $orderDeliveryOption->getKey()
    )
        ->afterCommit()
        ->delay(now()->addSeconds(5));
}

Job 示例:

namespace App\Jobs;

use App\Models\OrderDeliveryOption;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class UpdateOrderParcelLithiumStatusJob implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $orderDeliveryOptionId;

    public function __construct(int $orderDeliveryOptionId)
    {
        $this->orderDeliveryOptionId = $orderDeliveryOptionId;
    }

    public function handle(): void
    {
        $option = OrderDeliveryOption::query()
            ->find($this->orderDeliveryOptionId);

        if ($option === null) {
            Log::notice('OrderDeliveryOption missing; job skipped.', [
                'order_delivery_option_id' => $this->orderDeliveryOptionId,
            ]);

            return;
        }

        // 在这里查询所需关联,并执行实际更新逻辑。
    }
}

记录不存在时是否跳过,要由业务决定。如果记录必须存在,应使用 findOrFail(),或抛出明确的业务异常;如果允许依赖的记录稍后创建,则需要设置重试次数和时间上限。

多连接或多租户场景还需要传递明确的标量上下文,在查询前选择正确的连接或初始化租户。只传主键无法解决数据库定位问题。

afterCommit() 和延迟 5 秒能解决什么?

afterCommit() 可以避免相关数据库事务尚未提交,worker 就开始读取数据。在支持该机制且配置正常的情况下,任务会等 Laravel 管理的相关事务提交后再入队。

它无法保证以下条件:

  • Invoice 已由另一个独立事务或另一个 Job 创建。
  • worker 的连接、数据库和租户配置正确。
  • 记录在任务执行前不会被删除。

delay(now()->addSeconds(5)) 设置的是任务可执行的时间点,worker 可能更晚才执行。它也无法保证任务与其他任务的完成顺序。延长延迟时间只能改变问题出现的概率,无法修复错误的模型属性或连接配置。

直接执行逻辑能够成功,说明当时的数据和运行环境满足条件。直接调用 handle() 通常没有经过队列的序列化、等待和反序列化过程,因此不能据此排除任务还原问题。

如果所用版本支持 $deleteWhenMissingModels = true,可以用它让框架在模型还原失败时丢弃任务,但前提是模型消失后,这项任务确实已无执行意义。只保存 ID、在 handle() 中自行查询的 Job,仍需自行处理查询结果。

先确认失败任务的 payload 是否包含 Invoice 标识,再找出引入它的属性赋值,才能区分模型序列化问题和执行时的数据查询问题,避免反复调整 Observer 或五秒延迟。

备注:内容仅供参考。