如何设计可靠的消息队列任务


队列任务本质上就是序列化后被持久化的 PHP 对象,再经过传输、反序列化最终被处理器进程执行。它们可以多次运行,也可以在多台机器运行,还可以并发运行。

今天我们就来探讨如何设计可靠的队列任务。

让任务自给自足

队列任务执行的时机是不确定的,而系统状态并不是一成不变的,可能在推送和执行的时候发生了改变。因此,需要确保队列任务可以做到「自给自足」:即在推送和执行时状态是一致的,或者说,在推送的时候就已经包含了所有执行时需要的东西。

以下面这个部署任务为例:

-w728

虽然我们也可以在 handle 方法中获取最近一次 commitHash,但是执行时的 commitHash 和推送时可能已经不是一个值了,所以这里在推送的时候就通过 commitHash 属性将其传递到队列任务。

当我们设计队列任务时,需要仔细审查该任务的所有属性信息,并且基于执行时使用的数据是推送时的状态还是最新状态来决定是否需要在推送时传递额外冗余信息。

让任务变得简单

任务对象会被序列化、经过 JSON 编码、推送到队列存储器、通过网络进行传输、最后被反序列化回原始对象进行执行。

如果队列有着非常复杂的依赖和嵌套,会在序列化/反序列化时消耗更多 CPU 资源,也会在存储时消耗更多存储空间,向队列推送/拉取也要更多 IO 时间(网络)。

所以尽可能保持队列任务的简单可以优化队列系统的性能,比如让队列任务的属性尽可能是简单的标量类型,比如数字、字符串、布尔类型、数组等,如果必须传递对象,最好也是包含简单属性的简单对象:

-w640

这里依赖两个复杂对象,可以对其进行优化,缓存管理器完全可以在处理队列任务时获取:

-w666

由于处理器进程使用的是常驻内存的单个应用实例,而缓存处理器又以单例模式绑定在服务容器中,所以解析成本很低。

另一个需要引入的就是 SerializesModels Trait:

-w672

它会将 Eloquent 模型类序列化为只包含主键 ID 的简单 PHP 对象,详细信息会在执行队列任务时从数据库加载来获取。

让任务变得轻量级

通过 Redis 存储队列任务时,会占用部分 Redis 内存。SQS 的话会有每个队列任务不能超多 256 KB 的限制。除了这些考量之外,如果任务类太大,还会影响在网络上的传输速度,增加网络延时。

所以需要让队列任务类的尺寸尽可能小,如果必须要传递一个很大的队列任务,可以试图将其存储在别处,然后传递其引用到任务类。

影响队列任务尺寸的要素如下:

  • 任务类属性;
  • 队列化的闭包大小;
  • 队列化的闭包使用了变量;
  • 队列任务链。

我们看一个闭包:

-w644

为了序列化闭包进行存储,Laravel 使用了 opis/closure 这个扩展包,这个包会读取闭包的函数体并将其序列化一个字符串:

-w608

此外还使用了应用的密钥(APP_KEY)对序列化字符串进行签名,以便在反序列化时进行验证,避免内容在传输或存储时被篡改。

传递给闭包的变量也会被序列化并存放在载荷数据中:

-w694

你也可以看到,Laravel 会自动将 Eloquent 模型实例转化为只包含主键 ID 的简单对象,如果你想要传递一个复杂的 PHP 对象或者很大的二进制数据,它们也会被存储到队列任务的载荷数据中。

如果你发觉队列闭包越来越大,不仅仅是几行代码了,则需要适时将其重构为基于任务类实现。

对于任务链而言,所有任务的载荷数据都会存放到第一个任务的载荷数据中:

-w684

例如 EnsureNetworkHasInternetAccessCreateDatabase 的载荷数据就会被以 chained 属性值的方式存放到 EnsureANetworkExists 的载荷数据中。

如果任务链很长,导致载荷数据非常大以致失控,可以考虑使用更少的任务来启动任务链,然后在最后一个任务中添加更加任务到任务链:

-w658

让任务幂等

所谓幂等任务指的是多次运行该任务不会产生任何副作用:

-w644

在这个任务中,我们在退款前首先检查账单是否已退款,这样一来,即便多次运行,也不会进行重复退款了。

让任务可并行执行

多个队列任务可能会并行运行,从而导致试图读写同一个资源时产生竞态条件。

如果队列任务并行运行会对系统状态产生负面影响的话,你可以使用缓存锁/分布式锁来阻止多个任务并行运行。

还可以使用漏斗来限制指定任务并行运行的数量。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 在应用部署时重启队列任务处理进程

>> 下一篇: 基于 Bus 门面或 dispatch 函数推送队列任务