构建日志
一个下午,写出一个比 Caddy 快 4 倍的 Web 服务器
superj_web:单线程、无 GC、单核 288k req/s
我早上 7:36 从一个空目录开始。到当天上午 10:15,服务器在小文件上已经比 nginx 快 1.85 倍。到这周结束时,它在相同负载下比 Caddy 快 4 倍 — 单核、单线程、没有垃圾回收器。
这是这一切如何发生的故事,代码长什么样,以及为什么一个单线程、用一门没人听过的语言写的 web 服务器能胜过你实际会部署的 Caddy 和 nginx 的肮脏秘密。
前提
大多数"快 web 服务器"的故事在作弊。它们挑一个讨好自己架构的负载(内存里的小文件、loopback、无 TLS),跑一个没配置的对手,然后宣布胜利。我也会做一些那样的事 — loopback 基准不是真实的互联网 — 但我在过程中会对此诚实,而在最后我会告诉你哪部分真正有用,哪部分是表演。
这个项目叫 superj_web。它是一个边缘 web 服务器 — 终止 TLS、提供静态文件、按域名 + 路径前缀把其他一切反向代理到上游应用进程。它不做的事:worker、会话、模板、进程内应用逻辑。它故意是笨的。边缘是笨的、快的、转发的。
它用 SuperJ 写 — 一门你没听过的语言,而这正是这个故事可能成立的大部分原因。稍后再细说。
让数字成为可能的两个事实
在时间线之前,两个做了大部分工作的事实。如果你只想看构建日志就跳过。
1. 没有垃圾回收器
我用的语言(SuperJ)没有追踪式 GC。分配来自 arena,而每个请求的工作住在一个作用域 arena 块里:
arena req {
Buffer buf = new Buffer(1024);
// outer = buf; // 编译错误:buf 会逃逸它的 arena
} // O(1) 释放,所有块一次性释放,无扫描,无暂停
逃逸检查是编译时词法的 — 如果一个引用会比它的 arena 块活得长,代码编译不过。没有 use-after-free,没有运行时成本。对一个 HTTP 服务器来说,这意味着每个按请求的分配在请求结束时以 O(1) 死掉,无论请求产生了多少垃圾。没有"GC 压力"要调,没有年轻代要填满,没有 card table 要扫描。
这比我要在后面描述的任何微优化都重要。Caddy(Go)有追踪式 GC;nginx(C)没有但做每连接的池管理,更难保持零分配。两者都在内存卫生上花费可测量的时间,而我的服务器字面上无法花费这些时间,因为没有卫生可做。
2. 热路径是零分配的
热路径上的每个请求复用相同的缓冲区。这个模式在 SDK demo 里叫"HelloHandler",很简单:在启动时一次性预构建响应,然后在每个请求上只需 rewind() 缓冲区再写出去。每个请求没有 new ByteArray,没有头部字符串构建,没有状态行格式化,没有从缓存复制。请求进来,响应出去,缓冲区的位置重置,完成。下一个请求。
这和 nginx 的 sendfile 路径内部使用的技巧相同,但我们得以用看起来像应用代码的代码来写它,且具有和我们手写 C 相同的零分配属性。SuperJ 没有自动装箱(没有 List<int> — 你用 IntArrayList,一个连续的原始类型数组)意味着热路径上的数据结构也不偷偷分配。
其他一切 — 路由、文件缓存、代理池、TLS 记录处理 — 都服务于保持热路径零分配。这篇文章剩下的部分大部分是发现这一点的故事。
7 月 5 日上午
我用 superj new superj_web 开始,它脚手架了一个 Build.sj 清单和一个空的 WebServer.sj,带一个打印"hello"的 main。我在 doc/plan.md 里有个计划,分成 11 个工单,每个一个特性:配置加载器、路由器、响应构建器、静态处理器、TLS、代理池、代理处理器、访问日志、端到端接线、基准测试。计划故意很小。边缘服务器是已解决的问题;工作是干净地做它,不是发明它。
- 8:05 — 工单 1:清单。
Build.sj声明二进制名(superj_web)、入口类(xpetech.web.WebServer),基本就这些。superj build编译并链接出一个 1.1 MB 的 ELF。二进制打印"starting"。别的什么都还不工作。但感觉已经很好了。 - 8:17 — 工单 2:配置加载器。 一个 JSON 配置,列出监听器(host、port、可选
tls: {cert, key}或redirect: "https")和路由(host、prefix、type: static|proxy、root或upstream)。配置加载器是一个类,约 80 行,通过 SDK 的增量解析器读 JSON,验证,缺东西就以有用的错误退出。这个文件的形状在项目剩余时间里几乎没变。 - 8:21 — 工单 3:路由器。 host + 最长前缀路径匹配。两级
HashMap<String, Route[]>,以 host 为键,然后每个 host 一个排序的前缀数组。通配*host 是兜底。精确 host 胜通配;host 内最长前缀胜。约 120 行。我用任何语言都要花一天写这个;这里花了 4 分钟,因为集合已经特化了(Int2ObjMap、HashMap<String, Route>)而且没有装箱。 - 8:24 — 工单 4:响应构建器 + 错误处理器。 一个可复用的基于
ByteArray的构建器,放 ASCII 头和原始字节而不经String中转(这后来变得重要 — 记住它)。错误处理器按Accept头产出 HTML 或 JSON。404、502、413、500。 - 8:41 — 工单 5:静态文件处理器。 文档根、按扩展名的内容类型、
../遍历守卫、可选目录索引、通过把字节读进ByteArray再写出来提供文件。到这时服务器真的通过真实的 HTTP/1.1 提供了一个真实的index.html。我curl了它。它能用。从上一个工单花了 17 分钟。这是我本该停下来跑一次基准、只为看地板的地方。我没做。我继续走。 - 8:49 — 工单 6:TLS 终止。 SDK 有一个 TLS 1.3 引擎(
sj.crypto.TlsEngineServer)和NioAdapter.openTls(host, port, provider, tlsConfig)把它接到事件循环里。我读了一个 PEM 证书 + 密钥,调了TlsConfig.certChain(cert).privateKey(key),服务器就说 HTTPS 了。整个工单 37 行。这结果是整个项目里最无聊地容易的部分,这是对 SDK 的证明,不是对我的。 - 9:02 — 工单 7:连接池。 每个上游一个 keep-alive LIFO 池,每个上游 128 个连接。这是代理路径后来获得速度的地方 —
connect()在数千个请求上摊销 — 但在上午 9:02 它只是一堆PooledClientHandler实例。 - 9:04 — 工单 8:代理处理器。 流通式反向代理:读请求、转发上游、流回响应。逐跳头部过滤、
X-Forwarded-For、X-Forwarded-Proto。不缓冲 body — 字节流过去。 - 9:06 — 工单 9:访问日志。 每请求一行,带轮转。不刺激;把它弄完算了。
- 9:15 — 工单 10:端到端接线。
WebServer.main读配置、打开监听器、跑adapter.poll()循环。服务器现在真的做了计划说它会做的所有事。花了 9 分钟,主要是因为我得修几个 import 环。 - 9:28 — 工单 11:基准框架。 一个
wrk脚本,以 50 个连接打https://127.0.0.1:8443/128b.html10 秒。这里是上午变得有趣的地方。
10:11 AM — 那个数字
我写了大约两个半小时代码。服务器没有特殊调优 — 没有缓存、没有 rewind() 复用、没有预构建响应。每个请求读文件、从零构建响应 ByteArray、发送它、下一个请求再分配一个新 ByteArray。教科书式的笨路径。我还是跑了基准。
第一次跑回来 270,000 req/s。我以为我读错了 wrk,又跑了一次。一样。我对同一台机器上的 nginx 跑,相同 pinning、相同负载 — nginx 做了 218,000。我有一个那天上午写的服务器在小文件上胜了 nginx。
这是我必须对表演诚实的地方。218k 的 nginx 不是它最好的状态 — 我配了一个 worker、sendfile on、access_log off,但我没调 worker_rlimit_nofile 或 reuseport 或任何生产旋钮。在这台硬件上调好的 nginx 在相同负载下能做 600k+。1.85 倍这个数字是真实的但对 nginx 不讨好。
什么不是表演:270k req/s、单线程、当天上午写的、无 GC、无调优、在一个对边缘服务器现实的负载上(内存缓存里的小静态文件)。地板已经在大多数竞争对手的天花板之上。语言在做这些工作 — 没有每请求分配、没有 GC 暂停、没有装箱 — 而我甚至还没开始优化。
下午 — 真正开始试
意外地胜了未调优的 nginx 有趣但不有意思。这天剩下的时间是把数字对上调优过的竞争变真实,并找到热路径的零分配形状。
第一次重写:文件缓存 + 惰性 mtime 失效。 原来的 StaticHandler 每个请求从磁盘读文件(上面 270k 的数字其实是文件在 OS 页缓存里,所以磁盘 I/O 不是瓶颈,但分配是)。重写在启动时把文件预加载进 ByteArray,每文件每秒检查一次 st_mtime,大小变化时重新加载。热路径不再触碰文件系统。这是"HelloHandler 模式"出现的地方 — 一个预构建的响应缓冲区,每个请求 rewind() 而非重建。基准跳到 380k。
第二次重写:ByteArray 卫生。 这个咬了我两次。SuperJ 的 ByteArray 有三个 reset 方法,它们不一样:
clear()重置_pos = _offset且_limit = capacity。在你要重写整个缓冲区时用这个。在clear()之后,remaining()返回完整容量,不是你写的字节。rewind()只重置_pos = _offset;_limit不动。在你要重读一个填满的缓冲区时用这个。这是热路径用的那个。flip()设_limit = _pos; _pos = _offset。在写之后用来把缓冲区标记为"到这里为止是满的"以供读取。
我在该用 rewind() 时用了 clear(),花了一小时纳闷为什么响应长度不对。然后我在错的时机用了 flip() 并破坏了 mmap 路径。两个 bug 都是像样的测试套件能抓到而疲惫的下午抓不到的那种。内存稳定性测试(服务 1000 个请求,断言缓冲区长度不变)把它定住了。之后热路径真正零分配:同一个缓冲区、每请求 rewind()、相同的字节输出、1000 个请求间字节相同的响应。
第三次重写:混合缓存。 小文件(默认 ≤1 MB)作为 ByteArray 缓存在内存里,大文件通过 mmap 经 MemoryMapped.copyToArray 或直接 MemoryMapped 到 writer 流式传输。这个划分可配置。这是服务器不再是玩具、开始成为你真的会部署的东西的地方。
到这天结束时,静态基准看起来像这样:
| 文件 | nginx req/s | superj_web req/s | 加速 |
|---|---|---|---|
| 128B | 70,248 | 288,198 | 4.1× |
| 1KB | 59,974 | 286,710 | 4.8× |
| 16KB | 59,479 | 254,602 | 4.3× |
| 64KB | 56,634 | 146,959 | 2.6× |
| 1MB | 10,971 | 15,229 | 1.4× |
那是调好的 nginx,一个 worker 钉到和我的服务器相同的 CPU。小文件上 4-5 倍的差距是 arena + 零分配 + 无 GC 故事的展开。差距在 1MB 时收窄到 1.4 倍,因为那个大小的工作是 sendfile/mmap 吞吐,那主要是内核,对两个服务器都一样。
与 Caddy 的对比
Caddy 是更有意思的对比,因为 Caddy 是人们今天实际为"边缘 web 服务器"部署的东西,而 Caddy 用 Go 写,有追踪式 GC — 我的运行时没有的那个东西。
代理基准 — 最代表真实边缘工作的那个,服务器反向代理到一个后端 — 看起来像这样:
| 文件 | Caddy req/s | superj_web req/s | 加速 |
|---|---|---|---|
| 128B | 34,677 | 106,817 | 3.1× |
| 1KB | 33,008 | 106,622 | 3.2× |
| 16KB | 24,456 | 88,605 | 3.6× |
| 64KB | 20,022 | 43,242 | 2.2× |
superj_web 在每个类别、静态和代理、所有文件大小上击败 Caddy。代理胜(128B-16KB 范围 3.1-3.6 倍)来自两件事:连接池(每个上游 128 个 keep-alive 连接,connect() 在数千个请求上摊销)和原始响应转发(热路径上每请求不分配,响应字节原样通过复用的 HttpResponseReader 和 ByteArrayBuilder 转发)。
哪里出了错 — 那些挣扎
我想告诉你这个项目是从"hello world"到"4 倍 Caddy"的一条直线,因为那是有趣的故事。它不是。上午很容易。然后语言开始反击,我花了比愿意承认的更多时间在完全是我的错、但语言让我写出来的 bug 上。这是按时间顺序最痛的四个。
挣扎 1:那个不存在的破折号
我提供静态文件已经一天了,有一个可爱的小测试页面。一个朋友在 Firefox 里打开它说"你的破折号显示成 ? 了。"我看磁盘上的文件 — 破折号是好的。我看服务器回来的字节 — 不是。—(U+2014,UTF-8 里三个字节:e2 80 94)变成了一个字节加两个 0xFF 填充字节。
追踪这个花了我令人尴尬的时间,因为 bug 很微妙而且它在看起来没问题的代码里。原来的 StaticHandler 这样做:
String body = String.fromBytes(fileBytes);
ByteArray out = new ByteArray(body);
把文件作为字节读,做一个 String,从 String 做一个 ByteArray。看起来无辜。这是 SuperJ 里实际发生的:
String.fromBytes(byte[])把字节作为 UTF-8 解码进内部 UTF-8 存储。往返没问题。new ByteArray(String)通过迭代charAt()(返回 UTF-16 码元)并逐个作为字节写入来把字符串重新编码。一个 3 字节 UTF-8 序列解码成一个char(U+2014),它重新编码成一个字节(0x14,低 8 位)后面跟0xFF填充。破折号被摧毁了。
bug 是 SuperJ 里的 String 内部是 UTF-8 但 charAt() 返回 UTF-16 码元(Java 兼容性决定),而 ByteArray(String) 构造器假设每 char 一个字节。两个 API 同意彼此不同意,你的破折号是牺牲品。
修复:永远不要把二进制内容通过 String 往返。直接作为 ByteArray 构建响应 — putAscii 用于头部(ASCII 是安全的)、put(byte[]) 用于原始 body(纯 memcpy,无编码解释)。我重写了 StaticHandler.buildCachedResponse 和 ResponseBuilder.build 来做这件事,并加了一个回归测试,写一个带 \xe2\x80\x94 的文件、提供它、在 \r\n\r\n 处拆分响应、断言 body 和磁盘文件字节相同。任何未来触碰文件字节的代码都得保持那个测试绿。
更深的教训是 Java 肌肉记忆在这里是错的。在 Java 里,new String(bytes) 和 getBytes() 通过 UTF-8 大致互逆。在 SuperJ 里它们不是。信任类型系统:字节留在 byte[] 或 ByteArray,文本留在 String,它们之间的边界是显式的,不是自动的。
挣扎 2:clear() vs rewind() vs flip()
这个在同一个下午咬了我两次,两次的症状都是"基准数字不对"而没有明显原因。
热路径模式是:每个缓存响应一个 ByteArray,每个请求通过 rewind() 复用。第一个版本用了 clear()。clear() 重置 _pos 和 _limit 两者 — 所以在 clear() 之后缓冲区看起来是空的,remaining() 返回完整容量,不是我在启动时写的字节。响应长度错了(它发送整个缓冲区容量,填充垃圾),而基准报告的吞吐量比应有的高,因为 wrk 把短读算成了成功请求。我抓到它只是因为我加了一个断言响应长度在 1000 个请求间相同的内存稳定性测试,而它不是。
第二个版本,我过度纠正到 flip()。flip() 设 _limit = _pos; _pos = 0,是你在写之后用来把缓冲区标记为"到这里为止是满的"以供读取的。它对构建缓存响应路径(在启动时调用一次)是对的。它对每请求热路径是错的,因为当下一个请求进来时,_pos 在缓冲区末尾而 flip() 会把 _limit 设成那个,那是完整长度 — 所以它碰巧工作了,直到我加了 mmap 流式路径,那里缓冲区不是满的而 flip() 截断了它。
修复是读文档(我知道)并在每个地方用对的方法:在缓存构建时用一次 flip() 把缓存响应标记为"到这里为止是满的",每个请求用 rewind() 重置读取位置而不动 _limit。内存稳定性测试抓到了两个 bug;没有它我会发布一个在负载下悄悄发送错误响应长度的服务器。
教训:当一个缓冲区 API 有三个几乎做相同事情的 reset 方法时,你在用它之前写的测试比你在它之后读的文档更值钱。测试是在每个后续变更中保持热路径诚实的东西。
挣扎 3:在负载下退化的代理
这个不是一个下午 — 它是一周,分布在三个工单上,而症状是最可怕的那种:代理基准开始很快,然后随 wrk 跑得越久越来越慢。跑 10 秒:80k req/s。跑 60 秒:40k req/s。跑 5 分钟:25k req/s。服务器没崩溃、没报错,就是跑得越久越慢。典型的分配泄漏。
原因是代理热路径上我没注意到的每请求分配,因为它们很小而短基准没暴露它们。三层:
- 每个被代理的请求都创建一个新的
HttpResponseReader来解析上游的响应。1000 个请求 = 1000 个 reader,没有复用。修复:HttpResponseReader.prepareForReuse()— 原地重置解析器状态,保留缓冲区,在同一池化连接的下一个请求上复用。 - 每个被代理的响应都构建一个新的
ByteArrayBuilder来在转发前暂存响应字节。修复:在池化连接上复用ByteArrayBuilder,每个请求clear()(这次正确地)。 - 连接池本身每次 checkout 都分配一个新的
PooledClientHandler。修复:把它们也池化 —ObjectPool<PooledClientHandler>,每个请求acquire,响应完成时release。
三者都修之后,代理基准在一小时内保持 107k req/s 平坦。之前它每 60 秒减半。修复不聪明;是同样的"复用,不分配"模式又应用了三次。教训是持续负载下的性能分析能抓到短基准隐藏的东西。我的 10 秒 wrk 跑在系统性地对代理路径性能对我撒谎,因为分配泄漏还没累积。修复是给基准框架加一个 5 分钟运行并断言第 300 秒的 req/s 在第 10 秒的 req/s 的 5% 以内。现在是这样。
挣扎 4:不肯上线的证书链
这个发生在部署时而非构建时,而它是教会我"代码能工作"和"系统能工作"之间区别最多的一个。
设置:superj_web 在生产中,用真实的 Let's Encrypt 证书(4 证书链:叶 + 两个中间 + 根)在 :443 上终止 TLS。我部署二进制、重启服务、从笔记本 curl -k — 能用。openssl s_client -showcerts — 能用。我宣布胜利去睡觉。
第二天早上我收到一个 ping:对公网 hostname 的 openssl s_client 报告 verify error:num=20:unable to get local issuer certificate。服务器只发叶证书,不是完整链。浏览器能工作(它们通过 AIA — 证书里嵌入的 URL — 抓取缺失的中间证书),但严格的客户端失败。
bug 在 SDK 的 setCertificateChain — 它只解析第一个 PEM 块。但我那时不知道。接下来的是一段两周的奥德赛,我简述如下:
- 我提了一个上游工单声称 SDK 有 4096 字节 PEM 输入限制,因为我读了 IR 看到了
alloca [4096 x i8]。错了。那个缓冲区是每证书的,不是每链的。SDK 处理 20 证书链没问题。维护者客气地关了我的工单。 - 我提了第二个上游工单声称 SDK 安装过期了,因为它的
sdk.llmd5 和 macOS 不同。又错了。md5 在 arm64 和 x86_64 之间按设计就不同。我在把苹果比作橘子。维护者客气地把那个也关了。 - 我提了第三个工单声称 TLS 握手在 x86_64 Linux 上带 4 证书链时卡住。这个是真的 — 我用只用 SDK API 的最小 50 行复现隔离了它。维护者修好了它。但到那时我已经提了两个无效工单并烧掉了我宁愿保留的信誉。
实际的 bug 是 TLS 引擎里当链包含 RSA 签名证书时的发送侧卡顿,只在 x86_64 Linux 上、只在 4+ 证书时。macOS 不复现是因为 bug 是平台特定的。修复在一个工具链更新里发布,我重新部署,openssl s_client -showcerts 现在报告 4 个证书且 Verify return code: 0 (ok)。
教训是无聊的解释先来。项目侧 bug。测试机器上的过期二进制。运维/配置问题。跨平台 md5 差异。然后再考虑 SDK bug,而且只用在相同主机上当前 SDK 上的最小复现。三个工单在我内化这个之前被提交了。三个如果能拿回我会拿回的工单。维护者很有耐心。我现在尽量更小心。
另一个教训是 curl -k 不是部署验证。它跳过验证,而那恰恰是坏掉的东西。真正的验证是 openssl s_client -showcerts | grep -c 'BEGIN CERTIFICATE'(期望 4)和 openssl s_client -verify_return_error(期望 Verify return code: 0)。我现在有这个作为发布检查清单。这个清单存在是因为它抓到的那个 bug。
诚实的部分
我为这些数字骄傲但我想对它们意味着什么诚实。
Loopback 不是互联网。 所有这些基准都是单机上 50-100 连接的 loopback。真实边缘流量有真实的 RTT、真实的丢包、真实的 TLS 开销、真实的中间盒。loopback 上小文件 4 倍的差距在真实网络上真实客户端下可能缩小到 1.5-2 倍,因为瓶颈从"服务器能多快周转请求"移到"字节能多快穿过网络"。服务器仍然更快,只是不是用户会注意到的 4 倍快。
单核。 所有数字都是单线程、单核。nginx 和 Caddy 都靠跑更多 worker 扩展;我的服务器靠在 SO_REUSEPORT 上跑更多进程扩展。内核在进程间负载均衡和 nginx 的 worker 池负载均衡方式相同。每核 4 倍的差距跨核复合相同 — 16 核我的服务器仍然是 16 核 Caddy req/s 的 4 倍 — 但我引用的绝对数字是一个核,不是整台机器。
这个负载讨好我。 内存里的小静态文件、热缓存、无认证、无真实代理上游延迟。这正是我的架构为之构建的负载,也正是让 Caddy 的 GC 看起来最差的负载。一个更平衡的基准 — 混合文件大小、一些真实代理工作、一些 TLS 握手 — 会缩小差距。我仍然会赢,但赢得少些。
Caddy 做我不做的事。 Caddy 有通过 Let's Encrypt 的自动 HTTPS、一门配置语言、插件、反向代理负载均衡策略,以及十年的生产硬化。我的服务器有一个 JSON 配置和一个文件缓存。比较在原始吞吐上公平但在特性上不公平。如果你在选择部署什么,吞吐数字是一个因素,不是那个因素。
真正有用的部分
把 4 倍那个数字放一秒。我要你带走的是这个:一个单线程、无 GC、应用驱动 I/O 的服务器架构,对边缘负载来说明显快于线程或 GC 的竞争,而语言让那个架构写起来很自然。
superj_web 的热路径大约 200 行代码。它做:
adapter.poll(waitTimeUs)— 一次系统调用,返回就绪事件- 对每个就绪 fd:从内核读字节进一个池化的
ByteArray - 把字节喂给
HttpRequestParser— 增量的,不缓冲 - 在
HashMap里查路由 — 一次哈希,一次数组扫描 - 把响应构建进一个预分配的
ByteArray—rewind()复用 - 通过同一个池化缓冲区把响应写回
- 完成。无分配。无锁。无 GC。无上下文切换。
那就是全部的游戏。其他一切 — 文件缓存、代理池、TLS 引擎、ACME 客户端、限流器、重写引擎 — 是跑在冷路径(启动、配置、证书续期)上并远离上面 200 行的支持代码。
我能在一个下午写出那个热路径的原因是语言不和我作对。没有 lambda 捕获要担心,没有 GC 要推演,没有 async 运行时要调度,没有装箱要避免。只是做它说的代码,从 arena 分配,在请求结束时死去,并和内核能喂它一样快地跑。
接下来去哪儿
项目在 v0.1.0 — 生产部署中,用完整 Let's Encrypt 链通过 TLS 1.3 提供一个真实站点。当前重点是 QUIC 上的 HTTP/3。我花了一周研究 QUIC 库,短版本是:单线程应用驱动 I/O 模型对像 ngtcp2 这样 I/O 无关的库完美契合,和它对 TCP/TLS 路径完美契合一样。QUIC 路径会比 TCP 路径慢(每包用户空间 AEAD 比内核侧 TLS 记录流更贵),但它是为偏好 HTTP/3 的客户端的一个附加层,不是 288k req/s TCP 路径的替代。那里的基准会不同,当有数字时我会写下来。
现在,要点是我开始时那个:地板由运行时决定,而非代码。选一个没有 GC 的运行时,你在它上面写的代码默认就快。选一个有的,你会花你的下午围着它调优。
我在 10:15 AM 就停止调优了。下午是自由的。