Nexus 3 npm 仓库迁移踩坑手册

1. 背景

1.1 问题现象

Nexus 3 npm-group(聚合仓库)中部分包查询返回 HTTP 500,导致 npm install 失败。

1.2 根因分析

npm-group = [npm-proxy, npm-hosted]
  • npm-proxy:远程代理仓库,缓存 npmjs 公共包,包版本齐全
  • npm-hosted:本地托管仓库,所有私有发包上传于此
  • 两仓库存在重叠包(同名),group 合并元数据时冲突
  • Nexus 3 内部解析重叠包的 dist-tags/versions 产生异常,抛 500

1.3 仓库架构

用户请求 → npm-group(统一入口)
              ├─ npm-proxy(远程缓存,~X万包)
              └─ npm-hosted(本地存储,~Y万包)

2. 修复方案:四阶段迁移

阶段 操作 并发 说明
1 全量检查重叠包 12 线程 对 proxy/hosted 共有包逐包请求 group,标记 500 失败
2 计算缺失版本 串行 获取 proxy 全量版本,与 hosted 差集得出需补清单
3 多线程下载→上传 4-20 线程 从远程源下载 tarball,上传到 hosted
4 验证 + 重建 12 线程 逐一复查失败包,确认 group 恢复正常

最终放弃割接方案,改为修改 Nexus 源码新增 hostedFirst 版本级合并策略。

3. 断点续传机制

# 核心:基于结果文件的 Set 去重
processed = set()
if os.path.exists(RESULT_FILE):
    for line in open(RESULT_FILE):
        r = json.loads(line)
        processed.add((r['name'], r['version']))

pending = [e for e in all_entries if (e['name'], e['version']) not in processed]
  • 结果文件 fix500_result.jsonl 逐行追加,每行包含 {name, version, action, source, size}
  • 重启时加载全部已处理 key → Set 去重 → 筛选待处理
  • 支持任意次数中断重启,进度不丢
  • 每个 worker 完成立即 flush 写入,单条不丢失

4. 性能踩坑实录

4.1 坑一:下载源优先级

现象:早期下载源配置 npmmirror → npmjs → proxy,单包耗时 3 分钟,速率 ~0.5/min

根因:npmmirror 为国内镜像 CDN,但走公司代理后限速至 300 KB/s;而内部 Nexus proxy 仓库直连可达 6.5 MB/s(22 倍差距)

npmmirror(走代理):  300 KB/s  → 55MiB/186s
proxy(内部直连):    6.5 MB/s  → 55MiB/8.6s

解决:源优先级改为 proxy → npmmirror → npmjs

4.2 坑二:代理绕过

现象:试图让 npmmirror 不走代理直连,结果彻底超时

根因:公司网络所有出站流量必须走指定代理,直连 npmmirror CDN 的 TCP SYN 被直接丢弃

解决:回退成所有请求统一走代理,利用内部 Nexus proxy 仓库作为首选源

4.3 坑三:并发悖论

现象:20 线程反而比 4 线程慢

根因:代理出口带宽固定(~3 MB/s),每个线程分到的带宽 = 总带宽 / 线程数

线程数 单线程带宽 55MiB 文件耗时 完成间隔
2 ~1.5 MB/s ~35s ~18s
8 ~375 KB/s ~150s ~19s
20 ~150 KB/s ~370s ~18s

结论:单文件完成时间与线程数成反比,但完成间隔(吞吐量)恒定,由总带宽÷文件大小决定

实践策略

  • 大文件段(>10MB):2-4 线程,减少连接开销和内存
  • 小文件段(<1MB):8-20 线程,充分利用 HTTP 并发

4.4 坑四:大包家族

迁移过程中发现两类巨型 npm 包严重拖慢进度:

包家族 版本数 总大小 最大单包 特点
@iconify/json 1,491 ~57 GB 84 MiB 全图标库 JSON,每个版本独立打包全部图标
@swc/core-* 8,580 ~20 GB 49 MiB Rust 编译的 JS 工具链,每个平台/架构各一个包

影响:这两族包仅占总数的 3%,却消耗了 80% 的迁移时间(iconify 约 8 小时,swc 约 3 小时)

应对:大包段降低并发避免互抢,确保优先用内部 proxy 源下载

4.5 性能演进总览

阶段 配置 速率 瓶颈
初始 15 线程,npmjs 优先 ~2/s npmjs 国际线路限速
优化1 20 线程,npmmirror 优先 ~7/s → 2/s 大包出现后回归
回退 20 线程,npmmirror 直连 ~0.5/min 直连超时 + 全回退
修正 8 线程,走代理 ~1/min proxy 慢 + 大包
翻盘 2-8 线程,proxy 优先 ~20/s 小包段起飞

5. 磁盘空间优化

迁移完成后,npm-proxynpm-hosted 存在大量重叠冗余包。

清理逻辑:遍历 proxy 中所有包,在 hosted 中找到同名包则删除 hosted 元数据。Nexus 内部 blob store 通过 "Compact blob store" 定时任务回收释放空间。

实际回收:数百 GB

6. 操作手册

6.1 环境变量

export NEXUS_URL="http://<NEXUS_HOST>:8081/nexus/content"
export NEXUS_USER="<USER>"
export NEXUS_PASS='<PASS>'

6.2 脚本参数

