2,277,845 个 socket,CPU 还有 41.9% 空闲:WhatsApp 如何在一台机器上塞下两百万连接
Published:
这是 🚀 Extreme Scale 系列的第二篇。
2012 年 1 月 6 日,WhatsApp 发了一篇标题故意轻描淡写的博客——《1 million is so 2011》——里面有一段命令输出,至今值得盯着看:
jkb@c123$ sysctl kern.ipc.numopensockets
kern.ipc.numopensockets: 2277845
一台服务器,2,277,845 个打开的 socket。更重要的是后半句:同一篇博客里,这台机器的用户态 CPU 是 37.9%、系统态 13.6%、中断 6.6%——还有 41.9% 空闲,内存也剩了几十 GB。两百万连接不是这台机器的上限,只是团队停手的地方,因为剩下的瓶颈已经不是显而易见的那些了。
硬件并不夸张:双路 Xeon X5675,24 个逻辑 CPU、约 100 GB 内存,跑 FreeBSD 和 Erlang/OTP R14B03。两个月后,WhatsApp 工程师 Rick Reed 在 Erlang Factory 上讲了他们是怎么做到的,他对整个项目的总结就藏在幻灯片的一句话里:
“Contention, contention, contention. From 200k to 2M were all contention fixes.”(争用、争用、还是争用。从 20 万到 200 万,修的全是争用。)
这句话就是全文。WhatsApp 的扩展之道不是找到一种更快的服务单个连接的方法,而是反复找到那个让数百万廉价连接互相排队的地方——一把锁、一个定时器轮、一张表、一个事务管理器——然后把那条队列打碎。理解每个共享点为什么会串行化、打碎它到底改变了什么,才是可迁移的教训。
背景:为什么一个连接必须近乎免费
2011 年初的起点是单台服务器约 20 万连接,Reed 说增长前景不乐观、集群在故障和过载面前很脆。他定的目标很直白:单机 100 万连接,同时要扛住软件故障、硬件故障和世界事件——他的幻灯片把增长、地震和足球比赛并列,因为一场大赛的进球和一次地震,在服务器侧产生的是同一种东西:大量用户在同一刻打开应用的相关性涌入。
先算一笔排除常规设计的账。一个连接一个线程的服务器,要为每部闲置手机付一个操作系统线程。就算按每个线程 1 MB 栈加记账开销这个乐观数字算,两百万连接光是“存在”就需要约 2 TB 内存,还没发一条消息。而 WhatsApp 整台机器只有约 100 GB。所以并发的单位不可能是操作系统线程,必须是运行时能以 KB 而非 MB 为成本创建、调度和销毁的东西。
这正是 Erlang 的 BEAM 虚拟机提供的东西,WhatsApp 的技术栈围绕它的三个性质构建。下面每个机制之所以重要,是因为它防住了某个具体的失败——而不是因为“Erlang 擅长并发”这种抽象说法。
1. 每个连接一个轻量进程,自带堆和邮箱。 BEAM 进程不与其他进程共享内存,靠把消息复制到对方邮箱来通信。对消息服务器来说,这种隔离是承重墙:某个连接的处理器崩溃、或某个客户端发来垃圾数据,损坏的只是它自己那个小堆,别人的不受影响。正常消息路径上没有共享可变状态要保护,自然也不需要一把全局锁去保护它。
2. 按 reduction 计数的抢占式调度,而不是协作式让出。 Erlang/OTP 官方文档把规则写得很明白:调度是抢占式的,一个进程消耗的 reduction(大致相当于函数调用次数)超过定额就会被抢占。每个核心跑一个调度器线程,WhatsApp 是 24 个逻辑 CPU 对 24 个调度器。协作式事件循环给不了的公平性保证,这里有了:某个连接进程开始做昂贵工作时,不能无限期占住调度器、让成千上万个连接等着挪动自己那条小消息。它的预算耗尽,就会被换下,队列继续前进。闲置为主的负载下,尾延迟主要就是“谁被谁堵住”的故事——reduction 计数就是给这个“谁”设上限的机制。
3. 每进程独立垃圾回收。 每个进程有自己的堆,回收它就是一个小规模的本地工作,成本只跟那一个连接的存活数据走——而不是成本跟全机内存走的 stop-the-world 暂停。在一台装着两百万连接的机器上,全局暂停不是一次延迟毛刺;面对数百万大多闲置的进程,它反复发生,就是产品本身。小而独立的堆,把 GC 从全集群事件变成了背景噪声。
这三点解释了 20 万连接为何可行,但解释不了 200 万。两者之间的差距,是藏在运行时和内核内部的共享点——在那些地方,本应独立的进程其实在同一件东西上排队。
指向真正病灶的诊断信号
Reed 的团队不是靠猜。他们先补上了 BEAM 缺失的仪表:把调度器利用率拆成有用工作、等待、睡眠和空转;可以对进程和端口运行时开关的锁计数;以及消息队列统计——排了多少条、多少个队列非空、最长队列是谁、每个进程在 1 秒、10 秒、100 秒窗口内的入队和出队速率。
这套仪表打出了定义整个项目的信号。在第一个严重瓶颈、约 42.5 万连接处,调度器报告的利用率约 95%,而整机 CPU 只用了约三分之一到不足一半。两个数字放在一起看,结论无可回避:调度器 95% 的时间不是在做有用工作,而是忙着被堵住——在锁上空转或睡眠。CPU 有余量,工作却到不了 CPU,因为太多工作必须先逐个穿过一个串行点。
这就是为什么“争用”是一个机制判断而不是一种感觉。锁在争用下增加的不只是它自己的持有时间:它把并行工作变成一条队列,而当到达率逼近串行服务率时,排队延迟会非线性增长。过了那个点,再加连接几乎不增加有用吞吐,只是把队列拉长——这正是 WhatsApp 在 20 万到 42.5 万之间撞上的平台期。下面每个修复立足的方式,要么是缩短在串行段里停留的时间,要么——更好——把一个串行段打碎成许多独立的段,让单个队列不复存在。
修复 1:别再向时钟问一个必须排队才能回答的问题
最有教育意义的修复也是最小的。Erlang 较老的时间戳调用 erlang:now 不只是读时间,它保证全节点范围内连续调用返回唯一且递增的值。这种保证不可能靠一次简单的时钟读取维持:如果两个调度器在同一瞬间问时间,必须有某个东西决定谁拿哪个值,而那个东西就是一把锁。在消息服务器上,时间戳无处不在——每条消息、每个连接事件都要取——于是数百万个本应独立的进程,仅仅为了知道现在几点,全都 funnel 进同一把节点级锁。Reed 的幻灯片专门点名了 timeofday 锁,包括它的投递路径。
修复方法是换一个问题。WhatsApp 的 Erlang 代码改成优先用 os:timestamp,它直接读操作系统时钟,不提供节点级唯一性保证。没有要强制执行的保证,就没有要抢的锁,24 个调度器可以并行读时间,谁也不用排队。代价是真实的,必须说清:你放弃了保证的唯一性和单调性。如果有代码曾默默地把 erlang:now 的值当唯一标识或全序来用,os:timestamp 会把它弄坏——两个事件现在可以共享同一个时间戳,也可以看起来乱序。这个修复成立,只是因为 WhatsApp 的消息排序和身份标识实际上并不依赖那个保证。这就是通用规律:争用消除的一半工作,是发现某个你一直在为每次操作付钱的保证,其实根本没人需要。
内核侧是同样的形状。在 BEAM 之下取时间本身也很贵,所以 WhatsApp 回移植了 FreeBSD 基于 TSC 的 timecounter,让 gettimeofday 便宜得多。每个消息都要调一次的调用,必须既单次便宜、又无争用;只修其中一个,另一个就是天花板。
修复 2:定时器——用每进程的截止时间取代中央轮盘
一台两百万连接的消息服务器,就是一台有数百万个定时器的服务器:保活、重试、超时、在线状态过期。BEAM 内置的函数定时器当时放在中央结构里——一个必须被遍历的定时器轮,和一张 Reed 团队不得不加宽的哈希表(因为查找在撞车)。要盯住的机制是遍历:走一遍共享轮盘的工作量,与别人的定时器有多少成正比,而且是在共享访问下完成的,所以每新增一个连接,都让其他所有连接的定时器处理贵了一点、争用多了一点。这个系统里存在一种逻辑上互不相关的操作、成本却随总负载增长的结构。
幻灯片里持久的修复不只是“把哈希加宽”——虽然他们也这么做了——而是把内置定时器搬到了 receive 超时上。Receive 超时是进程本地的:进程说“这么久没消息就叫醒我”,截止时间跟着进程和它的调度器走,而不是登记在全节点的中央名册里。因果链与给数据库表分片完全一样:每个操作都必须访问的一个结构,变成每个操作单独访问的许多个结构。不再遍历别人的定时器,不再有共享哈希可撞。它修不了什么也要说清:一个进程若真需要许多个独立并发的定时器,仍得自己记账;receive 超时只表达一个截止时间——你当前正在等的那个。它用放弃一个通用中央服务的方式消除了中央瓶颈,而这没问题,恰恰因为大多数连接定时器本来就是“这个连接安静太久就叫醒我”这一件事。
修复 3:内存分配——给每个调度器自己的分配器
24 个调度器线程若经由共享分配结构分配内存,分配本身就成了另一条队列:线程等分配器锁,拿到的内存还会在核心之间来回弹跳缓存行。Reed 的调优清单读起来像一份专门消除这一点的检查表:
- 每个调度器一个分配器实例(
+Mut 24)——让调度器为其进程走的常见分配路径不必与其他调度器协调。 - 优先使用内存段而非普通
malloc,并做大尺寸、2 MB 对齐的段分配——让操作系统能用超级页(superpage) backing,从而降低 TLB(地址翻译缓存)压力。在一台 100 GB、循环使用数百万小堆的机器上,地址翻译不是脚注;浪费它,CPU 就卡在页表遍历上而不是运行连接进程。 - 调度器行为调优——低调度器唤醒阈值,让调度器不陷入醒来很慢的深睡(
+swt low)、打过补丁的调度器空转次数、以及以实时调度优先级运行——全都对着同一个失败:一个可运行的进程在等待,不是在等锁,而是在等一个睡着的调度器注意到它。
这些都不改变系统算什么,只改变 24 个核心在管理计算之下的内存与调度机器时、彼此等待的频率。这也是为什么聚合 CPU 数字在这里会骗人:收益体现在调度器把周期花在进程上、而不是协调上。
修复 4:数据分区、访问本地化、集群保持小
应用层的修复遵循同一条规则,只是作用在数据而非运行时内部。
给 ETS 和 Mnesia 表分区,并把访问本地化到更少数的进程。 ETS 和 Mnesia 是 BEAM 的内存型与分布式数据存储。一张单独的表就是一个串行点:并发访问要经由那张表的结构协调,事务型访问还要经过事务管理器。灵药本该是“把表做快”,WhatsApp 却选择把表拆成分区,并安排访问由一组更小的本地进程处理。拆分有效的原理与数据库分片相同:N 个分区大致有 N 个独立的争用域,落到任何一个上的到达率随之下降,排队延迟跟着降——而且本地就能服务的访问,不必再为只有一小片连接会碰的数据支付跨进程协调。边界同样熟悉:任何真正需要全局视图的操作——跨全部分区的查找、集群级群组成员——不会因分区而变快。WhatsApp 不得不把最大的这类情况单独处理:对进程组 pg2 的成员列表做去规范化,让查找直接读一份预备好的列表,而不是在查询时在争用下现算成员。
让 dirty 事务绕开单一管理器。 Mnesia 的事务管理器按设计是一个协调进程——这使它成为一条队列。WhatsApp 给它打了补丁,把异步 dirty 事务分发到每张表独立的进程。Dirty 操作不需要完整的事务协调;硬让它们也走协调器,等于每个轻量写都要为自己不用的排序机器付费,还要排在真正需要协调的操作后面。每表分发让每张表有自己的服务率,热表堵不住冷表的写。它给不了的,是你跳过的那个保证:如果你真的需要跨表原子性,这条路不是它的加速版,而是一个不同且更弱的契约——只对那些本来就能接受这种弱化的操作安全。
Mnesia 集群保持小。 Reed 的这条建议只有一行,很容易被低估。分发与事务协调的成本随必须达成一致、保持联系的节点数增长。小集群从构造上封顶了这个成本,代价是要运维更多集群、且没有任何单个集群能看到全局。WhatsApp 是有意识地选了有界的协调成本。
去掉被惯用法悄悄收税的协调。 幻灯片里两个例子值得点名,因为它们都是默认惯用法在悄悄给每次操作收税。第一,跨节点 gen_server 调用被改写成不用 monitor 的版本:monitor 能在被调用方死掉时通知调用方,但每次调用都建一个、拆一个,会给每次调用都加上分发流量和进程链接锁争用,不管被调用方是否真的会死。去掉它就去掉了这笔按次收的税;代价是调用方得自己另想办法处理被调用方已死的情况,因为现在没人会通知它了。第二,团队减少了 accept 路径和把 socket 交给控制进程的调用里的 socket 选项设置——每个连接的建立期工作,在一千个连接时无足轻重,在两百万个连接时就是一笔可观的串行成本。
还有一处把集群绑在一起的吞吐修复:Erlang 分发接收缓冲从 4 KB 涨到 256 KB 并变得可配置。WhatsApp 的节点间流量不是边缘情况——2 月峰值时系统每秒移动超过 20 万条分发消息和 57.1 万个数据包。这条路径上 4 KB 的接收缓冲意味着接收侧只能小口小口地排干,反压不断触发,发送进程卡在等缓冲补充上而不是做自己的工作。64 倍大的缓冲让分发通道能按线速吸收突发。它修的是突发吸收问题,不是持续过载问题:如果对端永久比发送速率慢,更大的缓冲只是把队列暴露出来的那一天推迟——这也是为什么 Reed 团队还做了消息队列仪表,能按进程提前看见那一天的到来。
修复买到了什么——又没有买到什么
按顺序看结果:瓶颈在约 42.5 万连接处出现;第一轮修复把单机推过 100 万;约一个月后到了 200 万;新年开始做应用代码优化后,2012 年 2 月一次无意的纪录冲击峰值到了 280 万连接,随后被运维人员干预停下——当时是每秒 57.1 万个数据包、超过 20 万条分发消息。而 1 月份 WhatsApp 自己在 227 万 socket 处的快照还显示着 41.9% 的 CPU 空闲。Reed 还报告,争用消除后 Erlang 在 24 个逻辑 CPU 上达到了 85% 以上的 CPU 利用率——正好是 42.5 万时那个信号的镜像:那时调度器利用率高,代表的是空转,不是干活。
接下来是诚实的边界,因为标题数字容易引来三种误读。
两百万连接不等于两百万活跃对话。 使这个结果成为可能的负载,是数百万大多闲置的长连接,每个偶尔发一条小消息。被扩展的昂贵之物是存在本身——一个 socket、一个进程、一组每用户定时器——而不是每连接的持续计算。一个每条连接都在持续做重活的负载,会在远未到两百万连接时就把 24 个核心打满,再多的争用消除也改变不了这笔账。Reed 的团队冲击过 300 万,截至 2012 年 3 月的演讲还没成功;即便是“存在”也有上限,由内核 socket 结构、每 socket 内存和文件描述符上限决定——这也是为什么 FreeBSD 侧的工作主要是抬高硬上限:最大 socket 数抬到 240 万、最大文件数抬到 300 万、TCP 哈希表抬到 524,288 条,让在两百万条目里查一个连接状态不退化成长碰撞链的遍历。
一连接一进程隔离了故障,但救不了节点本身。 如果装着 227 万连接的那台机器死了,227 万客户端会重连——而同时重连的涌入本身就是 Reed 列为设计威胁的相关性负载事件之一。隔离把单个进程故障的爆炸半径 bound 在那个进程内;节点故障仍然是节点级的事件,防线在集群和重连行为,不在 actor 模型。
给运行时打补丁既是能力也是债。 Reed 直说,大多数修复都需要 BEAM 补丁,外加 FreeBSD 回移植。这给 WhatsApp 买到了任何配置文件都给不了的性能——也让它维护着一个分叉,其改动必须由一个很小的团队持续携带、理解和重新验证。这个策略之所以理性,只是因为团队先把锁计数和调度器仪表做到了足以证明每个补丁打的是一条被测量过的队列。没有那份证据,打过补丁的运行时只是让你错得更刺激的一种方式。
可偷走的教训
- 直接测量争用;CPU 利用率会骗你。 调度器 95%、整机不足 50%,这是锁排队的指纹,不是苦干的指纹。不给锁、调度器等待状态和队列长度装仪表,你就会去“优化”那些从来不是瓶颈的代码。
- 每个保证的背后都有一条队列。 唯一单调时间戳、按次的死亡通知、为一笔 dirty 写做完整事务协调——每一个都是合理的默认项,每一个在两百万连接时都是一个串行点。把你热路径强制执行的保证列出来,逐个标价,删掉那些其实没人消费的。
- 分区胜过强化。 定时器轮、表、事务管理器、分配器:每一处的持久修复都不是“一个更快的共享物”,而是“许多个独立物”。一条队列会饱和;N 条队列随 N 扩展——前提是你的访问模式真的能本地化,而那是必须靠设计建出来的性质,不是一个能拨的开关。
- 闲置也是一种负载,而且它就是产品。 对消息、在线状态、物联网和推送来说,服务本身就是足够便宜地握住数百万安静连接,让偶尔的突发成为日常。每条闲置连接的成本——内存、定时器、内核结构——值得与峰值吞吐同等的工程注意力,因为在这个规模上,它决定你要买多少台机器。
- 有意识地让协调域保持小。 小集群、本地化的表访问、每表分发:WhatsApp 反复选择有界协调,而不是一个能看到一切的单一系统。协调成本增长得比负载更快;封顶它的唯一可靠办法,是在结构上拒绝默认全局协调。
十四年过去,运行时已经吸收了其中许多教训,很少有团队还需要给虚拟机打补丁才能握住一百万 socket。但那套诊断纪律一点都没过时:找到共享点,用仪表证明工作在那里排队,把它打碎成独立的碎片,然后大声说出你交换掉的是哪个保证。20 万就是这样变成 2,277,845 的——而且 CPU 还有富余。
Sources
- WhatsApp, “1 million is so 2011,” WhatsApp Blog, January 6, 2012. https://blog.whatsapp.com/1-million-is-so-2011 ——2,277,845 个打开 socket 的计数、CPU 与内存快照、硬件、FreeBSD 与 Erlang 版本以及内核上限均来自此文。
- Rick Reed (WhatsApp), “Scaling to Millions of Simultaneous Connections,” Erlang Factory SF, March 30, 2012 ——幻灯片:https://www.slideshare.net/slideshow/scaling-to-millions-of-simultaneous-connections-by-rick-reed-from-whatsapp/52848143 ;演讲页:https://www.erlang-factory.com/conference/SFBay2012/speakers/RickReed ——20 万起点、42.5 万瓶颈、100 万 / 200 万 / 280 万的推进过程、争用结论,以及全部 BEAM、OTP、FreeBSD 与应用层修复均来自此演讲。
- Erlang/OTP 文档,
erlang模块参考(进程优先级与调度),Erlang/OTP。https://www.erlang.org/docs/22/man/erlang.html ——关于调度是抢占式的、进程在消耗定额 reduction 后被抢占的规则。
Share on
Twitter Facebook LinkedIn☕ Buy me a coffee! 💝
If you found this article helpful, consider buying me a coffee to support my work! 🚀
