18 命名与寻址:ID、命名空间、DNS、服务发现

开篇提问

先问你一个看似无聊、实则极深的问题:

互联网上有一台服务器,IP 地址是 93.184.216.34。为什么不直接用这个数字,而要搞一个域名 example.com 出来?

你可能会说:“因为域名好记啊。”

这个答案对,但只对了最表面的一层。请往深里想:

“好记”只是好处之一。更关键的是——如果明天这台服务器换了机房、IP 变成了 1.2.3.4,会发生什么?

如果用 IP:所有引用它的地方(代码配置、书签、其他服务)全部要改。 如果用域名:改一下 DNS 记录,什么都没变。

这一下就揭示出命名的真正价值:

命名的本质是”间接层”——把”你要找的东西”和”它在哪里”解耦。

这一章讲的,就是这个看似平凡、实则支撑了整个计算机世界的核心机制。


子主题一:命名的三层价值

价值一:人类可读性(最表面)。 example.com 比 93.184.216.34 好记。

价值二:间接性 / 可迁移性(最关键)。 名字不变,位置可变。

  • 服务器换机房 → 改 DNS,用户无感
  • 数据库主从切换 → 用虚拟 IP 或域名,应用无感
  • 微服务扩容缩容 → 用服务名,不用记 IP

价值三:抽象与聚合(最深刻)。 一个名字可以代表一组东西。

  • www.google.com 背后是几十万台服务器
  • 一个服务名 user-service 背后是 10 个实例
  • 一个变量名背后是一块内存

第三层是最容易被忽视、但威力最大的:名字让”一个”可以代表”多个”,让”具体”可以代表”抽象”。这是所有负载均衡、集群、分布式系统的基础——如果名字只能指向唯一一个实体,你根本没法做集群。


子主题二:计算机各层的”命名-寻址”对照

命名和寻址不是网络的专利,它在计算机的每一层都存在,而且结构惊人地一致。看懂这张对照表,你会对整个计算机体系有更清晰的认识:

层次名字(Name)地址(Address)解析机制
变量变量名 userCount内存地址 0x7ffd...编译器/链接器
进程进程 ID(PID)进程的内存布局操作系统内核
文件路径 /home/x/a.txt磁盘块号(inode)文件系统
网络主机域名 example.comIP 93.184.216.34DNS
网络主机IP 地址MAC 地址ARP
服务实例服务名 user-serviceIP:Port 列表服务发现(注册中心)
容器容器名/IP容器网络命名空间CNI / kube-proxy
数据库分片逻辑表名具体分片位置分库分表中间件
对象存储对象 key具体存储节点对象存储的元数据服务

看到规律了吗?每一层都在做同一件事:

上层用一个”稳定的、语义化的名字”,下层提供一个”易变的、物理的地址”,中间有一个”名字→地址”的解析机制。

而且这个模式会层层叠加:域名 → IP → MAC → 物理端口。每一层都在”翻译”上一层给的名字。

理解这个”命名-寻址”的通用模式,你就获得了一个强大的分析工具:面对任何新系统,先问三句——

  1. 它的”名字”是什么?
  2. 它的”地址”是什么?
  3. 名字到地址是怎么解析的?(解析的服务是谁?解析结果怎么缓存?缓存多久?)

第三问往往是系统设计和故障排查的关键:DNS 的 TTL 决定改 IP 多久生效;服务发现的缓存决定实例下线后多久被发现;ARP 缓存决定换网卡后多久恢复。


子主题三:ID 的设计——命名的第一等公民

在分布式系统里,一个最基础也最容易被做错的事情:给数据分配 ID。

ID 就是这个数据的”名字”,而 ID 的设计会深远影响整个系统。

ID 的几种经典方案:

方案一:数据库自增 ID(Auto Increment)

  • 优点:简单、递增(对索引友好)
  • 致命缺点:
    1. 单点(依赖数据库)
    2. 分库分表时会冲突(两个库各自从 1 开始)
    3. 可猜测(暴露业务量,有安全风险——遍历 ID 就能爬数据)

方案二:UUID / GUID

  • 优点:本地生成,无需协调;全局唯一;不可猜测
  • 缺点:
    1. 无序 → 作为数据库主键时会导致索引频繁页分裂,性能差(这是最被诟病的一点)
    2. 占空间大(128 位,字符串形式 36 字节)

方案三:雪花算法(Snowflake)及其变体——这是分布式 ID 的工业标准

  • 结构(64 位):1位符号 + 41位时间戳 + 10位机器ID + 12位序列号
  • 优点:
    1. 趋势递增(时间戳在高位)→ 对索引友好
    2. 分布式生成,每台机器独立发号,无需协调
    3. 性能好(本地生成,无网络开销)
    4. 可反解(从 ID 能推出生成时间和机器)
  • 缺点:
    1. 时钟回拨问题——机器时钟往回调会导致 ID 重复(这是雪花算法最著名的坑,需要特殊处理)
    2. 机器 ID 需要分配(不能重复)

方案四:号段模式(Segment / 批量发号)

  • 从数据库批量取一段 ID(比如一次取 1000 个),本地发完再去取
  • 优点:对数据库压力小、ID 递增
  • 缺点:需要数据库(有单点,但压力小)、服务重启会浪费号段

选 ID 方案的判断标准:

  • 需要全局唯一吗?需要趋势递增吗(影响索引性能)?
  • 能容忍”不可猜测”的代价吗?
  • 时钟能信任吗(雪花算法的前提)?

一个关键的工程提醒:ID 一旦发出去,就几乎不可能改。 所以 ID 方案的设计,是系统里最不可逆的决策之一,务必在项目早期就想清楚。


子主题四:DNS —— 人类规模最大的命名系统

DNS 值得单独讲,因为它是”命名-寻址”最成功、规模最大的实践,而且它的设计充满了智慧。

DNS 解决的核心矛盾: 全世界的域名解析请求量巨大(每秒数十亿次),但域名会变、会新增、会删除。怎么让这个系统既准确又快,还能扛住全球流量?

答案:分层 + 缓存。

分层(树状命名空间):

根(.)
├── com
│   └── example
│       └── www
├── cn
└── org
  • 根服务器知道 com 在哪
  • com 服务器知道 example.com 在哪
  • example.com 的服务器知道 www 的 IP

这个分层的价值:职责分散。 没有一台服务器需要知道全世界的域名——com 的服务器只需要管 .com 下的域名。这让系统可以无限扩展。

缓存(这是 DNS 能扛住全球流量的关键):

  • 每一层都有 TTL(缓存有效期)
  • 本地 DNS 服务器缓存结果,TTL 内不再向上查询
  • 绝大多数的 DNS 查询,根本不会到达根服务器——都被各级缓存挡住了

DNS 的设计智慧,值得你迁移到任何系统:

  1. 分层命名空间:让职责和负载分散,可无限扩展
  2. TTL 缓存:用”暂时的不精确”换取”巨大的性能”(这又是精确 vs 性能的权衡,第 2 章)
  3. 最终一致性:改 DNS 记录不是立即生效的,要等 TTL 过期——这是故意的代价,换来的是系统的可扩展性

DNS 的工程教训(每一条都是血泪):

  • TTL 决定故障恢复速度:TTL 设一天,意味着 IP 改错了要一天才恢复。所以关键服务不要设太长的 TTL(但这会增加查询量,又是权衡)
  • DNS 是隐式的依赖:你的系统可能没意识到自己依赖 DNS,直到 DNS 挂了
  • 客户端缓存可能被忽略:浏览器、JVM、操作系统都有自己的 DNS 缓存,改了 DNS 不一定立即生效

子主题五:服务发现——微服务时代的”DNS”

在微服务架构里,服务实例是动态变化的(扩容、缩容、故障重启),IP 地址不固定。这时候需要服务发现(Service Discovery):

服务启动时注册自己(我是 user-service,地址是 10.0.1.5:8080),下线时注销;调用方查询”user-service 有哪些实例”。

三种典型实现方式:

方式一:客户端服务发现

  • 有个注册中心(etcd、Consul、ZooKeeper、Nacos)
  • 服务注册到中心;客户端从中心拉取实例列表,自己做负载均衡
  • 优点:客户端可以做更智能的负载均衡
  • 缺点:客户端要集成 SDK,多语言支持麻烦

方式二:服务端服务发现

  • 客户端只请求一个固定的入口(负载均衡器 / API 网关)
  • 由它查询注册中心并转发
  • 优点:客户端简单
  • 缺点:多一跳,且负载均衡器本身要高可用

方式三:DNS 式服务发现(Kubernetes 的做法)

  • 给服务分配一个 DNS 名(如 user-service.default.svc.cluster.local)
  • 客户端用 DNS 查询,得到实例 IP 列表
  • 优点:完全透明,任何语言、任何框架都能用,零侵入
  • 缺点:DNS 缓存导致实例变化不即时(TTL 权衡又来了);DNS 协议本身的能力有限(无法传递健康状态、权重等元信息)

服务发现的核心难点:健康检查与”注销时机”。

一个实例挂了,什么时候从列表里移除?

  • 太早(误判):把健康的实例移除,容量下降
  • 太晚(漏判):请求继续发到死实例,报错

这和第 11 章的失败检测是同一个问题——又回到”崩溃 vs 慢无法区分”的老难题。所以服务发现的健康检查通常用:

  • 心跳 + 超时(不能太敏感)
  • 主动探测(HTTP / TCP 健康检查端点)
  • 连续失败 N 次才摘除(避免抖动)

子主题六:一次完整的 DNS 解析——从敲下回车到拿到 IP

来,做个思想实验。你在浏览器地址栏敲下 www.example.com,回车。请问:在第一个 TCP 握手包发出去之前,机器到底干了什么?

大多数人的答案是”查 DNS”。太粗糙了。我们把它拆成一帧一帧的慢动作。

第 0 步:先看有没有”本地捷径”。

浏览器在真正发起网络查询之前,会先翻自己的口袋:

  1. 浏览器自身的 DNS 缓存(Chrome 有独立的主机解析缓存,有 TTL 上限,也受 --host-resolver-rules 影响)
  2. 本机 hosts 文件(/etc/hosts 或 C:\Windows\System32\drivers\etc\hosts)——这是一张没有 TTL、优先级通常高于 DNS 的静态表。它是 DNS 的”史前祖先”,也是你本机调试时最顺手、也最容易埋雷的东西
  3. 操作系统的解析器缓存(Windows 的 DNS Client 服务、Linux 上常见的 systemd-resolved / nscd / dnsmasq)

第 1 步:stub resolver 出场。

如果本地都没有,应用调用 getaddrinfo(),操作系统里那个极简的解析器(俗称 stub resolver,也叫”存根解析器”)登场。它不会自己去跑遍全球——它只有一个任务:把问题丢给配置好的递归解析器(Local DNS / Recursive Resolver),等答案。

这个 Local DNS 通常是你的运营商给的,或者是 8.8.8.8、1.1.1.1、223.5.5.5 这种公共递归服务。它写在你网卡的 DHCP 下发的配置里。

第 2 步:递归解析器开始”替你跑腿”。

这里就是最关键的分岔口。递归解析器拿到请求后:

  • 先看自己缓存里有没有 www.example.com 的 A 记录,且 TTL 没过期 → 直接返回(这一种情形占了真实流量的绝大部分)
  • 没有 → 它必须从树根往下走一遍

第 3 步:根 → 顶级域 → 权威服务器,三段迭代。

① 递归解析器  →  根服务器(a~m.root-servers.net,13 个逻辑名,任播上千实例)
   问:www.example.com 的 IP 是多少?
   答:我不知道。但 .com 的 TLD 服务器在这里(给你一组 NS 记录 + glue 的 A/AAAA 记录)

② 递归解析器  →  .com 的 TLD 服务器
   问:www.example.com 的 IP 是多少?
   答:我不知道。但 example.com 的权威服务器在这里(NS 记录 + glue)

③ 递归解析器  →  example.com 的权威服务器(ns1.example-dns.com 之类)
   问:www.example.com 的 IP 是多少?
   答:93.184.216.34(这条应答带了 aa 标志位,表示"我是权威的")

第 4 步:结果回传 + 沿途缓存。

