Maven 本地仓库(~/.m2/repository)清理实战:如何安全删除无用依赖,释放大量磁盘空间

2026-08-08 1点热度 0人点赞 0条评论

一、问题的前因后果

如果你是 Java 后端开发,日常用 Maven 管理项目,手头维护或参与的项目又比较多,大概率会遇到这样的情况:某天打开磁盘占用分析工具,发现 ~/.m2/repository 这个文件夹已经悄悄涨到了几十 GB。

原因也很好理解:

  • 每个项目的依赖版本可能不一样,Maven 不会自动清理旧版本,只会不断"新增";
  • 项目迭代过程中依赖升级(比如 SpringBoot 从 2.x 升到 3.x),旧版本的一堆 jar 包永远留在仓库里;
  • 参与过的临时项目、POC 项目、已经归档不再维护的项目,它们当初拉下来的依赖也从未被清理过;
  • 很多依赖还会连带一堆传递依赖(transitive dependencies),一个版本升级可能牵连出几十个 jar。

于是仓库越滚越大,但你其实并不知道里面哪些还在用、哪些早就是"僵尸文件"。

二、直觉方案 & 踩坑记录

在真正解决问题之前,我尝试过几种"直觉上应该可行"的方案,这里记录一下踩过的坑,避免大家走弯路。

坑 1:整个仓库删掉重新下载

最简单粗暴的想法是把 ~/.m2/repository 整个移走,让 Maven 重新构建时自动下载。

bash
mv ~/.m2/repository ~/.m2/repository.bak

这个方法在"所有项目都能顺畅访问远程仓库"的前提下没问题,但如果你有项目依赖来自:

  • 已经下线或无法访问的内网私服;
  • 版本已经被覆盖或从远程仓库撤下的 jar(这种情况比想象中常见);

那么删除后重新构建就会直接失败,而且很难第一时间定位是"网络问题"还是"依赖真的没了"。这个方法本质上是拿"能不能正常构建"做赌注,风险偏大,不建议在依赖来源复杂的团队环境里用。

坑 2:按文件访问时间(atime)清理

第二个直觉方案是按"最后访问时间"清理,思路是:很久没被访问过的 jar,大概率就是没人用了。

bash
find ~/.m2/repository -name "*.jar" -atime +180

这里踩了一个坑:macOS 的文件系统(APFS)默认的 atime 更新策略并不精确,很多情况下走的是 relatime 语义,只有在明显早于修改时间时才会更新访问时间。也就是说,即便你上周刚编译过某个用到某个 jar 的项目,它的 atime 也可能显示很久以前,导致误判。

结论:atime 在 Mac 上不可靠,这个方案基本作废,除非你能确认自己的文件系统精确记录访问时间。

坑 3:mvn dependency:purge-local-repository

Maven 自带一个 dependency:purge-local-repository 目标,可以清理"当前项目"用到的依赖缓存:

bash
mvn dependency:purge-local-repository -DreResolve=false

问题在于:它只能针对单个项目当前用到的依赖生效,对于"清理其他项目遗留下来的旧版本"完全无能为力。如果你手头有几十个项目,逐个跑一遍不仅低效,而且它解决的根本不是"仓库里到底哪些版本没人用"这个问题。

三、真正有效的思路:交叉比对"实际在用的依赖" vs "仓库里存在的依赖"

踩完前面几个坑后,思路逐渐清晰:真正安全的清理方式,是先把手头所有项目"实际用到的依赖版本"枚举出来,再和本地仓库里"实际存在的依赖版本"做差集,差集里的内容才是真正可以放心删除的"僵尸依赖"。

具体拆解成三步:

  1. 找到所有项目的根目录:项目通常放在一个统一的代码目录下,但目录结构可能不规整,有的项目直接在顶层(<git-root>/project-a/pom.xml),有的在分组子目录下(<git-root>/group-name/project-b/pom.xml)。所以不能简单假设固定的目录深度,需要用"最外层含 pom.xml 的目录"作为项目根这个规则来自动识别,这样多模块项目的子模块 pom 会被自动跳过(因为已经嵌套在根目录下面,跑一次 reactor 构建就能覆盖到所有子模块)。
  2. 对每个项目根跑 mvn dependency:list,把解析出来的 groupId:artifactId:version 汇总去重,得到"全公司/全部门实际在用的依赖版本全集"。
  3. 遍历本地仓库目录结构groupId 对应多层目录、下面是 artifactId、再下面是 version),列出仓库里实际存在的所有版本目录,与第 2 步的集合做差集,差集即为候选删除项。

