One self-hosted console to run your entire business — commerce, ERP, HRM, CRM & manufacturing

高负载下的队列与 worker:从单个串行 worker 到自动伸缩的进程池

流量突发时,后台任务最先出问题。跟随一套真实环境,从只有 20% 到 26% 任务按时完成的单个串行 worker,走到拆分队列和几秒内就能伸缩的 Horizon 进程池。

Author

Anichur Rahaman

2 周前12 min read2 views
高负载下的队列与 worker:从单个串行 worker 到自动伸缩的进程池

考试当天早上 8:59,一个考试平台的值班工程师还剩一分钟。9:00 整,1,000 名学生在同一分钟内点下“提交”。Web 服务器每次点击都在几毫秒内响应,监控面板一片绿色。到了 9:03,客服邮箱开始被塞满:学生在屏幕上看到了“已提交”,却没有收到任何确认,阅卷组那边也只看到寥寥几份答卷。这是一个示意场景,但我确实为这样的流量高峰准备过一套真实的平台。

问题不在 Web 层。一个后台 worker 在逐个处理这些提交。队列会把突发流量变成一条长队,所以 worker 的设计,决定了这条队是排三秒还是排十六分钟。

本文回顾我如何从单个串行 worker 走到一个能自动伸缩的进程池,以及让这个进程池可以放心运行的任务设计习惯。

本文是“高并发系统工程”系列的第 2 篇。第 1 篇讲了请求从边缘到应用的路径:应对流量高峰的自动伸缩:真正能伸缩的是什么。

为什么后台任务总是最先出问题

Web 请求背后有人在等,慢了我们马上就知道。队列里的任务此刻没人盯着,所以积压大到一定程度之前,慢是看不出来的。队列还会把一次突发变成一条队伍:一分钟内来了 1,000 个任务,每个耗时一秒,单个 worker 就要 16 分钟以上才能处理完。

我当时的起点正是如此。一个 worker,用类似 queue:work --queue=retakes,default 的命令启动,把 1,000 个突发任务逐个处理。更糟的是,低优先级任务和实时提交排在同一条队里,一批不重要的任务就可能堵在客户正在等待的操作前面。在对那次高峰的回放中,只有 20% 到 26% 的任务按时完成。这是某一套环境的数字,不是通用基准,但这种故障的形态非常常见。

数据库没有问题。托管实例离上限还很远。瓶颈是一个设计决定:一个进程,一条队,没有优先级。

第 1 步:把 worker 从 Web 节点分开

第一个改动是物理层面的。Web 和 worker 原本共用一台 8 GB 的机器,worker 一忙就会抢走 PHP-FPM 的 CPU 和内存。网站变慢,恰恰是因为它自己放进队列的那些任务。

worker 被搬到了独立节点。现在,忙碌的队列想用多少核都行,不会影响任何一次页面加载;Web 层出了问题,也不会妨碍队列继续消化。这样还能按各自的工作给机器定规格:Web 节点面向大量短请求,worker 节点面向吃内存的任务。

前后对比图:共用一个队列的 Web 与 worker 混合节点,对比分开的 Web 节点和 worker 节点,每种优先级一个队列
之前:一台机器,一条队。之后:Web 与 worker 分开,每类任务各有一个队列。

第 2 步:按优先级和类型拆分队列

第二个改动是不再把所有任务混在一条队里。我按任务对客户的意义建了几个独立的队列:

  • Submissions:面向客户的实时任务,必须在几秒内完成。
  • Notifications:邮件、短信和推送消息。很重要,但晚一分钟可以接受。
  • Image processing:很重、很吃内存,从不紧急。
  • Reports:缓慢的导出和汇总,可以等高峰过去再跑。

队列分开后,每个队列可以有自己的 worker 数量、超时时间和重试规则。一个要跑四分钟的报表任务,再也堵不住一笔支付确认。经验法则是:两类任务的紧急程度、内存需求或失败方式不同,就该放进不同的队列。

在一个 worker 里按优先级排列多个队列(排在前面的总是先处理),比混在一起要好,但仍然只是一个进程。给每个队列配专用进程,才能彻底解决低优先级任务插队的问题。

第 3 步:到底需要多少个 worker

加 worker,队伍消化得更快,但只到某个点为止。在我的测试里,任务较轻时,约 12 个 worker 进程可在大约 3 秒内处理完 1,000 个任务;任务较重时,大约需要 10 秒。超过 20 个左右之后再加就没有用了:它们只是在数据库写入上互相排队。

Worker 进程数我观察到的结果(同一套环境)
1任务逐个执行;只有 20–26% 按时完成
约 121,000 个轻任务约 3 秒,重任务约 10 秒
超过约 20没有收益;worker 在等待数据库写入