递归解析器把答案返回给 stub resolver,同时按每条记录各自的 TTL 缓存下来。操作系统也缓存,浏览器也缓存。

所以要回答”绝大多数 DNS 查询会不会打到根服务器”——不会。根服务器看到的流量,只是”所有递归解析器上的缓存都过期了”的那一部分。这就是分层 + 缓存的威力:它把全球数十亿次查询,削峰成每秒几十万到根服务器。


递归查询 vs 迭代查询:一句话讲清楚

这是面试高频题,但绝大多数人答得含糊。我用个生活类比你就永远不会忘:

递归查询 = 你把问题丢给秘书。“你帮我去查一下这事的结论,查完直接告诉我结果,中间别来烦我。” 秘书跑断腿,你只等一个答案。

迭代查询 = 你亲自去问路。“请问出口在哪?”——“我不知道,你去问三楼的保安。” 你再去找保安——“我也不知道,你去问前台。” 每一步都是你自己在跑,对方只给你下一站的线索(这个线索在 DNS 里叫 referral,就是 NS 记录 + glue)。

回到真实链路:

环节查询类型谁在干活
应用 → Local DNS递归(报文里 RD=1,Recursion Desired)Local DNS 跑全程
Local DNS → 根 / TLD / 权威迭代Local DNS 自己一跳一跳跑

为什么根服务器只提供迭代、不给递归? 想想看:如果全世界几十亿台设备都把”帮我查到底”的请求发给 13 个根,根服务器会瞬间被压垮。根服务器只做一件事——“我不知道答案,但我知道谁可能知道”,然后立刻挂电话。这个应答极小、无状态、可被任播无限复制、可被各级缓存大量吸收。这正是一个系统能扛住全球流量的设计精髓:把”贵的工作”下推给靠近用户的那一层。

顺带纠正两个常见误解:

  • 误解一:“根服务器知道所有域名。” 错。根服务器只知道 1500 多个顶级域的 NS 在哪。它的数据库只有几百 KB 量级,装得下一张软盘。
  • 误解二:“13 个根服务器就是 13 台机器。” 错。13 是逻辑名(a~m),因为一个 UDP 报文中塞 13 组地址就接近 512 字节的老限制;今天每个逻辑名背后通过 anycast(任播) 部署了成百上千个物理实例,全球几百个位置。

再补三个真实工程细节,能救你的命:

  1. UDP 还是 TCP? DNS 默认走 UDP 53 端口。响应超过 512 字节(无 EDNS)时,服务器会设置 TC 标志位截断,客户端改用 TCP 重查。今天 DNSSEC 和大型响应让 TCP 占比显著上升。还有 DoT(RFC 7858)、DoH(RFC 8484)、DoQ(RFC 9250)这些加密 DNS 变体。
  2. 负缓存(negative caching):查询一个不存在的域名(NXDOMAIN),也会被缓存!它的 TTL 来自该区 SOA 记录的最后一个字段(RFC 2308)。这个坑极常见:你刚创建了一条记录,之前查过它的人手里已经存着”这个域名不存在”,还得等负缓存过期。
  3. QNAME 最小化(RFC 7816):现代递归解析器在问上级时,只问必要的那一段(问 TLD 时只问 example.com 而不是完整的 www.example.com),减少信息泄露。

子主题七:DNS 的工程实践——记录类型、TTL、负载均衡,以及那个”隐式的单点故障”

记录类型:你真正需要掌握的其实只有几种

DNS 的记录类型有几十种,但工程上天天打交道的就这几个:

类型作用关键坑
A域名 → IPv4最基本的记录
AAAA域名 → IPv6若配了 AAAA 却不通,客户端会先试 IPv6 再回退(Happy Eyeballs),造成明显延迟
CNAME域名 → 另一个域名(别名)不能与同名的其他记录共存;不能用于 zone apex(裸域,如 example.com)——很多云厂商用 ALIAS/ANAME 这种私有类型绕过
NS指定该域由哪些服务器权威解析委托(delegation)的核心;指向自己子域的名字时需要 glue 记录打破循环依赖
MX邮件服务器,带优先级数字数字越小优先级越高(和 SRV 一样,反直觉)
SOA区的元信息里面有 serial(序列号)、refresh/retry/expire、minimum(负缓存 TTL)
PTRIP → 域名(反解)邮件服务器强烈依赖;没有正确 PTR 容易被判为垃圾邮件
SRV服务发现式记录(_service._proto.name → 优先级/权重/端口/目标)Kubernetes、Consul、XMPP 都在用;这才是”DNS 做服务发现”的正统姿势
TXT任意文本域名所有权验证、SPF/DKIM/DMARC、ACME(Let’s Encrypt)挑战
CAA指定哪些 CA 可以给你签发证书安全加固项

CNAME 的两个硬规矩,必须记住:

  1. CNAME 是”这个域名的全部身份都让渡给另一个名字”。所以一旦 a.example.com 是 CNAME,就不能再有 a.example.com 的 A、MX、TXT——解析会直接乱套。
  2. 裸域不能用 CNAME。因为裸域必然已经有 SOA 和 NS 记录了(它是区的起点),与 CNAME 互斥。

为什么这条规矩在服务发现里很痛? 因为你想给 user-service 用 CNAME 指向某个动态的负载均衡域名,但你会发现自己的编排系统里塞不进去别的信息(比如权重、健康状态)。这正是子主题五说的”DNS 协议能力有限”的根源之一——DNS 的记录模型太穷了,它只能表达”名字到名字/地址”的映射,没法顺便表达”这个实例现在健康吗、权重多少、是什么版本”。

TTL 的选择:一个没有标准答案的旋钮

TTL 是”这条记录可以被缓存多少秒”。选择它的本质是在”新鲜度”和”成本/延迟”之间选一个点。

先算一笔账。 假设你的域名 QPS 是 10 万,权威解析每次查询成本是 C,那么:

  • TTL = 60s:理论上权威侧每秒要承受 100000 / 60 ≈ 1667 次穿透(实际会被各级缓存进一步吸收,但量级如此)
  • TTL = 3600s:权威侧每秒约 28 次

差了近 60 倍。所以短 TTL 不是免费的——它把压力推给了你的权威服务器和解析服务商,很多云解析产品还会对短 TTL 或超量查询单独收费。

反过来算恢复时间。 假设你把 A 记录指错了一个 IP:

最坏恢复时间 ≈ TTL + 各级缓存的传播延迟 + 客户端本地缓存的残留时间

TTL 设 3600,意味着最坏情况下你要等整整一小时——注意,是”最坏”,因为不同递归解析器在不同时刻缓存,清空是渐进的,不是齐刷刷的。

那到底设多少?工程上的经验法则:

场景建议 TTL理由
稳定的基础设施域名(官网、静态资源)3600 ~ 86400几乎不变,追求极低成本和最快解析
普通的业务域名300 ~ 600折中:故障切换最长 5~10 分钟
准备做切换/迁移的域名提前降到 60 ~ 120见下条”降 TTL 仪式”
依赖 DNS 做故障切换的域名(GSLB)30 ~ 60切换速度优先,接受成本和解析延迟
服务发现场景(K8s/CoreDNS)0 ~ 30(且通常配合 watch/推送)实例变化频繁,宁可多查

一个必须养成的运维仪式:降 TTL。

如果你计划明天把 www 从机房 A 迁到机房 B,今天就得先把 TTL 从 3600 降到 60,并且等一个老的 TTL 周期(3600 秒)过去,让全网的旧缓存都自然淘汰掉,之后再做切换。切换完成、观察稳定后,再把 TTL 调回去。

为什么必须提前降? 因为如果你在 TTL=3600 的情况下直接改记录,全世界手里那一小时陈旧缓存还在,你的流量会同时打向新旧两个地方,而且持续一小时——这往往比停机更可怕(想想数据库双写、缓存不一致、session 漂移)。

还有两个”隐藏的 TTL”,降 TTL 时别忘了它们:

  • CNAME 链的 TTL 是”木桶效应”:a → b → c → 1.2.3.4,这条链上只要任何一段过期,那一段就得重新查。所以CNAME 链越长,有效 TTL 越接近最短的那一段,而且解析延迟叠加(每一跳都是一次网络往返)。建议链长控制在 2~3 跳以内。
  • 客户端侧的缓存不受你控制:浏览器有自己的 DNS 缓存;JVM 的 networkaddress.cache.ttl 在某些配置下默认是”永久缓存直到 JVM 重启”(sun.net.inetaddr.ttl),这是 Java 应用改 DNS 不生效的头号元凶;Go 的 net 库默认不缓存但连接池会长期持有连接;容器的 nscd 也会缓存。

用 DNS 做负载均衡:能用,但别指望太多

机制极其简单:给一个域名配多条 A 记录。

www.example.com.  IN  A  93.184.216.34
www.example.com.  IN  A  93.184.216.35
www.example.com.  IN  A  93.184.216.36

权威服务器(或递归服务器)返回时会打乱顺序(round-robin)。客户端拿到一组 IP,挑一个连。

看起来很美,实际上有五个硬伤:

  1. 没有健康检查。 三个 IP 里挂了一个,DNS 依然会把它返回给客户端。DNS 不知道也不关心你的服务器死没死。 客户端连上去才发现超时,然后自己重试——这段超时就是白给的用户体验损失。
  2. 流量完全不均。 DNS 只在”建立连接之前”生效。一旦客户端建了长连接(HTTP keep-alive、WebSocket、数据库连接池、gRPC 流),它就会一直粘在那一台机器上。连接持续时间越长,DNS 轮询的均衡效果越差,极端情况下流量会长期严重倾斜。
  3. 顺序不可控。 客户端拿到列表后挑第几个,取决于实现。很多解析器/客户端就是”取第一个”。
  4. TTL 锁死了调整速度。 你改了 IP 集合,得等 TTL 过期才生效。
  5. 无法按容量加权。 三台机器性能不一样?DNS 说:抱歉,我没有”权重”这个概念(SRV 记录有,但几乎没有客户端支持)。

所以结论是什么?

纯 DNS 轮询只适合”粗粒度、客户端有重试能力、能容忍少量失败”的场景。 真正要做全局流量调度,必须是 “DNS + 健康检查 + 短 TTL + 自动摘除” 的组合,也就是 GSLB / 云厂商的”智能 DNS / 全局流量管理”。它本质上是:在 DNS 前面加一层会探活的控制器,探到故障就自动改记录,并把 TTL 压到 30~60 秒。

还有一个 DNS 特有的能力值得单独提:基于地理位置的解析(GeoDNS / ECS)。

传统递归查询里,权威服务器看到的是递归解析器的 IP,不是用户的 IP——这就导致一个北京的用户用了美国的 DNS,会被导到美国的服务器上。解决办法是 EDNS Client Subnet(ECS,RFC 7871):递归解析器在查询时带上”真实用户的 IP 段”,让权威服务器能做出更准的判断。CDN 的智能调度极度依赖这个机制。

顺带说清一个本质区别,很多人搞混:

DNS 负载均衡L4/L7 负载均衡器(LVS/Nginx)
生效时点连接建立之前(寻址阶段)每一次连接/每一个请求(转发阶段)
健康检查无(除非额外控制器)有,秒级甚至亚秒级
粒度域名级连接级 / 请求级
长连接影响严重(长期粘滞)较小(每个新连接重新调度)

一句话:DNS 决定”你去哪家店”,负载均衡器决定”进了店之后由哪个服务员接待你”。

DNS:你架构里那个”隐式的单点故障”

现在说一件让人后背发凉的事。

你的架构图画得很漂亮:多可用区部署、无状态服务、自动扩缩容、数据库主备切换、异地多活……看起来没有任何单点故障。

然后我问你一句:你这个系统里所有组件,互相之间是怎么找到对方的?

  • 连数据库用域名
  • 调第三方 API 用域名
  • 发告警用域名
  • 拉镜像用域名
  • 连注册中心用域名

它们全压在同一个 DNS 上。 而这个 DNS,很可能不在你的架构图里,因为它”理所当然”。

这就是”隐式的单点故障”:一个你没意识到自己依赖的依赖。它的恐怖之处在于——当它挂掉时,你的监控系统可能也没法工作,因为监控系统也要用 DNS 去连告警网关。你可能是最后一个知道出事的人。

