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

应对流量尖峰的自动扩缩容:什么真正能扩,什么不该扩

预定的流量尖峰在几秒内到顶,而新服务器要几分钟才能就绪。本文讲清突发流量下什么能扩、为什么预扩容胜过响应式规则、如何配置 SSR 和 PHP-FPM 层,以及怎样做压测。

Author

Anichur Rahaman

3 周前11 min read2 views
应对流量尖峰的自动扩缩容:什么真正能扩,什么不该扩

每秒 20 个请求,平平稳稳。限时抢购开始前的晚上 11:59,一家网店的流量只有这么多,平台负责人正盯着监控面板。自动扩缩容(autoscaling)规则已经就位:CPU 连续五分钟超过 70%,就加一台服务器。(这是一个示意性的场景,并非真实店铺。)

午夜,抢购开始,大约一千名买家在同一分钟里同时涌入。负载一下跳到每秒 440 个请求。12:01,监控面板还是绿的,因为五分钟的平均值几乎没动。12:03,结账开始超时。新服务器在 12:06 才加入,那时买家早已去了竞争对手那里。

自动扩缩容是在负载出现之后才做出反应,而预定的尖峰恰恰赶在这个反应之前到来。本文讲的是:突发流量下什么真正能扩,什么不该指望它去扩,以及怎样在活动之前而不是活动当中把这件事弄清楚。

下面的数字来自我自己在同一套 Laravel + Next.js 环境里的测试。它们演示的是方法,不是通用基准,请在你自己的系统上重新测一遍。

本文是五篇系列“高并发系统工程”的第 1 篇,讲从边缘到应用的请求链路。队列、数据层、可观测性和安全发布,会在第 2 至第 5 篇展开。

尖峰不是忙碌的一天

两种流量形态,需要两种不同的应对。缓慢上升给了任何控制回路发现并响应的时间;阶跃式跳升则不给。如果负载在一分钟内从每秒 20 个请求跳到 440 个,一条盯着五分钟平均 CPU 的规则,这时还什么都没看到。

好消息是,预定的尖峰是可以预测的。你知道开始时间,大致知道会来多少人,往往还能把上一次活动的请求组合重放一遍。这种提前量是你最大的工具,本文大部分内容就是讲怎么用好它。

请求链路:从边缘到应用

决定扩什么之前,先把一个请求走过的路径画出来。在我运行的环境里,它是这样的:最前面是 CDN 和 WAF,接着是托管的负载均衡器,两个后端 Web 节点,一个或多个服务端渲染(SSR)前端节点,一个处理后台任务的 worker 节点,最后面是数据层。

参考架构:CDN 与 WAF、负载均衡器、后端 Web 节点与 SSR 前端节点、worker 节点,然后是数据库、Redis 和对象存储
从边缘到数据的请求链路。每一层的扩展方式不同,速度也不同。

每个方框都有自己的故障模式和扩展手段。把它们当成一台“服务器”,结果往往是给从来不是瓶颈的那一层加内存。

边缘与负载均衡器

先在最前面放一个 CDN。静态文件、图片,以及所有访客看到都一样的页面,尖峰期间根本不该到达你的服务器。同一层的 WAF 和机器人规则,还能挡住爬虫,免得它们吃掉你留给买家的容量。

CDN 后面用带健康检查和连接排空(connection draining)的托管负载均衡器。健康检查会自动摘掉有问题的节点;连接排空让节点在离开前先处理完手头的请求,发布和缩容因此对用户无感。

有两个细节值得核对。第一是调度算法。不少云负载均衡器默认用轮询(round robin),它假定每个请求的开销一样。结账和搜索显然不是。“最少未完成请求”或“最少连接”模式,会把下一个请求交给手上活最少的节点。以 AWS 为例,Application Load Balancer 默认是轮询,最少未完成请求是目标组上的一项设置。第二,负载均衡器本身也是需要扩容的服务。AWS 文档说明,Application Load Balancer 大约能在五分钟内把容量翻倍,并提供预留容量,用于五分钟内流量增长超过一倍的活动(见容量预留指南)。其他云厂商也有类似的限制,建议问清楚你用的那家。

在我的压测里,负载均衡器从未成为瓶颈:最高请求速率下 CPU 始终不超过 8%,零错误。这很典型,先去链路更深处找。

