Day 13 / 共 20 天 · 第 3 周 xDS 与上游
Cluster Manager 集群管理
Cluster(上游服务集群)是"流量要转发去的目的地"。今天看 ClusterManagerImpl 怎么管理集群,尤其是它的线程本地集群状态——Envoy 高性能的又一关键设计。
📍 你在整门课的位置 · 第 3 周「xDS 与上游」(D12 配置怎么订阅 → 今天看订阅来的集群怎么被管理和高性能读取)
D11 xDS 总览→
D12 配置订阅→
D13 集群管理→
D14 负载均衡→
D15 健康检查/发现
L01
Cluster 是什么
Cluster = "一组等价的后端服务器"
你的服务通常不止一台机器——比如
user-service 部署了 5 个实例。Cluster(集群)就是"这一组等价后端"的抽象:它有名字(user-service)、一堆端点(5 个 IP:Port)、一个负载均衡策略(怎么在 5 个里选一个)、健康检查规则。Envoy 把请求路由到某个 Cluster,再由 Cluster 的负载均衡器选一个具体后端转发。Cluster 是"逻辑目的地",端点是"物理机器"。CDS 定义集群属性、EDS 提供端点(Day 11 的层级链)。L02
ClusterManagerImpl
// source/common/upstream/cluster_manager_impl.h:230
class ClusterManagerImpl : public ClusterManager {
Status initialize(const Bootstrap& bootstrap) override; // :234
StatusOr<bool> addOrUpdateCluster(const Cluster& cluster, const std::string& version) override; // :241 CDS 增删改落点
ThreadLocalCluster* getThreadLocalCluster(absl::string_view cluster) override; // :304
bool removeCluster(const std::string& cluster, ...) override; // :306
};
读法:ClusterManager 是"所有集群的总管":增删改集群(
addOrUpdateCluster,CDS 动态更新的落点)、按名取集群(getThreadLocalCluster)。它是数据面和上游服务之间的桥梁。💡 一句话:Cluster = 餐厅里"某道菜的出餐窗口组"
user-service 这道菜有 5 个等价的出餐窗口(5 个后端实例)。Cluster 就是"这组窗口"的抽象:一个名字 + 一批端点 + 一套"该找哪个窗口"的规则(负载均衡)+ 健康检查。路由先选到"哪组窗口"(Cluster),再由负载均衡挑"具体哪个窗口"(Host)。今天这套"餐厅"比喻会贯穿到 L06。📝 举个例子:一个 Cluster 长什么样
名字
user-service → 端点 {10.0.0.1:8080, 10.0.0.2:8080, …共5个} → LB 策略 least_request → 健康检查 /healthz 每5秒。其中"端点从哪来"由 CDS/EDS 决定(D11 的层级链)。L03
初始化状态机
ClusterManagerInitHelper 管理启动顺序(cluster_manager_impl.cc):
Primary(主)集群先初始化 → onStaticLoadComplete()(cc:260)
→ Secondary(次,如需 DNS 解析的)集群 startInitializingSecondaryClusters()(cc:268)
→ 最后才启动 CDS 动态订阅 setCds()(cc:274)
为什么初始化要讲顺序?
有个"先有鸡还是先有蛋"问题:Envoy 要通过 xDS 从控制面拉动态集群,但"连控制面"本身就需要一个集群(指向控制面的那个 gRPC 集群)先就绪!所以顺序必须是:先把配置里写死的静态主集群(含控制面集群)启动 → 再启动需要 DNS 解析的次集群 → 最后才用已就绪的控制面集群去拉 CDS 动态集群。这个初始化状态机保证了"承载 xDS 的集群先活,才能拉别的集群"——启动依赖顺序的经典处理。
🤔 如果让你自己实现"多线程共享集群列表",你会怎么写?
朴素做法:一个全局集群表,所有 Worker 线程读它、主线程改它,读写都加一把锁。问题:每处理一个请求都要拿锁查集群——锁成了热路径瓶颈,核越多越抢得凶,扛不动百万并发。
💡 本质:每个服务员随身一份菜单副本(Thread-Local,无锁)
Envoy 的招数:主线程负责"算配置",然后给每个 Worker 线程发一份只读副本(thread-local)。Worker 处理请求时读自己那份副本,没人会改它 → 完全不用加锁。就像每个服务员随身带一份自己的菜单,不用挤到前台抢看同一张。这是 Envoy 多核线性扩展、扛百万连接的核心设计,D03 线程模型里你还会反复见到它。
主线程算配置,把只读副本"投递"给每个 Worker;Worker 读自己的副本,热路径零锁。这就是 getThreadLocalCluster 飞快的原因。
⚠️ 常见误解:以为"更新时主线程直接去改各 Worker 的数据"。其实相反——主线程只是投递任务(runOnAllThreads),让每个 Worker 在自己线程里改自己的副本,全程无竞争、不加锁。
L04
线程本地集群状态
Envoy 是多 Worker 线程(Day 03 会讲)。集群更新在主线程,但每个 Worker 要无锁读取集群/连接池。方案:Thread Local Storage(TLS,线程本地存储)。
// cluster_manager_impl.h:774 struct ClusterData —— 主线程侧集群数据
// 内部类 ThreadLocalClusterManagerImpl —— 每个 Worker 线程一份副本
// tls_.set(...)(cc:447)在每个 Worker 上创建
线程本地存储:Envoy 无锁高性能的秘密(重要!)
多线程共享数据通常要加锁(防止一个线程读时另一个在改),但锁会拖慢热路径。Envoy 的招数:主线程负责"计算配置",然后给每个 Worker 线程一份只读副本(thread-local)。Worker 处理请求时读自己的副本,完全不用加锁(因为没人会改它的副本)。配置变了?主线程算好新副本,通过"消息投递"更新到各 Worker(下一课)。"每线程一份副本 + 消息投递更新"替代了"共享 + 加锁"——这是 Envoy 能在多核上线性扩展、扛百万连接的核心设计。你在 Day 03 还会看到这个模式贯穿整个 Envoy。
L05
getThreadLocalCluster
// cluster_manager_impl.cc:986
ThreadLocalCluster* ClusterManagerImpl::getThreadLocalCluster(absl::string_view cluster) {
ThreadLocalClusterManagerImpl& cluster_manager = *tls_; // 当前线程的副本
auto entry = cluster_manager.thread_local_clusters_.find(cluster);
if (entry != cluster_manager.thread_local_clusters_.end())
return entry->second.get(); // 无锁哈希查找!
return cluster_manager.initializeClusterInlineIfExists(cluster); // 惰性初始化
}
读法:请求处理时调这个函数拿集群——直接从当前线程的哈希表查,零锁。这就是 L04 设计的兑现:Worker 读自己的
thread_local_clusters_,飞快。Router(Day 09)就是调它拿到目标集群。L06
跨线程传播
集群更新怎么从主线程传到各 Worker?postThreadLocalClusterUpdate()(cc:1167)通过 tls_.runOnAllThreads(...)(cc:1201)把新主机集合投递到每个 Worker,各 Worker 的 ClusterEntry(持有 LB + 连接池)据此重建。
读法:
runOnAllThreads = "把这个更新任务发到每个 Worker 的事件队列,让它们各自在自己的线程里执行更新"。不是主线程去改 Worker 的数据(那要锁),而是让 Worker 自己改自己的副本(在它自己线程里,无竞争)。这就是"消息投递代替共享内存加锁"的并发模型(和 OpenClaw 的会话串行、LangGraph 的检查点异曲同工——都在解决并发安全,各有各的招)。L07
CDS 驱动集群管理
// source/common/upstream/cds_api_impl.h:35
class CdsApiImpl : public CdsApi, SubscriptionBase<Cluster> {
// onConfigUpdate(cc:50/:102)收到集群列表 → 委托 helper_.onConfigUpdate
// → 对每个资源调 ClusterManager 的 addOrUpdateCluster / removeCluster
// → 完成后 runInitializeCallbackIfAny() 通知初始化完成
};
读法:把 Day 12 的订阅和今天的集群管理串起来:CDS 是一个
SubscriptionBase<Cluster> 订阅者,收到下发的集群列表,就调 ClusterManager 增删改集群。这就是"控制面改集群配置 → CDS 下发 → Envoy 热更新集群"的完整链路。L08
今日小结 + 动手
🧠 今天你应该能回答
- Cluster 是什么?和端点什么关系?
- ClusterManager 的核心职责?
- 初始化状态机为什么要讲顺序?
- 线程本地集群状态怎么做到无锁读?(Envoy 高性能关键)
- 跨线程更新为什么用 runOnAllThreads 而非加锁?CDS 怎么驱动?
✋ 动手
cd /Users/bitmart/work/codes/github/higress-group/envoy
sed -n '230,310p' source/common/upstream/cluster_manager_impl.h | head -50
sed -n '986,1000p' source/common/upstream/cluster_manager_impl.cc
grep -n 'runOnAllThreads\|postThreadLocalClusterUpdate' source/common/upstream/cluster_manager_impl.cc | head
明天预告 · Day 14:负载均衡——集群里多个后端,怎么选一个?
LoadBalancer::chooseHost、轮询(取模)、最少请求(P2C 二选一)、EDF 加权公平调度。