这不是危言耸听,是发生过的大事:

  • 2016 年 10 月 21 日,Dyn(美国主要 DNS 服务商)遭 Mirai 僵尸网络大规模 DDoS。Twitter、GitHub、PayPal、Netflix、Airbnb、Spotify、Reddit 等一众互联网服务在北美大面积瘫痪数小时。这些公司自己的服务器一台都没坏,坏的是”别人找到它们的路”。
  • 2021 年 10 月 4 日,Facebook 全线服务(含 Instagram、WhatsApp)下线约 6 小时。根因是一次网络配置变更导致骨干网 BGP 路由被撤销,Meta 的数据中心从互联网上”消失”,连带它们自己的权威 DNS 服务器也不可达,于是全世界(包括 Meta 员工自己的内部工具)都找不到 Facebook 了。

怎么缓解?给你一份清单:

  1. 至少两家 DNS 服务商。一个域名的 NS 可以委托给多组服务器(ns1.服务商A.com + ns1.服务商B.com),解析器会在它们之间选择。注意:不要让所有 NS 都落在同一个 TLD 或同一家厂商的同一个机房。
  2. NS 记录别搞出循环依赖。example.com 的 NS 指向 ns1.example.com 时,必须同时配置 glue 记录(在上游注册局里直接登记这些 NS 的 IP),否则解析会陷入”要知道 ns1.example.com 得先解析 example.com”的死循环。
  3. 客户端保留”上次成功 IP”的兜底缓存。解析失败时,用上次成功的地址重试一次,同时后台异步刷新。浏览器的 Happy Eyeballs、很多 RPC 框架的”DNS 解析失败后降级”都是这个思路。
  4. 关键内部服务考虑本地 hosts 兜底或 NodeLocal DNSCache(K8s 场景下尤其重要)。
  5. 把 DNS 本身纳入监控:从多个地区、多个运营商、多个公共 DNS 主动拨测你的关键域名,并校验解析结果是否符合预期。别等用户投诉。
  6. 别在关键路径上用”短 TTL + 无兜底”的 DNS 依赖。短 TTL 意味着 DNS 一抖动,你的服务立刻跟着抖。

子主题八:UUID 到底差在哪——从 v1 的隐私泄露,到 B+ 树的页分裂,再到 UUIDv7

子主题三我们给了结论:“UUID 无序,对索引不友好”。现在把它彻底剖开。因为这个结论如果不理解到物理层面,你就不知道什么时候该怕它,什么时候可以无视它。

UUID 的版本家族:它们根本不是同一个东西

UUID 是 128 位,通常写成 36 个字符(32 个十六进制 + 4 个连字符)。

RFC 9562(2024 年 5 月发布,取代了 1999 年的 RFC 4122)定义了 8 个版本:

版本生成依据特点
v1时间戳(60 位,100 纳秒精度)+ 时钟序列 + 节点 MAC 地址(48 位)可按时间排序;泄露 MAC 和时间
v2DCE Security(在 v1 基础上把部分位换成 POSIX UID/GID)极少使用,规范含糊
v3命名空间 + 名字,用 MD5 哈希确定性:同样的输入永远得到同样的 UUID
v4随机数(122 位随机)最常用;完全无序
v5命名空间 + 名字,用 SHA-1 哈希确定性,比 v3 安全
v6v1 的”重排版”——把时间戳高位提到最前面兼顾 v1 的信息量与字典序可排序
v7Unix 毫秒时间戳(48 位) + 版本位 + 随机/子毫秒精度 + 62 位随机新宠:字典序可排序 + 时间可提取 + 无隐私泄露
v8厂商自定义留给实现者

先说 v1 的隐私问题,这个比”性能”刺激多了。

v1 把生成机器的 MAC 地址和精确到 100 纳秒的时间戳直接编码进了 ID。这意味着什么?

你发出的每一个 UUID,都在向全世界广播:我是哪块网卡(全球唯一,可定位到设备型号甚至个人)、我在什么时刻生成了这个 ID。

这不是理论风险。1999 年的 Melissa 宏病毒(史上第一个造成大规模破坏的邮件蠕虫),调查人员正是从病毒文档中遗留的一个 GUID 里,提取出了作者机器的 MAC 地址,顺藤摸瓜定位到了作者本人。一个”随机 ID”,成了法庭上的证据。

所以规则很简单:凡是会出现在系统外部(URL、订单号、票据、日志外发、API 响应)的 ID,绝对不要用 v1。 而 v4 是纯随机的,不泄露任何信息——这也是它能成为事实标准的原因。

再说 v4 的性能问题,这才是本节的重点。

为什么随机 UUID 会让数据库”变慢”?——把 B+ 树拆开看

很多人背下了”UUID 随机 → 页分裂 → 慢”,但说不清中间的三步。我们一步步走。

第一步:InnoDB 的主键就是数据的物理顺序。

InnoDB 是聚簇索引(clustered index)组织:表数据本身就存在主键的 B+ 树的叶子节点上。B+ 树是有序的,叶子节点之间通过指针串成链表。

第二步:自增主键发生什么?

插入 ID 1, 2, 3, 4, 5, ...

新值永远比已有值大 → 永远插在最右边那个叶子页。这个页填满(默认 15/16 的填充因子)就新开一页,继续往右边追加。

结果:

  • 页分裂极少,且是最高效的”最右分裂”
  • 磁盘 I/O 是顺序的,对 SSD 友好、对 HDD 更是天差地别
  • Buffer Pool 的局部性极好——热点永远集中在最右侧少数几个页,它们常驻内存
  • 填充率高(接近 15/16),空间利用率好

第三步:随机 UUID 发生什么?

插入 7f3a..., 1c9e..., e04b..., 0a2d..., ...

新值均匀地落在整个键空间的任意位置。要插的那个页,十有八九:

  • 在磁盘上,不在内存里 → 一次随机读(Buffer Pool 未命中)
  • 已经满了 → 页分裂:新建一个页,把原页大约一半的记录搬过去,更新父节点的指针,父节点也可能满、也可能继续向上分裂
  • 分裂后两个页的填充率都只有约 50%,而且后续插入会持续制造碎片

后果是连锁的:

现象后果
随机 I/O磁盘寻道/写放大,HDD 上差几个数量级
页分裂额外的数据搬移、索引页锁、redo/undo 日志膨胀
填充率下降索引体积膨胀(可能达到 1.5~2 倍),Buffer Pool 能装下的热数据变少 → 命中率下降 → 更多随机 I/O → 恶性循环
碎片需要定期 OPTIMIZE TABLE,而这是锁表/大开销操作
二级索引膨胀InnoDB 的每个二级索引叶子节点都存主键值。36 字节的 UUID(或 16 字节的二进制)会同时膨胀所有二级索引,进一步放大上述问题

第四步:量化一下感受。

一个经典结论:在机械盘 + 内存不富裕的老环境里,用随机 UUID 做主键的写入吞吐,可能只有自增主键的 1/10 到 1/3。到了 NVMe SSD + 大内存的时代,差距大幅缩小(随机写不再那么致命),但索引体积膨胀、Buffer Pool 命中率下降、二级索引膨胀这三项依然存在。

所以更准确的说法是:

随机 UUID 的问题不是”绝对慢”,而是”它把你本来能装进内存的热数据挤了出去,并且让写入路径从顺序变成了随机”。数据量小的时候你感觉不到;数据量上千万、内存装不下索引的时候,它会突然变成一个灾难。

这也解释了一个常见现象:为什么很多系统用 UUID 做主键,上线一两年都好好的,到第三年突然崩了? 因为前两年索引还装得进 Buffer Pool,第三年装不下了。

有序 UUID 怎么解决:UUIDv7 / ULID

思路朴素得可爱:把”时间”放到 ID 的最高位,让 ID 大致按生成时间递增。

UUIDv7 的结构(RFC 9562):

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms (48位,大端,毫秒级 Unix 时间戳)          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|unix_ts_ms|  ver  |rand_a (12位,可选作亚毫秒精度)              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                rand_b (62位随机数)                         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                            rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

关键特性:

  1. 前 48 位是毫秒时间戳(大端) → 按字典序(也就是字节序)比较时,等价于按时间比较。字符串排序 = 时间排序,这在 MySQL/Postgres 里直接成立。
  2. 后 74 位是随机 → 同一毫秒内的并发不会冲突(74 位随机,碰撞概率可忽略),且不泄露 MAC 地址。
  3. 可以从 ID 反解出生成时间,排查问题时极其方便(“这个订单是啥时候建的?“——看 ID 就知道,不用查 created_at)。
  4. 单调递增到毫秒级:整体趋势递增,只在”同一毫秒内部”无序。而一毫秒内能产生多少条记录?对绝大多数业务,这个”小乱序窗口”对 B+ 树的影响微乎其微。

ULID(Universally Unique Lexicographically Sortable Identifier) 是同思路的另一个实现:

01ARZ3NDEKTSV4RRFFQ69G5FAV
|----------||------------|
  时间戳48位    随机80位
  • 用 Crockford Base32 编码(去掉了容易混淆的 I/L/O/U),26 个字符,比 UUID 的 36 个短
  • 大小写不敏感、URL 安全、无连字符
  • 字典序 = 时间序,精度到毫秒

注意:ULID 不是 RFC 标准的 UUID,它是一个独立格式。如果你的生态强依赖 UUID 标准(比如某些 ORM、Postgres 的 uuid 类型),用 UUIDv7 更合适;如果你纯粹想要”短、可读、可排序”,ULID 很香。

四个必须知道的配套技巧:

  1. 不要存成 CHAR(36)! 存成 BINARY(16)(MySQL)或 uuid(Postgres 的原生类型,就是 16 字节)。36 字节的字符串 vs 16 字节的二进制,索引体积差一倍多,而二级索引会把它再放大 N 遍。
  2. 如果你被迫用 v4,可以”重排字节”让它变有序。 MySQL 8.0 提供了 UUID_TO_BIN(uuid, 1) 和 BIN_TO_UUID(bin, 1),第二个参数 swap_flag=1 会把时间相关字段(对 v1 而言)调到前面,让它接近有序。注意:这个交换只对 UUID v1 有意义,对 v4 没用(v4 里没有时间字段可换)。
  3. 时间戳在最高位意味着”所有写入都打在最右侧”——这引入了新的右倾热点(right-most hotspot)和尾部页锁竞争。这是有序 ID 的固有代价,只是它比随机 I/O 的代价小得多。极高写入吞吐的场景需要额外考虑。
  4. 别在 UUID 里编码业务语义(地区码、用户类型、分片位……)。ID 应该是一个纯粹的、不透明的标识。一旦你在 ID 里塞了语义,这个语义就永久冻结了——你改不掉它,只能迁移数据。

子主题九:Snowflake 深挖——位分配为什么是这样,以及时钟回拨到底怎么救

位分配的算术:每一个数字背后都是一次取舍

Twitter 在 2010 年开源了 Snowflake,经典布局是 64 位:

 0        1                                  41                   51              63
+--------+-----------------------------------+--------------------+---------------+
| sign   |         timestamp (41位)          |   workerId (10位)  | sequence(12位)|
| (1位)  |        毫秒级,相对某个起始时间    |   机器/节点标识     | 毫秒内序号    |
+--------+-----------------------------------+--------------------+---------------+

我们把每一个数字都算清楚。

① 1 位符号位:永远是 0。

为什么留着?因为 ID 往往会被放进 有符号的 64 位整数(Java 的 long、MySQL 的 BIGINT)。符号位固定为 0,保证 ID 永远是正数。这是为了让”ID”和”long”在类型上天然兼容——一个纯粹的工程便利。

② 41 位时间戳:能用多久?

约 69 年。 但注意——这 41 位存的是”相对某个自定义起始时间(epoch)的偏移量”,不是绝对 Unix 时间戳。

Twitter 用的 epoch 是 1288834974657(2010-11-04 01:42:54 UTC)。所以它的 ID 大概能用到 2080 年。

