有一类工程问题,不是因为代码写错了,而是因为系统架构在规模增长之后触碰到了物理极限。
Cloudflare 的机器学习团队经历的正是这种情况。他们的 Bot 检测系统需要对每一个 HTTP 请求做实时机器学习推理,而 Cloudflare 的流量峰值是每秒 6300 万次请求。在这个规模下,任何看起来"还好"的延迟,乘以请求量之后都会变成一个天文数字。
这篇文章记录了他们如何把机器学习特征提取的 p99 延迟从9.5 毫秒降到18 微秒——降幅 99.8%,速度提升 528 倍。
原文链接:https://blog.cloudflare.com/scalable-machine-learning-at-cloudflare/
mmap-sync 开源地址:https://github.com/cloudflare/mmap-sync
先理解这套系统在做什么
Cloudflare 的 Bot Management 系统需要对每一个 HTTP 请求打分,判断它是真实用户还是机器人流量。这个评分在所有检测机制中覆盖面最广,为超过72%的 HTTP 请求提供最终的 Bot Score 决策。
核心推理引擎是CatBoost,一个以低延迟推理著称的梯度提升框架。但推理本身只是延迟方程的一部分,另一部分是特征提取和准备。
这两件事合在一起,才决定了每个请求实际感受到的延迟。
特征的类型也在演进:早期只用"单请求特征",比如某个 HTTP 头是否存在、值是什么。但这类特征太容易伪造,攻击者稍作修改就能绕过检测。后来引入了跨请求聚合特征,比如某个 IP 在过去一段时间内关联过多少个不同的 User-Agent,这类特征更难伪造,但也更难实时提取。
旧系统:Gagarin 的能与不能
负责提供跨请求聚合特征的系统叫Gagarin,用 Go 实现,本质上是一个特征服务平台。
工作流程大致如下:
- HTTP 请求到达边缘节点,从请求属性中提取维度键
- 先查多层 Lua 缓存,命中则直接用
- 缓存未命中时,通过 Unix Domain Socket 发 memcached 请求给 Gagarin
- Gagarin 返回对应的特征向量
- 特征向量送入 CatBoost 模型,产出 Bot Score
早期这套系统运转良好,p50 延迟约200 微秒。但随着特征数量增加、流量持续增长,缓存命中率开始下降,延迟随之恶化:
- p50:500 微秒
- p99 峰值:10 毫秒
团队对 Gagarin 做了大量的低层次调优,最终触碰到了一个无法通过调参解决的边界。
问题的根源:Unix Socket 不够快
Cloudflare 团队用第一性原理的方式重新审视了这个问题:操作系统提供的最高效进程间通信方式是什么?
他们用 ipc-bench 这个开源工具测量了 Linux 上各种 IPC 机制的延迟(条件:百万次 1024 字节消息的双向通信):
| IPC 方式 | 平均延迟 (μs) | 平均吞吐 (msg/s) |
|---|---|---|
| TCP socket | 8.74 | 114,143 |
| Unix Domain Socket | 5.61 | 177,573 |
| 管道(Pipe) | 4.73 | 210,369 |
| 消息队列 | 4.40 | 226,421 |
| Unix 信号 | 2.45 | 404,844 |
| 共享内存 | 0.598 | 1,616,014 |
| 内存映射文件 | 0.503 | 1,908,613 |
结论一目了然:Unix socket 已经是相对高效的选项,但和共享内存、内存映射文件相比,还差了整整一个数量级。
旧系统的延迟问题,根源之一就在于必须经过 Unix socket 这条"慢路"。
评估了六种方案,最终选了内存映射文件
在确定方向之前,团队系统性地排除了其他选项:
继续优化 Gagarin:Go 的 GC 暂停、hashmap 查找性能、Unix socket 同步开销,这些是语言和架构层面的固有限制,调参无法根治。
迁移到 Quicksilver(Cloudflare 内部的分布式 KV):特征的更新频率太高,会对 Quicksilver 的其他用途产生负面影响,且底层仍然是 Unix socket。
扩大多层缓存:把几千万个维度键连同特征向量全部缓存在内存里,会导致每个 worker 线程各维护一份副本,内存消耗不可接受。
对 Unix socket 做分片:能部分缓解争用,但引入了额外复杂度,且治标不治本。
换用 RPC:RPC 仍然需要某种通信总线(TCP/UDP/UDS),性能不会有本质改变。
最终选定:内存映射文件(mmap)。
三个关键技术决策
选定内存映射文件之后,还需要解决三个核心问题:如何在文件里高效查找特征?如何在高并发下安全更新?如何消除反序列化开销?
无等待同步(Wait-free)
普通的加锁(mutex/spinlock)在高并发下会引入争用,正是 Unix socket 方案的痛点之一。
Cloudflare 的设计受 Linux 内核RCU(Read-Copy-Update)模式和Left-Right 并发控制技术启发,采用了无等待同步:
- 维护两份数据副本,存储在两个独立的内存映射文件中
- 由单个 writer管理写入,写入时更新非活跃副本,完成后原子切换版本号
- 多个 reader 可以并发读取活跃副本,无需任何锁
- 第三个文件专门存储同步状态(当前活跃文件索引、数据大小、校验和、各副本活跃读者计数)
这套机制保证了读操作永远不会被阻塞,且在有限步骤内必然完成——这是比 lock-free 更强的保证。
零拷贝反序列化(rkyv)
从文件里读数据,通常需要把字节流反序列化成内存中的数据结构,这个过程有拷贝和计算的开销。
零拷贝反序列化的思路是:让序列化后的字节布局和内存布局完全一致,这样可以直接把文件内存映射后的字节当作 Rust 结构体来用,不需要任何拷贝或转换。
Cloudflare 选用了rkyv框架,它是少数几个能对HashMap做零拷贝访问的 Rust 序列化库之一。读取特征时,数据不会被复制,也不会有额外的解析计算,直接引用映射内存中的字节。
mmap-sync:把三个技术打包成一个 Rust crate
Cloudflare 把内存映射文件 + 无等待同步 + 零拷贝反序列化三者结合,封装成了一个 Rust crate,命名为mmap-sync,并开源发布。
核心是一个叫Synchronizer的结构体,接口极其简洁:
implSynchronizer{// 写入任意可序列化的 Rust 结构体pubfnwrite<T>(&mutself,entity:&T,grace_duration:Duration)->Result<(usize,bool),SynchronizerError>{...}// 零拷贝读取,返回带生命周期保护的引用pubfnread<T>(&mutself)->Result<ReadResult<T>,SynchronizerError>{...}}读操作返回一个 RAII guard,持有期间自动增加活跃读者计数,出作用域后自动减少——writer 会等到所有读者离开之后才回收旧副本,整个过程对调用方完全透明。
BLISS:新系统的完整架构
基于 mmap-sync,Cloudflare 重新设计了整个 Bot 检测的基础设施,命名为BLISS(Bots Liquidation Intelligent Security System),由两个组件构成:
bliss service
用 Rust 实现的多线程 sidecar daemon,负责数据的写入侧:
- 周期性地从上游拉取最新的机器学习特征和维度数据
- 解析后通过 mmap-sync 写入内存映射文件
- 基于 Tokio 异步运行时,擅长大批量数据处理和 I/O 密集操作
bliss library
用 Rust 实现的单线程动态库,负责读取和推理侧:
- 通过 FFI 嵌入每个 worker 线程,用 Lua 模块调用
- 从内存映射文件中零拷贝读取特征
- 调用 CatBoost 模型完成推理,产出 Bot Score
- 零堆分配:所有数据结构预先分配,运行时只用栈内存;用 dhat 堆分析器在集成测试中强制验证这一点
- SIMD 优化:对某些请求属性的 hex 解码使用 AVX2/SSE4 指令集,速度提升 10 倍
编译配置也做了深度调优:
[profile.release] codegen-units = 1 # 全程序优化,不分片 lto = "fat" # 跨 crate 链接时优化 opt-level = 3 # 最高优化级别 debug = true # 保留调试符号,不影响性能上线数据:延迟改善了多少个数量级
| 延迟指标 | 改造前 (μs) | 改造后 (μs) | 变化 |
|---|---|---|---|
| p50 | 532 | 9 | 降低 98.3%,快 59 倍 |
| p99 | 9,510 | 18 | 降低 99.8%,快 528 倍 |
| p999 | 16,000 | 29 | 降低 99.8%,快 551 倍 |
在整体 HTTP 请求处理层面:
- 整体平均处理延迟降低12.5%
- Bot Management 模块延迟降低55.93%
团队用了一个很直观的换算来说明这个改善的量级:在 Cloudflare 每秒 4600 万请求的规模下,每个请求节省 523 微秒,等价于每天节省超过 65 年的处理时间。
除延迟之外,其他指标同样改善显著:
- 特征可用性从有损达到 100%:消除了 Unix socket 超时,误报和漏报率下降
- 释放了等价于数千个 CPU 核心和数百 GB 内存的资源,提升了整体服务器利用率
- 清除了数千行 Lua 和 Go 代码,降低了技术债务
- 机器学习能力大幅扩展:可以承载数百个特征、数十个维度和多个并行模型
这套方案的本质
这次重构表面上是一次延迟优化,但背后的工程逻辑值得单独提炼。
第一,从性能测量开始,而不是从直觉开始。用 ipc-bench 系统性地测量各种 IPC 方式的延迟,数据驱动选型,不靠猜测。
第二,找到真正的瓶颈,而不是在边际上优化。Gagarin 经过大量调优之后,p99 依然是 10ms。因为问题不在代码质量,在于 Unix socket 这条通信路径本身的物理限制。换掉通信路径,才能突破上限。
第三,把解决方案抽象成可复用的基础设施。mmap-sync 不只是 BLISS 的内部实现细节,而是一个可以独立使用的 Rust crate,开源之后整个社区都可以用同一套机制解决类似问题。
在 Cloudflare 这个规模上,每一微秒的节省都在被放大,但解决问题的思路——测量、找根因、换架构、抽象复用——在任何规模的系统里都适用。