自研微服务依赖地图 Atlas:从两张表到实时拓扑的完整实践
发布时间:2026/9/26 21:47:07来源:尧图网络
这个项目的想法不是从“我们要做一个地图系统”开始的而是从一个很憋屈的场景冒出来的。服务从几十个涨到两百多个的时候团队里已经没人能说清楚某个接口被谁调用、某个数据库被谁写入、Kafka 的 topic 到底是谁在消费。新同学入职两周问得最多的不是怎么写代码而是“这个模块依赖谁”“线上的机器在哪”“那个过期的 MQ 还有没有在跑”。群里每天都有人贴链路图可图一多反而没人信了。后来我干脆把“搞一张所有人都愿意看、并且自己会变新的地图”当成正经项目名字就叫 Atlas。它解决三件事一是把分布式环境里的服务和依赖关系变成一张实时更新的拓扑图二是让任何模块都能按“地图坐标”的方式被检索到三是通过自动对比历史数据把不该发生的变更主动报出来。Atlas 不是给某个业务做的功能它更像一张不断长出来的地图——你每接入一个服务它就多一格你每次改一次配置它就标一笔。适合的读者包括后端开发、SRE、以及所有被微服务治理问题折腾过的人。下面这几段是我从模型设计、存储选型、可视化到上线踩坑的完整记录代码和 SQL 都是真实的方便你拿去做最小复刻。1. 内容整体设计与思路拆解1.1 把“地图”拆成两个词实体和关系我给 Atlas 立的第一个规矩是把自己框死在一个很简单的模型里实体层和关系层。实体是最小单元一个服务、一个数据库、一个缓存实例、一个 Kafka 集群、一块网关路由配置都是实体关系则是实体之间发生的连接比如 A 调用了 B 的 HTTP 接口、C 写入了 D 的库、E 订阅了 F 的 topic。地图清晰与否不取决于实体列表有多长而取决于关系是否完整。死掉的服务如果不再被调用它在地图上就只是一个灰色块而一个活跃的依赖如果没被记录那才是真正的坑。在一开始我犹豫过要不要把重心放在“资源”而不是“服务”上毕竟保障团队管的是机器和中间件业务团队只关心服务调用。后来决定统一用 type 来表达资源类型。好处是一套模型能同时覆盖机器、中间件、内部平台坏处是检索的时候要先多看一眼 type 字段。实践下来把 type 作为实体的第一过滤维度体验上最贴近运维直觉索引也最好设计。1.2 方案选型为什么绕了一圈还是自研动手之前我认真列过四个可选项也真实评估过不是上来就拍脑袋写代码。方案优点缺点结论文档手工维护零成本上手快三天后过期无人信只适合 20 个服务以内的场景现成 CMDBNetBox、RackTables设备管理和 IPAM 很强服务级调用关系支持很弱不适合做服务地图不是它擅长的事APM 链路追踪系统能自动生成运行时拓扑只覆盖真实调用覆盖不到配置/权限/任务调度可以当成一个数据源但不是地图本身自研 Atlas模型可控数据源可定制研发和运维成本最高最终选择为什么最后选自研因为我发现我们有配置中心和注册中心数据源都是活的Atlas 更多承担的是“汇聚和解释”不需要从零把所有采集端重做一遍。换句话说自研的成本没有想象中高。如果团队连服务注册都没有所有信息都要靠手工录那我大概率不会选自研老老实实买现成平台更划算。但只要你已经有注册中心、配置中心、CMDB 这类基础设施自研 Atlas 其实就是写几个 scanner 再加一个前端页面的事。1.3 架构上的三个铁律我给自己定过三个很死板的原则后来证明这三条帮了大忙。第一只读汇聚。Atlas 原则上不修改上游数据源的任何数据所有扫描器只做读取绝不回写。哪怕发现某个服务在注册中心里缺少了健康检查字段也只在 Atlas 里标一个 warning不允许顺手替它“修正”。一旦数据源权限被 Atlas 接管出问题的时候你根本分不清到底是上游问题还是扫描器改坏的。第二扫描器独立。每个数据源对应一个独立扫描任务注册中心一个、配置中心一个、Kubernetes 集群一个、人工申报入口一个。它们之间不共享数据库连接池不互相调用任何一路挂了都不能影响其他路的扫描。这样既方便单独重跑也方便定位故障在哪一路。第三存储与渲染分离。存储层只做关系型数据保存对外提供一套只读 API前端渲染走另一套轻量接口。有人会问一个小工具搞这么分层是不是过度设计但实际上这条在后期给我带来最大的灵活性当我们需要把 Atlas 的数据接入内部监控大盘时直接调只读 API 就行完全不用碰页面逻辑也没有人因为误操作改了地图配置。2. 存储模型与数据采集地基怎么打2.1 实体表和关系表两张表扛住所有地图数据Atlas 的地面建筑看起来很朴素核心就是两张表实体表和关系表。这是我在反复琢磨后确定的最小模型。实体表用来记录所有节点CREATE TABLE atlas_entities ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, namespace VARCHAR(128) NOT NULL DEFAULT default, meta JSON, state VARCHAR(16) NOT NULL DEFAULT active, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL, last_seen_at DATETIME(3) NOT NULL, UNIQUE KEY uk_entity (type, namespace, name), KEY idx_type_state (type, state) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;在这个表里name 我故意设计成不带环境信息环境信息由 namespace 承担比如prod、staging、dev。这样同一个服务在不同环境里可以共存避免把“生产”和“测试”的节点混在同一条记录里。关系表用来记录边CREATE TABLE atlas_relationships ( id BIGINT PRIMARY KEY AUTO_INCREMENT, source_id BIGINT NOT NULL, target_id BIGINT NOT NULL, rel_type VARCHAR(32) NOT NULL, metadata JSON, discovered_at DATETIME(3) NOT NULL, valid_until DATETIME(3) NULL, UNIQUE KEY uk_rel (source_id, target_id, rel_type), KEY idx_target (target_id), KEY idx_valid (valid_until) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关系表里最值得说的是 valid_until 字段它做的是软删除而不是物理 DELETE。删除操作是最容易被忽略的雷一旦某个扫描器误判把一条真实存在的关系标记为删除整个链路在地图上就会断开排查问题的时候你会误以为服务挂了其实是数据错了。软删除让每条关系都有生命周期历史可追溯diff 也能发现“某个依赖是今天消失的”还是“已经消失三天了”。2.2 三类 scanner抓取数据流的三种方式Atlas 的扫描器不是单一形态而是按数据源的特点分了三类。第一类事件驱动型主要对应注册中心。我们的注册中心支持发布/订阅服务上线、下线、心跳异常都会产生事件。扫描器只做两件事收到事件写实体和关系。这类扫描器时效性最高服务上下线后地图基本秒级更新。第二类定时轮询型主要对应配置中心和 Kubernetes。这类数据没有事件机制或者事件接口太复杂不值得接保险做法是 5 分钟一个周期全量扫描一次。扫描内容包括配置里的路由规则、Kafka 的 topic ACL、K8s 里的 Service 和 Deployment。轮询虽然时效性比事件型低但简单可靠更重要的是它天然支持“自我修复”即使事件丢失下一轮也能把数据补回来。第三类人工申报型。总有些信息是任何基础设施都体现不了的比如“这个服务只能由支付组的账号调用”。我在 Atlas 里留了一个手工表单字段很简单依赖方、被依赖方、关系类型、说明。人工录入的数据一定要打上 owner_tag manual这样自动扫描更新时就不会覆盖它。2.3 为什么用 MySQL 而不是图数据库实体/关系这种模型很多人第一反应是上 Neo4j 之类的图数据库。我当时也认真考虑过但最终还是选了 MySQL理由很实际团队对 MySQL 的运维、备份、监控已经非常熟练而 Neo4j 需要额外的集群运维成本安全审批也是一道坎。再看数据量Atlas 在目前的规模下最多也就两万实体、二十万关系MySQL 完全扛得住根本不需要图数据库级别的横向扩展。图数据库的核心优势是多跳查询和模式匹配而 Atlas 需要的核心能力是深度优先遍历这两件事都可以用 BFS/DFS 在代码里做性能反而更可控。提示如果你的团队已经有人能熟练运维图数据库那换 Neo4j 无可厚非。否则我建议你别为了“看起来高级”去引入一个团队不熟悉的新组件。数据量没上百万之前关系型数据库加内存遍历是性价比最高的方案。3. 服务树与可视化地图怎么画3.1 从关系数据到一棵可展开的服务树数据存进去之后地图怎么画出来我最初犯过一个错就是试图直接从数据库里查出所有节点和边一次性灌给前端。对 Atlas 的数据量来说这不至于卡死但在交互上很失败因为页面一次性展示几千个节点根本没法看。后来我改成“以某个服务为中心按调用深度展开一棵服务树”用户输入一个服务名Atlas 先找到对应实体然后以它为根节点通过 BFS 递归找它依赖的所有下游。核心伪代码长这样frontier [start_id] depth_map {start_id: 0} while frontier: current frontier.pop(0) for rel in get_outgoing_relationships(current): target rel.target_id if target not in depth_map: depth_map[target] depth_map[current] 1 frontier.append(target)需要注意的是这里不能用简单的递归因为服务间的依赖很可能出现循环。A 调 BB 调 A如果递归不做去重栈直接就爆了。depth_map 承担两个作用一是记录深度二是去重缓存。只要一个节点被访问过就永远不会再入队列这样既防了循环依赖也保证了每个节点只被展开一次。3.2 渲染层SVG 的方案、折叠和性能优化渲染层我在 SVG 和 Canvas 之间来回试过。Canvas 在几万个节点时性能优势明显但我们画的是关系图需要节点点击、悬浮、拖拽这些交互SVG 对交互支持更好代码也更直观。最后选了 SVG因为 Atlas 做了“默认折叠”的策略树默认只展开两层第三层及以下会被收进一个聚合节点里点击聚合节点再展开下一层。这个策略直接把一次渲染的节点数量降了一个量级。我实测过单张关系图 1500 个节点用 SVG 可以实现流畅滚动和拖拽基本没有明显掉帧。跟我担心的性能瓶颈不一样真正的瓶颈反而出在后端查询上——如果按树的每一层循环查数据库会出现 N1 查询一次页面渲染要好几百次 SQL。优化的办法很简单一次性查出该服务的全部出边在内存里做 BFS。数据库只负责“取数据”图遍历完全在内存里做。3.3 地图感从哪里来状态颜色与图例既然叫 Atlas画面上就该有地图的“感觉”。我做的最有用的一个调整是把状态映射成颜色再在页面右下角放一个固定的图例。健康节点是绿色有告警是橙色长时间没上报数据是灰色存在依赖漂移是红色。用颜色表达状态之后团队不再需要一字一句读状态文字扫一眼就能看出来“今天地图上多了一抹红”。这个设计是典型的“看山是山”思路我把它当成地图而不是监控面板来想。监控面板讲究数字精确地图讲究概览和方位。谁在什么位置、谁和谁挨得近、哪里有一片明显的异常色块这些信息用颜色和布局来表达比表格高效得多。后来团队内部开始习惯说“我看下地图上 xx 那块一片灰”我就知道这个设计没走偏。4. 变更监测与告警让地图自己说话4.1 依赖漂移检测看地图上多了什么地图如果只是静态更新那它只是一个还算好看的 CMDB。让 Atlas 真正变得不可替代的是它能把“变化”主动讲出来。我实现了依赖漂移检测每一轮扫描结束后把当前关系快照和前一日快照做一次 diff。diff 的输出包含三类变化新增关系、消失关系、权重变化。权重变化是后来加的功能。一开始我只关心有没有新关系后来发现在实际运行时某些服务的调用量分配会出现明显偏移某个接口原本只承担 1% 的流量某天突然变成 30%。这大概率不是正常调整而是灰度策略出了问题或者路由配错。于是我在关系表的 metadata 里增加了 weight 字段扫描器定期写入统计值diff 的时候对 weight 变化超过阈值的记录单独告警。4.2 告警分级不能把所有人都炸起来告警最怕的就是狼来了。Atlas 也踩过这个坑刚上线那会我对所有漂移都一视同仁往群里发结果第一天大家还点开看第三天就开始屏蔽群消息。后来我做了两级分级。级别条件通知方式P0核心服务新增依赖或关系消失电话或短信P1非核心服务依赖变化工作群消息P2权重变化超阈值且影响面 5%记录到日报不主动打扰这里说的“核心服务”并不是拍脑袋定的我在实体表里维护了一个 core 字段由各业务线负责人自己申报。一旦某个核心服务的依赖发生变化就必须升级到 P0因为它很可能意味着线上行为发生了不可预期改变。P2 级的告警虽然不主动推送但会汇总到次日早报里等周会时一起审核。这样既不错过隐患也不让告警变成噪音。4.3 预览地图与发布机制另一个很重要的是告警不直接变成最终地图。我在系统里加了一个“预览地图”的概念扫描器跑完一轮产生的 diff 会先落在预览区生成一张假设变更后的地图。维护人员可以打开预览看看这次变化是否符合预期。如果符合就一键发布到正式地图如果不符合就直接丢弃。很多人觉得这个机制多余但恰恰是它救了 Atlas。有一次配置中心的扫描器因为权限过期读取到的数据不完整把所有服务的下游关系都清空了。如果自动发布整张地图会瞬间变成一片空白。由于改动先进预览区维护人员发现整张图明显不对直接丢弃避免了一次严重事故。这个设计的本质是“机器提供建议人来决定是否生效”在 Atlas 这种跨团队共享的数据平台上人永远应该在决策链路上留一环。4.4 告警排查速查表一眼定位现象常见原因快速排查方案地图上某个服务整片灰色扫描器权限过期或数据源欠费先看扫描日志再看数据源鉴权配置关系大面积消失配置中心全量轮询超时检查轮询超时时间把全量拆成多批分片只有某个类型的关系消失该类型的扫描器代码挂了看对应 scanner 的异常监控单独重跑即可告警频率突然变高上游服务在做大规模下线先和业务团队确认是否为计划内变更再决定是否调整核心服务清单预览地图与实际不符权限列表和实际 ACL 不一致用维护账号重新拉取一次数据源做人工比对这张表后来直接贴在了 Atlas 的运维手册首页问题出现时大家第一反应不是问“这是怎么回事”而是先按表里查一遍。真正的效率提升不是写更多功能而是把常见故障的定位时间从小时级压到分钟级。5. 实操经验从立项到上线的几个坑5.1 数据源不统一的坑Atlas 的接入过程没有想象中顺利最早碰到的不是技术问题而是数据源本身的字段不规范。注册中心里的服务名有时候叫payment-service有时候叫payment_svc同一个服务在不同配置里名字对不上实体表就会插入两条记录地图上出现两个看似无关的节点。这个问题让我意识到实体唯一键不是设计出来的而是清洗出来的。后来我加了一层“归一化映射表”把各种别名指向同一个标准服务名扫描器读到的每一个名字都先过一次映射再决定是新增还是更新。对已经有大量存量脏数据的团队这一步最好提前做不要等地图画出来才发现一半的线是断的。5.2 幂等性扫描器跑重了怎么办扫描器因为网络超时或者手动重跑同一份数据被重复处理是非常常见的事。如果代码直接 INSERT第二次跑就会主键冲突或重复建关系如果代码用INSERT ... ON DUPLICATE KEY UPDATE又会把其他扫描器写入的字段覆盖掉。我的解决办法是在所有写操作里带上 owner_tag 条件每个扫描器只更新自己负责的字段遇到不属于自己的数据直接跳过。这样即使两个扫描器同时操作同一个实体也不会互相覆盖。-- 伪代码示意只在更新语句中带上 owner 条件 UPDATE atlas_entities SET meta JSON_SET(meta, $.owner, :owner_tag), updated_at NOW() WHERE id :id AND JSON_EXTRACT(meta, $.owner) :owner_tag这个模式的本质是“乐观锁加所有权隔离”。对于 Atlas 这种多数据源汇聚的场景它比全局锁高效得多也从根本上杜绝了谁最后执行谁就赢的数据错乱问题。5.3 支持复杂关系的隐藏问题关系表里我用了rel_type区分是什么关系但一开始我把关系类型设计得太多像 created_by、depends_on、runs_on、subscribes_to加起来有二十多种。类型越多业务表达越灵活但页面上画出来就越乱。因为人脑根本没有办法同时处理二十多种不同颜色的连线。后来我把展示层的关系类型收敛成四类调用、写入、订阅、部署。其余类型仍然存在表里用来支持检索但不再展示到地图上。这是我做过的最影响观感的决定之一——有些能力存起来就好没必要都摆到台面上。5.4 团队接受度好用比技术堆料重要最后一个坑不是技术上的是团队接受度。Atlas 刚上线时大家不习惯用总觉得多一个系统多一份负担。老老实实说靠制度压着大家每天打开看效果很差。后来我做了两个动作一是把 Atlas 接入新人入职文档新同学第一天就能自己搜出一个服务看整条依赖链省去老员工反复答疑的时间老员工第一个月开始觉得 Atlas 有用二是把 Atlas 和日常工作流绑定发布流程里要求看一眼变化预览不做这个动作就提不了单。当 Atlas 变成一个“不看不给发版”的公共环节时它才真正活下来。最后再分享一个我实际做下来的体会让我总结一下我认为 Atlas 这类内部工具的核心从来不是技术多高深而是“有没有真的成为团队每天会看一眼的东西”。我在实际运行中发现任何流程只要注入“看见”这一步就会发生特别明显的变化原来一个人偷偷改配置没人知道现在任何依赖变化都会进入预览区至少会被维护人员扫一眼这种由地图带来的“被看见感”比一堆告警规则更能长期维持秩序。所以如果你也想做一个类似的 Atlas我的建议是从最小闭环开始一张实体表一张关系表一个注册中心扫描器一个只能搜索和展示的页面。这个版本只需要几天就能跑通。地图一开始不完整没关系只要能持续每天更新它就会在团队里慢慢长成大家默认的那个“我们系统长什么样”的答案。最后一个小技巧内部工具别起名叫“管理系统”或“监控平台”叫“地图”。名字决定大家怎么用也会决定这个项目会不会真的被用起来。
网站建设高端定制企业官网