为什么要用自定义 epoch? 因为相对偏移能”偷”出额外的时间跨度。如果你用 1970 作起点,2026 年已经消耗掉了约 1.77e12 ms,超过 2^41 = 2.2e12 的 80%——也就是说你只剩 14 年可用。把 epoch 往后挪到项目启动那天,等于把 69 年的额度重置了。 这是零成本的性能优化,没有理由不做。

③ 10 位机器 ID:多少个节点?

Twitter 的原始实现把它拆成 5 位 datacenterId + 5 位 workerId(32 个数据中心 × 每个 32 台机器)。你也可以不拆,直接用 10 位表示 1024 个节点。

④ 12 位序列号:每毫秒多少 ID?

换算一下:

  • 单节点峰值:4096 × 1000 ≈ 409.6 万 ID/秒
  • 全集群理论上限:1024 × 409.6 万 ≈ 41.9 亿 ID/秒

这个数字对绝大多数业务是天文数字。 真实的业务系统每秒能到几万 ID 就算很高了。所以——

Snowflake 的性能天花板,从来不是业务的瓶颈,它的瓶颈在别处:机器 ID 的分配,和时钟的可靠性。

把设计取舍讲透:

位段位数给的额度为什么是这个数
时间戳4169 年再少就不够用了(30 位只有 12 天);再多没必要,69 年后这系统早重写了
机器 ID101024 节点覆盖绝大多数集群规模。超过了怎么办?要么减少时间戳位(不推荐),要么拆业务用多套 ID 空间(推荐)
序列号124096/ms/节点单节点 400 万/秒,绰绰有余。给 10 位(1024/ms ≈ 100 万/秒)在高并发场景会不够

如果你要改位分配,记住一条铁律:时间戳必须在最高位。 因为只有时间戳在高位,ID 才整体递增,B+ 树索引才会友好。把机器 ID 放高位会怎样?那 ID 就变成”按机器聚集”——每台机器内部递增,机器之间交错,索引的局部性会大幅下降。

序列号用完了怎么办?

同一毫秒内,一个节点发满了 4096 个 ID。第 4097 个请求来了,怎么办?

标准做法:自旋等待到下一毫秒。

// 伪代码(Twitter 原始实现的核心逻辑)
protected synchronized long nextId() {
    long timestamp = timeGen();
 
    if (timestamp < lastTimestamp) {
        throw new RuntimeException("时钟回拨!");  // 见下文
    }
 
    if (timestamp == lastTimestamp) {
        // 同一毫秒内,递增序列号
        sequence = (sequence + 1) & SEQUENCE_MASK;   // & 0xFFF 相当于 % 4096
        if (sequence == 0) {
            // 序列号溢出(这一毫秒用完了),阻塞到下一毫秒
            timestamp = tilNextMillis(lastTimestamp);
        }
    } else {
        sequence = 0;   // 新的一毫秒,序列号归零
    }
 
    lastTimestamp = timestamp;
    return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
         | (workerId << WORKER_ID_SHIFT)
         | sequence;
}
 
private long tilNextMillis(long lastTimestamp) {
    long timestamp = timeGen();
    while (timestamp <= lastTimestamp) {
        timestamp = timeGen();     // 忙等(可加 Thread.yield() / 短暂 sleep)
    }
    return timestamp;
}

注意这里的设计哲学:ID 生成是”稀缺资源”,宁可让调用方等一毫秒,也不允许发出重复的 ID。 这也是为什么 Snowflake 实现通常要加 synchronized——锁的是”发号”这个动作,代价极小(纳秒级),换来的是单节点内绝对不重复。

时钟回拨:Snowflake 的头号杀手

问题是什么?

Snowflake 的正确性完全建立在”机器时钟单调递增”这个假设上。一旦时钟往回拨,timestamp 可能回到一个已经用过的毫秒值,而序列号又从 0 重新开始 → 生成出一模一样的 ID。

时钟为什么会回拨?(不是假设,是常态)

  1. NTP 同步校正:NTP 发现本机时钟快了,会把它往回调。这是最常见的原因。
  2. 人工误操作:运维手动改系统时间。
  3. 虚拟机/容器:宿主机负载高导致时钟漂移;VM 快照恢复、暂停后恢复(时钟可能倒退或大幅跳变)。
  4. 跨主机迁移 / 热迁移:容器漂到一台时钟偏慢的宿主机上。
  5. 硬件时钟(CMOS)电池没电:重启后系统时钟回到出厂默认值(比如 2000 年),这是最惨烈的一种。

处理方案,从简单到复杂,我按推荐程度排:

方案一:小回拨等一等,大回拨直接拒绝(最常用)

if (timestamp < lastTimestamp) {
    long offset = lastTimestamp - timestamp;
    if (offset <= MAX_BACKWARD_MS) {          // 比如 5ms ~ 100ms
        // 回拨幅度小:睡一会儿,等时钟追上
        wait(offset + 1);                      // 或自旋到 timestamp > lastTimestamp
        timestamp = tilNextMillis(lastTimestamp);
    } else {
        // 回拨幅度大:不敢继续发号,抛异常 + 告警
        throw new ClockMovedBackwardsException(
            "Clock moved backwards by " + offset + "ms, refusing to generate id");
    }
}

思路:小幅回拨是”抖动”,忍一忍就过去了;大幅回拨是”事故”,继续发号有真实风险,宁可让服务失败并报警,也不能发出可能重复的 ID。

为什么”宁可失败”? 因为 ID 重复的后果是静默的数据污染——两条不同的记录用了同一个主键,其中一条被覆盖或插入失败。这种 bug 极难发现,而且几乎不可能修复(ID 已经散落在各个系统、日志、外部对接方手里)。相比之下,“服务暂时不可用 + 立刻告警”是响亮的失败,运维可以马上介入。

这是一条普适的工程原则:静默的错误数据,比响亮的服务失败危险得多。

方案二:借用位,做”回拨计数器”

预留几位(比如从序列号里借 1~2 位,或从机器 ID 里借),每检测到一次回拨就 +1。这样即使时钟回到过去,ID 也不会重复——因为”回拨代数”不同。

代价:借走的位数降低了每毫秒的容量或节点数,且代数计数器溢出后会重新绕回,依然存在理论风险。适合”回拨是已知常态”的环境(比如某些云主机的时钟质量很差)。

方案三:换一个 workerId 继续发号

检测到回拨时,向协调服务(ZK/etcd)申请一个新的 workerId,用新身份继续。旧 ID 空间被”冻结”。

代价:workerId 资源被消耗(1024 个很快就没了),需要回收机制。复杂度高,一般不用。

方案四:逻辑时钟兜底(最稳妥的思路)

不用纯物理时钟,而是:

当前逻辑时间 = max(物理时钟, 上次发号时间 + 1)

永远保证单调递增,物理时钟回拨时就用”上次 + 1”顶上。

代价:如果时钟真的回拨了很久,你会**“预支未来”——ID 里的时间戳跑到真实时间前面去了,导致”从 ID 反解生成时间”这个功能失真,而且重启后如果时钟还是慢的,会立刻进入这种状态。所以通常配合”偏移量超过阈值就报警”**一起用。

方案五:从源头治理——把时钟搞对

这是最该做、也最容易被忽略的一步。 在发号机器(或 K8s 节点)上:

  1. 用 chrony 而不是老 ntpd,并配置 slew 模式(缓慢调整,绝不”跳步”)。关键配置:makestep 1.0 3(只允许启动时、且偏差超过 1 秒时跳变最多 3 次),平时一律用 slew 以 ppm 级别慢慢追。
  2. 多 NTP 源 + 本地 GPS/原子钟兜底(金融级场景)。
  3. 监控时钟偏移量,纳入告警。chronyc tracking / ntpstat / node_exporter 的 node_timex_offset_seconds。
  4. 禁止手动改时间,或者至少让改时间的操作触发服务摘流。

方案六:终极方案——不依赖时钟

如果这个 ID 重要到你不敢赌时钟,那就换方案:号段模式(Segment)。ID 的”递增性”由中心化的数据库保证,而不是由本地时钟保证。代价是引入了一个中心依赖(虽然压力极小)。

美团 Leaf、滴滴 TinyID 都支持 snowflake 和 segment 双模式,就是为了让业务自己选”要性能还是要省心”。

三个实战级提醒

① 前端精度问题(新手踩得最多的坑)。

JavaScript 的 Number 是 IEEE 754 双精度浮点数,安全整数范围是 ±(2^53 - 1) = 9007199254740991(16 位)。而 Snowflake ID 是 64 位(19 位十进制),超了。

后果:

JSON.parse('{"id": 1433248572915671040}')
// → 1433248572915671000   ← 精度丢了!最后三位变成 000

这个 ID 拿去查数据库,查不到。 而且极难排查——因为后端日志里 ID 是对的,只有前端显示和回传时错了。

解决:所有对外的 API(尤其是 JSON)把 ID 序列化成字符串。@JsonSerialize(using = ToStringSerializer.class),或者全局配置 Long → String。

② 趋势递增 ≠ 严格递增。

Snowflake 只保证趋势递增:同一节点内递增;跨节点时,如果两台机器的时钟有几毫秒偏差,ID 就可能”倒挂”。所以——

永远不要假设 Snowflake ID 的全局顺序严格等于生成时间顺序。 要严格排序就用 created_at 字段,别用 ID。

③ workerId 的分配,在容器时代是个真问题。

老办法:写配置文件、按 IP 后八位算、按 hostname 编号。在 K8s 里这些都不可靠——Pod IP 会变,Pod 会重建。

现代做法:

  • StatefulSet 的稳定序号(ordinal,如 0~1023)
  • 通过 ZK/etcd 抢一个临时序号节点,Pod 挂了自动释放
  • 用数据库自增分配一张 worker 表
  • K8s Downward API 注入 Pod 序号 + 协调服务去重

workerId 冲突的后果是灾难性的:两台机器在同一毫秒、同一序列号下生成 ID → 完全相同的 ID,而且是持续、大量的冲突,不是偶发一次。

④ 机器 ID 用满了怎么办?

1024 个节点用完了,不要去改位分配(意味着 ID 格式变更,数据迁移)。正确做法是拆出多个独立的 ID 空间(按业务线、按租户),每个空间有自己的一套 workerId 池和自己的 epoch。格式变了才叫灾难,多套并行只是运维复杂度。

子主题十:服务发现深挖——CAP 怎么选,健康怎么查,注销什么时候才算数

子主题五讲了三种模式。现在深入到三个真正决定生死的问题。

一、注册中心的 CAP 站队:这不是学术题,是生产事故预演

先复习一下 CAP:网络分区(P)发生时,你只能在**一致性(C)和可用性(A)**之间选一个。

现在问你:注册中心是干什么的?

它的核心职责是回答一句话:“user-service 现在有哪些可用实例?”

再问你:这个问题的答案,需要多精确?

想象两个场景:

场景 A:注册中心选择了 CP(宁可不可用,也不错答案)。

机房之间网络分区了。为了保证一致性,少数派那边拒绝提供服务(或者拒绝写入)。结果:

  • 服务实例无法注册(新的 Pod 起不来,因为注册不进去)
  • 调用方无法拉到实例列表(或者拉到的是报错)
  • 整个集群的所有服务互相找不到对方 → 全站不可用

场景 B:注册中心选择了 AP(宁可给错答案,也不拒绝服务)。

网络分区了。注册中心继续工作,但它手里的信息可能是陈旧的:

  • 某个实例其实已经挂了,但列表里还有它 → 调用方连过去,报错、超时、重试
  • 某个新实例起来了,但列表里暂时没有它 → 少了一点容量

结果:部分请求失败,但系统整体还在跑。

看懂了吗?这就是关键洞察:

对于服务发现,“返回一份稍微陈旧的地址列表”的代价,远小于”什么都返回不了”。 因为调用方拿到陈旧列表,最坏结果是”打到一台已经下线的机器上”——而这完全可以由调用方的重试 + 负载均衡的故障摘除来兜底。但如果注册中心直接不可用,所有服务调用都无从发起,整个系统瞬间归零。

这就是为什么 Netflix 的 Eureka 坚定地选择了 AP,而且在分区时还有个著名的”自我保护模式(Self Preservation)”:

