揭秘 BEAM 二进制文件:我们如何在不使用任何 NIF 的情况下,让 DNS 库达到极速运行
在像 NameOcean 这样同时处理域名注册、DNS 托管和云基础设施的平台上,性能不仅仅是一个“锦上添花”的选项,更是可靠性的基石。这就是为什么当我们看到来自 Erlang 生态系统的巧妙优化案例时会感到兴奋,也正是为什么我们想为大家深度解析其中一个我们最喜欢的案例。
最近,我们读到了 DNSimple 团队关于优化其开源 DNS 库 dns_erlang 的一篇精彩深度解析。他们的成果令人瞩目:每个查询消耗的 CPU 减少了 12%,每秒处理的查询数增加了 14%——而这一切完全来自于纯粹的 BEAM 优化,无需使用 NIF(原生函数接口)。对于任何使用 Elixir 或 Erlang 构建高吞吐量系统的人来说,这都是必读内容。
让我们来拆解一下这一切是如何实现的。
BEAM 的二进制内存模型
这里有一个会让许多 Elixir 开发者感到惊讶的知识点:在紧凑循环中追加(append)二进制数据,其开销并没有你想象的那么大。
当你编写如下代码时:
def build_response(acc, []) do
acc
end
def build_response(acc, [h | t]) do
build_response(<<acc::binary, h>>, t)
end
你可能会认为这是 $O(n^2)$ 的复杂度——每次迭代都会创建一个新的二进制对象并复制所有内容。但 BEAM 比这更聪明。
由于二进制数据是不可变的,编译器可以证明:如果 acc 是由上一次追加操作刚刚创建的,且没有其他变量持有它的引用,那么直接在现有内存中写入是安全的。第一次追加会分配一些额外的容量,随后的追加则直接写入其中。结果是:每次追加的摊还复杂度为 $O(1)$——这与 C++ 中 std::vector 的工作方式完全相同。
难点在哪里? 如果你的二进制数据“逃逸”了——意味着它被存储到了进程字典中、返回给了调用者,或者被追加到了其他数据上——那么这种优势就消失了。你又回到了复制数据的老路上。
这种模式在二进制协议处理中随处可见。当你迭代构建 DNS 响应时,保持累加器(accumulator)流的紧凑性至关重要。
子二进制:免费切片(基本意义上的)
对二进制数据进行模式匹配会产生 BEAM 所称的“子二进制”(sub-binaries)——这是一种指向原始数据的小型项,无需复制即可访问。当你编写:
<<Header:12/binary, Rest/binary>> = Packet
数据并不会被复制。Rest 只是原始数据包的一个“窗口”。
但这里有一个容易让人掉坑的细节:当你进行递归匹配时,BEAM 在内部会维护一个匹配上下文,本质上是一个在字节间移动的游标。除非你真正让它“逃逸”——比如将其返回、存储或传递到某个可能长期存在的地方——否则它不会实例化一个新的子二进制。
这对 DNS 解析至关重要。每个查询都以二进制形式到达,需要与协议结构进行匹配并生成响应。理解何时是在“创建新工作”还是仅仅在“查看数据”,是决定解析器是能随负载扩展,还是在压力下崩溃的关键。
为什么整数表示法比你想象的更重要
在 64 位 BEAM 上,整数需要占用几个比特位作为类型标签。这留给实际数值的空间只有 60 位。能装进这个空间的整数被称为“小整数”(small integers)——它们是直接存在于进程寄存器中的立即数,永远不会触及堆内存。
任何更大的数值都会变成“大整数”(bignum),这需要堆内存分配并涉及垃圾回收(GC)的考量。这听起来很深奥,但在进行二进制协议工作所需的位操作(bit-twiddling)时,它变得至关重要。
这种技术被称为 SWAR(寄存器内 SIMD)——将多个值打包进单个整数,以便在 CPU 层面并行操作。对于 DNS 来说,这意味着每次操作可以解析多个字节的数据。但前提是你的打包值必须落在“小整数”的黄金区间内。
如果超出了这个范围,你不仅要支付内存分配的成本,还可能触发原本不需要的垃圾回收周期。在每秒数百万次查询的规模下,这些周期累积起来的影响是巨大的。
这对你的代码意味着什么
以下是实际的经验总结:你不需要编写 NIF 或调用 C 语言来获得显著的性能提升。你需要理解 BEAM 是如何思考数据的。
以下是值得内化的几种模式:
1. 保持二进制累加器的紧凑性。 “仅追加”的循环是你的好伙伴。一旦二进制数据发生逃逸,成本模型就会发生剧变。
2. 尽可能原地匹配。 利用模式匹配来“查看”数据,而不是“复制”数据。BEAM 对此进行了优化。
3. 注意整数大小。 在进行算术或位运算时,思考你的数值是否会保持在“小整数”范围内。
4. 以“归约”(reductions)为核心进行性能分析。 BEAM 以归约(大致相当于函数调用次数)来调度工作。一个通过更少的函数调用完成更多匹配的热点循环,从调度器的角度来看,其工作量更小。
对于任何构建处理二进制协议系统的人来说——无论是 DNS、WebSocket 帧还是自定义协议实现——这些模式都直接适用。BEAM 为编写高性能代码提供了强大的工具,但只有当你理解其模型时,这些工具才真正有用。
我们见过许多团队在明明可以通过深入理解 BEAM 语义就能获得 90% 性能提升的情况下,却选择了复杂的 NIF 或 Rust NIF。运行时比我们通常认为的要聪明得多——我们的工作就是编写能让它大显身手的代码。
在 NameOcean,我们始终在思考大规模场景下的性能问题。无论你是为下一个项目启动 Vibe Hosting,还是在为高流量应用管理 DNS,理解这些基础知识都能帮助你构建在繁忙时依然保持快速的系统。
祝编程愉快,愿你的二进制追加始终高效。