Day 19 / 共 20 天 · 第 4 周 扩展/可观测/构建
Buffer 零拷贝 + Bazel
Envoy 快的最后一块拼图:零拷贝 Buffer——数据在系统里尽量不复制。再看它为什么用 Bazel 构建、以及扩展裁剪机制(Higress 定制 Envoy 的关键)。
📍 你在整门课的位置 · 第 4 周「扩展/可观测/构建」(D18 讲了"看见" → 今天补上高性能最后一块拼图·零拷贝,再讲 Bazel 构建)
D16 扩展机制→
D17 Wasm→
D18 可观测→
D19 Buffer/Bazel→
D20 收官
L01
为什么零拷贝
"拷贝"是性能杀手
代理转发数据,数据要经手很多环节(读 socket → 过滤器 → 写 socket)。每次把数据从一块内存复制到另一块,都花 CPU + 内存带宽。海量流量下,反复拷贝会成为瓶颈。零拷贝(zero-copy)的目标:数据"移动"时尽量只传递"指针/所有权",不真复制字节。好比搬家——能直接把箱子(整块内存)搬过去,就别把箱子里每样东西掏出来再装进新箱子。Envoy 的 Buffer 系统就是为此精心设计的。
💡 Slice = 一块内存 + 三个游标,分出三段
一块连续内存被游标分成三段:已读走的(Drained) | 待处理的有效数据(Data) | 还没用的可写空间(Reservable)。读走数据
drain() 只是把游标往后挪(O(1),不删字节);写只往 Reservable 段写。"追加/排空"都变成"移游标"而非"搬数据"——这就是快的根源。📝 举个例子:drain 10 字节为什么 O(1)
buffer 里有
[已排空 20 | 有效 100 | 可写 30]。上层消费了 10 字节 → drain(10) 只做 data_ 游标 += 10 → 变成 [已排空 30 | 有效 90 | 可写 30]。没有 memmove,没有 realloc,就挪个数字。Slice 三段模型:四个偏移把内存分成"已排空/有效/可写"。读写都是挪游标,不搬字节。
L02
Slice 三段模型
// source/common/buffer/buffer_impl.h:37 Slice
// 一块连续内存用四个偏移分成三段:
// [已排空 Drained | 有效数据 Data | 可写 Reservable]
// base_ / data_ / reservable_ / capacity_
// OwnedImpl(:643)持有 SliceDeque slices_(:753,自定义环形队列,比 std::deque 快)
Slice = "带三个游标的内存块"
一块内存被三个游标分成三段:已读走的(Drained)| 待处理的有效数据(Data)| 还没用的可写空间(Reservable)。读走数据
drain() 只是把 data_ 游标往后移(O(1),不删数据);写数据只往 Reservable 段写。一个 Buffer 是多个 Slice 组成的队列(SliceDeque)——数据"多但不连续"也没关系,逻辑上是一整块。这种设计让"追加/排空"都是移游标而非搬数据。Envoy 嫌 std::deque 慢,自己写了带内联小环的 SliceDeque。L03
reserve / commit
// 读路径零拷贝:socket readv 直接写进预留内存
// reserveForRead() 预留 RawSlice → readv 把数据直接写进去 → commit(实际字节数)
// commit() 只做 reservable_ += len(buffer_impl.cc:472),零拷贝入队
读法:从 socket 读数据时,不是"读到临时 buffer 再拷进 Envoy buffer",而是直接让内核把数据
readv 写进 Envoy 预留(reserve)的内存,然后 commit 一下(只移游标)——全程零拷贝。这是"读路径零拷贝"的关键:数据从网卡到 Envoy buffer,中间不经过额外复制。L04
零拷贝 move
// source/common/buffer/buffer_impl.cc:334 move
// coalesceOrAddSlice(:312):
// 小切片(< CopyThreshold 且尾片有空位)→ 拷贝合并(减碎片)
// 否则 → slices_.emplace_back(std::move(other_slice)) 整体转移 Slice 所有权,底层字节不动
move:把数据从一个 buffer "转移"给另一个
过滤器链里,数据要从一个 buffer 传到下一个。
move() 不复制字节,而是把 Slice 的所有权整个转移过去(std::move)——底层那块内存原地不动,只是"归属"变了。只有很小的碎片才拷贝合并(减少碎片、提升后续访问效率),这是权衡后的优化。所以数据在 Envoy 内部多个组件间流转,绝大多数时候零拷贝——这是它能低延迟处理大流量的物理基础。加上 Day 03 无锁、Day 04 事件驱动、今天零拷贝,Envoy 高性能的三大支柱就齐了。L05
水位缓冲背压
// source/common/buffer/watermark_buffer.h:22 WatermarkBuffer : public OwnedImpl
// 三回调:below_low_watermark_ / above_high_watermark_ / above_overflow_watermark_
// setWatermarks(watermark_buffer.cc:119):low = high/2(隐式滞回)
// 越高水位 → 触发回调(通常暂停读/暂停上游);排空到低水位 → 恢复
背压(backpressure)= "下游堵了就叫上游慢点"
如果下游(客户端)读得慢,而上游(后端)拼命发,Envoy 的 buffer 会越积越多,最终内存爆炸。水位缓冲的招数:设一个高水位线,buffer 积压超过它就触发回调——通常"暂停从上游读"(叫上游别发了);等 buffer 排空到低水位线以下,再"恢复读"。就像水库:水太满就关闸,水位下去再开闸。低水位 = 高水位的一半(滞回,避免在阈值附近反复开关)。这就是流控/背压,防止快的一方压垮慢的一方——分布式系统的必备机制。
⚠️ 常见误解:以为"零拷贝 = 一次都不拷贝"。其实是尽量少拷贝——很小的碎片(< 阈值)反而会主动拷贝合并(减少碎片、让后续访问更快),大块才走
move 转移所有权。工程是权衡,不是教条。💡 顺带记住:Bazel = "锁死食材版本的菜谱"
Envoy 依赖 abseil/protobuf/gRPC/BoringSSL/V8… 一堆库,每个都要特定版本。Bazel 用 URL+sha256+版本把每样"食材"锁死——任何人、任何厨房,照这份菜谱做出的"菜"(二进制)都一模一样(可复现)。代价是菜谱难学,但对"依赖地狱 + 要极致可靠"的 Envoy 值得。
口诀:Envoy 快靠三支柱(本课性能主线收束)
无锁(D03/D13 线程本地)+ 事件驱动(D04)+ 零拷贝(今天)——三根支柱撑起"百万并发还低延迟"。明天 D20 会把它们和请求全链路一起收官。这套模式(TLS 无锁 + 事件循环 + 零拷贝)可带走用到任何高性能网络系统。
L06
为什么用 Bazel
Envoy 用 Bazel(Google 的构建系统):.bazelversion=7.7.1,C++20,clang/libc++,静态大二进制(envoy-static)。
为什么不用 CMake,非要 Bazel?
Envoy 依赖巨量 C++ 库(abseil、protobuf、gRPC、BoringSSL、V8、WAMR…),每个都要特定版本 + 打补丁。Bazel 的优势:①可复现——依赖用 URL+sha256+版本锁死,任何人任何机器构建出的二进制一致;②近 hermetic(封闭)——不依赖系统里装了什么,自带工具链;③管理巨大依赖图 + 增量构建快;④sanitizer/coverage/远程缓存一体化。代价是学习曲线陡。对 Envoy 这种"依赖地狱 + 要求极致可靠"的项目,Bazel 是合理选择。构建命令:
bazel build -c opt //source/exe:envoy-static。L07
扩展裁剪机制(Higress 定制关键)
extensions_build_config.bzl(275 条)= "规范名 → Bazel target"字典,决定编入哪些扩展。Envoy 把它当外部仓库 @envoy_build_config。下游(Higress/istio-proxy)只需在自己 WORKSPACE 里注册一个 envoy_build_config 指向自定义的 extensions_build_config.bzl,即可整体替换编入的扩展集——不改 Envoy 源码。
这就是 Higress 怎么"定制 Envoy"的答案:Higress 不 fork 改 Envoy 每个文件(那样难以跟上游同步),而是①用
#if defined(HIGRESS) 宏做少量必要的内核增强(你在 Day 07/08/17 见过)②通过这个裁剪机制换掉扩展集(去掉用不到的、加上 Higress 自己的)。加上 Day 17 的 Wasm(运行时动态加载插件),Higress 就能在不魔改 Envoy 的前提下,构建出一个功能定制、可扩展的 AI 网关数据面。Bazel 宏 envoy_cc_library 默认 alwayslink=True——保证 Day 16 的 REGISTER_FACTORY 静态注册不被链接器优化掉。L08
今日小结 + 动手
🧠 今天你应该能回答
- 为什么"拷贝"是性能杀手?零拷贝的目标?
- Slice 三段模型?drain/commit 为什么是 O(1)?
- 读路径怎么零拷贝?move 怎么零拷贝?
- 水位缓冲背压解决什么?低水位为什么是高水位一半?
- 为什么用 Bazel?Higress 怎么靠裁剪机制定制 Envoy?
✋ 动手
cd /Users/bitmart/work/codes/github/higress-group/envoy
sed -n '22,40p' source/common/buffer/buffer_impl.h # Slice ASCII 图
sed -n '119,150p' source/common/buffer/watermark_buffer.cc
head -5 .bazelversion; grep -c '' extensions_build_config.bzl
明天预告 · Day 20(结业):收官串讲——Envoy 全景回顾、三大高性能支柱、它在 Istio/Higress 中的角色,以及承上启下引出下一个子项目 Istio(控制面)。