当 Eureka Server 在短时间内丢失了超过阈值比例(默认 15%)的心跳时,它会认为”不是实例都挂了,而是我自己网络出问题了”,于是停止剔除任何实例,把当前注册表冻结住,宁可保留一批可能已经死掉的实例。

这个设计的狠在哪? 它明确承认了一个事实:“我分不清是实例挂了,还是我自己瞎了,而误删的代价远大于误留。“(这就是第 11 章失败检测里”崩溃 vs 慢无法区分”的又一次现身。)

对照表:

注册中心CAP 倾向一致性协议关键行为
EurekaAP无强一致,节点间异步复制(peer replication)自我保护模式;客户端缓存 + 定时全量/增量拉取;Server 挂了客户端还能用本地缓存工作
ConsulCP(KV 与目录默认强一致)Raft默认一致读;也支持 stale 读模式(从任意节点读,容许陈旧);Agent 本地健康检查 + gossip
etcd / ZooKeeperCPRaft / Zab选举期间(几十秒到数分钟)不可写;强一致读;watch 机制推送变更
Nacos可切换Distro(AP)/ Raft(CP)临时实例(心跳上报)走 AP 的 Distro 协议;持久实例走 CP 的 Raft;业务自己按注册方式选择

几个必须讲清的细节:

  1. Consul 的”CP”不是绝对的。 它提供三种读模式:default(Leader 读,强一致)、consistent(Leader 校验自己仍是 Leader)、stale(任意节点读,可能陈旧)。你要主动选 stale 才能拿到 AP 的行为。 默认给你 CP。
  2. etcd/ZK 的”不可用”窗口是真实存在的。 Raft 选举期间(几十秒到数分钟)不可写。如果你的服务注册依赖 etcd 写入,那么 etcd 选举期间新的 Pod 无法完成注册——这在滚动发布时会卡住发布流程。这是 CP 的实打实的代价。
  3. Nacos 的双模设计很聪明,因为它承认了一个事实:不同类型的实例,对一致性的要求是不同的。
    • 临时实例(微服务的无状态实例,靠心跳续约):挂了就挂了,稍有陈旧无所谓 → AP
    • 持久实例(数据库地址、VIP、需要人工配置的 endpoints):必须准确,宁可不给 → CP

给一条选型原则:

如果你的注册中心只服务于”无状态的、可随时重建的、调用方有重试能力”的微服务实例 → 选 AP(或 Nacos 的临时实例模式)。 如果它还承载了”配置""选主""分布式锁”这类要求强一致的职责 → 必须 CP(etcd/ZK/Consul)。 而最稳妥的做法是:把这两类职责分开。 用 AP 的注册中心管实例,用 CP 的 etcd 管配置和选主。别指望一个组件同时做好两件相反的事。

二、健康检查的三种方式:谁来判断”你还活着”

先问一个直击灵魂的问题:一个进程还在、还能响应心跳,它就”健康”吗?

不一定。想想这些情况:

  • 进程活着,但数据库连接池耗尽了 → 每个请求都卡在等连接
  • 进程活着,但磁盘写满了 → 写操作全部失败
  • 进程活着,但 GC 卡死(stop-the-world 30 秒)→ 谁都连不上它
  • 进程活着,但它依赖的下游全部熔断了 → 它虽然能响应,但响应全是错误
  • 进程活着,但它被调度到了一台网络抖动的宿主机上 → 时通时不通

所以”健康”从来不是一个布尔值,它是一个光谱。 三种探测方式,本质上是三种不同的观察视角。


方式一:客户端心跳(Client Heartbeat / TTL 租约)

机制:服务实例自己主动周期性地向注册中心说”我还活着”(比如每 5 秒一次)。注册中心维护一个倒计时,超时(比如 3 个周期 = 15 秒)没收到就标记为不健康。

也叫 TTL 租约(lease)——我给你 15 秒的租期,你必须在这期间续租,否则我默认你死了。

优点:

  • 注册中心的开销恒定:O(实例数),与”谁调用谁”无关。10000 个实例,就是每 5 秒收 10000 个心跳,跟服务之间的调用拓扑无关。
  • 大规模友好:这是它能支撑上万实例的根本原因。

缺点:

  • 只能证明”进程还活着、网络还通”,证明不了”服务可用”。进程可以一边开心地发心跳,一边拒绝所有业务请求。
  • 依赖客户端诚实。客户端卡死(GC、死锁)就发不出心跳——这反而倒是好事,能被发现。但如果客户端代码写错了,只发心跳不干活,就永远发现不了。
  • 默认行为要谨慎:有些实现在心跳失败时保留实例(自我保护),有些剔除。你要清楚自己用的是哪种。

代表:Eureka(30 秒续约 + 90 秒剔除)、Nacos 临时实例(5 秒心跳 + 15 秒不健康 + 30 秒剔除)。


方式二:服务端主动探测(Active Probing)

机制:注册中心(或它部署在每台机器上的 Agent)主动去探你的服务。

三种探测粒度:

  • TCP 探活:能连上端口就算活(最轻,但只知道”端口在监听”)
  • HTTP 探活:请求一个专门的端点(如 /health),按状态码判断(最常用)
  • gRPC / 自定义协议探活:走应用自己的协议(最准,但最重)

优点:

  • 能发现”进程活着但服务不可用”的情况——只要你的 /health 端点写得够实在(真的去查一下数据库连接、磁盘、关键依赖),它就能反映真实健康度。
  • 不依赖客户端配合。客户端代码有 bug 也无所谓。

缺点:

  • 规模是硬伤。如果注册中心集中探测所有实例的所有服务,开销是 O(实例数 × 探测频率),而且探测方的网络位置和服务间的真实网络位置不同——探测方能连上,不代表调用方能连上。
  • 探测太频繁会拖垮被探测的服务(/health 端点如果实现得重,比如真的跑一条 SQL,高频探测会变成一种攻击)。
  • 探测本身会失败(探测方自己的网络抖动),需要”连续失败 N 次才判定不健康”来去抖。

代表:

  • Consul:健康检查由部署在每个节点上的 Agent 本地执行,结果通过 gossip 传播。这是很聪明的设计——把集中式的探测压力打散到每台机器上,探测路径也是”本机到本地进程”,最接近真实。
  • Kubernetes:livenessProbe / readinessProbe / startupProbe,由每个节点上的 kubelet 本地执行。同样的思路。

方式三:第三方探测 / 被动观测(Passive / Outsider Observation)

这一类是不主动问,而是看真实流量的表现。

两种形态:

(1)黑盒探测(外部拨测):从系统外部(另一个机房、另一朵云、第三方监控服务)模拟真实用户请求。它能发现”内部全绿但用户就是访问不了”的问题(比如 DNS、BGP、防火墙、ISP 链路故障)。

(2)被动健康检查(Passive Health Check / Outlier Detection):不主动探,而是统计真实请求的失败率。

这是服务网格(Istio/Envoy)的核心机制:

连续 N 次返回 5xx / 连接超时 / 连接重置
    → 把这个实例从负载均衡池里"弹出"(eject)一段时间(如 30 秒)
    → 之后半开,放少量流量试探,成功就恢复

优点:

  • 最贴近真实。它衡量的就是”真实流量下这个实例表现如何”,探测不到的问题根本不存在。
  • 零额外开销。不需要额外的探测请求。

缺点:

  • 必须先有流量才能发现问题。一个实例从来没人调,你永远不知道它是不是坏的。
  • 新实例上线时无法做准入判断(“冷启动”实例还没接过流量,被动健康检查对它无话可说)。
  • 可能误伤:偶发的下游抖动导致某实例连续失败,会被错误地弹出。

所以正确的做法是:三者叠加,各司其职。

方式回答的问题生效阶段
主动探测(liveness/readiness)“这个实例能不能接流量?“准入:实例刚起来时
客户端心跳 / TTL”这个实例还在吗?“存续:长期维持注册
被动健康检查”这个实例表现好不好?“运行期:根据真实表现动态摘除

类比:招一个员工。 面试(主动探测)决定他能不能入职;打卡(心跳)决定他还在不在公司;绩效考核(被动健康检查)决定他现在还该不该被派活。三件事,缺一不可。

三、“注销时机”难题:为什么优雅下线那么难做正确

这是服务发现里最折磨人的工程问题。

一个实例要下线,理论上就一句话:从注册中心删掉它。实际上呢?

问题一:删掉注册中心的记录,调用方立刻就不打它了吗?

不会。 因为调用方手里有本地缓存。

我给你算一笔”下线延迟总账”:

总延迟 = ① 故障/下线检测延迟
       + ② 注册中心内部传播延迟
       + ③ 调用方拉取/推送的延迟
       + ④ 调用方本地缓存的残留(若用 DNS,则是 TTL)
       + ⑤ 已建立长连接的存活时间

具体数字(一个典型配置):

环节典型延迟
① 心跳超时判定15 ~ 90 秒
② 注册中心多节点间同步亚秒 ~ 数秒
③ 调用方轮询间隔(若用 watch 则是亚秒)1 ~ 30 秒
④ 调用方本地缓存 TTL(若用 DNS,60 秒很常见)0 ~ 60 秒
⑤ 已建立的长连接分钟到小时级

第 ⑤ 项是最容易被忽略、也最致命的。

关键洞察:你从注册中心注销,只能阻止”新连接”打到这台机器上。那些已经建立的、keep-alive 的、池化复用的连接,完全不受影响——它们会继续把请求送到已经”下线”的实例上,直到连接被关闭。

HTTP keep-alive 连接池、数据库连接池、gRPC 的长连接(HTTP/2 多路复用,一个连接跑所有请求)——这些都是长期存在的连接。gRPC 尤其典型:客户端可能只有一个到某实例的连接,上面跑着成百上千个并发流。这个连接不断,你的”下线”就是假的。

这就是”优雅下线(Graceful Shutdown)“必须存在的理由。


正确的优雅下线流程(以 K8s 为例,但思想通用):

T0:  收到 SIGTERM
     ↓
T0:  立刻把 readinessProbe 置为失败(或主动向注册中心注销)
     → 这一步只是"停止接新流量",已经在处理的请求继续
     ↓
T0 ~ T0+Δ:  【关键】等待传播期(drain / sleep)
     Δ 要 ≥ 上面那张表的 ②③④ 之和,实践中常取 5~30 秒
     → 目的:让所有调用方都"知道"我下线了,并停止建立新连接
     ↓
T0+Δ: 主动关闭存量连接
     - HTTP: 响应头加 Connection: close,或者 graceful shutdown 等待在途请求完成
     - gRPC: 发送 GOAWAY,让客户端优雅地新建连接到别处
     - 数据库连接池: 归还并关闭
     ↓
T0+Δ+δ: 进程退出

Kubernetes 里对应的三个配置:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]   # 关键:等待端点传播
terminationGracePeriodSeconds: 60          # 从 SIGTERM 到 SIGKILL 的总宽限期
readinessProbe:            # 不健康 → 从 Endpoints 移除 → 不再接新流量
  httpGet: { path: /ready, port: 8080 }
  periodSeconds: 5

为什么 preStop 里的 sleep 是必需而不是”脏 hack”?

因为 K8s 的删除流程是并行的:

                    ┌─> kubelet 发 SIGTERM 给容器 ──────────────┐
kubectl delete pod ─┤                                           ├─> 并行!
                    └─> Endpoint 控制器从 Endpoints/EndpointSlice 移除
                        └─> kube-proxy 更新所有节点的 iptables/IPVS 规则
                            └─> CoreDNS 更新(若用 DNS 访问)
                        (这一串是异步的,需要时间!)

Endpoint 的移除和规则的同步需要时间(几百毫秒到几秒)。如果你 SIGTERM 一到就立刻退出进程,那么在这段”规则还没同步完”的窗口里,依然有流量会被转发到已经死掉的 Pod 上 → 用户看到 502 / connection refused。

preStop 的 sleep 就是在人为地”等待规则追上现实”。 它不优雅,但极其有效。

还有一个隐藏很深的坑:readinessProbe 失败 ≠ 存量连接断开。

