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.com | IP 93.184.216.34 | DNS |
| 网络主机 | IP 地址 | MAC 地址 | ARP |
| 服务实例 | 服务名 user-service | IP:Port 列表 | 服务发现(注册中心) |
| 容器 | 容器名/IP | 容器网络命名空间 | CNI / kube-proxy |
| 数据库分片 | 逻辑表名 | 具体分片位置 | 分库分表中间件 |
| 对象存储 | 对象 key | 具体存储节点 | 对象存储的元数据服务 |
看到规律了吗?每一层都在做同一件事:
上层用一个”稳定的、语义化的名字”,下层提供一个”易变的、物理的地址”,中间有一个”名字→地址”的解析机制。
而且这个模式会层层叠加:域名 → IP → MAC → 物理端口。每一层都在”翻译”上一层给的名字。
理解这个”命名-寻址”的通用模式,你就获得了一个强大的分析工具:面对任何新系统,先问三句——
- 它的”名字”是什么?
- 它的”地址”是什么?
- 名字到地址是怎么解析的?(解析的服务是谁?解析结果怎么缓存?缓存多久?)
第三问往往是系统设计和故障排查的关键:DNS 的 TTL 决定改 IP 多久生效;服务发现的缓存决定实例下线后多久被发现;ARP 缓存决定换网卡后多久恢复。
子主题三:ID 的设计——命名的第一等公民
在分布式系统里,一个最基础也最容易被做错的事情:给数据分配 ID。
ID 就是这个数据的”名字”,而 ID 的设计会深远影响整个系统。
ID 的几种经典方案:
方案一:数据库自增 ID(Auto Increment)
- 优点:简单、递增(对索引友好)
- 致命缺点:
- 单点(依赖数据库)
- 分库分表时会冲突(两个库各自从 1 开始)
- 可猜测(暴露业务量,有安全风险——遍历 ID 就能爬数据)
方案二:UUID / GUID
- 优点:本地生成,无需协调;全局唯一;不可猜测
- 缺点:
- 无序 → 作为数据库主键时会导致索引频繁页分裂,性能差(这是最被诟病的一点)
- 占空间大(128 位,字符串形式 36 字节)
方案三:雪花算法(Snowflake)及其变体——这是分布式 ID 的工业标准
- 结构(64 位):
1位符号 + 41位时间戳 + 10位机器ID + 12位序列号 - 优点:
- 趋势递增(时间戳在高位)→ 对索引友好
- 分布式生成,每台机器独立发号,无需协调
- 性能好(本地生成,无网络开销)
- 可反解(从 ID 能推出生成时间和机器)
- 缺点:
- 时钟回拨问题——机器时钟往回调会导致 ID 重复(这是雪花算法最著名的坑,需要特殊处理)
- 机器 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 的设计智慧,值得你迁移到任何系统:
- 分层命名空间:让职责和负载分散,可无限扩展
- TTL 缓存:用”暂时的不精确”换取”巨大的性能”(这又是精确 vs 性能的权衡,第 2 章)
- 最终一致性:改 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 步:先看有没有”本地捷径”。
浏览器在真正发起网络查询之前,会先翻自己的口袋:
- 浏览器自身的 DNS 缓存(Chrome 有独立的主机解析缓存,有 TTL 上限,也受
--host-resolver-rules影响) - 本机 hosts 文件(
/etc/hosts或C:\Windows\System32\drivers\etc\hosts)——这是一张没有 TTL、优先级通常高于 DNS 的静态表。它是 DNS 的”史前祖先”,也是你本机调试时最顺手、也最容易埋雷的东西 - 操作系统的解析器缓存(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(任播) 部署了成百上千个物理实例,全球几百个位置。
再补三个真实工程细节,能救你的命:
- UDP 还是 TCP? DNS 默认走 UDP 53 端口。响应超过 512 字节(无 EDNS)时,服务器会设置
TC标志位截断,客户端改用 TCP 重查。今天 DNSSEC 和大型响应让 TCP 占比显著上升。还有 DoT(RFC 7858)、DoH(RFC 8484)、DoQ(RFC 9250)这些加密 DNS 变体。 - 负缓存(negative caching):查询一个不存在的域名(
NXDOMAIN),也会被缓存!它的 TTL 来自该区 SOA 记录的最后一个字段(RFC 2308)。这个坑极常见:你刚创建了一条记录,之前查过它的人手里已经存着”这个域名不存在”,还得等负缓存过期。 - 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) |
| PTR | IP → 域名(反解) | 邮件服务器强烈依赖;没有正确 PTR 容易被判为垃圾邮件 |
| SRV | 服务发现式记录(_service._proto.name → 优先级/权重/端口/目标) | Kubernetes、Consul、XMPP 都在用;这才是”DNS 做服务发现”的正统姿势 |
| TXT | 任意文本 | 域名所有权验证、SPF/DKIM/DMARC、ACME(Let’s Encrypt)挑战 |
| CAA | 指定哪些 CA 可以给你签发证书 | 安全加固项 |
CNAME 的两个硬规矩,必须记住:
- CNAME 是”这个域名的全部身份都让渡给另一个名字”。所以一旦
a.example.com是 CNAME,就不能再有a.example.com的 A、MX、TXT——解析会直接乱套。 - 裸域不能用 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,挑一个连。
看起来很美,实际上有五个硬伤:
- 没有健康检查。 三个 IP 里挂了一个,DNS 依然会把它返回给客户端。DNS 不知道也不关心你的服务器死没死。 客户端连上去才发现超时,然后自己重试——这段超时就是白给的用户体验损失。
- 流量完全不均。 DNS 只在”建立连接之前”生效。一旦客户端建了长连接(HTTP keep-alive、WebSocket、数据库连接池、gRPC 流),它就会一直粘在那一台机器上。连接持续时间越长,DNS 轮询的均衡效果越差,极端情况下流量会长期严重倾斜。
- 顺序不可控。 客户端拿到列表后挑第几个,取决于实现。很多解析器/客户端就是”取第一个”。
- TTL 锁死了调整速度。 你改了 IP 集合,得等 TTL 过期才生效。
- 无法按容量加权。 三台机器性能不一样?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 了。
怎么缓解?给你一份清单:
- 至少两家 DNS 服务商。一个域名的 NS 可以委托给多组服务器(
ns1.服务商A.com+ns1.服务商B.com),解析器会在它们之间选择。注意:不要让所有 NS 都落在同一个 TLD 或同一家厂商的同一个机房。 - NS 记录别搞出循环依赖。
example.com的 NS 指向ns1.example.com时,必须同时配置 glue 记录(在上游注册局里直接登记这些 NS 的 IP),否则解析会陷入”要知道 ns1.example.com 得先解析 example.com”的死循环。 - 客户端保留”上次成功 IP”的兜底缓存。解析失败时,用上次成功的地址重试一次,同时后台异步刷新。浏览器的 Happy Eyeballs、很多 RPC 框架的”DNS 解析失败后降级”都是这个思路。
- 关键内部服务考虑本地 hosts 兜底或 NodeLocal DNSCache(K8s 场景下尤其重要)。
- 把 DNS 本身纳入监控:从多个地区、多个运营商、多个公共 DNS 主动拨测你的关键域名,并校验解析结果是否符合预期。别等用户投诉。
- 别在关键路径上用”短 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 和时间 |
| v2 | DCE Security(在 v1 基础上把部分位换成 POSIX UID/GID) | 极少使用,规范含糊 |
| v3 | 命名空间 + 名字,用 MD5 哈希 | 确定性:同样的输入永远得到同样的 UUID |
| v4 | 随机数(122 位随机) | 最常用;完全无序 |
| v5 | 命名空间 + 名字,用 SHA-1 哈希 | 确定性,比 v3 安全 |
| v6 | v1 的”重排版”——把时间戳高位提到最前面 | 兼顾 v1 的信息量与字典序可排序 |
| v7 | Unix 毫秒时间戳(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 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
关键特性:
- 前 48 位是毫秒时间戳(大端) → 按字典序(也就是字节序)比较时,等价于按时间比较。字符串排序 = 时间排序,这在 MySQL/Postgres 里直接成立。
- 后 74 位是随机 → 同一毫秒内的并发不会冲突(74 位随机,碰撞概率可忽略),且不泄露 MAC 地址。
- 可以从 ID 反解出生成时间,排查问题时极其方便(“这个订单是啥时候建的?“——看 ID 就知道,不用查
created_at)。 - 单调递增到毫秒级:整体趋势递增,只在”同一毫秒内部”无序。而一毫秒内能产生多少条记录?对绝大多数业务,这个”小乱序窗口”对 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 很香。
四个必须知道的配套技巧:
- 不要存成
CHAR(36)! 存成BINARY(16)(MySQL)或uuid(Postgres 的原生类型,就是 16 字节)。36 字节的字符串 vs 16 字节的二进制,索引体积差一倍多,而二级索引会把它再放大 N 遍。 - 如果你被迫用 v4,可以”重排字节”让它变有序。 MySQL 8.0 提供了
UUID_TO_BIN(uuid, 1)和BIN_TO_UUID(bin, 1),第二个参数swap_flag=1会把时间相关字段(对 v1 而言)调到前面,让它接近有序。注意:这个交换只对 UUID v1 有意义,对 v4 没用(v4 里没有时间字段可换)。 - 时间戳在最高位意味着”所有写入都打在最右侧”——这引入了新的右倾热点(right-most hotspot)和尾部页锁竞争。这是有序 ID 的固有代价,只是它比随机 I/O 的代价小得多。极高写入吞吐的场景需要额外考虑。
- 别在 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 的分配,和时钟的可靠性。
把设计取舍讲透:
| 位段 | 位数 | 给的额度 | 为什么是这个数 |
|---|---|---|---|
| 时间戳 | 41 | 69 年 | 再少就不够用了(30 位只有 12 天);再多没必要,69 年后这系统早重写了 |
| 机器 ID | 10 | 1024 节点 | 覆盖绝大多数集群规模。超过了怎么办?要么减少时间戳位(不推荐),要么拆业务用多套 ID 空间(推荐) |
| 序列号 | 12 | 4096/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。
时钟为什么会回拨?(不是假设,是常态)
- NTP 同步校正:NTP 发现本机时钟快了,会把它往回调。这是最常见的原因。
- 人工误操作:运维手动改系统时间。
- 虚拟机/容器:宿主机负载高导致时钟漂移;VM 快照恢复、暂停后恢复(时钟可能倒退或大幅跳变)。
- 跨主机迁移 / 热迁移:容器漂到一台时钟偏慢的宿主机上。
- 硬件时钟(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 节点)上:
- 用 chrony 而不是老 ntpd,并配置 slew 模式(缓慢调整,绝不”跳步”)。关键配置:
makestep 1.0 3(只允许启动时、且偏差超过 1 秒时跳变最多 3 次),平时一律用slew以 ppm 级别慢慢追。 - 多 NTP 源 + 本地 GPS/原子钟兜底(金融级场景)。
- 监控时钟偏移量,纳入告警。
chronyc tracking/ntpstat/ node_exporter 的node_timex_offset_seconds。 - 禁止手动改时间,或者至少让改时间的操作触发服务摘流。
方案六:终极方案——不依赖时钟
如果这个 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 倾向 | 一致性协议 | 关键行为 |
|---|---|---|---|
| Eureka | AP | 无强一致,节点间异步复制(peer replication) | 自我保护模式;客户端缓存 + 定时全量/增量拉取;Server 挂了客户端还能用本地缓存工作 |
| Consul | CP(KV 与目录默认强一致) | Raft | 默认一致读;也支持 stale 读模式(从任意节点读,容许陈旧);Agent 本地健康检查 + gossip |
| etcd / ZooKeeper | CP | Raft / Zab | 选举期间(几十秒到数分钟)不可写;强一致读;watch 机制推送变更 |
| Nacos | 可切换 | Distro(AP)/ Raft(CP) | 临时实例(心跳上报)走 AP 的 Distro 协议;持久实例走 CP 的 Raft;业务自己按注册方式选择 |
几个必须讲清的细节:
- Consul 的”CP”不是绝对的。 它提供三种读模式:
default(Leader 读,强一致)、consistent(Leader 校验自己仍是 Leader)、stale(任意节点读,可能陈旧)。你要主动选stale才能拿到 AP 的行为。 默认给你 CP。 - etcd/ZK 的”不可用”窗口是真实存在的。 Raft 选举期间(几十秒到数分钟)不可写。如果你的服务注册依赖 etcd 写入,那么 etcd 选举期间新的 Pod 无法完成注册——这在滚动发布时会卡住发布流程。这是 CP 的实打实的代价。
- 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 的变化翻译成节点上转发规则”的组件。它有三种工作模式:
| 模式 | 机制 | 复杂度 | 调度算法 | 现状 |
|---|---|---|---|---|
| userspace | kube-proxy 进程自己做代理转发 | 最差(内核↔用户态多次拷贝) | 轮询 | 已淘汰 |
| iptables | 纯内核 netfilter DNAT | O(n) 规则线性匹配 | 随机(概率规则) | 长期默认 |
| IPVS | 内核 LVS(hash 表) | O(1) | 丰富:rr/lc/dh/sh/sed/nq | 大规模集群推荐 |
iptables 模式的问题:
- 规则是线性的。iptables 链是一条条往下匹配的,1000 个 Service 就意味着上千条规则要遍历。Service 数量到几千时,规则更新和匹配都明显变慢,而且更新是接近全量的(kube-proxy 会定期全量 sync)。
- 只有随机算法。没法做最少连接、基于延迟的智能调度(会话保持可以用
sessionAffinity: ClientIP,基于recent模块实现)。 - 没有真正的健康检查。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 造成数倍压力。
两个优化:
- 访问外部域名时,在末尾加一个点:
api.github.com.(这是标准 FQDN 写法,点数达标,跳过 search)。 - 在 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,不做任何代理 |
| Ingress | HTTP 层的路由 | 不是 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 |
| ARP | IP → MAC | 本机 ARP 缓存 | 通常几十秒到几分钟 |
| DHCP | IP 地址租约 | 客户端持有 | 租约时间(lease time) |
发现共性了吗?
任何一个”读多写少、可容忍短暂陈旧、查询量巨大”的”名字→值”系统,都会长成同一个样子:树形分层 + 每级缓存 + TTL。
这不是巧合,这是同一个约束下的同一个最优解。当你设计一个”全局查询服务”时,先问自己:能不能套这个骨架?
模式二:TTL 作为”一致性旋钮”
这是本章最值得带走的一个心智模型。
TTL 的本质是什么?是”我愿意忍受多长的不一致”的量化表达。
TTL 就是一个旋钮:往左拧 = 更一致、更慢、更贵;往右拧 = 更快、更便宜、更陈旧。
它在各处现身:
| 场景 | TTL 旋钮 | 拧小时 | 拧大时 |
|---|---|---|---|
| DNS | 记录 TTL | 切换快、权威压力大 | 解析快、切换慢 |
| HTTP 缓存 | max-age | 内容新鲜、源站压力大 | 秒开、内容陈旧 |
| 服务发现 | 客户端缓存时间 | 实例变化立刻感知 | 注册中心压力小 |
| 连接池 | maxLifetime / idleTimeout | 快速漂移、建连开销大 | 复用率高、连接陈旧 |
| JVM DNS | networkaddress.cache.ttl | DNS 变更快速生效 | 少解析,但可能永久陈旧 |
| TCP 保活 | keepalive 间隔 | 快速发现死连接 | 省带宽 |
| ARP 缓存 | base_reachable_time | 换网卡快速恢复 | 少广播 |
| DHCP 租约 | lease time | 地址快速回收 | 少续约流量 |
| TLS / OCSP | OCSP 响应有效期 | 吊销状态及时 | 少请求 |
| K8s informer | resync period | 快速修正状态漂移 | 减少 API Server 压力 |
| gRPC 连接 | maxConnectionAge | 流量再均衡快 | 少重建连接 |
给一个实用的分析框架。当你评估任何一个带缓存的系统时,问这四个问题:
- TTL 是多少?(标称值是多少,实际生效值是多少)
- 最坏情况下的陈旧时间是多少? 注意:不是 TTL,而是
TTL + 传播延迟 + 客户端缓存残留。多层缓存会叠加,不是取最大值那么简单。 - 有没有主动失效(invalidation)机制? 纯 TTL 意味着”变更最快也要等 TTL”,而主动失效能做到亚秒级。
- 降级行为是什么? 缓存全部失效、后端不可用时,系统是”报错”还是”用陈旧数据继续服务”?(
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 精度问题,对外一律字符串 |
| 6 | ID 里需要携带信息吗?(分片位、业务类型、时间) | 需要 → 自定义 Snowflake 变体(但注意:这是把语义冻结在 ID 里,谨慎) |
决策树:
需要趋势递增吗?
/ \
否 是
/ \
需要不可猜测? 能接受中心化依赖?
/ \ / \
是 否 是 否
/ \ / \
UUIDv4 自增ID 号段模式 时钟可信吗?
(BINARY(16)) (简单场景) (Leaf-segment) / \
是 否
/ \
Snowflake 号段模式
(或 UUIDv7) (放弃本地生成)
各方案最终对照:
| 方案 | 长度 | 趋势递增 | 不可猜测 | 中心依赖 | 主要风险 | 适用场景 |
|---|---|---|---|---|---|---|
| 数据库自增 | 64 位 | ✅ 严格 | ❌ | 强 | 单点、分库冲突、暴露业务量 | 单体、小系统、非对外 ID |
| 号段模式 | 64 位 | ✅ 严格 | ❌ | 弱(批量取) | 重启浪费号段、maxId 需持久化 | 最平衡的选择:有 DB 的中大型系统 |
| UUIDv4 | 128 位 | ❌ | ✅ | 无 | 索引页分裂、体积大、二级索引膨胀 | 小数据量、非主键、纯粹的”全局唯一标识” |
| UUIDv7 / ULID | 128 位 | ✅ 毫秒级 | ✅ | 无 | 右倾热点、生态支持尚在普及 | 新项目首选:想要 UUID 的好处又怕索引问题 |
| Snowflake | 64 位 | ✅ 趋势 | ❌(可反解) | 弱(仅 workerId 分配) | 时钟回拨、workerId 冲突 | 高并发、有运维能力的中大型系统 |
| Snowflake 变体(Leaf/TinyID/UidGenerator) | 64 位 | ✅ 趋势 | ❌ | 弱到中 | 同上,但有成熟运维配套 | 生产环境直接用这些,别自己造 |
几条硬性建议:
- 能不自己造就不自己造。 用美团 Leaf、百度 UidGenerator、滴滴 TinyID。它们把时钟回拨、workerId 分配、号段持久化这些脏活都做了。你自己写,八成会漏掉某个边界条件。
- 新项目优先考虑 UUIDv7(如果生态支持)或 Snowflake 变体。UUIDv4 做主键是一个”现在没事、以后要命”的选择——尤其当你的表预计会涨到千万级以上时。
- 对外暴露的 ID 和内部主键 ID 分开。 内部用递增的
BIGINT(索引友好),对外用一个随机/不可逆映射的 ID(或加一层 ID 映射表)。这样既保住索引性能,又不暴露业务量。很多公司都在这上面栽过跟头——被竞争对手用 ID 差值算出你的日订单量。 - ID 存成二进制,别存字符串。
BINARY(16)vsCHAR(36),索引体积差一倍多,而且会放大到每一个二级索引。 - 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 和 step | offset 必须与库编号严格对应,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)来调节——缓存越久越快,但变化生效越慢。
本章思考题
【思考题】
-
命名的三层价值是什么?为什么说”一个名字可以代表一组东西”是最深刻的一层?
-
请说出至少 5 个计算机不同层次的”名字→地址”对照。理解这个通用模式有什么用?
-
雪花算法(Snowflake)相比自增 ID 和 UUID,解决了什么问题?它的著名缺陷是什么?
-
DNS 靠什么扛住全球流量?TTL 带来了什么权衡?
【参考答案】
-
三层:人类可读性(域名比 IP 好记)、间接性/可迁移性(名字不变位置可变,换机房改 DNS 即可)、抽象与聚合(一个名字代表一组东西,如 www.google.com 背后是几十万台服务器)。第三层最深刻,因为”一个代表多个”正是负载均衡和集群的基础——若名字只能指向唯一实体,就无法做集群、无法横向扩展,也无法让调用方对实例变化无感知。
-
对照:变量名→内存地址(编译器/链接器)、文件路径→磁盘块/inode(文件系统)、域名→IP(DNS)、IP→MAC(ARP)、服务名→IP:Port 列表(服务发现)、逻辑表名→分片位置(分库分表中间件)、对象 key→存储节点(对象存储元数据服务)。用途:获得通用分析工具,面对任何新系统先问”名字是什么、地址是什么、怎么解析”,其中第三问(解析服务是谁、结果怎么缓存、缓存多久)往往是系统设计和故障排查的关键。
-
自增 ID:简单递增但单点、分库分表冲突、可猜测(安全风险)。UUID:本地生成全局唯一、不可猜测,但无序导致主键索引频繁页分裂、性能差,且占空间大。Snowflake:用”时间戳+机器ID+序列号”实现趋势递增(对索引友好)、分布式生成(无协调)、性能好、可反解;缺陷是时钟回拨——机器时钟往回调会生成重复 ID,必须特殊处理,另外机器 ID 需要统一分配不能重复。
-
DNS 靠”分层 + 缓存”:分层(根→com→example→www)让职责和负载分散、可无限扩展;缓存(各级 DNS 服务器按 TTL 缓存)让绝大多数查询根本到不了根服务器。TTL 的权衡:缓存越久查询越快、上级压力越小,但 DNS 记录变更生效越慢——TTL 设一天意味着 IP 改错要一天才恢复。这是”精确/新鲜度 vs 性能”的权衡,关键服务不宜设过长 TTL。