跨机集群实战:地址的三重语义,与"两台机器下无解"的边界
主题:跨机通信的地址语义 + 两机复制的固有限局
先说结论
这次跨机我踩了两件事,一件是"连不上",一件是"连上了但不敢写"。
第一件:单机时"地址"是一个值;跨机后它变成三个语义—— 改错任意一个,症状都一样:服务在跑、端口在听、注册也有,链路就是不通。
① 监听地址:进程绑哪个网卡收包 ← 绑 127.0.0.1 → 跨机【永远连不上】 ↓ ② 注册地址:注册中心里别人看到谁 ← 注册容器 IP → 消费方【直连不可达】 ⭐ 本次根因 ↓ ③ 连接地址:副本回连/上报时用谁 ← 上报容器 IP → 切换成功但【回不来】 贯穿的一条认知修正: 公网 IP 是 NAT、不在网卡上 → 绑私网 IP 只是【纵深防御】,真正的公网闸门只有安全组第二件:给主从复制加"防脑裂参数"之后,出现了一个反直觉后果 —— 从库一挂,主库拒绝所有写(读还能用,写全挂)。想明白之后我发现:这是两台机器下的无解问题,不是配置错了。
⚠️ 一层边界:副本必须分布在"会独立故障的机器"上 → 只有两台机器时,"某个节点挂了就不能写" 与 "允许静默丢数据" 只能二选一所以排查第一步永远是问一句:“这个地址是给谁看的?”—— 而不是先去怀疑业务代码;而在谈可用性之前,先承认边界。
一、问题:跨机 Dubbo 全断,报No provider available from registry
把秒杀副本挪到第二台机器后,副本调用商品/订单的 Dubbo 接口全部失败,客户端超时。端口在听、进程在跑、注册中心里也有 provider,就是不通。
根因:注册进注册中心的地址,是消费方连不上的地址。因为链路是**“消费方订阅接口 → 注册中心返回 provider 地址列表 → 消费方直连该地址”,而注册中心只做注册发现、不做流量代理**(它不是网关,不会替你转发)—— 所以注册进去的地址必须是"消费者能直连的地址"。
实测发现注册中心里 provider 注册的全是容器网段地址(形如172.x.x.x:<协议端口>),因为容器默认解析到的就是容器 IP:
服务器 B 副本 → 问注册中心要 provider 地址 → 拿到 容器网段 IP:<协议端口>(服务器 A 的容器网段) → 服务器 B 路由表里没有这个网段 → No provider available from registry / client-side timeout| 维度 | 修复前 | 修复后 |
|---|---|---|
| 注册地址 | 容器网段 IP | 宿主内网 IP |
| 端口 | 容器内端口(未发布) | 映射发布后的宿主端口 |
| 跨机调用 | ❌ 不可达 | ✅ 通 |
| 本机调用 | ✅(同网段碰巧通) | ✅ |
⚠️为什么一直没暴露:单机内同网段互通,容器 IP 直连也通。这类坑只要不跨机,就永远不会自己浮出来—— 它不是"新引入的 bug",是"一直存在、只是没被触发"。
二、方案:注册层改写,而不是网络层打通
| 路径 | 做法 | 评价 |
|---|---|---|
| ✅ 注册层改写(选定) | provider 显式注册宿主内网 IP + 映射后的宿主端口(服务框架为容器/NAT 场景提供的官方环境变量) | 声明式、官方机制、无"网段变更即失效"隐患 |
| 网络层(回退) | 服务器 B 加静态路由打通服务器 A 的容器网段 | 服务器 A 零改动,但属手工版 overlay:依赖对端网段、无自愈、单向可达,非规范做法 |
三个关键细节(都是"不写就静默失效"的那种):
- 协议端口在本项目里配成了
-1(自动分配)—— Dubbodubbo协议的默认端口是 20880,-1是"自动选空闲端口"的显式写法;跨机必须显式固定,否则根本没法做端口映射。 - 注册的端口要填"映射后的宿主端口",不是容器内的端口—— 两个 provider 的宿主端口必须错开,否则第二个起不来。
- 改动面按需收敛—— 扫过代码后确认:跨机依赖只有"秒杀副本 → 商品/订单"的 Dubbo 调用(秒杀侧没有任何 HTTP 声明式调用)。所以服务器 A 只改 2 个服务,其余 19 个容器零改动。
分层归属:为什么一个用 host 网络、一个用 bridge
| 组件 | 网络模式 | 为什么 |
|---|---|---|
| Redis / 哨兵 | host | 它没有注册中心,唯一要求是"主/哨兵看到的地址可达" —— host 网络天然满足 |
| 秒杀副本 | bridge + 显式覆盖 | 它必须在注册中心注册宿主 IP→ 需要端口映射 + 显式覆盖,与服务器 A 同构 |
📌 一句话规则:有没有注册中心,决定用不用 host 网络。
# 错误:只发布端口,注册地址仍是容器 IP → 跨机不可达ports:["<宿主端口>:<容器端口>"]# 正确:显式覆盖注册用的 IP 与端口(端口为映射后的宿主端口)environment:DUBBO_IP_TO_REGISTRY:<宿主内网 IP>DUBBO_PORT_TO_REGISTRY:"<映射后的宿主端口>"DUBBO_PROTOCOL_PORT:"<容器内协议端口>"三、⭐ 更深一层的认知:三层语义里,"注册地址"是最容易骗过你的那一层
监听地址错了会立刻痛(连不上就是连不上,telnet一试便知)。注册地址错了最阴—— 进程在、端口在听、注册中心里也明明有那个实例,ss -lnt看一切正常。所有本地自检全绿,只有跨机调用不通。
而这背后是一条更本质的结论:
注册中心不是网关。它是"名片墙",不是"信箱"。
你把名片贴上去,别人是照着名片直接找上门的 —— 所以名片上必须写别人真的能走到的地址。
顺着这条线,第三个语义就顺理成章了:"被降级"的一方也要能回得来。Redis 主从切换后,原主库被降级成从库,此时它要向新主上报自己的地址;如果上报的是容器 IP,服务器 B 根本连不回来 →复制直接断链。这就是"切换成功,但回不来"的隐形杀手 —— 只配好选主是不够的,回连地址也必须是对端可达的。能选出新主 ≠ 拓扑能收敛。
顺带一个同族的坑:哨兵的配置必须能落盘。哨兵每次状态变化(发现从库 / 发现其它哨兵 / 选出新主)都会重写自己的配置文件(写临时文件 → rename 覆盖),用来在重启后恢复状态:自身 id、已知副本、已知哨兵,以及被改写的"监控哪个主库地址"。写不进去时,哨兵内存里照常工作(监控、投票、切换全都正常),但一旦重启就退回监视旧主库地址—— 而旧主库此时已经是只读从库 →又回到"切换成功但回不来"。
⚠️ 单文件挂载不能被 rename 覆盖(容器挂载点的固有限制)→必须挂目录。这类问题不重启、不演练根本发现不了,所以必须修而不是"先放着"。
最后一条认知修正:公网闸门只有安全组。我原以为"把端口绑到私网 IP = 不对外暴露",在云上不成立—— 因为公网 IP 是 NAT 映射、根本不在网卡上:
外部 → 公网 IP:<端口> (不在任何网卡上) ↓ 云平台 NAT 层 DNAT 私网 IP:<端口> (包到这里才被"监听"看见) ↓ 监听 0.0.0.0 的进程 ✅ 命中 / 监听 私网 IP 的进程 ✅ 也命中所以监听0.0.0.0与监听私网 IP 的公网可达性完全等价。(实测口径:一批端口公网全 blocked,唯一一个被放行的 80 端口通 —— 与它们各自的绑定方式毫无关系。)
正确的边界模型是分层的:
| 层 | 手段 | 作用 |
|---|---|---|
| L1边界 | 安全组 / 防火墙 | 决定"包能不能到网卡" ←唯一真正的公网边界 |
| L2 监听面 | bind/ports指定 IP | 决定"本地哪些地址接受连接" ← 绑私网 IP 属这层,纵深防御 |
| L3 身份 | 密码 / ACL | 决定"连上后能不能执行命令" |
| L4 传输 | TLS | 防窃听 / 中间人 |
一句话判断法:先问"包怎么到网卡"(L1),再问"谁在监听"(L2),最后才问"连上能干什么"(L3)。把 L2 当成 L1,就是这套误解的根源。
四、⭐ 第二件:两台机器下的"无解" —— 为什么从库一挂,主库就不让写了?
链路通了、双实例跑起来了,我以为收工了。结果在一次演练里冒出一个反直觉的现象:
(error) NOREPLICAS Not enough good replicas to write.业务表现:下单失败、库存扣减失败、分布式锁拿不到 → 读还能用,写全挂。
4.1 先建立直觉:两个人的记账小组
你和搭档每人一本账本。规矩是:你负责记账(主库),搭档负责抄一份(从库);你记完就完事,不等搭档抄完(这就是"异步复制",为了快)。
你会时不时喊一声"在吗?"(心跳)。如果没人应答,你分不清是哪种情况:
| 情况 A:他请假了 | 情况 B:电话线断了 | |
|---|---|---|
| 他的账本 | 还在,只是没更新 | 还在,而且他老板可能已经让他当正本了 |
| 你继续记账 | ✅安全(你是唯一的账本) | ❌危险(你记的会作废) |
| 你该怎么做 | 继续记 | 立刻停笔 |
你分不清 A 和 B,就只能选一个动作—— "停笔"只是麻烦,"记了却作废"是灾难 → 所以选停笔。
这就是那个参数的全部含义:“我(主库)没法确认数据已经安全复制出去时,宁可不写。”
它不是"从库挂了所以主库坏了",而是"主库主动选择不写,以避免写了也白写"。
4.2 两个根因
根因一:复制是异步的。客户端写命令 → 主库写进自己的内存/AOF →立刻回复"成功"(不等、不确认从库)→ 异步发给从库。所以默认情况下主库是"先干了再说",它并不知道数据到底有没有到从库。
根因二:主库分不清两种"失联"。
| 场景 A:从库真的挂了 | 场景 B:只是网络断了 | |
|---|---|---|
| 主库看到的 | connected_slaves = 0 | connected_slaves = 0(一模一样) |
| 真相 | 它是唯一副本,继续写是安全的 | 对面可能已经选出了新主,主库写的注定会丢 |
| 该不该写 | 应该写 | 绝对不能写 |
| 主库能分辨吗 | 不能← 这就是全部问题的根源 | — |
一个参数、两种情况、只能选一种行为→ 只能"一刀切":宁可误拒,也不静默丢数据。
4.3 它真正要防的场景(网络分区时间线)
t=0 两台机器网络断开(两台机器都活着) t=+5s 服务器 B 上的三个哨兵判定"主库连不上" → 客观下线 t=+6s 哨兵提升服务器 B 的从库 → B 成为新主 t=+6s 服务器 A 上的服务【仍然能连本机主库】→ 继续写! (服务和主库在同一台机器上,网络分区对它们完全无感) t=愈合 服务器 A 被降级为从库 → 全量同步 → 【刚才写的数据全部丢弃】→ 这个参数让服务器 A 在t=+6s 就停止接受写,把"静默丢数据"变成"明确报错"。
关键认知:分区时继续写,不是"写进了安全的地方",而是"写进了一个即将被格式化的磁盘" —— 分区恢复时哨兵按配置纪元收敛到新主,旧主被降级并全量同步覆盖,它刚才写的全部作废。
为什么宁可报错?报错 → 用户看到失败、运维立刻知道、可重试可处理;静默丢数据 → 一切看起来正常,几小时后才发现,无法挽回。
4.4 为什么"把从库放到主库同一台机器"是伪解
| 原因 | 说明 |
|---|---|
| 保护会失效 | 分区时主库能看到本机从库→ 参数满足 → 继续写 → 场景 B 重新出现 |
| 不是真冗余 | 同一台机器上的主+从一起挂,对"主机宕机"零防护 |
参数检查的是"有没有从库",不区分"从库在不在同一台机器上"。
4.5 拓扑对照:加副本到底能解决什么
| 拓扑 | 服务器 B 宕机 | 服务器 A 宕机 | 网络分区 |
|---|---|---|---|
| 1 主(A) + 1 从(B)←本项目现状 | 拒写 ❌ | 可写 ✅ | 拒写 ✅ |
| 1 主 + 2 从(都在 B) | 拒写 ❌ | 可写 ✅ | 拒写 ✅ |
| 1 主 + 1 从(B) + 1 从(A 本地) | 可写 ✅ | 可写 ✅ | 保护失效 ❌ |
| 1 主 + 2 从(B + 第三台) | 可写 ✅ | 可写 ✅ | 拒写 ✅(完美) |
结论:两台机器下无解—— 要么"某个节点挂了就不能写",要么"允许静默丢数据"。真正的解法是把第二个副本放到第三台机器上。
📌 记住这句话:同一台机器上的副本,在"这台机器挂掉"的场景里,等于没有副本。这就是"再给同一台机器加一个从库"没有意义的原因。
4.6 我的取舍(成本 vs 完整性)
| 项 | 决策 | 理由 |
|---|---|---|
| 是否加第三台服务器 | ❌不加 | 学习项目,两台已经在按量付费;第三台的月费换来的只是"消除一个罕见场景",不划算 |
| 参数开在哪 | ✅只开主库侧 | 覆盖真正的风险场景(分区下旧主库继续被写);从库侧开了会导致切换后新主库拒写 |
| 计划内停机(演练 / 维护) | 演练开关 | 先把它设成0,恢复后设回1 |
| 结果 | 🟡接受这个局限 | “已知、已量化、有开关” 比 “不知道” 强得多 |
4.7 如果以后有条件,正确的演进方向
| 方案 | 做法 | 效果 |
|---|---|---|
| 加第三台机器 | 1 主 + 2 从(跨 3 台机) | 任一节点故障都不影响写入 + 分区保护 ✅ |
| 只对关键写要求同步 | 关键写后调用WAIT 1 100(等至少 1 个从库确认,超时 100ms) | 把"全局拒写"改成"只对那一次关键写要求冗余",可用性影响小得多 |
| 业务层兜底 | 写失败重试 + 告警 | 即使拒写也不至于完全瘫 |
共同原则:副本必须分布在"会独立故障的机器"上 —— 这和哨兵官方要求"三个哨兵放在会独立故障的机器上"是同一条原则。
五、踩坑清单
| 认知 / 坑 | 说明 |
|---|---|
| ⭐三重语义框架 | 同一"地址"分监听 / 注册 / 连接;排查入口永远是"这个地址给谁看",而不是先怀疑业务代码 |
| ⭐注册中心只做注册发现,不做流量代理 | 它不是网关、不会替你转发 →注册进去的地址必须是消费者能直连的地址;注册容器 IP = 单机碰巧通、跨机必断 |
⭐协议端口默认自动选(-1)是隐形地雷 | 跨机必须显式固定协议端口,否则没法做端口映射 |
| ⭐注册的端口是"映射后的宿主端口" | 不是容器内端口;多个 provider 的宿主端口必须错开 |
| ⭐优先"注册层改写"而非"网络层打通" | 官方环境变量是声明式机制;静态路由属手工版 overlay(依赖对端网段、无自愈、单向可达) |
| ⭐降级也要能"回得来" | 被降级的节点要上报对端可达的地址,否则"切换成功但回不来"、复制断链 |
| ⭐多数派机制必须奇数 | 哨兵容忍度 =(哨兵数−1)/2 → 3 哨兵容忍挂 1、2 哨兵容忍挂 0;数据节点不需要奇数(复制不是投票) |
| ⚠️单文件挂载不能被 rename 覆盖 | 哨兵重写配置是"写临时文件 → rename"→必须挂目录;写不进去时内存照常工作、重启才暴露 |
⚠️替换密码前必须先判空;命令块里禁止exit | 批量替换把密码换成空值会静默破坏配置且服务照起;守卫写exit 1粘进交互式会话会直接断连 |
| ⚠️跨机铺路的坑几乎全在"流程/环境"而非"技术" | 配置属主/权限(容器内进程 uid 与宿主不同)、挂载点属 root、环境变量文件权限过严导致工具跑不起来 ——没有一个是算法或架构问题 |
| ⭐公网闸门只有安全组 | 公网 IP 是 NAT、不在网卡上 → 监听0.0.0.0与监听私网 IP 的公网可达性完全等价;绑私网 IP 是纵深防御,不是边界 |
| ⭐跨机一律走内网 | 内网不限速、不计流量费;走公网 = 又慢又花钱(固定带宽机型会成为瓶颈) |
| ⭐按需改造、改动面最小 | 谁真的跨机调用就改谁;服务器 A 只动 2 个服务,其余零改动 |
| 🔑改造会顺手带来验证机会 | 注册层改写后,服务器 A 同时看到2 个 provider→ 顺带就能验证服务框架层的负载均衡 |
| ⭐“防脑裂参数"的适用前提是"至少 2 个从库” | 只有 1 个从库时,任何一台机器故障都会让写入停摆—— 这不是配置错了,是副本数量不足时的固有局限 |
| ⭐副本必须分布在"会独立故障的机器"上 | 同一台机器上的主+从,在"这台机器挂掉"的场景里等于没有副本;"再加一个从库到同一台机器"是伪解(还会让分区保护失效) |
| ⭐分区时继续写 = 写进即将被格式化的磁盘 | 恢复时哨兵按配置纪元收敛到新主,旧主被降级并全量同步覆盖 → 刚才写的全部作废;宁可报错,不要静默丢数据 |
| 🔑计划内停机要有"演练开关" | 停从库做演练/维护前先把它设成0、恢复后设回1;"已知 + 已量化 + 有开关"比"不知道"强得多 |
六、一句总结
同一份"集群",一半是地址问题,一半是边界问题:
地址有三重语义(监听决定谁能进、注册决定别人走去哪、连接决定降级后能不能回得来),而公网那道门从来不由绑定地址决定;
边界则是一条更硬的规律 ——副本必须分布在"会独立故障的机器"上,否则你只能在"某节点挂了不能写"和"允许静默丢数据"之间二选一。
本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。