readiness 失败只是让 iptables 规则不再把新连接 NAT 到这个 Pod。已经建立的连接不受 iptables 影响——因为 Linux 的 conntrack 表里已经有这条连接的 NAT 记录了,它会继续被转发。这就是为什么你在 K8s 上删 Pod 时,如果有长连接,依然可能看到 502。

解决办法只能是:应用自己主动断连接(GOAWAY / Connection: close / server.Shutdown()),并在宽限期结束后被 SIGKILL 兜底。


反过来,“注销”还有另一面:什么时候不该注销?

这就是 Eureka 自我保护的另一半智慧:

大规模的心跳丢失,更可能是”注册中心自己网络分区了”,而不是”所有实例同时挂了”。

想想这个概率问题: 你手下有 1000 个实例,突然 5 秒内 200 个都没心跳了。这是”200 台机器集体暴毙”,还是”我跟这 200 台之间的网络断了”?显然是后者。

所以自我保护模式做了一件反直觉但正确的事:心跳大量丢失时,反而停止剔除实例。 保留一批”可能已经死了”的实例,代价是少量请求失败;而错误地剔除一批健康实例,代价是容量腰斩 + 剩余实例被压垮 + 雪崩。

这就是为什么”连续失败 N 次才摘除”是必须的:它是对**抖动(flapping)**的防御。一个实例在网络抖动时反复上下线,比它干脆死掉危害更大——因为每次状态变更都会触发全集群的缓存失效和重新负载均衡。


子主题十一:Kubernetes 的服务寻址——一个”稳定虚拟 IP”背后的 iptables 魔法

K8s 把本章所有概念串成了一个完整体系:名字(Service)→ 稳定地址(ClusterIP)→ 动态地址(Pod IP)。我们把它一层层拆开。

第一个问题:Pod 的 IP 不稳定,怎么办?

Pod 是”牲畜”不是”宠物”(cattle vs pets):随时创建、随时销毁、随时漂移。每次重建,Pod IP 都会变。

如果你直接用 Pod IP 访问服务,那么每次 Pod 重建,调用方配置就要改一次。这显然不可行。

于是 K8s 引入了 Service:一个稳定的抽象。

apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  type: ClusterIP          # 默认类型
  selector:
    app: user              # 通过 label 选择器圈定 Pod 集合
  ports:
  - port: 80               # Service 暴露的端口(稳定的)
    targetPort: 8080       # 转发到 Pod 的端口(可变的)

创建之后,API Server 会从集群的 Service CIDR 网段里分配一个虚拟 IP(比如 10.96.123.45)。

现在关键的认知来了:

ClusterIP 是一个”虚拟 IP”——它不绑定任何网卡,不由任何进程监听,你甚至 ping 不通它(在 iptables 模式下)。它只是 etcd 里的一条记录,以及散落在每个节点上的一组 iptables/IPVS 规则。

它的唯一作用是:作为 iptables 规则里的”匹配目标”。 数据包匹配到”目标是 10.96.123.45:80”,就被 DNAT 改成某个真实 Pod 的 IP:Port。改完了,这个虚拟 IP 就从数据包里彻底消失了。

这就是”一个稳定虚拟 IP 背后是 iptables 规则转发到 Pod”的真实含义。

完整链路:从域名到 Pod

① 应用代码里写:http://user-service:8080
        ↓
② 解析:Pod 的 /etc/resolv.conf 指向 CoreDNS
   nameserver 10.96.0.10
   search default.svc.cluster.local svc.cluster.local cluster.local
   options ndots:5
        ↓
   CoreDNS 返回 Service 的 ClusterIP:10.96.123.45
   (注意:返回的是 ClusterIP,不是 Pod IP!
     只有 Headless Service 才返回 Pod IP 列表)
        ↓
③ 应用向 10.96.123.45:80 发起连接,数据包出网卡
        ↓
④ 内核 netfilter 拦截,走 PREROUTING/OUTPUT → KUBE-SERVICES 链
        ↓
⑤ 匹配到 KUBE-SVC-XXXXX 链(每个 Service 一条)
   -A KUBE-SVC-XXXXX -m statistic --mode random --probability 0.333 -j KUBE-SEP-A
   -A KUBE-SVC-XXXXX -m statistic --mode random --probability 0.5   -j KUBE-SEP-B
   -A KUBE-SVC-XXXXX                                                -j KUBE-SEP-C
        (通过概率规则实现"等概率随机选一个 Pod")
        ↓
⑥ 进入 KUBE-SEP-YYYYY 链(每个 Pod 一条)
   -A KUBE-SEP-YYYYY -j DNAT --to-destination 10.244.1.7:8080
        ↓
⑦ 数据包被改写目标地址,路由到真实 Pod

注意第 ⑤ 步那个精妙的概率设计:用 1/3、1/2、1 这样的条件概率串联,实现三选一的均匀分布。这是 iptables 没有”随机负载均衡”原语时的经典 hack。

kube-proxy 的三种模式

kube-proxy 是那个”把 Service/Endpoint 的变化翻译成节点上转发规则”的组件。它有三种工作模式:

模式机制复杂度调度算法现状
userspacekube-proxy 进程自己做代理转发最差(内核↔用户态多次拷贝)轮询已淘汰
iptables纯内核 netfilter DNATO(n) 规则线性匹配随机(概率规则)长期默认
IPVS内核 LVS(hash 表)O(1)丰富:rr/lc/dh/sh/sed/nq大规模集群推荐

iptables 模式的问题:

  1. 规则是线性的。iptables 链是一条条往下匹配的,1000 个 Service 就意味着上千条规则要遍历。Service 数量到几千时,规则更新和匹配都明显变慢,而且更新是接近全量的(kube-proxy 会定期全量 sync)。
  2. 只有随机算法。没法做最少连接、基于延迟的智能调度(会话保持可以用 sessionAffinity: ClientIP,基于 recent 模块实现)。
  3. 没有真正的健康检查。iptables 只做转发,它不知道对端 Pod 死没死——这个信息来自 readinessProbe,由 Endpoint/EndpointSlice 提供。如果 readiness 检查不及时,iptables 就会把流量转发给死 Pod。

IPVS 模式的优势:

  • 用 hash 表查找,Service 数量从 100 涨到 10000,性能基本不下降
  • 支持 多种调度算法(rr 轮询、lc 最少连接、dh 目标地址哈希、sh 源地址哈希、sed 最短期望延迟、nq 不排队……)
  • 规则更新是增量的,不用全量重写

代价:需要节点加载 ip_vs 内核模块;IPVS 模式下 kube-proxy 会把所有 ClusterIP 绑到一个叫 kube-ipvs0 的 dummy 网卡上(所以在 IPVS 模式下 ClusterIP 是可以 ping 通的,而 iptables 模式下通常不行)。

进阶知识:Kubernetes 1.31 引入了 nftables 后端(作为 iptables 的现代替代),规则匹配效率更高、更新更快,正在逐步走向稳定。

EndpointSlice:为了规模而做的拆分

早期 K8s 用 Endpoints 对象存一个 Service 的所有 Pod IP。问题:一个 Service 有 5000 个 Pod,Endpoints 对象就有几千条记录,任何 Pod 变动都要更新并广播这整个大对象 → 全集群的更新风暴。

EndpointSlice(1.21 GA) 把大对象切成小块:每个 EndpointSlice 默认最多 100 个端点,一个 Service 对应多个 EndpointSlice。Pod 变动时只更新它所在的那一个小片,大幅减少更新风暴。这是”分而治之”思想在数据同步上的又一次应用。

CoreDNS 与那个臭名昭著的 ndots:5

K8s 的 DNS 由 CoreDNS 提供(早期是 kube-dns)。CoreDNS 监听 API Server 的 Service/Endpoint 变化,动态生成记录。

命名规则:

<service>.<namespace>.svc.<cluster-domain>
user-service.default.svc.cluster.local

Pod 内的 /etc/resolv.conf:

nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

ndots:5 是什么意思? 如果一个域名里的点的数量少于 5 个,系统就认为它不是完整域名(FQDN),会先依次用 search 域去拼,拼不中才查原始名字。

这意味着什么? 你的 Pod 里访问 api.github.com(只有 2 个点),系统会先查:

api.github.com.default.svc.cluster.local   → NXDOMAIN
api.github.com.svc.cluster.local           → NXDOMAIN
api.github.com.cluster.local               → NXDOMAIN
api.github.com                             → 终于命中!

三次无谓的查询,每次都是一个网络往返。 在高 QPS 下,这会显著增加延迟并给 CoreDNS 造成数倍压力。

两个优化:

  1. 访问外部域名时,在末尾加一个点:api.github.com.(这是标准 FQDN 写法,点数达标,跳过 search)。
  2. 在 Pod 的 dnsConfig 里调低 ndots(比如降到 2),或精简 search 域。

另一个常见优化:NodeLocal DNSCache。 在每个节点上跑一个 DNS 缓存 DaemonSet,让 Pod 的 DNS 查询先走本机缓存,不走跨节点的 CoreDNS。这既降低延迟,又大幅减少 CoreDNS 的压力和 conntrack 表占用(DNS 走 UDP,高并发时 conntrack 表被打满导致丢包是经典故障)。这也是”分层缓存”思想在 K8s 里的直接应用。

Headless Service:什么时候你要的是 Pod IP 而不是 VIP

spec:
  clusterIP: None      # 关键!

CoreDNS 对 Headless Service 的行为完全不同:它直接返回所有 Pod 的 IP 列表,而不是一个 ClusterIP。

为什么要这样?

  • 需要自己做负载均衡/服务发现的客户端:比如 Kafka、Elasticsearch、ZooKeeper、Redis Cluster 这类有状态、需要感知具体实例的分布式系统。它们的客户端需要知道”有哪些节点、谁是主”,一个聚合的 VIP 反而帮倒忙。
  • 配合 StatefulSet:每个 Pod 有稳定的 DNS 名(mysql-0.mysql.default.svc.cluster.local),可以直接点对点访问特定实例。

注意 Headless Service 也吃 DNS TTL 的亏——客户端拿到 Pod IP 列表后会缓存,Pod 重建后 IP 变了,客户端可能还在用旧的。所以有状态应用的客户端通常要自己控制重解析频率。

四种 Service 类型 + 一层 Ingress

类型作用域实现
ClusterIP集群内虚拟 IP + iptables/IPVS
NodePort集群外可达在每个节点的固定端口(默认 30000-32767)开监听,再 DNAT 到 ClusterIP
LoadBalancer集群外,云环境云厂商创建外部 LB,指向 NodePort
ExternalName把外部服务映射进集群纯 DNS CNAME,不做任何代理
IngressHTTP 层的路由不是 Service,是 L7 路由规则,由 Ingress Controller(Nginx/Traefik)实现

ExternalName 值得一说——它是本章”命名”主题最纯粹的体现:它什么都不转发,只是给外部服务起了一个集群内的”别名”。

kind: Service
spec:
  type: ExternalName
  externalName: rds-xxx.amazonaws.com

集群内访问 my-db.default.svc.cluster.local → CoreDNS 返回 CNAME 到 rds-xxx.amazonaws.com → 再解析。

这就是”间接层”的全部意义:把”外部的、可能变化的地址”藏在一个”内部的、稳定的名字”后面。


子主题十二:模式的迁移——“分层 + 缓存”和”TTL 作为一致性旋钮”

现在我们拔高一层。DNS 那套设计,早就被反复复用到计算机世界的各个角落。看懂这个迁移,你就获得了一种”设计模式的模式”。

模式一:分层命名空间 + 逐级缓存

DNS 的骨架是三层:分层命名空间(树)+ 委托(每层只知道下一层在哪)+ 各级缓存(TTL)。

这个骨架被复用到哪里了?