这些数字用简单的算术就能对上。每个任务的耗时是由结果反推出来的,请当作示意:一个约 0.03 秒的轻任务,1,000 个就是 30 秒的工作量,12 个 worker 分摊后大约 2.5 秒。一个约 0.12 秒的重任务,总量是 120 秒,12 个 worker 约 10 秒完成。而单个 worker 要整整 120 秒,那时实时提交的时间窗口早就过去了。

这也是结尾那份容量清单背后的道理:正确的数字不是“越多越好”,而是再多一个 worker 也不会让队伍变短的那个临界点。要用压测找到它,不要靠猜;在压测证明数据库确实饱和之前,也不要急着升级数据库。数据层的内容我放在第 3 篇。

伸缩进程,而不是机器

固定 12 个进程,在平静的周二是浪费,在大日子里又可能不够。答案是自动伸缩,但关键在于伸缩哪一层。启动一台新的虚拟机要一到三分钟,而定时高峰几秒钟就到顶了。正如第 1 篇所说,已知的活动要提前扩容。对队列来说还有更好的办法:在一台已经运行的机器内部伸缩进程。

Laravel Horizon 通过 balance 设置做到这一点。根据 Laravel 官方文档,Horizon 提供三种策略:simple 把新任务平均分给各个 worker 进程,auto 根据每个队列当前的负载调整该队列的进程数,false 则关闭均衡。使用 auto 时,几个选项决定它的反应速度。

设置项作用我在提交队列上用的值
balance策略:auto、simple 或 falseauto
minProcesses每个队列即使空闲也保留的进程数1
maxProcessesHorizon 最多可扩到的进程数上限10
balanceMaxShift一次调整最多增减多少个进程3
balanceCooldown两次调整之间等待的秒数3

Horizon 文档中的默认值更保守:每次调整一个进程,每三秒一次。我把 shift 调到 3,因为突发流量不该花半分钟才爬升到位。结果是:队伍变长时,Horizon 在几秒内就拉起更多 worker 进程;队伍清空后又把它们回收。不用新机器,不用集群,也没有额外开销。

示意图:突发期间队列深度上升,worker 进程数随之增加,随后回落
一次示意性的突发:worker 在几秒内跟着队列深度向上爬升,队伍清空后回落。

内存要按最大值来定

按进程伸缩有一个陷阱:容器必须装得下最大值,而不是平均值。如果每个任务峰值约 128 MB,允许 10 个进程,就是大约 1.3 GB,所以我把容器设成了约 2 GB。如果按平静状态来定规格,第一次真正的突发就会触发内存不足而被杀掉进程,内核会在任务执行到一半时直接干掉 worker。

内存要按每个任务来量,而不是按每个 worker,因为某一类重任务就可能吃掉整个预算。

买硬件之前,先给重任务瘦身

我的环境里有一个任务,峰值达到 288 MB。它自己通过 HTTP 去拉取图片,又从本地磁盘读文件,所有内容同时放在内存里。改成从对象存储以流的方式读取后,峰值降到约 128 MB。仅这一项改动,就省下了整整一档机器规格。

在加内存或换更大的节点之前,先对每个重任务问三个问题:

  1. 它能用流式读取,却把整个文件加载进了内存吗?
  2. 它是不是通过网络拉取本可以放进任务数据里,或者从就近存储读取的内容?
  3. 能不能把一个大任务拆成许多小任务?

小任务也更适合 Horizon 的均衡机制,因为它可以一小步一小步地调配容量。

对比各种自动伸缩方案

Horizon 不是伸缩 worker 的唯一办法。下面是针对 Laravel 风格队列的四种常见方案的对比。

方案伸缩对象反应时间成本与投入
固定副本(Compose 或 Swarm)不伸缩;数量由你手动设定手动简单,但始终为峰值容量付费
Kubernetes + HPA按 CPU 或自定义指标伸缩 pod几分钟需要集群和指标链路
Kubernetes + KEDA按队列长度伸缩 pod几分钟需要集群;依据真实信号伸缩
Horizon auto-balance容器内部的进程几秒免费,无需集群,受限于一台机器

KEDA 是个好项目。根据它的文档,它会在每个轮询间隔(默认 30 秒)检查各个触发器,比如 Redis 列表的长度,而从一个副本扩到多个副本,则由 Kubernetes 的 Horizontal Pod Autoscaler 完成。增加 pod 还意味着调度并启动容器。对几秒内就到顶的定时高峰来说,这仍然太慢;对小团队来说,集群本身也是一笔实实在在的成本。

我的做法是:用 Horizon auto-balance 应对快速变化,并在已知活动前提前把机器或最小值拉高。当单个节点已经装不下你每天的工作量时,再考虑 KEDA。

