1. 从一张内部拓扑图说起为什么我们需要重新做服务发现五年前刚接手公司微服务治理这块的时候我碰到一个特别头疼的场景每次上线前运维同事都要手工维护一份 Excel 表格里面记录着各个服务的 IP、端口、所属团队、依赖关系。这份表格在钉钉群里传来传去改一次崩一次经常出现A 服务说 B 服务的地址已经换了但 B 团队说他们压根没通知过这种扯皮事。当时市面上已经有不少服务发现组件像 Consul、Zookeeper、Eureka 都算成熟。但真落到我们的业务场景里问题就来了。我们的服务实例数不算特别多大概小几百个但部署环境特别杂有物理机、有自建虚拟机、还有不同云厂商的容器节点。网络的隔离策略、防火墙规则、跨机房延迟每一样都在影响服务发现的稳定性。Consul 的 gossip 协议在跨网络分区时容易产生脑裂Zookeeper 的强一致模型在频繁上下线时会有性能抖动Eureka 呢又太依赖客户端缓存服务变更的感知时延能到好几分钟。这就是我们决定自研 Atlas 的起点。Atlas 这个名字没什么花哨含义就是地图集——我们希望它能像一本精确的地图一样把公司内部所有服务的调用关系、实例位置、健康状态、灰度分组都画得清清楚楚。项目定位也从一个单纯的服务注册中心慢慢演变成了一个带元数据中心、健康检查、路由策略下发、调用链追踪辅助的基础设施。在这篇文章里我会把 Atlas 从设计到落地过程中踩过的坑、做过的取舍、沉淀下来的经验完整梳理一遍。如果你也在纠结自研一套服务发现与注册系统或者想理解现有开源组件背后的设计权衡这篇内容应该能给你一些不太一样的东西。2. 核心设计思路注册、发现、健康检查三者如何平衡2.1 数据模型的设计不是只有 Service 和 Instance最开始设计数据模型的时候我犯了一个所有新手都会犯的错误——只建了两张概念表服务表、实例表。结果做到第三周就发现不够用了。为什么因为实际生产环境里同一个服务不同的实例可能处于完全不同的状态有的实例是新版本刚发布还在观察期只接收部分流量有的实例是旧版本已经在缩容流程里但还没完全摘除有的实例挂在某个专有网络里只能被特定部门调用于是我引入了租户Tenant、服务版本Version、实例分组Group这三个概念。每个实例必须归属于某个服务版本而服务版本又归属于某个租户。分组则是运营层面的概念比如金丝雀组、稳定组用于路由策略的匹配。Atlas 存储层用的是自研的 Raft 状态机副本底层对接 RocksDB每个注册项的数据结构包含{ service: order_service, tenant: trading, version: 2.4.1, group: canary, instances: [ { id: 8f2a9c1e-3e10-4b5d-8f21-2c5b7a8e9f01, ip: 10.23.45.67, port: 8080, metadata: { region: hz, rack: r12, weight: 100 }, status: UP, last_heartbeat: 1730000000 } ] }这个设计的好处到后面做灰度发布的时候才完全体现出来。路由组件可以直接根据 group: canary 这个标签把测试流量路由到特定实例组上而不需要业务代码做任何硬编码判断。2.2 注册与注销的细节临时节点与持久节点的取舍Zookeeper 用临时节点来表示服务实例客户端会话断开节点就自动消失。这个思路听上去很优雅但生产环境里网络分区的时候会话超时session timeout往往要 10 秒甚至 30 秒意味着一个实例挂了至少 10 秒内还会有流量打进来这让人非常难受。Atlas 采用了一种混合方案实例注册时默认创建的是临时节点由客户端通过心跳续租但我们允许通过配置把实例标记为持久节点这个字段主要给一些无状态但重启频率极低的基础组件用比如配置中心、网关网关。之所以保留持久节点是因为这些组件如果一断网就被摘除可能引起整个调用链的雪崩——宁可短暂打到它身上让它在超时层面兜底也不要从注册表里瞬间消失。心跳参数我列在下面这是一组经过压测的推荐值参数默认值说明心跳间隔5 秒客户端向 Server 上报存活的频率心跳超时15 秒Server 连续未收到心跳则标记为可疑摘除周期30 秒可疑实例在多少秒后正式从注册表移除重连缓冲60 秒客户端断线重连的最大容忍窗口权衡在于心跳间隔越短服务感知越快但网络开销和 Server 的压力会线性上升。500 个实例每个 5 秒一次心跳均摊下来每秒才 100 个包对现代服务器来说九牛一毛。但如果客户端有 1 万个还都是 5 秒心跳那每秒就是 2000 个包这时候就该把心跳间隔调到 10 秒。2.3 健康检查的两层机制主动探测与被动心跳很多服务发现组件只做被动心跳也就是客户端自己上报。但客户端进程活着不等于服务可用——比如一个 Java 进程过了 Full GC 阶段假死心跳线程可能还能跑但实际的 RPC 请求已经超时。这时候必须有主动探测兜底。Atlas 的 Agent我们部署在每个节点上的轻量进程会定期对本地服务发起 TCP 连接测试和 HTTP 健康端点探测。TCP 连接测试解决的是端口不通的问题HTTP 探测解决的是业务就绪问题。这两个探测结果会被 Agent 打成指标上报给 ServerServer 综合心跳信息和探测信息算出实例的真实健康分数。有个小技巧是探测频率不应该固定。如果服务启动刚完成处在冷启动阶段Agent 应该用较慢的频率探测避免因为线程池正在预热而误判。等运行稳定后再把探测频率加急。这套自适应探测机制最早源于一次事故——我们有一次上线新版本服务实际启动花了 40 秒但健康检查线程在 15 秒时就绪了结果注册中心把还在加载缓存的实例分配了大量流量直接导致缓存穿透。3. 数据一致性方案为什么最终一致性在这个场景里是更好的选择3.1 强一致和最终一致的场景辨析服务注册与发现这个场景很多人第一反应是需要强一致注册表不能出现一个实例被两个客户端看到不同状态的情况吧理论上是这样但真实生产里我们更需要的是高可用和低延迟。如果采用 ZAB 或 Raft 这类强一致协议写请求必须经过 Leader 确认Leader 挂了还要重新选举选举期间注册请求不可用。虽然这个时间窗口只有几秒但对于依赖注册中心的业务方来说这几秒可能就是雪崩的开始。更麻烦的是如果注册中心在跨地域部署每台 Server 之间还要同步状态网络延迟会让每次注册的响应时间变得极不可控。Atlas 的保证级别是最终一致 读己之写。每个 Server 节点都接受写入写入先落到本地 Raft 日志然后异步同步给其他副本。客户端访问任意一个节点都能注册成功只是服务列表在短时间内可能存在微微差异。3.2 版本号与变更日志如何避免更新冲突最终一致不等于是脏数据关键是靠版本号和数据变更日志来保证更新是幂等和有序的。每个注册项都带一个单调递增的 version 字段。Server 在更新实例状态时会做一次 CASCompare And Swap——如果提交的版本号小于当前版本号就拒绝这次更新。客户端在做服务发现时会把本地缓存的版本号和服务端返回的版本号对比一旦发现服务端版本号更大就增量拉取变更日志。这个设计保证了即使两个 Server 之间存在同步延迟客户端也不会拿旧数据覆盖新数据。变更日志保留最近 5000 条超过的部分直接做全量快照。这样设计是参照了 CouchDB 的 MVCC 思想。实际写代码的时候我特意在变更日志里加了一个 change_id是用雪花算法生成的全局唯一 ID。可别小看这个 ID它在排查问题的时候作用巨大——你能精确到哪一秒、哪台机器、哪个进程改了哪个实例的状态。3.3 容灾设计多副本部署与故障域隔离部署 Atlas Server 集群时我强烈建议至少三个机房每个机房至少一个节点。这不是为了秀财力而是为了让任何单一机房的故障都不会让整个集群失去仲裁能力。我们的 Raft 协议组是 5 节点模式允许 2 个节点同时宕机而不影响选主。但如果 5 个节点分布在 2 个机房万一其中一个机房整体断电另一个机房只有 3 个节点还保留仲裁能力但跨机房的网络中断会导致另外两个节点的消息全丢。所以最稳妥的做法是 3-2-2 分布或者 2-2-2 加上一个松散节点确保单个机房挂掉时剩下的节点仍然能达到 N/21 的选主条件。这里有个容易忽略的点Raft 做选主时需要方根日志到多数节点如果跨地域的网络延迟是 50 毫秒那一次选主可能耗时 2 秒以上。实际运营下来我们的方式是把 Raft 的 election timeout 调大一点比如从 1 秒调到 3 秒让网络抖动不至于频繁触发选主。4. SDK 接入与本地调试让业务侧十分钟接入4.1 接入流程总览To B 的基建系统最怕的是业务方觉得接入成本太高。Atlas 的 SDK 我设计的就是一个极简的接入方式分成四步引入依赖Maven/Gradle/NPM/Pip 均可配置文件里填上 Atlas Server 地址列表启动时调用AtlasClient.init()传入服务名和端口通过AtlasClient.getInstance()获取服务列表整个过程不要求业务方改造代码逻辑甚至不需要处理原始 Socket。/Atlas 客户端会自动完成注册、心跳、拉取服务列表、缓存更新、故障转移这些脏活累活。初始化代码如下以 Python 为例from atlas_sdk import AtlasClient client AtlasClient( servers[10.10.1.1:8848, 10.10.1.2:8848, 10.10.1.3:8848], service_nameorder_service, host0.0.0.0, port8080, heartbeat_interval5 ) client.start() # 获取下游服务实例列表 instances client.get_instances(payment_service) for ins in instances: print(ins.ip, ins.port, ins.status)4.2 本地开发模式与服务端配置写这个 SDK 的时候我刻意加了一个 local mode 的开关。开发者在本地调试时不需要连接远程的 Atlas Server直接把服务名解析到 127.0.0.1 即可。这是从很多老开发者用 HOSTS 文件改本机映射的痛苦经历中提炼出来的需求。本地模式下的建议配置atlas: mode: local fallback_servers: - 10.10.1.1:8848 service: name: order_service_dev port: 8080这里有个细节我记得有一个同事在本地开发时服务名忘记加 _dev 后缀结果本地调试时把远程测试环境的服务列表都拉下来了别人在测试环境发了个新版本他本地服务瞬间被打到异常流量。所以接入层最好强制开发者在服务名上打环境标签。4.3 缓存与兜底机制Server 挂了也不怕最影响稳定性的环节其实是客户端拿到注册表之后。网络再可靠也难免有链路闪断。Atlas 客户端在本地做了三层缓存兜底内存缓存最新一份服务列表常驻内存响应时间在毫秒级磁盘快照每 5 分钟把内存缓存写一份到本地磁盘。进程重启时优先加载磁盘快照而不是傻等 Server 响应配置中心兜底如果 Server 彻底连不上客户端会退化为读取运维手工维护的配置文件这个设计在真出过一次故障时救了命。有一次 Atlas Server 集群因为底层磁盘被写满整个集群只读不可写持续了大约 20 分钟。在这 20 分钟里所有业务方因为本地缓存的存在服务路由基本没有受影响只有注册新实例和变更权重这类写操作报错了。这让我深刻理解了一件事服务发现系统最重要的是让读一直可用而不是让写时刻一致。5. 生产环境实战一次服务上下线导致的雪崩式误判排查全过程5.1 现象描述上线后流量异常升高这场事故发生在 Atlas 上线后的第二个月。当时我们的支付服务做了一次常规发版按照预案先在金丝雀组投了两个实例观察 10 分钟。结果上线才 3 分钟监控告警就开始响了支付服务的新实例 CPU 飙到 90%错误率从 0.01% 跳到 3%。然后是连锁反应上游三个服务都出现了超时重试。直觉上第一怀疑是代码有 bug。回滚后把新版本的代码原地跑了一会儿CPU 又变正常了。这就奇怪了——代码没问题实例也正常为什么上线时会有这么大压力5.2 排查链路监控、日志、注册表三管齐下我先去查 Atlas 注册表里的实例状态。发现一件诡异的事服务列表里居然还有两个已经下线了 2 天的旧实例。为什么会有旧实例按道理注销和心跳摘除应该把它们干掉。顺着日志往下查发现这两个旧实例的注册方式是持久节点也就是说不会因为心跳消失而自动摘除。当初部署的时候有位同学为了图省事把支付服务的一个灰度实例设成了持久模式结果后面的下线流程里只调用了实例的 STOP 接口没有调用注销接口。旧实例状态是 UP权重也还是初始值。繁忙流量打过来的时候负载均衡器会按权重分配旧实例带着服务却实际上已经没法处理请求了——因为它们在旧版本代码上而且端口因为环境清理已经关闭。请求打到关闭的端口TCP 层直接 RST触发上游的重试机制同一个请求被重试了 3 到 5 次瞬间就把新实例的 CPU 打满了。5.3 根因定位与代码层面的修正根因有三个层级运营层面下线流程没有强制要求先注销再关闭进程导致僵尸实例长期存在于注册表SDK 层面持久节点没有被赋予自动摘除的能力一旦漏调注销接口就永远不会消失路由层面负载均衡器不感知实例的进程健康状态只是简单按注册表的权重转发修复方案分三步走。第一在 SDK 的服务关闭钩子shutdown hook里强制调用注销接口而不是让运维脚本去敲命令import atexit atexit.register def unregister_on_exit(): client.deregister() client.stop()第二给持久节点也增加一个最长存活时间过期标记。任何实例如果超过 24 小时没有更新元数据不是心跳是元数据版本路由组件会自动把它降级为可疑状态。这样即使运营流程漏了也能兜底清理。第三路由组件的负载均衡策略里增加一个TCP 可用性预检查。每次从注册表捞到实例列表后先按 RTT 和 TCP 探测结果排序把连接失败的实例直接剔除出本次调用候选。这个预检查是每 5 秒做一次的异步任务不会影响请求链路的实时性。5.4 回归验证与长期监控修复上线后我专门做了一次压测模拟 1000 QPS 的业务流量一边压一边用脚本强制 kill 掉三个实例的进程。结果很好服务发现系统在 30 秒内就感知到了实例异常路由组件自动把流量摘除总错误率控制在 0.1% 以下。这次事故给团队定了一条铁律所有服务上下线必须走统一的发布平台不能直接通过手工方式调用注册接口。同时监控面板上也加了一个僵尸实例巡检指标每个小时检查一次注册表里是否存在状态为 UP 但 7 天内没有任何元数据更新的实例。现在已经跑了半年多再没出现过类似事故。6. 路由策略与流量调度权重、区域优先与灰度发布6.1 权重动态调整通过 Atlas 实现秒级负载均衡变更服务发现只是 Atlas 的底座路由策略才是真正让它变得不可替代的部分。我们实现了一套基于权重的负载均衡算法运维或开发可以通过 Dashboard 随时调整某个实例的权重而不需要重启服务。场景很常见一台物理机配置特别好想多扛一点流量另一台老机器慢慢下线。以前的做法是改 Nginx 配置再 reload现在只需要在 Atlas 控制台把老机器的权重从 100 降到 0新机器的权重调到 200变更会在 5 秒内推送到所有调用方。这里的关键是权重变更的推送机制不是让每个调用方每隔几秒去轮询一次而是通过持久连接我们自研的轻量协议直接推送更新。轮询的快照模式推送到每个客户端会造成网络突发而且更新时效差。我们采用的推送机制类似于 gRPC 的 Server-Side Streaming客户端和 Server 之间维持一条长连接变更发生时 Server 主动下发。这个做法把端到端延迟从秒级轮询间隔缩减到毫秒级推送。6.2 区域优先与跨区域容灾调用一个服务时最理想的情况是找同机房的实例实在找不到再跨机房这样延迟最低、带宽成本也最低。Atlas 在实例元数据里提前写入了 region、zone、rack 字段路由模块会优先匹配同一 zone 的实例。我加了一个参数叫 zone_failover_threshold默认设为 10%。意思是当同 zone 的健康实例比例低于 10% 时流量会开始溢出到其他 zone。这个阈值不能设太死——如果你设成 0%意味着同 zone 的实例只要有一个活着流量就困在原地一旦这个实例被爬坡压垮整个调用链就断了。设成 10% 是一个经过多次压测得出的平衡点。跨区域调用时还绕不开一个坑机房之间的网络延时不能只看 ping 值还要看丢包率。机房链路拥塞时ping 值可能在 20ms 左右但丢包率却到了 10%导致 TCP 重传不断。所以区域优先的判断逻辑里我加了一个最小丢包率优先的二级排序而不是单纯依据 RTT。6.3 灰度发布的路由规则基于标签匹配实现精确流量分发最后说一下灰度发布。Atlas 实现灰度不需要业务方改任何代码只需要在调用方启动时声明自身所属的标签组Atlas 会根据注册表里的标签做动态匹配。比如订单服务是稳定版标签 groupstable支付服务新版本要灰度金丝雀组打了 groupcanary。订单服务调用支付服务时Atlas 路由模块会自动做两组匹配稳定版订单优先调稳定版支付金丝雀订单只调金丝雀支付。只有当目标组没有健康实例时才回退到全局可用实例。这套方案的优点是隔离性做得比较好但因为业务方会比较多标签本身的命名规范就变得非常重要。我们 Grumpy 的经验是标签必须走配置中心统一下发不允许各个团队自己起名。之前就有过一个小团队把金丝雀标签写成了 canary-test结果路由模块完全匹配不上流量全跑到了稳定版把灰度发布搞成了全量发布。后来我们在 SDK 层加了一个标签自动校验机制凡是不在配置中心声明过的标签启动就会报 warning。7. 性能压测与调优纪要支撑万级实例的关键参数7.1 单机性能基准测试所有设计做完之后必须用数据说话。我在测试环境搭了一个 8 核 16G 的 Atlas Server 节点客户端模拟 5 万个实例同时注册测出来核心指标如下指标数值备注注册 QPS8200每秒可处理的注册请求数心跳 QPS15000心跳请求消耗更少 CPU查询 P99 延迟3.2ms客户端请求服务列表的响应时间变更推送端到端延迟50msP99从 Server 接收变更到客户端收到推送单节点推送连接数32000与客户端保持的长连接数压测过程中发现最耗资源的操作其实是查询时做 JSON 序列化。5 万个实例的完整列表一次序列化要生成 20MB 的数据。我们可以用 Protobuf 把 payload 压缩到 2MB 左右但这会牺牲排查问题时的可读性。最后的方案是双模序列化Machinery 内部走 Protobuf提供给外界调试的 HTTP 接口还走 JSON。7.2 常见的性能瓶颈与调优方向如果你也打算自研类似系统下面这几点可能是最花时间的地方网络模型。早期的 Atlas Server 用的是 NIO 多路复用每个客户端连接分配一个 ChannelInboundHandler。到了 5000 个连接后GC 压力就开始飙升原因是每个连接都要独立维护缓冲区。后来改成 Netty 的 epoll 模式加上共享的 ByteBuf 池连接数上到 3 万才出现可感知的 GC 停顿。状态存储。存注册表我一开始在内存里直接用了 ConcurrentHashMap后面发现写多读多的情况下 ConcurrentHashMap 的锁竞争太严重。改成了读写分离的 CopyOnWriteArrayList 存实例列表读操作完全无锁写时才复制一份数组。实例数量大但变更频率低每分钟几十次这个改动的收益非常明显。推送风暴。当一个服务同时有几百个实例上下线时Server 会给所有订阅了该服务的客户端都推送一次全量变更。如果客户端数量是 2000订阅关系很广就会触发广播风暴。我们的解法是给变更做批处理同一服务在 100ms 内的所有变更合并成一次推送并在变更数据里带一个 diff 字段客户端只需要增量更新而不是全量替换。7.3 瘦客户端与胖客户端的资源占用很多公司在做服务发现 SDK 时喜欢把负载均衡、熔断、限流全塞进去做成一个全宇宙框架结果业务方每引入一个版本就要升级一堆依赖。Atlas 的定位是瘦客户端只做注册、发现、缓存、健康检查上报至于负载均衡、重试、熔断这些由上层框架如微服务网关来负责。瘦客户端的直接好处是内存占用特别低。一个典型的 Java 进程引入 Atlas SDK 后JVM 堆外内存增加不超过 20MBCPU 占用只有 0.5% 左右。这种画像让业务团队完全没理由拒绝接入。如果你遇到的情况是团队体量小、架构简单我建议也可以考虑用 OpenResty etcd 来实现类似能力没必要自研完整的注册中心。但一旦服务数量超过 500 个、团队超过 20 人、有多个环境分区和复杂灰度需求自研一套 Atlas 这样的系统带来的灵活性和掌控感是引入开源组件替代不了的。8. 后续演进从服务发现走向流量治理平台8.1 配置中心与会话保持的结合Atlas 目前的发展方向是在服务发现基础上并入配置中心和会话亲和session affinity能力。做这块的最大动机是公司的基础设施里服务发现、配置管理、分布式锁、分布式队列经常是四套独立系统每次排查跨系统问题时得同时打开五个控制台心智负担特别大。我们并不打算重建一套操作系统级别的数据中心而是在 Atlas 里增加一个配置分组的抽象把同一服务的配置项和服务实例挂在一起。这样发布一个服务版本时不仅能更新代码还能同时把配置版本的一致性推给所有实例。会话亲和则利用实例元数据里已经有的 session_affinity 字段让网关在路由时对同一客户端的请求始终打向同一台实例这对 WebSocket 类业务非常重要。8.2 对可观测性的集成从注册数据反推依赖关系最后想说一下可观测性。很多团队在做 tracing 或者 metric 时会重新梳理一遍服务间的依赖关系那真的是重复造轮子。Atlas 注册表里本身就有每个服务依赖了哪些下游服务第三方、DB、MQ 等的信息这些信息完全可以作为 tracing 系统初始化时的拓扑图基准。我在 Atlas 的控制台上接了一张依赖拓扑图直接用注册表数据生成服务间的调用边。虽然它不是运行时事实比如灰度流量和全量流量的差异不一定完全一致但作为一个静态基线已经够用了。当 APM 系统报出某个服务错误率升高时我先看 Atlas 拓扑图能快速判断它把请求转发给了哪些下游再逐层排查省掉了很多漫无目的的翻日志时间。8.3 Roadmap插件化与多语言 SDK 覆盖Atlas 的 SDK 除了最开始支持的 Java 和 Python后面陆续补了 Go、Node.js 和 C。每种语言实现的客户端都要保持完全一致的行为这本身工程量不小踩的坑更多。比如 Node.js 做心跳时不能简单用 setInterval因为事件循环阻塞会导致定时器延迟Go 这边则要注意 goroutine 泄漏尤其是连接断开重连时的上下文取消。好在 SDK 的核心逻辑都被抽象成了一套独立的协议描述文件协议字段先定义好各种语言的实现在本地跑一套测试用例。这套测试里我埋了几十个故障注入用例包括网络分区、Server 高延迟、脏数据返回就是为了确保任意一个语言版本在极端情况下不会出现客户端挂死。从最开始的一张 Excel 表格到 Atlas 这套系统稳定支撑了公司所有微服务的注册发现和流量调度这中间大概经历了一年半的时间。回头来看技术上并没有使用什么特别深奥的算法但把无数细节做对、做扎实把这套系统压到极限后依然稳定这本身就是工程上最有价值的部分。如果你也在做类似基建欢迎在评论区聊聊你们踩过的坑我觉得每个坑都值得写成一篇文章。