这个思路的好处是:不依赖不精确的文件系统元数据(如 atime),也不用赌网络能否重新下载,完全基于"当前所有项目真实需要什么"来判断,安全性高很多。

四、踩坑提醒:这个方案本身也有需要注意的地方

  • 有些项目可能编译失败(比如临时切换了 JDK 版本、依赖了无法访问的私服、pom 本身有问题等),这类项目的依赖扫描会失败。一定要单独记录这些失败的项目,不能把"扫描失败"等同于"没有依赖",否则会把它们真实在用的依赖误判为"未使用"从而被清理掉,这是最容易埋雷的地方。
  • 多模块项目(parent pom + 多个子模块)要在根目录跑一次完整的 reactor 构建,而不是逐个子模块目录单独跑,否则子模块引用父 pom 的相对路径可能解析不到。
  • 建议默认只生成"待删除清单 + 删除脚本",人工确认后再手动执行删除,不要一上来就自动删除,给自己留一道复查的机会。

五、示例代码:项目依赖扫描 + 仓库比对脚本

下面是一个可以直接使用的 Python 脚本(无需额外依赖,Python 3 自带库即可运行),思路完全按照上面第三节展开:

#!/usr/bin/env python3
"""
scan_and_clean_m2.py

用途:
  扫描指定目录下所有 Maven 项目(自动识别项目根目录,支持多层子文件夹结构),
  收集这些项目实际用到的依赖 (groupId:artifactId:version),
  然后与本地 ~/.m2/repository 中实际存在的依赖版本做比对,
  找出"仓库里有,但没有任何项目在用"的版本,生成一份可审查的删除清单。

用法:
  python3 scan_and_clean_m2.py --repos-root /path/to/your/projects

产出(写在当前目录下的 m2_clean_report/ 文件夹里):
  - used_artifacts.txt     所有项目实际使用到的 groupId:artifactId:version 清单
  - failed_projects.txt    mvn 扫描失败的项目列表(请人工检查,不会被当成"未使用")
  - unused_candidates.txt  仓库里存在但没有项目在用的版本,及各自占用空间
  - delete_candidates.sh   对应的删除脚本(默认不会自动执行,需要你自己运行)
"""

import argparse
import os
import subprocess
import sys
from pathlib import Path

MARKER_FILE = ".mvn-deps-scan.txt"


def find_project_roots(repos_root: Path):
    """
    找出所有"项目根目录":即包含 pom.xml,且没有任何上层目录
    (在 repos_root 范围内)也包含 pom.xml 的目录。
    这样多模块项目只会在最外层根目录被识别一次。
    """
    all_pom_dirs = []
    for dirpath, dirnames, filenames in os.walk(repos_root):
        dirnames[:] = [d for d in dirnames if d not in (
            "target", ".git", "node_modules", ".idea")]
        if "pom.xml" in filenames:
            all_pom_dirs.append(Path(dirpath))

    all_pom_dirs.sort(key=lambda p: len(str(p)))

    roots = []
    for d in all_pom_dirs:
        is_nested = False
        for r in roots:
            try:
                d.relative_to(r)
                is_nested = True
                break
            except ValueError:
                continue
        if not is_nested:
            roots.append(d)
    return roots


def run_mvn_scan(project_root: Path, timeout_sec: int):
    """在项目根目录跑 mvn dependency:list,结果写到标记文件中"""
    cmd = [
        "mvn", "-q", "-B",
        "dependency:list",
        f"-DoutputFile={MARKER_FILE}",
        "-DincludeScope=test",
    ]
    try:
        result = subprocess.run(
            cmd, cwd=str(project_root),
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            timeout=timeout_sec, text=True,
        )
        if result.returncode != 0:
            return False, result.stdout
        return True, ""
    except subprocess.TimeoutExpired:
        return False, f"超时(>{timeout_sec}秒)"
    except FileNotFoundError:
        return False, "找不到 mvn 命令,请确认 Maven 已安装并在 PATH 中"


