这个项目的想法不是从“我们要做一个地图系统”开始的,而是从一个很憋屈的场景冒出来的。服务从几十个涨到两百多个的时候,团队里已经没人能说清楚某个接口被谁调用、某个数据库被谁写入、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 个服务以内的场景 |
| 现成 CMDB(NetBox、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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;在这个表里,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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;关系表里最值得说的是 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 调 B,B 调 A,如果递归不做去重,栈直接就爆了。depth_map 承担两个作用:一是记录深度,二是去重缓存。只要一个节点被访问过,就永远不会再入队列,这样既防了循环依赖,也保证了每个节点只被展开一次。
3.2 渲染层:SVG 的方案、折叠和性能优化
渲染层我在 SVG 和 Canvas 之间来回试过。Canvas 在几万个节点时性能优势明显,但我们画的是关系图,需要节点点击、悬浮、拖拽这些交互,SVG 对交互支持更好,代码也更直观。最后选了 SVG,因为 Atlas 做了“默认折叠”的策略:树默认只展开两层,第三层及以下会被收进一个聚合节点里,点击聚合节点再展开下一层。
这个策略直接把一次渲染的节点数量降了一个量级。我实测过,单张关系图 1500 个节点,用 SVG 可以实现流畅滚动和拖拽,基本没有明显掉帧。跟我担心的性能瓶颈不一样,真正的瓶颈反而出在后端查询上——如果按树的每一层循环查数据库,会出现 N+1 查询,一次页面渲染要好几百次 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,我的建议是从最小闭环开始:一张实体表,一张关系表,一个注册中心扫描器,一个只能搜索和展示的页面。这个版本只需要几天就能跑通。地图一开始不完整,没关系,只要能持续每天更新,它就会在团队里慢慢长成大家默认的那个“我们系统长什么样”的答案。最后一个小技巧:内部工具别起名叫“管理系统”或“监控平台”,叫“地图”。名字决定大家怎么用,也会决定这个项目会不会真的被用起来。