MongoDB Split Horizon 深度解析:基于 SNI 的多网络区域副本集地址通告机制
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
导读
Split Horizon(水平分割)是 MongoDB 副本集的一项核心机制,它允许同一个副本集成员根据客户端所处的网络区域,向其通告不同的host:port地址,从而支撑"内部网络 + 外部网络"混合部署场景下的地址可达性。本文以当前仓库中 split_horizon 模块 为骨架,结合 split_horizon.cpp、member_config.cpp 与 replication_coordinator_impl.cpp 等源码实现,完整讲解horizons配置、SNI 驱动的区域判定、正反向映射数据结构、构造校验规则、hello响应集成与序列化细节,让你既能写出可落地的replSetReconfig配置,也能理解其底层工作原理。
一、为什么需要 Split Horizon:多网络区域的地址通告问题
一个典型的 MongoDB 部署会横跨内部网络与外部网络。假设某个副本集成员同时被两类客户端访问:
- 内部客户端通过
internal.example.com:27017访问该成员; - 外部客户端则通过另一条 DNS 名称(如
external.example.com:25000)访问同一个成员。
如果没有 Split Horizon,服务器在hello(或旧版isMaster)响应中只能通告一个地址。此时必然有一侧客户端拿到的地址在自己的网络区域内无法路由,导致连接失败。Split Horizon 正是为了解决这一矛盾而引入的机制:同一个成员在不同网络视图(horizon)下拥有不同的地址,服务器根据客户端的来源区域返回对应的那组地址。
从副本集配置的角度看,这就是 MongoDB 官方文档中副本集成员配置选项horizons背后的实现机制。当前仓库中 repl_set_config.cpp 与 repl_set_config_checks.cpp 承担着该配置项的解析与合法性校验职责。
二、核心概念:Horizon 与__default
2.1 什么是 Horizon
一个horizon(水平线)是"一个命名网络视图"。每个副本集成员始终拥有一个__defaulthorizon,它对应成员配置中的host字段;额外的 horizon 则通过成员配置中可选的horizons子文档声明。例如:
{ "_id": 0, "host": "internal.example.com:27017", "horizons": { "external": "external.example.com:25000" } }host字段internal.example.com:27017自动成为__defaulthorizon 的地址;horizons.external声明了一个名为external的额外视图,地址为external.example.com:25000。
__default这个名字是保留字,不能显式出现在horizons子文档中。这一点在源码中有直接体现:在 split_horizon.cpp 的 BSON 解析逻辑中,遇到名为__default的字段会直接抛出BadValue,提示"Horizon name "__default" is reserved for internal mongodb usage";空名字同样会被拒绝("Horizons cannot have empty names")。
2.2 通过 SNI 判定客户端所属 Horizon
当 TLS 连接建立时,服务器会从 TLS 握手过程中捕获SNI(Server Name Indication,服务器名称指示)主机名,并将其以SplitHorizon::Parameters的形式存储在Client对象上。当客户端随后发出hello(或旧版isMaster)命令时,复制协调器(Replication Coordinator)调用SplitHorizon::determineHorizon(),用 SNI 主机名去查询反向映射表,找到与之匹配的 horizon;如果未命中(或根本没有 SNI),则回退到__default。
最终确定的 horizon 决定了hello响应中包含哪一组地址——每个成员都从"客户端所在网络区域"的视角返回自己的地址,从而保证客户端拿到的 host list 在其区域内可路由。
三、数据模型:正反向两张映射表
SplitHorizon类建模的是单个成员在所有 horizon 下的地址映射,它不是集群级结构——每个MemberConfig各自持有一个SplitHorizon实例。这一点在头文件注释中写得很明确:SplitHorizonmodels a single member's view across all horizons, not views for all of the members(见 split_horizon.h)。
3.1 正向映射(ForwardMapping)
StringMap<HostAndPort> horizon name --> host:port把每个 horizon 名称(如"__default"、"external")映射到该成员在此 horizon 下可达的HostAndPort。正向映射始终至少包含__default项。在源码中它的类型定义为using ForwardMapping = StringMap<HostAndPort>;(见 split_horizon.h),底层是一个字符串键的有序映射容器。
3.2 反向主机映射(ReverseHostOnlyMapping)
std::map<string, string> hostname --> horizon name把该成员可被访问到的每个主机名(不含端口)映射回使用它的 horizon 名称。determineHorizon()正是基于这张表,用传入的 SNI 名称去匹配 horizon。源码中类型为using ReverseHostOnlyMapping = std::map<std::string, std::string>;(见 split_horizon.h)。
关键约束:由于查找只依据主机名(而非host:port组合),对于同一个成员,每个主机名在所有 horizon 中必须唯一——两个 horizon 不能共用同一个主机名,即使端口不同也不行(这点在下一节"构造与校验"中会看到源码级证据)。
反向映射的构建逻辑位于 split_horizon.cpp 的computeReverseMappings():先把__defaulthorizon 的主机名预置进去(这是为了正确处理__default内部的主机名歧义情况),再遍历正向映射逐个emplace。
四、构造方式与校验不变量
SplitHorizon提供两种构造途径(见 split_horizon.h):
- 从 BSON 构造:
SplitHorizon(const HostAndPort& host, const boost::optional<BSONObj>& horizonsObject),用于解析副本集配置(replSetInitiate/replSetReconfig)。host参数成为__default条目,可选的horizonsObject提供额外 horizon。 - 直接从
ForwardMapping构造:SplitHorizon(ForwardMapping forward),供内部逻辑和测试使用。
两条路径最终汇聚到统一构造器SplitHorizon(AllMappings)(见 split_horizon.h),由computeForwardMappings()和computeReverseMappings()依次完成正反向映射的构建。构造期间强制执行的不变量包括:
__defaulthorizon 必须始终存在;- horizon 名称必须非空且唯一;
- 保留名
__default不得出现在horizonsBSON 对象中; - 主机名必须在所有 horizon 间唯一(同一成员的两个 horizon 不能共享主机名,即使端口不同);
horizonsBSON 对象若存在则不能为空。
违反上述任何一条都会产生BadValue错误。这些规则在 split_horizon_test.cpp 中有大量测试覆盖:
basicConstruction用例验证了"两个 horizon 使用相同 host:port""相同 host 不同 port"都会抛出BadValue,且错误信息包含重复的主机名(如Duplicate horizon member found "same.example.com"),而不会误报未冲突的成员(见 split_horizon_test.cpp);BSONConstruction用例验证了空horizons对象报horizons field cannot be empty, if present、重复 horizon 名称报Duplicate horizon name found(见 split_horizon_test.cpp);determineHorizon用例覆盖了"无 SNI 回退__default""SNI 未命中回退__default""SNI 命中返回对应 horizon"以及"主机名冲突导致构造失败"四类场景(见 split_horizon_test.cpp)。
值得一提的是,BSON 构造路径中horizons字段的值必须为字符串类型,否则抛出TypeMismatch,提示horizons.<name> field has non-string value of type ...(见 split_horizon.cpp)。
五、与复制协调器的集成:从 SNI 捕获到hello响应
Split Horizon 的价值最终体现在hello/isMaster响应中。整个链路分布在三个组件中:
| 组件 | 与 Split Horizon 的关系 |
|---|---|
MemberConfig(member_config.h) | 持有SplitHorizon实例,将getHostAndPort(horizon)与determineHorizon(params)委托给它。构造时通过_splitHorizon = SplitHorizon(host, getHorizons())初始化(见 member_config.cpp) |
replication_info.cpp(hello/isMaster处理器) | 连接建立阶段调用SplitHorizon::setParameters()捕获客户端的 SNI 名称;随后把SplitHorizon::getParameters()传给复制协调器,使hello响应按正确 horizon 的地址构建 |
ReplicationCoordinator(replication_coordinator_impl.cpp) | 使用来自客户端的 horizon 参数,为拓扑响应中的每个成员选择返回哪个HostAndPort |
5.1 SNI 捕获与参数存取
SplitHorizon::Parameters结构体只有一个字段boost::optional<std::string> sniName(见 split_horizon.h)。参数本身通过Client::declareDecoration<SplitHorizon::Parameters>()声明为 Client 上的装饰器存储(见 split_horizon.cpp),setParameters()在持有 Client 锁的情况下写入,getParameters()读取(见 split_horizon.cpp)。
在连接建立阶段,replication_info.cpp 从客户端会话中取出 SNI 名称并写入:
// Set split horizon parameters. auto sniName = client->getSniNameForSession(); SplitHorizon::setParameters(client, std::move(sniName));在hello命令处理时,则取出参数传入复制协调器:
const auto& horizonParams = SplitHorizon::getParameters(opCtx->getClient()); // ... replCoord->awaitHelloResponse(opCtx, horizonParams, clientTopologyVersion, deadline);(见 replication_info.cpp)
5.2 horizon 字符串的推导
复制协调器内部通过_getHorizonString()(见 replication_coordinator_impl.cpp)完成最终判定:仅在自身是配置中的有效成员时,才调用self.determineHorizon(horizonParams)得到 horizon 字符串;否则返回boost::none。determineHorizon()的核心逻辑非常简洁(见 split_horizon.cpp):若存在 SNI 名称且在反向映射中命中,则返回对应 horizon;否则一律返回__default。
5.3 面向每个 horizon 的 hello 响应与拓扑版本
从源码结构可以看到,ReplicationCoordinatorImpl维护了一个_horizonToTopologyChangePromiseMap(horizon → promise 的映射,见 replication_coordinator_impl.cpp):每次配置刷新时会为该成员配置中的每个 horizon 建立独立的 promise,可等待的hello(awaitable hello)会针对自己所属的 horizon 等待拓扑变化通知;响应构建通过_makeHelloResponse()→_topCoord->fillHelloForReplSet(response, *horizonString)完成(见 replication_coordinator_impl.cpp)。
当一次replSetReconfig改变了 horizon 映射时,所有正在等待旧 horizon 拓扑变化的hello请求都会收到ErrorCodes::SplitHorizonChange错误并重新发起(见 replication_coordinator_impl.cpp),同时拓扑版本会被递增以标记 horizon 变更。这也解释了为什么该机制能够与客户端基于topologyVersion的变更流式监听(awaitable hello)无缝配合——它保证了"网络视图变化"对客户端可见且可重试。
此外,启动时会对配置中的非默认 horizon 映射做一次检查:如果某个 horizon 映射可以被解析为合法的 CIDR/IP 地址,会输出启动警告,提示"Found split horizon configuration using IP ..."(见 replication_coordinator_impl.cpp),引导运维使用 DNS 名称而非 IP 配置 horizon。
六、序列化规则:toBSON()输出确定性
SplitHorizon::toBSON()负责生成成员配置中的horizons子文档(见 split_horizon.cpp),其行为规则如下:
- 若成员只有
__defaulthorizon,则不输出horizons字段——因为此时它与host字段完全冗余; __default条目永远不会被序列化进horizons对象;- 输出前会按名称字典序对 horizon 条目排序,保证序列化结果确定、可复现(源码注释明确指出
StringMap的迭代顺序是不确定的,必须先排序)。
toBSON与BSONRoundTrip两组测试共同验证了该行为:前者断言"仅__default时不输出horizons字段",后者验证了"序列化 → 反序列化"往返后正反向映射完全一致,且两次toBSON结果逐字节相同(见 split_horizon_test.cpp)。这意味着由SplitHorizon重写出的配置是幂等且稳定的。
七、实战配置示例与注意事项
7.1 配置一个跨内外网的副本集成员
结合前文,一个完整的rs.reconfig()片段如下:
const cfg = rs.conf(); cfg.members[0].host = "internal.example.com:27017"; cfg.members[0].horizons = { external: "external.example.com:25000" }; rs.reconfig(cfg);配置要点:
host与horizons.external的主机名不能相同(即使端口不同),否则replSetReconfig会以BadValue拒绝;horizons中不要使用__default作为键名;horizons子文档不能为空;值为字符串形式的host:port;- 建议使用 DNS 名称而非 IP 地址,避免启动时收到 CIDR 形式的警告。
7.2 客户端侧如何自动命中 horizon
当外部客户端以 TLS 方式连接external.example.com:25000时,其 TLS 握手携带的 SNI 名称就是external.example.com。服务端据此匹配反向映射,返回的hello响应中该成员即呈现为external.example.com:25000;内部客户端连internal.example.com:27017时,SNI 匹配__default,响应中呈现internal.example.com:27017。两侧客户端都能在各自网络区域内使用响应中的地址完成后续连接。
7.3 与 awaitable hello / 拓扑版本的关系
从 replication_coordinator_impl.cpp 可以看到,等待中的hello会按其所属 horizon 挂起在对应的 promise 上;一旦 reconfig 改变了 horizon 映射,等待者会收到SplitHorizonChange错误并重试。因此在进行涉及 horizon 的配置变更时,客户端驱动需要具备基于topologyVersion的重试能力——这是 MongoDB 官方驱动标准行为,运维无需额外干预。
八、模块文件索引
若希望深入源码,建议按以下顺序阅读:
- split_horizon/README.md:模块设计文档(本文骨架来源);
- split_horizon.h:
SplitHorizon类与Parameters结构定义; - split_horizon.cpp:正反向映射构建、校验、
determineHorizon()与toBSON()实现; - split_horizon_test.cpp:构造、判定、序列化与往返测试;
- member_config.h / member_config.cpp:成员配置持有与委托调用;
- replication_info.cpp:SNI 捕获与
hello入口; - replication_coordinator_impl.cpp:horizon 感知的响应构建与
SplitHorizonChange处理; - repl_set_config_checks.cpp:
validateAllowingSplitHorizonIP()等配置校验路径。
总结
Split Horizon 通过"正向映射(horizon → 地址)+ 反向映射(主机名 → horizon)"两张表与 TLS SNI 机制,让 MongoDB 副本集能够按客户端网络区域返回各自可达的成员地址。其核心实现集中在split_horizon模块,构造与校验不变量严谨(保留名、唯一性、非空等),并通过MemberConfig→ReplicationCoordinator的委托链贯通到hello响应,最终以确定性的 BSON 序列化落回副本集配置。对于跨内外网部署、多 DNS 入口的 MongoDB 集群,正确理解并配置horizons是保证客户端连接高可用与正确路由的关键前提。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考