def collect_marker_files(project_root: Path):
    found = []
    for dirpath, dirnames, filenames in os.walk(project_root):
        if MARKER_FILE in filenames:
            found.append(Path(dirpath) / MARKER_FILE)
    return found


def parse_deps_file(path: Path):
    """
    解析 mvn dependency:list 生成的文件,每行形如:
      groupId:artifactId:packaging:version:scope
    或带 classifier:
      groupId:artifactId:packaging:classifier:version:scope
    """
    triples = set()
    if not path.exists():
        return triples
    for line in path.read_text(errors="ignore").splitlines():
        line = line.strip()
        if not line or ":" not in line:
            continue
        parts = line.split(":")
        if len(parts) == 5:
            g, a, _packaging, v, _scope = parts
        elif len(parts) == 6:
            g, a, _packaging, _classifier, v, _scope = parts
        else:
            continue
        triples.add((g.strip(), a.strip(), v.strip()))
    return triples


def cleanup_marker_files(files):
    for f in files:
        try:
            f.unlink()
        except OSError:
            pass


def scan_all_projects(repos_root: Path, timeout_sec: int):
    roots = find_project_roots(repos_root)
    print(f"发现 {len(roots)} 个项目根目录")

    used = set()
    failed = []

    for i, root in enumerate(roots, 1):
        print(f"[{i}/{len(roots)}] 扫描 {root} ...")
        ok, msg = run_mvn_scan(root, timeout_sec)
        if not ok:
            print(f"  失败:{msg[-300:] if msg else '未知错误'}")
            failed.append(str(root))
        markers = collect_marker_files(root)
        for m in markers:
            used |= parse_deps_file(m)
        cleanup_marker_files(markers)

    return used, failed


def index_local_repo(m2_repo: Path):
    """遍历 ~/.m2/repository,找出所有 (groupId, artifactId, version) 三元组及对应目录"""
    triples = {}
    for dirpath, dirnames, filenames in os.walk(m2_repo):
        has_artifact_file = any(
            f.endswith((".jar", ".pom", ".war", ".aar")) for f in filenames
        )
        if not has_artifact_file:
            continue
        version_dir = Path(dirpath)
        artifact_dir = version_dir.parent
        group_dir = artifact_dir.parent
        try:
            rel = group_dir.relative_to(m2_repo)
        except ValueError:
            continue
        group_id = ".".join(rel.parts)
        artifact_id = artifact_dir.name
        version = version_dir.name
        if not group_id or not artifact_id or not version:
            continue
        triples[(group_id, artifact_id, version)] = version_dir
    return triples


def dir_size(path: Path) -> int:
    total = 0
    for dirpath, _dirnames, filenames in os.walk(path):
        for f in filenames:
            fp = Path(dirpath) / f
            try:
                total += fp.stat().st_size
            except OSError:
                pass
    return total