无状态的 Web 节点:入场门槛

只有任何一个节点都能处理任何请求,才能把两个 Web 节点放到负载均衡器后面。这个特性叫无状态(stateless)。一开始做对很便宜,事后补救却很痛苦。加第二个节点之前,先对照这份清单。

  1. 会话放在 Redis 或 Valkey,不要放在节点磁盘的文件里。
  2. 应用缓存(cache)放在 Redis 或 Valkey,所有节点共用。
  3. 上传文件和生成文件放在对象存储(兼容 S3),绝不放本地磁盘。
  4. 调度器只有一个。类 cron 的定时任务只能在一个节点上跑,否则每个任务都会执行两次。
  5. 构建完全一致。每个节点跑同一个镜像,配置从环境变量读取。
  6. 各层的上传限制要对齐。每一层代理都有自己的请求体上限(nginx 的 client_max_body_size,PHP 的 post_max_size 和 upload_max_filesize)。在一套环境里,宿主机代理默认的 1 MB 上限一直悄悄拒绝上传,直到各层数值一致才解决。

像 StoreConsole 这样的自托管平台,正是出于这个原因把会话和缓存放在 Redis 里,让节点可以互相替换。

SSR 层:一个 Node 进程只相当于一个核

这是最让我意外的地方。Next.js 服务器在 Node.js 进程里渲染页面,而 Node 用单线程执行 JavaScript。机器再大,一个进程在渲染上也只能用到大约一个核。

我用 k6 重放了上一次高峰的真实请求组合,只打一个前端进程。它在每秒约 220 到 250 个请求时饱和。之后 p95 延迟从约 260 毫秒跳到约 3 秒,p99 达到 6.4 秒,而这一路宿主机上还有两个以上的空闲核。真实活动的高峰分钟需要每秒约 440 个请求,也就是说,一个进程会在最关键的那一刻失守。

解决办法很朴素:用同一个镜像跑多个一模一样的前端容器(我的例子是三个),前面用 nginx 的 least_conn,把每个请求交给活跃连接最少的服务器,然后按目标速率重新压测。Next.js 还有一点要注意:默认每个实例都有自己的本地缓存,所以多实例时,应按框架文档的做法改用共享缓存存储。

算一笔账

这里的容量规划就是简单的乘法:用实测的单进程上限乘以进程数,再和预期的高峰对比,并留出余量。

项目数值(一套环境)说明
高峰分钟的需求~440 req/s来自重放上一次真实活动
单个 SSR 进程,实测220-250 req/s超过后 p95 从 ~260 ms 升到 ~3 s
三个 SSR 进程~660-750 req/s约为高峰的 1.5 倍,作为余量
负载均衡器 CPU8% 或更低不是瓶颈

PHP-FPM:有意识地设定进程池大小

后端 Web 层的问题正好相反。PHP-FPM 运行一个 worker 进程池,每个 worker 一次只处理一个请求。池太小,请求就排队;池太大,节点内存耗尽,开始交换或杀进程。

一条好用的经验公式:需要的忙碌 worker 数 ≈ 每秒请求数 × 平均单次请求耗时。举个示意性的例子:每秒 200 个请求、每个 150 毫秒,大约需要 30 个忙碌的 worker,所以设 60 的池子能给慢请求留出空间。然后核对内存:如果单个 worker 占约 100 MB,60 个就要约 6 GB,节点内存必须比这更大。

我在一套环境里用的参数是 pm.max_children=60、start_servers=30、min_spare_servers=20、max_spare_servers=30、max_requests=1000,并在 nginx 与 FPM 之间启用长连接(keepalive 16 和 fastcgi_keep_conn on)。起步 30 个很重要:浪涌到来时池子已经热好了,不用在压力下临时 fork。

还有两个习惯值得养成。生产环境开启 OPcache 并设 validate_timestamps=0,发布时重启 worker。另外可以考虑 fastcgi_next_upstream error timeout http_500 http_503 配合 fastcgi_next_upstream_tries 2,它把 FPM 偶发的小故障变成了静默重试。注意这只对幂等请求安全。重试一个支付 POST 不是修复,而是 bug。

响应式扩容为什么总是来晚