系统分层结构缓存层级TTL/失效
DNS根 → TLD → 权威浏览器 → OS → 递归 → 各级服务器记录 TTL、SOA minimum
HTTP 缓存源站 → CDN → 代理 → 浏览器每层独立缓存Cache-Control: max-age、ETag/Last-Modified(条件请求)
CDN源站 → 区域缓存(L2)→ 边缘节点(L1)两级边缘缓存边缘 TTL + 主动刷新/预热 API
服务发现注册中心 → 客户端本地缓存 → 连接池多级拉取间隔 / watch 推送 + 兜底 TTL
配置中心服务端 → 客户端内存 → 本地文件快照Apollo/Nacos 的本地磁盘缓存长轮询 + 本地文件兜底(连不上服务端也能启动)
包管理官方源 → 镜像站 → 本地缓存(~/.m2、node_modules/.cache)多级索引 TTL / SNAPSHOT 更新策略
对象存储元数据元数据服务 → 本地缓存 → 客户端缓存多级元数据的版本号 / lease
ARPIP → MAC本机 ARP 缓存通常几十秒到几分钟
DHCPIP 地址租约客户端持有租约时间(lease time)

发现共性了吗?

任何一个”读多写少、可容忍短暂陈旧、查询量巨大”的”名字→值”系统,都会长成同一个样子:树形分层 + 每级缓存 + TTL。

这不是巧合,这是同一个约束下的同一个最优解。当你设计一个”全局查询服务”时,先问自己:能不能套这个骨架?

模式二:TTL 作为”一致性旋钮”

这是本章最值得带走的一个心智模型。

TTL 的本质是什么?是”我愿意忍受多长的不一致”的量化表达。

TTL 就是一个旋钮:往左拧 = 更一致、更慢、更贵;往右拧 = 更快、更便宜、更陈旧。

它在各处现身:

场景TTL 旋钮拧小时拧大时
DNS记录 TTL切换快、权威压力大解析快、切换慢
HTTP 缓存max-age内容新鲜、源站压力大秒开、内容陈旧
服务发现客户端缓存时间实例变化立刻感知注册中心压力小
连接池maxLifetime / idleTimeout快速漂移、建连开销大复用率高、连接陈旧
JVM DNSnetworkaddress.cache.ttlDNS 变更快速生效少解析,但可能永久陈旧
TCP 保活keepalive 间隔快速发现死连接省带宽
ARP 缓存base_reachable_time换网卡快速恢复少广播
DHCP 租约lease time地址快速回收少续约流量
TLS / OCSPOCSP 响应有效期吊销状态及时少请求
K8s informerresync period快速修正状态漂移减少 API Server 压力
gRPC 连接maxConnectionAge流量再均衡快少重建连接

给一个实用的分析框架。当你评估任何一个带缓存的系统时,问这四个问题:

  1. TTL 是多少?(标称值是多少,实际生效值是多少)
  2. 最坏情况下的陈旧时间是多少? 注意:不是 TTL,而是 TTL + 传播延迟 + 客户端缓存残留。多层缓存会叠加,不是取最大值那么简单。
  3. 有没有主动失效(invalidation)机制? 纯 TTL 意味着”变更最快也要等 TTL”,而主动失效能做到亚秒级。
  4. 降级行为是什么? 缓存全部失效、后端不可用时,系统是”报错”还是”用陈旧数据继续服务”?(stale-while-revalidate、stale-if-error 就是在明确选择后者)

模式三:从”被动等待 TTL”到”主动推送”——架构演进的主线

这里有个极其重要的演进规律。

DNS(1983 年设计)的最大历史包袱是什么?是它只有 TTL,没有主动失效机制。 权威服务器改了记录,没有任何办法通知全世界那些缓存了旧值的递归解析器。你只能等 TTL 自然过期。

这就是”纯 pull + TTL”模型的根本局限:变更生效的下界是 0,上界是 TTL。

后来的系统怎么改进的?加一条推送通道。

代际机制变更生效时间
第一代:纯 TTL 轮询DNS:客户端定期重查,不知道有没有变0 ~ TTL
第二代:长轮询(Long Polling)配置中心(Apollo/Nacos):请求挂起,有变更立即返回;无变更则超时后重发亚秒级
第三代:Watch / 事件流etcd(gRPC stream)、ZooKeeper(Watcher,一次性事件)、Consul(blocking query + 递增 index)毫秒级,推模式
第四代:Sidecar / 服务网格Istio:控制面通过 xDS 协议把服务列表推给每个 Envoy sidecar亚秒级

看清楚这个演进:从”定期问”(TTL 决定一切),到”变了就告诉我”(事件驱动)。TTL 从”主机制”降级成了”兜底机制”。

一张图总结:

纯 TTL 轮询:  一致性最差,实现最简单,扩展性最好   ← DNS 停在这里
       ↓ 加入长轮询
长轮询:       一致性较好,实现中等,需要连接保持
       ↓ 加入事件推送
Watch/推送:   一致性最好,实现复杂,需要连接管理+重连+全量重建  ← 现代系统
       ↓ TTL 退化为"兜底"(防止推送通道失效时永远收不到更新)

那为什么 DNS 不走这条演进路线?

因为向后兼容和规模。 DNS 的客户端是全世界几十亿台设备,协议的任何改动都要以数十年为尺度。而且 DNS 的读写比是百万比一——为了极小概率的变更,给所有查询加上”注册 watch”的开销,在 1983 年的网络条件下不划算。EDNS、DNSSEC、DoH 这些补丁打了三十年还没打完,你就知道改 DNS 有多难。

这个教训放到你的系统里就是:

在系统设计初期,就要想清楚”变更如何传播”。如果你的系统未来一定会需要”实时生效”,那就一开始就把推送通道设计进去。等系统上线后再从”纯 TTL”改造成”推送”,代价是伤筋动骨的——就像今天的 DNS。

模式四:缓存的黑暗面——投毒与雪崩

“分层 + 缓存”不是只有好处。它引入了两类新的故障模式。

(1)缓存投毒(Cache Poisoning)。

问题:缓存是”信任链”中的一环。如果攻击者能让缓存接受一个伪造的答案,那么所有信任这个缓存的用户都会被误导——而 TTL 决定了这个错误会持续多久。

经典案例:2008 年的 Kaminsky 漏洞(Dan Kaminsky 发现)。

DNS 用的是 UDP,无连接。攻击者伪造来自权威服务器的响应包,只要猜中 事务 ID(16 位)+ 源端口,就能把自己的答案塞进递归解析器的缓存。Kaminsky 发现:不需要猜中一次完整的响应,可以靠”反复触发对同一域名的查询 + 海量伪造响应”来暴力撞运气——几分钟内就能成功。

危害:把 bank.com 解析到攻击者的服务器上,用户毫无察觉地输入密码。因为浏览器地址栏显示的确实是正确的域名。

缓解手段(都是往协议里打的补丁):

  • 源端口随机化(原本端口固定为 53,只有 16 位 TXID 可猜;加了端口随机后搜索空间大幅膨胀)
  • 0x20 编码(DNS-0x20):查询时随机大小写混写域名,响应必须原样返回——又加了若干比特的熵
  • DNSSEC:用数字签名保证应答的真实性和完整性(注意:DNSSEC 保证的是”答案没被篡改”,不保证”答案的隐私”)
  • 最终的加密方案是 DoT / DoH / DoQ

这个案例告诉我们的通识:任何缓存都必须验证它缓存的内容来源。 一个不验证来源的缓存,就是一个任何人都能往里塞东西的公共信箱,而且它还会把脏东西分发给所有信任它的人,持续整整一个 TTL。

(2)缓存雪崩 / 击穿 / 穿透。

这三个是缓存的经典故障,它们同样适用于 DNS 和服务发现:

  • 雪崩:大量缓存同时过期 → 所有请求同时打到后端 → 后端被打垮 → 解决方案:给 TTL 加随机抖动(jitter)。K8s 的 informer resync、很多连接池的 maxLifetime 都会加 jitter,就是这个原因。
  • 击穿:一个极热的 key 过期 → 海量请求同时去重建它 → 解决方案:单飞(singleflight)/ 互斥重建
  • 穿透:查询一个根本不存在的 key → 缓存永远不命中 → 每次都打后端 → 解决方案:缓存空值(这就回到了 DNS 的负缓存:连 NXDOMAIN 都要缓存!)

你看,DNS 四十年前就把这些坑都踩过了。 这就是为什么学”通用问题”有意义——太阳底下没有新事,你今天在分布式缓存里遇到的坑,DNS 早就遇到并解决过一遍了。


子主题十三:实操手册——ID 选型清单与故障排查路径

讲了这么多原理,最后落到”我到底该怎么选、怎么查”。

一、ID 方案选型清单

先问六个问题,答案自然就出来了:

#问题如果答”是” → 倾向
1能给 ID 分配一个中心化依赖吗?(有 DB、有 etcd、有稳定的基础设施)是 → 号段模式可考虑;否 → 必须本地生成(UUID/Snowflake)
2需要 ID 趋势递增吗?(用作数据库主键、要建 B+ 树索引、有范围查询)是 → 排除随机 UUID,选 Snowflake / UUIDv7 / ULID / 号段
3需要 ID 不可猜测吗?(订单号、优惠券码、URL 里的资源 ID)是 → 排除自增 ID 和纯递增 ID(或额外加一层”对外 ID 映射”)
4机器时钟可信吗?(有 chrony slew、有时钟监控、不是频繁迁移的 VM)否 → 排除 Snowflake,选号段或 UUID
5有没有跨语言/跨系统对接?(ID 要在 Java/Go/JS/外部系统间流转)是 → 注意 JS 的 2^53 精度问题,对外一律字符串
6ID 里需要携带信息吗?(分片位、业务类型、时间)需要 → 自定义 Snowflake 变体(但注意:这是把语义冻结在 ID 里,谨慎)

决策树:

                    需要趋势递增吗?
                   /             \
                 否               是
                /                   \
        需要不可猜测?          能接受中心化依赖?
        /          \              /                \
      是            否          是                  否
     /                \        /                      \
  UUIDv4          自增ID   号段模式              时钟可信吗?
 (BINARY(16))    (简单场景)  (Leaf-segment)       /          \
                                                是            否
                                               /                \
                                          Snowflake          号段模式
                                        (或 UUIDv7)     (放弃本地生成)

各方案最终对照:

方案长度趋势递增不可猜测中心依赖主要风险适用场景
数据库自增64 位✅ 严格❌强单点、分库冲突、暴露业务量单体、小系统、非对外 ID
号段模式64 位✅ 严格❌弱(批量取)重启浪费号段、maxId 需持久化最平衡的选择:有 DB 的中大型系统
UUIDv4128 位❌✅无索引页分裂、体积大、二级索引膨胀小数据量、非主键、纯粹的”全局唯一标识”
UUIDv7 / ULID128 位✅ 毫秒级✅无右倾热点、生态支持尚在普及新项目首选:想要 UUID 的好处又怕索引问题
Snowflake64 位✅ 趋势❌(可反解)弱(仅 workerId 分配)时钟回拨、workerId 冲突高并发、有运维能力的中大型系统
Snowflake 变体(Leaf/TinyID/UidGenerator)64 位✅ 趋势❌弱到中同上,但有成熟运维配套生产环境直接用这些,别自己造

几条硬性建议:

  1. 能不自己造就不自己造。 用美团 Leaf、百度 UidGenerator、滴滴 TinyID。它们把时钟回拨、workerId 分配、号段持久化这些脏活都做了。你自己写,八成会漏掉某个边界条件。
  2. 新项目优先考虑 UUIDv7(如果生态支持)或 Snowflake 变体。UUIDv4 做主键是一个”现在没事、以后要命”的选择——尤其当你的表预计会涨到千万级以上时。
  3. 对外暴露的 ID 和内部主键 ID 分开。 内部用递增的 BIGINT(索引友好),对外用一个随机/不可逆映射的 ID(或加一层 ID 映射表)。这样既保住索引性能,又不暴露业务量。很多公司都在这上面栽过跟头——被竞争对手用 ID 差值算出你的日订单量。
  4. ID 存成二进制,别存字符串。 BINARY(16) vs CHAR(36),索引体积差一倍多,而且会放大到每一个二级索引。
  5. ID 一旦发出去,就不可逆。 所以选型时宁可保守,宁可多留位。

二、故障排查:命名与寻址的三类经典故障

故障一:DNS 改了不生效

症状:权威记录已经改了,但某些用户/某些机器还是解析到旧 IP。

排查路径(从上到下逐层排除):

# ① 先看权威侧对不对(绕过缓存,直查权威服务器)
dig @ns1.your-dns-provider.com www.example.com A
#   检查 ANSWER SECTION 的 IP 和 TTL
 