def human_size(n: int) -> str:
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024:
            return f"{n:.1f}{unit}"
        n /= 1024
    return f"{n:.1f}PB"


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repos-root", required=True,
                         help="存放所有项目的根目录")
    parser.add_argument("--m2-repo", default=str(Path.home() / ".m2" / "repository"))
    parser.add_argument("--skip-mvn", action="store_true")
    parser.add_argument("--timeout", type=int, default=300)
    parser.add_argument("--execute", action="store_true",
                         help="真正执行删除(默认只生成报告,不删除任何文件)")
    args = parser.parse_args()

    repos_root = Path(args.repos_root).expanduser().resolve()
    m2_repo = Path(args.m2_repo).expanduser().resolve()

    if not repos_root.is_dir():
        sys.exit(f"错误:项目根目录不存在:{repos_root}")
    if not m2_repo.is_dir():
        sys.exit(f"错误:.m2 仓库目录不存在:{m2_repo}")

    out_dir = Path.cwd() / "m2_clean_report"
    out_dir.mkdir(exist_ok=True)

    used_file = out_dir / "used_artifacts.txt"
    failed_file = out_dir / "failed_projects.txt"

    if args.skip_mvn and used_file.exists():
        used = set()
        for line in used_file.read_text().splitlines():
            parts = line.strip().split(":")
            if len(parts) == 3:
                used.add(tuple(parts))
        failed = failed_file.read_text().splitlines() if failed_file.exists() else []
    else:
        used, failed = scan_all_projects(repos_root, args.timeout)
        used_file.write_text(
            "\n".join(f"{g}:{a}:{v}" for g, a, v in sorted(used)) + "\n"
        )
        failed_file.write_text("\n".join(failed) + "\n")

    print(f"共收集到 {len(used)} 个被使用的依赖版本")
    if failed:
        print(f"⚠️ 有 {len(failed)} 个项目扫描失败,请人工检查 {failed_file}")

    repo_triples = index_local_repo(m2_repo)
    print(f"本地仓库中共有 {len(repo_triples)} 个版本目录")

    unused = {t: p for t, p in repo_triples.items() if t not in used}

    candidates_file = out_dir / "unused_candidates.txt"
    delete_script = out_dir / "delete_candidates.sh"

    total_size = 0
    lines = []
    delete_lines = ["#!/bin/bash", "# 请先人工检查本文件内容,再执行!", "set -e", ""]

    for (g, a, v), p in sorted(unused.items()):
        size = dir_size(p)
        total_size += size
        lines.append(f"{human_size(size):>10}  {g}:{a}:{v}  ->  {p}")
        delete_lines.append(f'rm -rf "{p}"')

    candidates_file.write_text("\n".join(lines) + "\n")
    delete_script.write_text("\n".join(delete_lines) + "\n")
    os.chmod(delete_script, 0o755)

    print(f"发现 {len(unused)} 个未被使用的版本目录,合计占用空间:{human_size(total_size)}")
    print(f"清单:{candidates_file}")
    print(f"删除脚本:{delete_script}(默认不会自动执行)")

    if args.execute:
        for (g, a, v), p in unused.items():
            subprocess.run(["rm", "-rf", str(p)], check=False)
        print("删除完成。")
    else:
        print("本次为 dry-run,未删除任何文件,确认无误后自行运行删除脚本。")


if __name__ == "__main__":
    main()

使用步骤

# 1. 进入一个用来存放报告的目录(不需要放在项目目录里)
cd ~/wherever-you-like

# 2. 执行扫描(把路径换成你实际存放项目代码的根目录)
python3 scan_and_clean_m2.py --repos-root /path/to/your/projects

# 3. 查看生成的报告
cat m2_clean_report/unused_candidates.txt
cat m2_clean_report/failed_projects.txt   # 一定要看这个,人工确认失败原因

# 4. 确认无误后,手动执行删除
bash m2_clean_report/delete_candidates.sh

六、实际效果

用这套方法跑下来,成功识别出了大量"仓库里存在、但当前所有项目都没有用到"的旧版本依赖目录,清理后释放出了相当可观的磁盘空间,而且因为整个判断逻辑是基于"当前项目真实需要什么"来做差集,不依赖不准确的文件访问时间,也不需要靠删了重下这种赌网络的方式,清理过程比较安心。

七、总结 & 建议

  • 不要依赖文件访问时间(atime)来判断依赖是否还在用,尤其是 macOS 上默认的文件系统语义并不精确。
  • "删了重新下载"看似简单,但对依赖来源复杂(私服、内网、版本下线)的团队风险较高,不建议作为首选方案。
  • 最稳妥的思路是"实际在用的依赖" vs "仓库里存在的依赖"做差集,谁在用什么、仓库里有什么,两边一比对,差集才是真正能安全删除的部分。
  • 扫描失败的项目一定要单独记录、人工复核,不能把"扫描不到"等同于"没有依赖",这是最容易埋雷、导致误删的地方。
  • 清理动作建议保留"先生成清单,人工确认后再执行"的习惯,给自己留复查空间,尤其是在团队共享的开发机或者 CI 构建机上操作时更要谨慎。

希望这篇记录能帮到同样在为本地 Maven 仓库瘦身的你。如果你有更好的清理思路或者踩过其他坑,欢迎交流。

admin

这个人很懒,什么都没留下

文章评论

您需要 登录 之后才可以评论