
在 Go monorepo 里做一次“安全删除”有多难我见过很多团队的典型困境静态扫描工具找出了一个函数已经三个月没有非测试代码引用但大家讨论了两周仍然不敢删。原因很简单——有人记得这个函数可能被某个外部服务通过消息协议调用另一个人担心它被 build tags 隐藏的旧版本使用还有人干脆说“先留着以后可能有用”。于是死代码就这么活下来了。Deadmono 正是瞄准这个场景的工具。从项目命名看它做的是“在 Go monorepo 里检测 dead code”。很多人第一反应是这不就是静态分析吗其实它真正要解决的不是“找到没用的代码”而是让你在大型仓库里把“删除代码”从一次高风险的赌博变成一个可验证、可回滚、可持续的工程动作。这里先亮明一个判断死代码的代价不是“看着碍眼”而是持续消耗团队的认知带宽、构建缓存和重构安全边际。Deadmono 这类工具的价值不在于输出那一份报告而在于它逼着你重新理解入口、调用图和可见边界。1. 先搞清楚 Go monorepo 里“死代码”具体指什么1.1 不是所有不可达代码都长得一样说到死代码很多人第一时间想到的是“永远走不到的 if 分支”或者“没有被调用的函数”。但在 monorepo 里问题要复杂得多。我通常会把 Go monorepo 里的死代码分成三类第一类是完全不可达的代码。函数、方法、类型、常量、变量在非测试代码里没有任何引用。这是最“干净”的死代码删除时基本没有心理负担。但实际仓库里这种纯死代码占比并不高因为编译器在部分场景下会直接提示或者 IDE 能快速发现。第二类是只在测试代码里被引用的非导出函数。这种状态最容易迷惑人。比如某个内部工具函数parseConfig只有_test.go在调用线上代码根本没用。从静态语义看它不是死代码因为测试代码也算 Go 包的一部分从生产运行角度看它完全没有被生产路径引用。要不要删这取决于它是不是测试专用的构造逻辑。如果是测试辅助函数保留没问题如果是曾经被生产代码调用、后来调用方被删了只留下测试那就值得警觉。第三类是最难发现的隐藏死代码。它可能通过接口、反射、unsafe、插件机制、外部 HTTP/gRPC 路由、构建标签组合等方式间接可达。静态分析能看到它“被引用”但无法判断这个引用是否真的会在运行时发生。这也是多数死代码检测工具误报和漏报最集中的地方。Deadmono 这类工具通常擅长处理的是前两类尤其是从调用图出发识别“不可达”。碰到第三类它只能给“疑似”结论真正判断还得靠人。理解这一点非常重要否则拿到报告后你会因为误报过多而放弃整个方案。1.2 Monorepo 让死代码的隐藏成本变得格外刺眼为什么非要在 monorepo 场景里聊死代码在单个小仓库里死代码的危害相对可控删不删都不会立刻爆炸。但 monorepo 会把问题放大。首先是构建缓存。Go 的构建缓存和测试缓存是按包粒度做的。一个包里的任何一个文件发生变化都会导致这个包及其依赖方重新编译、重新测试。如果你在一个被几十个服务引用的公共包目录里留着一个大功能模块它虽然没人调用但它仍然会让 CI 每次都需要处理这个包的变化。更隐蔽的是很多 monorepo 的构建系统会统计包变更死代码所在的包一旦被无关修改触发整个下游都要重建。其次是代码搜索和 IDE 索引的噪音。Go 的跳转、查找引用、重命名非常依赖语法级别的精确匹配。当一个符号在仓库里有十几处引用其中九处是死代码里的互相调用你想安全重构就会非常痛苦。gopls 虽然能基于类型信息做引用分析但它不会告诉你这些引用是否来自一个永远不会被执行的包。再次是团队认知负担。新成员进入 monorepo最常问的一句话是“这个函数现在还重要吗”如果仓库里有一堆“历史遗留但没人敢动”的代码他们会默认这些代码有隐晦用途于是不敢清理也不敢依赖。久而久之仓库的“入口—依赖—出口”关系越来越模糊整个架构变得难以演进。最后是迁移成本。很多 monorepo 会做服务拆分、目录重构、框架升级。死代码占用的包和目录会让依赖关系图膨胀影响工具链的遍历速度也让负责人更难判断哪些模块真正需要保留接口兼容性。这个成本不是单次爆发的而是每天在持续支付。1.3 Deadmono 的类型定位Go 语言 monorepo 场景Deadmono 这个命名包含两个关键限定Go 和 monorepo。Go 语言的静态特性决定了好用的死代码检测工具可以先从语法树和调用图入手而不需要像 Python、Ruby 那样大量依赖运行时插桩。Go 的包、导出符号、导入关系都能用标准库工具解析这为工具化提供了很好的基础。Monorepo 的限定则意味着工具不能只看单个包内部的引用它需要能处理跨模块、跨仓库目录、多 main 包的入口边界。一个工具如果能统计出 NODE 级别的入口集合再反向追踪所有可达节点就能比较准确地标记出不可达部分。这正是 Deadmono 这类工具最值得关注的能力它不再问“这个函数在包内有没有被引用”而是问“在整棵 monorepo 调用图中从所有真实入口出发能不能到达这个函数”。所以如果你想用 Deadmono 解决死代码问题最好先建立一个认知它做出的判断是“基于静态调用图的可达性分析”不是“运行时证明”。它能大幅缩小人工排查范围但不能替代最终的删除决定。2. 从调用图入手Deadmono 这类工具的检测思路2.1 两层分析先看结构再看可达性要把死代码找出来只做“单文件内未使用”的检查远远不够。工程上通常分两层。第一层是结构分析。把 Go 源码解析成抽象语法树找出所有类型、函数、方法、变量、常量的声明再找出它们的引用位置。这一层能发现“在本文件或本包内没有任何引用”的顶层声明。但问题是Go 语言里“包内未使用”并不等于“整个仓库未使用”。一个导出函数可能在另一个 package 里被引用而那个 package 本身可能是死包。所以单靠第一层漏报会很严重。第二层是调用图分析。从一组真正的入口出发沿着调用关系、类型实现、接口派发、函数类型赋值等边遍历标记所有可以到达的节点。遍历结束后调用图中没有任何路径能到达的节点就是可疑死代码。这个思路和垃圾回收里的“从根集合出发标记可达对象”很像。理解这一点你就会明白为什么 Deadmono 这样的工具对“入口定义”极其敏感。在 monorepo 里入口通常包括所有 main 函数所在的包通过go:generate或其他代码生成器产生的、会被编译进二进制的文件通过init函数注册到全局 registry 的插件被外部进程直接调用的 HTTP handler、gRPC service、cron job 入口构建标签组合下实际参与构建的文件。如果入口集合漏了工具会把实际在用的代码误报为死代码如果入口集合过宽工具又会把真正没用的代码标记为可达。所以用 Deadmono 之前你需要先检查工具如何定义入口以及它是否允许你手动补充入口列表。2.2 处理 monorepo 内的跨模块依赖Go monorepo 通常有两种组织方式。一种是单一go.mod的大仓库所有代码在同一个 module 内另一种是每个目录或每个服务维护自己的go.mod通过replace指令互相引用。前者的处理相对简单工具可以直接用go list -deps获得完整依赖图和编译单元。后者要麻烦得多因为工具需要在多个 module 之间切换还得处理不同版本的依赖、vendor 目录、构建标签差异。Deadmono 这类工具如果做得好通常会统一抽象出“包集合”和“入口集合”而不是简单依赖单个 module 的go list。它会遍历go.mod里的 replace 关系可能还会解析go.work来知道当前工作区里哪些 module 是本地编辑状态哪些来自外部依赖。这里有一个实用建议如果你的 monorepo 是多 module 结构第一次跑 Deadmono 前先确认它支持go.work或replace指令。否则工具看到的依赖可能来自本地缓存或旧版本分析出的调用图会失真。2.3 一个最小化检测流程示例我不清楚 Deadmono 的具体命令行设计但从同类工具的一般流程看它的使用方式大概率包含“扫描—输出报告—人工确认—清理”四个阶段。下面给出一个通用示例流程你换用任何类似工具都可以参考。# 1. 确认 Go 版本和当前工作区 go version go env GOMOD GOPATH # 2. 获取仓库内所有包列表单 module 示例 go list ./... packages.txt # 3. 运行死代码检测工具输出疑似死代码清单 deadmono scan ./... --entry ./cmd/... --format json deadcode.json # 4. 按包统计疑似死代码数量 jq [.unreachable[]] | group_by(.package) | map({package: .[0].package, count: length}) deadcode.json这只是示例结构不是 Deadmono 的真实命令。落地时你需要对照工具文档调整参数。但核心思想是通用的先把全仓库的包列表拉出来再把入口集合明确传给工具最后让工具输出机器可读的报告方便后续按包批量处理。2.4 为什么它不能完全替代人的判断调用图分析有一个天然的限制Go 语言里存在很多“静态图上能看到引用但运行时不一定触发调用”的情况。举几个常见例子interface方法派发。代码里某个结构体实现了接口接口被大量调用但具体调用的是另一个实现。静态分析只能看到“这个方法被接口引用”无法证明它一定会在某个路径里被真正调用。reflect动态调用。函数名通过字符串拼接出来再通过reflect.MethodByName调用。静态分析无法从字符串推断调用目标。unsafe指针转换和汇编实现。部分代码绕过类型系统工具根本无法建立调用关系。外部进程通过 HTTP/gRPC/消息队列触发的 handler。工具只能看到 handler 注册到了某个路由上但不知道这条路是否有流量。所以Deadmono 给出的结果更适合叫“不可达代码候选”而不是“可以安全删除的代码”。你需要建立一套验证机制把候选代码分成三类明确可删、需要进一步验证、必须保留。这个分类过程才是死代码治理真正见功夫的地方。3. 从单次扫描到持续清理落地要走的三个步骤3.1 第一次扫描不建议直接动手删很多团队拿到死代码报告后第一反应是“趁热打铁清一波”。但我更建议第一次扫描时克制一点。为什么因为第一次直扫往往面临两个问题一是工具检测结果里可能混着大量需要人工核实的疑似代码二是在没有历史基线的情况下你无法判断“删除这个函数会不会在某个没人记得的构建组合里被用到”。如果一上来就批量删除轻则破坏测试重则影响线上发布。正确的第一步是把报告当“地图”不是当“铲子”。先跑一次全量扫描输出一份包含包路径、符号名、引用位置、所属入口等信息的清单。然后挑一个风险最低的区域来做试点。什么样的区域风险最低通常满足这些条件包的依赖方很少或者只有一两个明确的服务依赖它包内的函数没有被任何接口实现包内没有init函数也没有反射调用全仓库搜索不到相关字符串引用测试覆盖率高删除后能通过测试快速验证。把这种区域作为试点删除后运行完整测试观察是否报错。如果顺利再逐步扩大范围。这个过程看起来慢但能让你建立对工具的信任感。信任感一旦建立后续的清理节奏可以加快。注意不要在一开始就把所有“疑似死代码”全部拉进一个删除分支。按包、按目录、按服务粒度拆成多个小提交每个提交对应一个可回滚单元才是安全的推进方式。3.2 把检测接入 CI让它成为日常流程的关键一步死代码治理不能靠“每季度集中清理”来维持。只要还有人在提交代码死代码就会持续产生。真正有效的做法是让死代码检测变成 CI 的一道门禁或者至少是定时任务。这里要有一个预期管理全量死代码扫描在大型 monorepo 里可能很慢不适合在每次提交时都跑全部代码。更务实的做法是分两个阶段第一阶段是变化检测。在不跑全量分析的前提下对本次提交涉及的文件做增量检查。如果工具支持基于 git diff 的变化范围分析可以让它只分析变更文件涉及的可达性。这样虽然无法发现远古死代码但至少能阻止新增死代码悄悄进入仓库。第二阶段是周期性全量扫描。比如每天凌晨或每周一次在主干分支上跑全量分析把新增的死代码数量和上次基线对比。连续几周观察后你就能得到一个趋势。趋势比单次报告更有价值它能让你知道哪个目录、哪个团队在持续制造死代码。在 CI 中集成时还要考虑输出格式。建议把报告做成机器可读格式比如 JSON 或 SARIF。这样可以直接接入 Code Review 工具在 MR 中自动指出新增的可疑死代码。也可以把告警阈值设置成递增模式比如“本次新增疑似死代码超过 5 个就失败”避免历史垃圾导致全量告警一直红灯。3.3 对结果做分类处理删、留、标注例外不是所有被检测出来的死代码都必须删除。一个成熟团队往往会建立一套分类规则。我常用的分类标准是这样的明确可删在调用图中不可达不是入口不涉及接口反射没有在生成代码里引用测试也覆盖不到。这类直接删除。需要进一步验证可能是通过字符串、运行时注册表、外部调用间接触发的代码。先用版本搜索、日志检索、临时埋点等方式确认是否真的还有调用方。如果确认无调用再走删除流程。保留并标注例外有些代码虽然没有运行时调用但承担着文档作用、市场占位、即将上线的功能、合规审计要求或客户定制分支。这些代码可以在工具配置里加入例外名单并在注释里写明保留理由。如果你用的是命令行工具通常它会支持 exclude 或 ignore 配置。比如在配置文件中指定某些目录、包名或符号名跳过检测。这种配置必须单独建一个文件不能散落在注释里。维护一份.deadcodeignore或类似文件比让每个成员记住“这个函数别删”要可靠得多。这里还要提醒一点标注例外不是永久的。建议给例外加上过期时间或负责人字段。比如# TODO: 2025-06-01 recheck by maintainer。否则例外名单会变成新的死代码庇护所。4. 为什么你的检测结果可能不准常见坑与排查链路4.1 误报来源工具只是镜像映射会失真死代码检测结果不准不一定是工具本身有问题很多时候是输入和配置不正确。构建标签是最常见的误报来源。同一个文件在 Windows 和 Linux 下会构建出不同的代码。如果你的检测工具只分析当前平台的构建视图它会漏掉其他平台下独有的文件。反过来如果你的入口集合没有包含某个平台特有的 main 包它也会把这个平台的实际代码误报为死代码。处理方式很简单在分析时列出所有目标平台组合或者至少在 Go module 支持的所有 GOOS/GOARCH 组合下各跑一遍。生成代码也是误报重灾区。*.pb.go文件里大量方法都是通过接口和反射机制被 gRPC 框架调用的。单纯从调用图看很多 Protobuf 方法确实没有直接引用但它们绝对不能删。所以工具要么自动识别生成文件并跳过要么你需要在配置里对生成目录加白名单。mock 代码是另一个典型干扰项。如果仓库里存在大量手写 mock 或使用 mock 框架生成的文件这些文件通常只被测试引用但测试代码在go test时是编译单元的一部分。如果你的死代码工具默认把测试代码排除在外它可能不会报 mock但如果它把测试也计入引用又可能掩盖真正的死代码。好的做法是单独运行两个模式只分析生产代码、只分析测试代码对比差异后人工确认。接口实现和函数类型赋值会导致“看起来被引用实际从不调用”的误报。工具在调用图里看到的是“这个类型实现了某个接口”所以被接口的调用集合引用。但实际上接口调用的目标是另一个实现。要减少这类误报需要工具能区分“直接调用”“接口调用”“函数值间接调用”三种引用强度并允许你在报告中按引用类型过滤。4.2 五步排查链路从结果倒推原因当 Deadmono 报警结果和你对代码的认知不一致时不要急着下“工具不行”的结论。按下面的顺序排查。第一步看结果格式和位置。先确认报告里的路径、行号、符号名是否准确。如果符号和代码对不上先检查工具版本是否和 Go 版本匹配。第二步看输入集合。工具扫描的是哪个目录入口列表是怎么定义的有没有漏掉cmd/下的某个 main 包有没有把生成目录排除掉输入集合错了输出必然失真。这一步是最容易出问题的地方。第三步看构建环境。当前 GOOS、GOARCH、CGO_ENABLED 是否覆盖了所有部署形态如果生产环境要跑 Linux amd64而你本机是 macOS arm64工具看到的构建视图可能完全不同。建议在 CI 里用和生产一致的平台执行分析。第四步看配置和过滤规则。检查是否配置了 exclude、ignore、entry 等参数。有些工具默认只分析当前 module不处理 workspace。如果你的仓库用go.work一定要确认工具支持并开启相应开关。第五步看工具边界。如果以上都排除了再考虑工具本身的能力边界。比如它是否支持 interface 派发分析支持哪种程度是否处理了 reflect 调用这些信息通常在工具文档的“已知限制”里。如果工具明确不支持反射那 report 里出现的“被反射调用保护”的代码就是预期内的误报不能怪工具。4.3 兜底策略删除前先做可逆验证即便 Deadmono 标记为“确定不可达”我在删除前也建议做一次可逆验证尤其是碰到大型共享包或者历史悠久的代码。最稳妥的方式是“软删除”把函数或方法移动到一个单独的deprecated.go文件里保留函数体但在顶层加一行注释说明“疑似死代码正在验证”。然后提交这个状态运行全量测试和 CI。等一个完整发布周期过去后再次用工具扫描。如果这个文件在第二阶段仍然没有被标记为可达再把它删除。这样做虽然多花一个迭代但能让你的“删除”保留可恢复窗口。如果工具允许也可以先输出一个“假设删除”的变更集看看哪些文件会因为符号缺失而报错。这是一个非常有效的验证手段。比如你用命令生成一个 patch把目标函数注释掉然后运行go build ./...所有编译错误都会指向真正的引用点。你会惊讶地发现有些你以为永远没人调用的函数在某个隐藏的构建标签下仍然被一个老接口实现引用着。注意验证的时候一定要覆盖go test ./...不要只跑go build ./...。因为测试代码里可能存在一些非测试引用这些引用不会影响构建但会影响测试运行。5. 给死代码治理沉淀一个可复用的四步框架5.1 扫描—验证—删除—回望把零散经验收束成一个框架死代码治理才能真正落地。我建议团队按这个四步法操作。第一步扫描。先定义完整的入口集合包含所有 main 包、生成入口、运行时注册点。然后运行 Deadmono 或同类工具生成机器可读报告。这一步的输出不是“待删列表”而是“候选列表”。第二步验证。对候选列表按风险分类。低风险的全量go test中风险的用git grep、日志检索、临时埋点验证高风险的在代码评审里专门讨论。验证的目的不是把每个候选都变成确定答案而是把“不知道会怎样”变成“我们知道它怎样”。第三步删除。按包或按目录拆分提交。每个提交只删除一小批提交信息里写清楚删除依据和验证命令。删除后立即跑构建和测试。如果涉及生成的代码文件要重新执行生成步骤确保改动不是依赖最新生成结果。第四步回望。在一个发布周期结束后用 Deadmono 再次全量扫描对比死代码总量和上次基线。如果数量下降说明框架有效如果数量反弹说明某个团队或目录在持续引入新死代码。再针对反弹点做定向沟通。这个四步框架不是一次性的它是循环。每一轮执行完你会更了解仓库的入口结构也会对工具的配置更有把握。5.2 评估一个死代码工具是否适合你的仓库并不是每个团队都适合立刻引入 Deadmono 类似的工具。动手之前可以先做一张小的评估表决定投入产出比。评估维度适合引入的信号可以暂缓的信号仓库规模包数量多服务入口多依赖关系复杂代码量少入口单一团队小入口确定性main 函数和外部路由明确可列举大量反射、插件、动态代码注册团队纪律有 code review、CI 门禁、提交规范几乎没有工程规范全靠个人自觉清理诉求正在做大规模重构或框架升级仓库正在快速试错代码生命周期短平台复杂度目标部署平台固定构建标签使用克制跨平台交叉编译多构建组合非常多这张表不是用来打分的而是提醒你死代码检测工具在“入口清晰、构建视图稳定”的仓库里价值最大。如果你的仓库大量使用反射和动态注册工具只能做辅助你需要投入额外精力设计验证流程。5.3 长期维护让“不产生死代码”成为团队约定工具只能替你发现已经存在的问题不能阻止新问题发生。死代码治理的终局是把“不产生死代码”变成写代码时的默认习惯。有几个做法可以尝试在 code review 里加入“删除旧代码”同等重要的原则。新代码引入时如果它让某些旧代码从“活跃”变成“只有测试引用”reviewer 主动提醒作者提交删除或迁移方案。把死代码检测报告与里程碑挂钩。比如每个大版本发布前要求死代码数量不高于上一版本。这个目标不需要很激进能止住“持续增长”就足够。定期组织“清理日”。挑出报告里数量最少的包用一小时时间集中删除和验证。这种活动除了清理代码更重要的价值是让团队成员熟悉调用图分析工具逐渐积累仓库的结构知识。说到底工具解决的是“我知不知道这里是死代码”而团队文化解决的是“我知道了以后愿不愿意处理”。两者缺一不可。回到最开始那个场景当你的团队再次面对一个“好像没人调用、但没人敢删”的函数时Deadmono 的价值不是替你拍板而是让整个讨论有据可依。你把报告、调用图、验证步骤摆在一起说“我们从所有入口出发确实到不了这里测试也证明没有依赖现在删除如果出了问题可以 revert”。这时候删除就从一个冒险变成了一个工程决策。这也是我写这篇文章最想传达的一点死代码检测工具最重要的产出不是那一堆红色标记而是让“删除代码”这件事变得可以被讨论、被验证、被记录。单次跑通只是起点持续维护才是真正困难也真正值得投入的部分。