# ② 看递归链路(从根开始完整走一遍,能看出哪一跳给的是旧数据)
dig +trace www.example.com
 
# ③ 对比不同公共 DNS(区分"全网没生效"还是"某一家没生效")
dig @8.8.8.8   www.example.com A
dig @223.5.5.5 www.example.com A
dig @1.1.1.1   www.example.com A
 
# ④ 看本机拿到的是什么、TTL 还剩多少
dig www.example.com A
#   TTL 数字在倒数 → 说明命中了缓存;一直是满值 → 每次都重新查了

原因清单(按出现频率排序):

原因怎么确认怎么解决
还没到 TTL 过期dig 看到的 TTL 在倒数等。下次记得提前降 TTL
本机 hosts 文件cat /etc/hosts;Windows 看 C:\Windows\System32\drivers\etc\hosts删掉
JVM 永久缓存(Java 应用头号元凶)JVM 重启后就好了-Dsun.net.inetaddr.ttl=60 或设置 networkaddress.cache.ttl
OS 解析器缓存换台机器查,结果不同Windows ipconfig /flushdns;Linux systemd-resolve --flush-caches;macOS 重启 mDNSResponder
浏览器 DNS 缓存换无痕模式试试Chrome chrome://net-internals/#dns → Clear host cache
容器 / nscd 缓存进容器 dig 与宿主机 dig 结果不同重启 nscd;或用 NodeLocal DNSCache
SOA 序列号没递增主 DNS 改了但从 DNS 没同步(AXFR/IXFR 依赖 serial 递增判断)手动把 serial +1(常用格式 YYYYMMDDNN)
权威 NS 之间不一致dig +nssearch example.com,各 NS 返回的 serial 不同修复主从同步
运营商/公共 DNS 不遵守 TTL用 ③ 对比多家,某家明显陈旧换 DNS;或改用 HTTPDNS
CNAME 链上某一环未刷新dig +trace 逐跳看等最短的那一段 TTL
负缓存(之前查过”不存在”的域名)之前 dig 返回过 NXDOMAIN等 SOA 的 minimum 字段过期

一个高价值的补充方案:HTTPDNS。

移动 App 场景尤其常见:运营商的 Local DNS 质量参差不齐(不遵守 TTL、被劫持、跨网解析错误)。HTTPDNS 绕过传统 DNS 协议,通过一个 HTTP 接口(自己可控的服务端)直接拿 IP,完全绕开了运营商 DNS。代价是 App 要集成 SDK,且要处理”HTTPDNS 自己挂了”的降级(回退到系统 DNS)。

这本质上是:当你无法信任某一层的解析服务时,就在它上面再架一层你信得过的。

故障二:服务发现摘除延迟(实例已下线,流量还在打过去)

症状:Pod 已经删了 / 进程已经停了,但调用方还在报 Connection refused / No route to host / 502。

用那张”延迟总账”逐段排查:

① 检测延迟 —— 实例挂了多久才被发现?
   查:注册中心上这个实例的状态还是 UP 吗?
   查:心跳超时配置是多少?(Eureka 90s / Nacos 15~30s)
   查:是不是触发了"自我保护模式"?Eureka 页面会显示
      "EMERGENCY! EUREKA MAY BE INCORRECTLY CLAIMING INSTANCES ARE UP..."

② 传播延迟 —— 注册中心多节点间同步了吗?
   查:打到注册中心的不同节点上查,结果一致吗?
   查:etcd/ZK 是不是在选举?(leader 变更期间 watch 会断)

③ 感知延迟 —— 调用方拿到新列表了吗?
   查:用的是轮询还是 watch?轮询间隔多少?
   查:客户端日志里有没有"实例列表变更"的记录
   查:直接调注册中心 HTTP API:"这个实例还在列表里吗?"

④ 客户端残留 —— 长连接!
   查:netstat / ss 看 ESTABLISHED 到那个已死实例的连接数
   查:连接池的 maxLifetime / idleTimeout 配置是多少
   查:gRPC 客户端是不是只有一条长连接?

⑤ 若是 DNS 方式 —— TTL
   查:dig 的 TTL 是多少?K8s CoreDNS 默认 TTL(通常 30s,
      但会和客户端缓存、负缓存叠加)

对应的修复手段:

延迟段修复
① 检测慢缩短心跳周期与超时(但要防抖动);用主动健康检查替代纯心跳;确认自我保护模式是不是在掩盖真实故障
② 传播慢检查注册中心节点健康;避免 etcd 与业务抢资源
③ 感知慢从轮询改成 watch;缩短客户端缓存 TTL;开启”实例列表变更”日志
④ 长连接(最常被漏掉)配置 maxLifetime(如 30 分钟)强制换连接;gRPC 用 maxConnectionAge;HTTP 启用优雅关闭 / Connection: close
⑤ DNS TTL降 TTL;用 Headless Service + 客户端主动刷新;或改用支持 watch 的服务发现

最优雅的通用解:服务端主动发 GOAWAY。

gRPC 的 maxConnectionAge(配合 maxConnectionAgeGrace)让服务端每隔一段时间主动告诉客户端:“这个连接我要关了,请新建一个。” 客户端新建连接时会重新做一次负载均衡(可能选到别的实例),从而实现长连接场景下的流量再均衡。

这个思路的普适版:给所有长连接都加一个”最大寿命”,让它们定期被重建。 这样任何”地址变更”最多在 maxLifetime 时间内就会生效,而不是无限期地粘在旧地址上。这本质上是在给”长连接”这个隐式缓存,也加一个 TTL。

故障三:ID 冲突 / ID 回退

症状:插入时主键冲突,或者发现两条不同数据的 ID 一样。

按 ID 方案分类排查:

A. Snowflake 类

原因排查修复
workerId 重复打印/记录每个节点的 workerId,看有没有两台一样用可靠的分配机制(StatefulSet 序号 / etcd 临时序号 / DB 分配);容器时代绝不要用 IP 后几位
时钟回拨查 NTP 状态:chronyc tracking;看应用日志里的 Clock moved backwards见子主题九;配置 chrony slew 模式;加时钟偏移告警
epoch 配错不同服务用了不同 epoch → ID 空间重叠全公司统一 epoch(写进公共配置中心)
位分配不一致A 服务用 41/10/12,B 服务用 42/9/13统一 ID 生成器库,禁止各服务自己实现
重启后 lastTimestamp 丢失内存中的 lastTimestamp 归零,若时钟恰好回拨持久化 lastTimestamp;或重启后短暂拒绝发号

B. 号段模式(Segment)

原因排查修复
maxId 没持久化(最经典)取了号段 [1,1000],用了 500 个,重启后又从 1 开始取号时立刻落库;或用 UPDATE ... SET max_id = max_id + step 原子操作
双 buffer 未正确切换号段用完了才去取,期间阻塞双 buffer:当前段用到 10% 时异步预取下一段
step / offset 配置错误分库场景下步长与库数不匹配step = 库数 × 单段大小,且 offset 必须严格对应库编号

C. 数据库自增 ID

原因排查修复
MySQL 5.7 及之前自增计数器不持久化InnoDB 的 auto_increment 计数器只存在内存,重启后用 SELECT MAX(id)+1 重新初始化。如果最大的那条记录被删了,ID 会被重用!升级到 MySQL 8.0(8.0 把计数器持久化到 redo log);或永不物理删除
主从切换后 ID 回退(8.0 之前)从库提升为主库,计数器重新计算同上;或用 auto_increment_increment/offset 预留间隔
分库分表步长错两个库用了相同的 offset 和 stepoffset 必须与库编号严格对应,step = 库总数。扩容时改 step 会导致历史数据冲突
双主双向复制两边同时插入,ID 撞车auto_increment_increment=2,auto_increment_offset 分别设 1 和 2

D. UUID 类

原因排查修复
版本混用不同服务分别用 v1、v4 生成统一版本
大小写不一致A3F2... vs a3f2...,在 DB 的 ci(大小写不敏感)排序规则下判为相同 → 出现”查得到但插不进”的怪事统一小写存储
UUIDv1 在同一节点、同一 100ns 内并发v1 时间精度是 100ns,理论上有碰撞窗口用 v4/v7;v1 本身有 clock sequence 防冲突,但实现不当会漏
外部导入数据的 ID 不在同一空间迁移数据时用了原始系统的 ID迁移时统一重新生成,或划出不重叠的 ID 空间

一条通用的预防原则:给 ID 生成器加”冲突自检”。

在开发环境/压测环境,用一个中心化计数器收集所有生成的 ID,检测是否有重复。生产环境可以做成”采样 + 异步”的。ID 冲突是那种”发生了就是灾难、而且很难发现”的问题——值得在早期就投入检测成本。


演进脉络

  • 早期:/etc/hosts 文件,手工维护主机名到 IP 的映射。规模一大就无法维护。
  • 1983:DNS 被发明(Paul Mockapetris),分层 + 缓存的设计一直沿用至今。
  • 1990s-2000s:分布式 ID 方案(UUID 标准化);负载均衡器(硬件 F5、软件 LVS/Nginx)成为服务端寻址的基础设施。
  • 2010s:微服务兴起,服务发现成为标配(Eureka、Consul、etcd);容器编排(Kubernetes)用 DNS + Service 抽象重新定义了服务寻址。
  • 现代:服务网格(Istio)把服务发现和负载均衡下沉到 sidecar;云原生 DNS 和服务注册进一步融合。

主线:命名与寻址的演进,是”间接层不断强化、解析机制不断自动化”的过程——从手工维护 hosts,到自动化的 DNS,到动态的服务发现。而贯穿始终的核心矛盾从未改变:名字要稳定,地址要可变,而”稳定”和”可变”之间的张力,就由缓存(TTL)来调节——缓存越久越快,但变化生效越慢。


本章思考题

【思考题】

  1. 命名的三层价值是什么?为什么说”一个名字可以代表一组东西”是最深刻的一层?

  2. 请说出至少 5 个计算机不同层次的”名字→地址”对照。理解这个通用模式有什么用?

  3. 雪花算法(Snowflake)相比自增 ID 和 UUID,解决了什么问题?它的著名缺陷是什么?

  4. DNS 靠什么扛住全球流量?TTL 带来了什么权衡?

【参考答案】

  1. 三层:人类可读性(域名比 IP 好记)、间接性/可迁移性(名字不变位置可变,换机房改 DNS 即可)、抽象与聚合(一个名字代表一组东西,如 www.google.com 背后是几十万台服务器)。第三层最深刻,因为”一个代表多个”正是负载均衡和集群的基础——若名字只能指向唯一实体,就无法做集群、无法横向扩展,也无法让调用方对实例变化无感知。

  2. 对照:变量名→内存地址(编译器/链接器)、文件路径→磁盘块/inode(文件系统)、域名→IP(DNS)、IP→MAC(ARP)、服务名→IP:Port 列表(服务发现)、逻辑表名→分片位置(分库分表中间件)、对象 key→存储节点(对象存储元数据服务)。用途:获得通用分析工具,面对任何新系统先问”名字是什么、地址是什么、怎么解析”,其中第三问(解析服务是谁、结果怎么缓存、缓存多久)往往是系统设计和故障排查的关键。

  3. 自增 ID:简单递增但单点、分库分表冲突、可猜测(安全风险)。UUID:本地生成全局唯一、不可猜测,但无序导致主键索引频繁页分裂、性能差,且占空间大。Snowflake:用”时间戳+机器ID+序列号”实现趋势递增(对索引友好)、分布式生成(无协调)、性能好、可反解;缺陷是时钟回拨——机器时钟往回调会生成重复 ID,必须特殊处理,另外机器 ID 需要统一分配不能重复。

  4. DNS 靠”分层 + 缓存”:分层(根→com→example→www)让职责和负载分散、可无限扩展;缓存(各级 DNS 服务器按 TTL 缓存)让绝大多数查询根本到不了根服务器。TTL 的权衡:缓存越久查询越快、上级压力越小,但 DNS 记录变更生效越慢——TTL 设一天意味着 IP 改错要一天才恢复。这是”精确/新鲜度 vs 性能”的权衡,关键服务不宜设过长 TTL。