python3 nexus-fix-500.py --phase 3 --workers 8
参数 默认 说明
--phase 1 阶段1 检查 group 500 包
--phase 2 阶段2 计算缺失版本
--phase 3 阶段3 多线程迁移(主力)
--phase 4 阶段4 验证修复
--phase 5 阶段5 更新 group 配置
--workers N 15 迁移并发数
--skip-phase1 - 跳过阶段1(用已有清单)
--skip-phase2 - 跳过阶段2(用已有清单)

6.3 典型用法

# 首次完整执行
python3 nexus-fix-500.py

# 断点续传:只跑迁移阶段
python3 nexus-fix-500.py --phase 3 --workers 8

# 换机器续传(拷贝 migration_data/ 目录即可)
scp -r migration_data/ new-host:/path/to/
python3 nexus-fix-500.py --phase 3 --workers 8

6.4 前置依赖

  • Python 3.6+
  • requests, urllib3
  • Nexus REST API 可用
  • 网络可达 <NEXUS_HOST>:8081

6.5 断点续传文件清单

文件 用途 大小参考
fix500_result.jsonl 已处理记录(核心) ~7 MiB
fix500_versions.jsonl 待处理版本清单 ~38 MiB
fix500_list.jsonl 失败包清单 ~300 KiB
migration_data/ 所有数据目录 ~46 MiB

7. 经验总结

  1. 内部源优先:Nexus 自带的 proxy 仓库是感知不到的外部瓶颈的,直连速度远超绕代理的外部 CDN
  2. 并发不是越高越好:带宽固定时,总吞吐量不变,线程越多反而增加连接开销和内存占用
  3. 大文件降并发:遇到 >10MB 包时,线程越少单文件完成越快,整体更稳定
  4. 断点续传是生命线:355K+ 版本不可能一次跑完,去重 Set + JSONL 追加的设计简洁可靠
  5. 先摸底后执行:统计大包家族的版本数和体积,合理预估时间,避免焦虑

迁移总耗时:约 18-24 小时(取决于网络),处理 355K+ 版本,总流量 ~100-150 GB

8. 源码修复:hostedFirst 版本级合并

8.1 背景

割接方案(proxy → hosted 全量迁移→group 只留 hosted)在落地过程中遇到多个工程问题:

  • 迁移脚本意外灌入 14 万预发布版本,hosted 膨胀
  • 清理预发布时 search API 深分页崩盘
  • 新 proxy 加入 group 后合并逻辑仍报 500

最终放弃割接,直接修改 Nexus 源码的 group 合并逻辑。

8.2 改动文件

plugins/nexus-repository-npm/src/main/java/org/sonatype/nexus/repository/npm/internal/NpmGroupFacet.java

8.3 改动要点

改动 说明
新增 hostedFirst 配置项 @Named("${nexus.npm.hostedFirst:-false}")
新增 safeVersionMerge() Repository.getType() instanceof HostedType 显式区分 hosted vs proxy
buildMergedPackageRoot() 增加分支 hostedFirst=true 时走 safeVersionMerge(),否则走原始 mergeContents()
新增 ensureMapField() 防御 corrupt metadata 导致 IllegalStateException

8.4 合并策略

  • versions: hosted 覆盖 conflict,proxy 补充缺失版本。hosted 有 {1.0, 1.1}、proxy 有 {1.0, 2.0, 3.0} → 结果
  • dist-tags: hosted 的 latest 固定优先,proxy 补充其余 tag(beta, next, rc 等)
  • latest: 合并后跨全版本 semver 重新计算
  • time: 与 versions 同策略,hosted 优先、proxy 补缺

8.5 与原始 mergeContents() 对比

原版 hostedFirst
reverse(contents) + NpmMergeObjectMapper.merge() JSON 深合并 逐版本手动 merge,hosted 为基座
依赖列表顺序决定优先级 Repository 类型显式区分 hosted/proxy
大 JSON 深合并异常 → 500 逐字段操作,无 deep merge 冲突
dist-tags 最后一条覆盖 hosted latest 优先 + semver 重算

8.6 启用方式

Nexus 服务器 nexus.properties 添加:

nexus.npm.hostedFirst=true

重启 Nexus 后生效。不加或设为 false 走原始逻辑。

8.7 清理预发布版本(辅助脚本)

Nexus 3.17 search API 深分页在 ~200 页(1 万条)后 continuationToken 触发 Elasticsearch shard 异常。改用 Browse API 遍历:

阶段 脚本 说明
阶段1 nexus-browse-prerelease.py Browse API 遍历目录树,提取预发布清单
阶段2 nexus-delete-prerelease.py 4 线程逐条 search + DELETE

结果:3,517 个包中 480 个有预发布,共 25,936 个版本,48 分钟删完。删除后需 Nexus UI 执行 Compact blob store 回收物理磁盘。

8.8 私有化管控的坑(总结)

根因 解法
group 合并 500 NpmMergeObjectMapper.merge() JSON 深合并异常 JAR 改写,hostedFirst 策略
迁移灌入预发布 migrate-proxy2hosted 未过滤版本 迁移脚本加 PRERELEASE_RE 过滤
search API 深分页 500 ES shard 异常 换 Browse API
删组件不掉磁盘 REST DELETE 只是逻辑标记 Compact blob store
proxy 首次拉 tarball 504 npm client 30s 超时 < 代理下载时间 调大 timeout / 预热
posted @ 2026-07-01 20:06  emiya丶zero  阅读(19)  评论(0)    收藏  举报