基础设施与扩容中小企业的云回迁:离开超大规模云何时省钱,何时不省
逐项对比计算、托管数据库、egress、备份和运维工时,附 37signals 的数据、留下或搬走的决策树,以及一份直到最后一步都保留回退路径的迁移计划。
流量突发时,后台任务最先出问题。跟随一套真实环境,从只有 20% 到 26% 任务按时完成的单个串行 worker,走到拆分队列和几秒内就能伸缩的 Horizon 进程池。
Author
Anichur Rahaman

考试当天早上 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% 的任务按时完成。这是某一套环境的数字,不是通用基准,但这种故障的形态非常常见。
数据库没有问题。托管实例离上限还很远。瓶颈是一个设计决定:一个进程,一条队,没有优先级。
第一个改动是物理层面的。Web 和 worker 原本共用一台 8 GB 的机器,worker 一忙就会抢走 PHP-FPM 的 CPU 和内存。网站变慢,恰恰是因为它自己放进队列的那些任务。
worker 被搬到了独立节点。现在,忙碌的队列想用多少核都行,不会影响任何一次页面加载;Web 层出了问题,也不会妨碍队列继续消化。这样还能按各自的工作给机器定规格:Web 节点面向大量短请求,worker 节点面向吃内存的任务。

第二个改动是不再把所有任务混在一条队里。我按任务对客户的意义建了几个独立的队列:
队列分开后,每个队列可以有自己的 worker 数量、超时时间和重试规则。一个要跑四分钟的报表任务,再也堵不住一笔支付确认。经验法则是:两类任务的紧急程度、内存需求或失败方式不同,就该放进不同的队列。
在一个 worker 里按优先级排列多个队列(排在前面的总是先处理),比混在一起要好,但仍然只是一个进程。给每个队列配专用进程,才能彻底解决低优先级任务插队的问题。
加 worker,队伍消化得更快,但只到某个点为止。在我的测试里,任务较轻时,约 12 个 worker 进程可在大约 3 秒内处理完 1,000 个任务;任务较重时,大约需要 10 秒。超过 20 个左右之后再加就没有用了:它们只是在数据库写入上互相排队。
| Worker 进程数 | 我观察到的结果(同一套环境) |
|---|---|
| 1 | 任务逐个执行;只有 20–26% 按时完成 |
| 约 12 | 1,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 或 false | auto |
| minProcesses | 每个队列即使空闲也保留的进程数 | 1 |
| maxProcesses | Horizon 最多可扩到的进程数上限 | 10 |
| balanceMaxShift | 一次调整最多增减多少个进程 | 3 |
| balanceCooldown | 两次调整之间等待的秒数 | 3 |
Horizon 文档中的默认值更保守:每次调整一个进程,每三秒一次。我把 shift 调到 3,因为突发流量不该花半分钟才爬升到位。结果是:队伍变长时,Horizon 在几秒内就拉起更多 worker 进程;队伍清空后又把它们回收。不用新机器,不用集群,也没有额外开销。

按进程伸缩有一个陷阱:容器必须装得下最大值,而不是平均值。如果每个任务峰值约 128 MB,允许 10 个进程,就是大约 1.3 GB,所以我把容器设成了约 2 GB。如果按平静状态来定规格,第一次真正的突发就会触发内存不足而被杀掉进程,内核会在任务执行到一半时直接干掉 worker。
内存要按每个任务来量,而不是按每个 worker,因为某一类重任务就可能吃掉整个预算。
我的环境里有一个任务,峰值达到 288 MB。它自己通过 HTTP 去拉取图片,又从本地磁盘读文件,所有内容同时放在内存里。改成从对象存储以流的方式读取后,峰值降到约 128 MB。仅这一项改动,就省下了整整一档机器规格。
在加内存或换更大的节点之前,先对每个重任务问三个问题:
小任务也更适合 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,会把任务写法上的每一个弱点都暴露出来。下面这些习惯让我的系统保持安全:

类似 cron 的定时任务必须只在一个节点上运行。如果你扩了 Web 或 worker 节点,而每个节点都跑着调度器,每份定时报表就会生成好几次,每条提醒会发两遍,每次清理还会互相冲突。
选定一个节点,写进文档,并让它远离任何自动伸缩组。后台命令和 Web 请求一样需要用心调优。在一套自托管环境中,调度器为每个小命令都要启动整个框架,既没有命令行 OPcache,内存限额又很紧,结果出现了数百次内存不足被杀的情况。把 relay 放进一个长期运行的进程里,并开启命令行 OPcache,问题就解决了。
worker 是长期运行的进程,所以在重启之前它一直保留着旧代码,而强行重启会杀掉正在执行的任务。安全的顺序是:先停止接收新任务,让正在执行的完成,再启动新的 worker。
包括蓝绿发布和回滚在内的完整发布流程,我放在第 5 篇。
下一次高峰之前,用这份简短的清单核对一遍:
回到考试当天 9:00。同样是 1,000 份提交,现在提交队列有自己专属的进程,前面没有任何东西挡路。Horizon 在大约十秒内从一个进程扩到十个,突发流量消化完的时间,也就是学生回头看一眼屏幕的工夫,确认消息在第一封客服邮件写出来之前就已发出。报表和图片任务则乖乖等着,本该如此。
下一篇高负载下的数据层,会讲 worker 快到足以给数据库施压之后会发生什么:连接池、Redis 的各种角色,以及专门存放 AI 向量嵌入的独立存储。
Anichur Rahaman 是一名软件架构师,也是 StoreConsole 的创建者。他为成长型企业设计电商与 ERP 系统,专注于事件驱动架构、数据完整性和自托管部署。
About the Author
Anichur Rahaman
Continue Reading