现在说核心观点。响应式扩容要先盯指标,等阈值被突破,再启动机器,再等它开机、加入负载均衡器、预热缓存。实际上这最快也要一到三分钟。默认配置还可能更慢:比如 AWS EC2 Auto Scaling 的默认冷却时间是 300 秒,基础监控每五分钟才发布一次实例指标,除非你付费开启一分钟粒度的详细监控。

预定的尖峰在几秒内就到顶了。新机器赶到时,人已经走了,更糟的是,他们已经看到了报错。虚拟机不重启也没法加内存,所以“原地加大”同样行不通。

时间线对比:响应式扩容在尖峰到顶几分钟后才增加容量,而预扩容在活动开始前就备好了容量
响应式扩容跟在负载后面,预扩容早已严阵以待。示意性时间线。

对已知活动,提前扩容

对预定的活动,在开始前把节点数下限调高,结束后再调回去。这比听上去便宜:多开几个小时的一个节点,成本远低于一场失败的促销。它也比任何规则更可靠,因为它不依赖某个指标恰好及时越线。

响应式规则留作应对意外的安全网,不要当成主方案。还要扩那些扩得快的东西。容器内的进程几秒钟就能启动,机器则要几分钟,这也是我按进程而不是按机器来扩队列 worker 的原因(见本系列第 2 篇)。

方式响应时间最适合
预扩容(调高下限)活动前已就绪预定的尖峰
响应式虚拟机扩容1-3 分钟或更久缓慢增长、计划外流量
进程级扩容几秒容器内的队列 worker
流程图:尖峰的开始时间是否已知、负载是否缓慢上升、任务是否是队列作业,以及每个答案指向哪种扩容手段
该拉哪根杠杆、什么时候拉:三个问题决定选预扩容、响应式扩容、进程级扩容,还是边缘防护。

像真实活动一样做压测

上面没有一个数字是猜出来的,都来自贴近真实活动的测试。只猛压首页,几乎证明不了什么。按这个顺序来。

  1. 提取真实请求组合,从上一次活动的访问日志里取:哪些 URL、什么比例、用户的操作间隔多长。
  2. 用 k6(或类似工具)重放,先按预期速率,再按 1.5 倍速率。
  3. 在脚本里设置中止阈值,例如 p95 超过 3 秒,或出现任何 5xx 或超时,让失败的测试自己停下,不去伤害共用的系统。
  4. 加一个守护脚本,盯着各节点的 CPU,一触及上限就停止运行。
  5. 把你的指标和云厂商的指标对照。我的测试里两者相差只有百分之几,说明监控面板是可信的。
  6. 每次只改一件事,每修一处就按目标速率重跑。

在测试证明上限在哪里之前,不要升级硬件。我这次机器本身没问题,上限是单线程进程,修复只花了配置的功夫。

同一个午夜,这次有准备

把开头的场景再演一遍,这次准备到位。晚上 11 点,Web 节点的下限从两个调到三个,SSR 层在 least_conn 后面跑三个进程。缓存已经预热,FPM 进程池一开始就有 30 个 worker。

午夜时分,每秒 440 个请求涌来。实测上限约 660,所以 p95 基本维持在平时的水平,而自动扩缩容规则一次也没触发,因为根本用不上它。12:30,下限调回原值。整场活动的成本,只是多开了几个小时的一个节点。

这条链路后面的几层,出问题的方式不一样。突发流量下,后台任务通常最先崩,第 2 篇讲大规模队列与 worker。第 3 篇转向高负载下的数据层,第 4 篇是可观测性,第 5 篇是安全与零停机发布。想判断业务增长时需要多大的服务器,可以看我之前那篇服务器扩容路线图。

核心要点

  • 预定的尖峰是阶跃,不是斜坡。响应式扩容要几分钟,总是在尖峰过后才动。
  • 对已知活动提前扩容:开始前调高节点数下限,结束后调低。
  • 让 Web 节点保持无状态:会话和缓存在 Redis,文件在对象存储,调度器只有一个。
  • 一个 Node.js SSR 进程大约只用一个核。用 least_conn 在前面挂多个相同进程,并按目标速率压测。
  • 按每秒请求数、请求耗时和单个 worker 的内存来设定 PHP-FPM 进程池,并提前预热。
  • 用真实请求组合、中止阈值和守护脚本做压测,先修复测出来的瓶颈,再考虑买硬件。

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

About the Author

Anichur Rahaman

Continue Reading