把任务设计成可以放心重试

一组速度很快的 worker,会把任务写法上的每一个弱点都暴露出来。下面这些习惯让我的系统保持安全:

  • 让任务具备幂等性。任务可能执行两次:超时、发布或重试之后。执行两次不能发出两封邮件,也不能扣两次款。在任务开头加一个唯一键,或检查当前状态。
  • 在提交之后再派发。如果任务是在数据库事务内入队的,worker 可能在数据存在之前就把它取走,或者事务回滚后留下一个没有意义的任务。只在提交成功之后再派发。
  • 对携带个人信息的任务数据加密。不加密的话,队列里的数据会以明文形式放在 Redis 中。能传 ID 就传 ID,其余部分加密。
  • 有意识地设置超时和重试。任务的超时应短于队列的重试窗口,重试之间要有退避,还要有一张你真的会去看的失败任务表。
单个队列任务的流程图:提交、派发、按类型入队、worker 取任务、检查是否已完成、执行、检查是否成功、延迟重试或进入失败任务表
一个任务,所有可能的结局:作为重复任务被跳过、顺利完成、延迟后重试,或落入失败表,让人能看见。

调度器只能有一个

类似 cron 的定时任务必须只在一个节点上运行。如果你扩了 Web 或 worker 节点,而每个节点都跑着调度器,每份定时报表就会生成好几次,每条提醒会发两遍,每次清理还会互相冲突。

选定一个节点,写进文档,并让它远离任何自动伸缩组。后台命令和 Web 请求一样需要用心调优。在一套自托管环境中,调度器为每个小命令都要启动整个框架,既没有命令行 OPcache,内存限额又很紧,结果出现了数百次内存不足被杀的情况。把 relay 放进一个长期运行的进程里,并开启命令行 OPcache,问题就解决了。

每次发布都要排空队列

worker 是长期运行的进程,所以在重启之前它一直保留着旧代码,而强行重启会杀掉正在执行的任务。安全的顺序是:先停止接收新任务,让正在执行的完成,再启动新的 worker。

  1. 执行 Horizon 的终止命令(horizon:terminate),让 worker 做完手头的任务后退出。
  2. 给容器一个宽裕的停止宽限期,要比你最长的任务还长,免得编排器提前把它杀掉。
  3. 在新版本上启动新的 worker。
  4. 最后启动调度器,避免有定时任务打到一个只部署了一半的系统上。

包括蓝绿发布和回滚在内的完整发布流程,我放在第 5 篇。

一份 worker 容量清单

下一次高峰之前,用这份简短的清单核对一遍:

  1. 量出活动高峰那一分钟里,每个队列会产生多少任务。
  2. 在真实 worker 上量出每类任务的运行时间和内存峰值。
  3. 用任务数除以运行时间,估算要在目标时间内消化高峰需要多少进程。
  4. 把上限设得比估算值稍高,容器内存按最大进程数乘以单任务内存峰值来定。
  5. 用真实的请求组合做压测,并盯住数据库写入耗时:它成为限制时,就停止加 worker。
  6. 确认调度器只运行一份、任务具备幂等性、发布时能干净地排空队列,并确定要盯哪些队列深度和等待时间指标(见第 4 篇)。

回到考试当天 9:00。同样是 1,000 份提交,现在提交队列有自己专属的进程,前面没有任何东西挡路。Horizon 在大约十秒内从一个进程扩到十个,突发流量消化完的时间,也就是学生回头看一眼屏幕的工夫,确认消息在第一封客服邮件写出来之前就已发出。报表和图片任务则乖乖等着,本该如此。

下一篇高负载下的数据层,会讲 worker 快到足以给数据库施压之后会发生什么:连接池、Redis 的各种角色,以及专门存放 AI 向量嵌入的独立存储。

核心要点

  • 突发流量下,后台任务通常最先出问题,而且积压变大之前,慢会一直隐藏。
  • 把 worker 从 Web 节点上拿开,并给每种优先级或任务类型各配一个队列。
  • 用 Horizon auto-balance 在机器内部伸缩 worker 进程,做到秒级响应,内存按最大值来定。
  • 买硬件之前先给重任务瘦身,数据库写入成为限制时就停止加 worker。
  • 任务要幂等,提交之后再派发,队列数据里的个人信息要加密,调度器只在一个节点上运行。
  • 每次发布都用宽裕的宽限期排空队列。

Anichur Rahaman 是一名软件架构师,也是 StoreConsole 的创建者。他为成长型企业设计电商与 ERP 系统,专注于事件驱动架构、数据完整性和自托管部署。

About the Author

Anichur Rahaman

Continue Reading