推送代码时被仓库平台拦下来,报错说 blob 对象太大,这种经历但凡碰到一次就够让人头疼的。最近我就在 CNB 上遇到了这个经典问题:本地仓库里有个 309 MiB 的文件,推送到远端时直接被拒绝,提示“blob 对象大小 309 MiB 超过了单个文件大小限制 256 MiB”。这个报错本身不复杂,但它牵扯出来的问题却很典型:Git 仓库里的大文件到底该怎么管,历史提交里的大对象怎么清掉,以及以后怎么避免再踩同一个坑。
这篇文章会把这次踩坑的完整过程、排查思路、解决方案和避坑经验都梳理一遍,包括 Git 对象存储的基本原理、平台侧限制的由来、用 Git LFS 管理大文件的具体操作、以及用 git filter-repo 重写历史把大文件彻底“抹掉”的做法。无论你是和我一样被这个报错卡住,还是单纯想搞明白大文件在 Git 仓库里应该怎么处理,这篇都能给你一份可以直接拿来用的方案。
1. 这个报错到底在说什么:309 MiB 的 blob 是怎么来的
先把报错本身拆开看。CNB 提示“推送的 blob 对象大小 309 MiB 超过了单个文件大小限制 256 MiB”,这里面的关键词有三个:blob 对象、单文件大小限制、推送。
blob 是 Git 内部四种对象类型之一(另外三种是 tree、commit、tag),你可以把它理解成 Git 存储文件内容的最小单位。当你往 Git 里添加一个文件并提交时,Git 会把这个文件的内容压缩后存成一个 blob 对象。重点在于:这个 blob 对象的大小就是文件本身的大小,只要这个文件进入过 Git 的历史,哪怕后来你把它删了,这个 blob 对象也依然会在对象数据库里存活。所以这个报错的本质就是:你当前提交分支里存在一个超过 256 MiB 的文件,或者更隐蔽的情况是——你的提交历史里某个历史版本存在过这样一个大文件。
这里有一个新手特别容易误解的点:很多人以为只要把大文件删掉再提交一次就能解决问题,事实是并不行。因为 Git 的历史是不可变的,之前那个包含大文件的 commit 依然挂在历史链上,推送时服务器端会检查整个接收范围内的所有对象,任何一个超限的 blob 都会被拦下来。这也是为什么这类问题往往“删了还报错”、让人一头雾水。
从这个角度说,CNB 作为托管平台,它在服务端做这个限制是合理的。256 MiB 的单文件限制意味着服务端在接收、存储、传输这个对象时的成本是可接受的,同时也能防止有人把 Git 仓库当成网盘来用。类似 GitHub 的限制是 100 MiB(超过 50 MiB 就会警告),Gitee 是单文件 100 MiB,不同平台策略不同,但核心逻辑一样:保护服务端存储和带宽资源。
再往深一层想,这个报错还暴露了一个工程管理问题:仓库里为什么会混进一个 300 多 MiB 的文件?最常见的来源是这几类:
- 二进制产物,比如编译出来的安装包、APK、固件;
- 模型文件,比如深度学习的权重文件;
- 数据集、日志、数据库备份;
- 视频、音频或大体积的压缩包;
- 不小心把 node_modules、dist 这类目录的整体压缩包提交了进去。
我的情况属于第一种,一个 APK 安装包不小心被打包进了提交里,当时想着反正是内部仓库,先推上去再说。结果就是被 CNB 的 256 MiB 限制拦得死死的,连带着正常的小文件推送也被阻塞了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定位元凶:怎么找出仓库里的大文件
遇到这个报错之后,第一件事不是急着去改历史,而是先搞清楚到底是哪个文件超了限制。这里有两个层面要查:当前工作区/当前分支里的文件,以及整个提交历史里存在过的所有大文件。
2.1 先检查当前分支里有没有超限文件
最简单的命令是直接在仓库里扫一遍,看看当前目录下有哪些文件超过 256 MiB:
bash复制find . -type f -size +256M -not -path "./.git/*" -exec ls -lh {} \;
这个命令会列出所有大于 256 MiB 的文件。如果你运气好,发现当前工作区就有一个这样的文件,先把它从 Git 跟踪中移除:
bash复制git rm --cached <大文件名>
echo "大文件名" >> .gitignore
git commit -m "chore: 移除超限大文件并加入忽略列表"
做完这一步,当前分支的问题就解决了。但前面说过,历史里的大对象还在,所以这个时候推送大概率还是会被拦,只是报错提示可能不变。别急,继续往下查。
2.2 排查整个提交历史里的超大对象
如果你查了当前工作区没有超限文件,或者明明删了还是报同样的错,那问题一定藏在了历史提交里。Git 没有直接提供“列出所有历史大文件”的命令,但可以通过 git rev-list 结合 git cat-file 来实现,原理是遍历所有 commit 关联的 blob 对象,按大小排序输出。
一条非常经典的命令是这样的:
bash复制git rev-list --objects --all | \
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | \
awk '/^blob/ {print $3, $4}' | \
sort -rn | head -20
这段命令拆开解释一下:
git rev-list --objects --all:列出所有提交涉及的文件路径和对应的对象 ID,--all表示包含所有分支、标签等引用。git cat-file --batch-check:批量查看对象信息,这里指定输出对象类型、对象名、大小和路径名。awk过滤出类型为 blob 的行,只保留大小和路径。sort -rn按大小倒序排列,head -20显示最大的 20 个。
跑完这条命令,你会看到类似这样的输出:
code复制309123456 path/to/large.apk
102400000 path/to/other-file.zip
12345678 path/to/another-file.bin
第一行那个 309 MiB 的就是本次的“元凶”。拿到文件路径之后,下一步就清晰了:要么用 Git LFS 接管它,要么从历史里彻底移除它。选哪条路,取决于这个文件对你的项目来说到底该不该进 Git。
3. 方案一:用 Git LFS 管理大文件,治标也治本
如果你的项目确实需要保存大文件(比如游戏资源、算法模型、设计源文件),那正确的解法是 Git LFS(Large File Storage),而不是硬塞进 Git 普通对象里。CNB 支持 Git LFS,这也是官方推荐的做法。
3.1 Git LFS 的原理:为什么它能绕开 blob 限制
先说清楚 LFS 和普通 Git 的本质区别。普通 Git 提交大文件时,文件内容直接压成 blob 对象存进 Git 对象库,这个对象会随着每次克隆、推送被完整传输。LFS 的机制完全不同:它把大文件的实际内容上传到 LFS 存储服务器(由托管平台提供),而 Git 仓库里只存一个几十字节的“指针文件”(pointer),这个指针里记录了大文件的版本 ID、大小等信息。真正的大文件放在 LFS 存储区,独立于 Git 对象库管理。
这样一来,Git 仓库里的 blob 对象就永远都是几十字节的指针文件,自然不会触发单文件大小限制。克隆仓库时,LFS 文件是按需下载的,也节省了网络带宽。
这个机制用生活里的例子类比,就好比你家里放不下一个大衣柜,于是把它存到仓库,在家里只挂一张照片标明“衣柜在 3 号仓”,需要的时候凭照片去取。照片永远只有几克重,衣柜再大也不影响你搬家。
3.2 实操步骤:把已有大文件迁移到 LFS
如果你的大文件已经提交进了 Git 历史,直接用 git lfs track 是管不到历史提交的,需要先让 Git 把大文件移交给 LFS 管理。完整步骤我实测过,按这个顺序做基本不会出问题。
第一步:安装 Git LFS 客户端
bash复制# macOS
brew install git-lfs
# Ubuntu/Debian
sudo apt-get install git-lfs
# Windows 直接去官网下载安装包,或者用 winget
winget install --id GitHub.GitLFS
安装完执行一次全局初始化:
bash复制git lfs install
这个命令会在你的 Git 配置里挂上 LFS 的过滤器(clean/smudge filter),后面所有和 LFS 相关的操作都会自动走对应的钩子。
第二步:用 lfs migrate 把历史中的大文件改写成 LFS 指针
git lfs migrate 是处理“已经写进历史的大文件”这个场景的正确工具。它能重写历史,把指定路径的大文件替换成 LFS 指针文件,同时把真实内容迁移到 LFS 存储区。命令如下:
bash复制git lfs migrate import --include="path/to/large.apk" --everything
几个参数的含义:
import:告诉 LFS 把匹配的文件导入到 LFS 管理。--include:指定要迁移的文件路径,支持通配符,比如--include="*.apk"或--include="*.zip,*.bin"。注意这里要用相对仓库根目录的路径,或用 glob 模式。--everything:迁移所有分支和标签。如果你只想迁移当前分支,可以不加这个参数,但建议加,否则其他分支上还有的话,之后切换分支时可能会出问题。
执行完之后,Git 历史已经被重写了,旧的包含大文件内容的 commit 会变成新的 commit 对象,里面是 LFS 指针,大文件实际内容则进了 .git/lfs/objects 目录。
第三步:确认 LFS 跟踪规则,并提交 .gitattributes
查看当前的 LFS 跟踪规则:
bash复制git lfs track
如果没有自动生成规则,手动执行:
bash复制git lfs track "path/to/large.apk"
git lfs track 会在仓库根目录生成或更新 .gitattributes 文件,这个文件必须提交到仓库里,否则其他人克隆时无法识别哪些文件应该走 LFS 流程。
bash复制git add .gitattributes
git commit -m "chore: 添加 Git LFS 跟踪规则"
第四步:推送代码和 LFS 对象
bash复制git push --all origin
git push --tags origin
git lfs push --all origin
注意最后一条 git lfs push --all origin 是把 LFS 对象推送到远端存储区。这一步容易被忽略,但漏掉的话,别人克隆仓库时会看到指针文件,但下载不到真实内容。如果之前历史里已经有大文件对象没有推上去,--all 会把本地 LFS 缓存里的所有对象都推一遍,是保险的做法。
3.3 为什么这个方案能解决 CNB 的 256 MiB 限制
回到报错本身。使用 LFS 之后,仓库里不再存在 309 MiB 的 blob 对象,取而代之的是几十字节的文本指针文件。CNB 服务端在检查推送对象时,看到的都是小文件,自然不会再触发 256 MiB 的限制。
这里有一个容易踩的坑:git lfs migrate 会重写历史,重写之后所有 commit 的哈希都会变。如果你的仓库已经有其他协作者,他们本地仓库还保留着旧历史,推送时就会产生大量冲突和分叉。所以在执行 migrate 之前,一定要先和团队成员沟通好,选定一个时间窗口统一操作。团队内部仓库还好,如果是开源项目,这种历史重写操作的影响面会更大,需要格外谨慎。
另外,有些人在执行 git lfs migrate 之前会先试 git lfs track,然后重新 commit 一次。这里要再提醒一遍:git lfs track 只对“之后新增”的文件生效,历史提交里已经存在的大文件仍然会以 blob 形式留在历史中,推送时依然会触发限制。只有 migrate(或者后面的 filter-repo 方案)才能真正重写历史、移除大 blob。
4. 方案二:从历史里彻底移除大文件,仓库轻装上阵
如果你的项目根本不应该包含那个大文件(比如不小心提交的 APK、日志、模型文件),那就不需要把它迁到 LFS,直接用工具把历史里的大 blob 彻底抹掉。这是让仓库“减肥”的正路。
4.1 用 git filter-repo 重写历史,比 filter-branch 靠谱得多
Git 官方自带的 git filter-branch 虽然能做这件事,但官方文档都明确标注了它性能差、容易出错,不推荐使用。第三方工具 git filter-repo 是当前社区公认的最佳选择,速度快、用法简单、结果干净。
安装方式:
bash复制# macOS
brew install git-filter-repo
# pip 方式(跨平台)
pip install git-filter-repo
安装后先备份你的仓库(保险起见),然后执行移除大文件的操作:
bash复制git filter-repo --path path/to/large.apk --invert-paths
这条命令的意思是说:对指定路径执行反向过滤,也就是把 path/to/large.apk 从整个历史中移除。--invert-paths 配合 --path 使用,表示“所有提交里,剔除这个路径的文件”。如果不加 --invert-paths,则是只保留这个路径,其他全部移除。
如果想一次性移除多个文件或目录:
bash复制git filter-repo --path path/to/large.apk --path path/to/other.zip --invert-paths
也可以用通配符,比如移除所有 apk:
bash复制git filter-repo --path-glob "*.apk" --invert-paths
4.2 filter-repo 执行后还需要做什么
git filter-repo 执行完成后,会有几个自动动作:
- 重写了所有 commit,旧的 commit 对象变成孤儿对象,不再被任何引用指向;
- 移除了原有的 remote 配置(这是它的设计之一,防止你在历史不一致的情况下直接推送到原远端);
- 自动执行了 expire 和 gc,把孤儿对象清理掉。
所以操作之后需要重新添加远端地址:
bash复制git remote add origin <你的远端地址>
然后推送。因为历史已经被重写了,和远端仓库完全不匹配,所以需要强制推送:
bash复制git push origin --force --all
git push origin --force --tags
有些托管平台在服务端还启用了推送保护(push protection),会对含敏感信息的提交做拦截,如果遇到这种报错,需要去仓库设置里临时关掉保护,推送完再打开。CNB 我没有遇到这个拦截,但其他平台例如 GitHub 上很常见,这里提醒一下。
4.3 为什么删除文件后仓库体积还是那么大
有不少人以为用 git rm 删掉大文件再提交就能瘦身,结果发现克隆仓库还是要几百 MiB。原因还是那句话:Git 的提交历史里仍然保存着那个大文件的 blob 对象。git rm 只是让当前分支不再引用这个文件,但要想真正从仓库里“消失”,必须重写整个历史。
用 git filter-repo 做完之后,可以用这些方式验证是否真的清理干净了:
bash复制# 查看仓库里还有没有超过 100M 的历史对象
git rev-list --objects --all | \
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | \
awk '/^blob/ {print $3, $4}' | \
sort -rn | head -20
# 查看仓库整体体积
du -sh .git
如果看到最大 blob 已经降到正常范围,说明清理成功。另外,git filter-repo 执行完后建议用 git count-objects -vH 检查一下对象库体积,正常情况下应该明显变小。
5. 对比和选择:两个方案我该用哪个
很多人在这一步会纠结:LFS 和 filter-repo 到底该选哪个?我的判断标准很简单:这个文件需不需要被项目团队长期共享和访问。
如果你需要团队里所有人在拉取代码时都能拿到这个文件(比如设计资源、测试数据、模型权重),那就用 Git LFS。它保证了文件在版本控制体系内有版本记录,更新和回滚都有迹可循,而且不再受单文件大小限制。
如果这个文件纯粹是个错误提交,或者只在某次构建中临时用了一下,那 filter-repo 是更干净的方案。它能让仓库回到“从来没有过这个大文件”的状态,仓库体积小,克隆速度快。缺点是历史被改写了,如果有其他人已经基于旧历史做了开发,他们会需要重新同步。
另外还有一种混合情况:如果你有多个大文件,而且未来还可能继续增加,建议一次性把整个仓库切换到 LFS 方案,把常见的大文件类型(*.apk、*.zip、*.bin、*.model 等)都加入跟踪规则,形成长期规范。
从团队协作角度来说,LFS 的成本更低,因为成员只需要在首次克隆时多跑一个 git lfs pull,日常使用和普通 Git 没有区别。而 filter-repo 重写历史虽然一劳永逸,但对团队来说是一次破坏性变更,需要全员配合。
6. 常见问题排查与避坑实践
这类大文件限制问题,我在实际处理中总结了一些高频问题和对应解法,按踩坑概率排个序。
6.1 为什么删了大文件、重新提交后,推送还是报同样的错
这是遇到最多的问题。原因是 Git 历史里仍然存在包含大文件的 commit。删除文件的新 commit 只是“从当前版本中移除”,旧 commit 上的 blob 对象依然是历史的一部分。服务器执行推送时,会扫描整个推送范围内的所有对象。解决方式就是上面说的 git filter-repo 重写历史,或者用 git lfs migrate 把历史中的文件改写成指针。
这里有一个排查技巧:在本地确认自己到底改没改干净,不要反复推送到远端试错。用 git rev-list 的命令先查一遍本地对象库,如果本地已经查不到大 blob,再考虑推送。
6.2 多人协作时,历史重写后其他人怎么同步
如果你用了 filter-repo 或 lfs migrate,历史变了,团队其他人拉取代码时大概率会遇到各种奇怪的分叉和冲突。最稳妥的做法是收齐所有人的本地未推送改动,然后统一用一个新仓库地址或者做一次 hard reset。
具体流程:先让团队成员提交本地所有改动并推送到一个临时分支(比如 wip-backup),然后由操作者在主分支完成历史重写并强制推送。之后大家重新克隆仓库,从 wip-backup 挑拣自己需要的最后改动,或者直接把 wip-backup 的分支指向新历史。听起来有点麻烦,但总比每个人都在旧历史上继续开发要好得多。所以重要的事情说三遍:重写历史前先同步团队,重写历史前先同步团队,重写历史前先同步团队。
6.3 使用 LFS 后克隆仓库时提示 LFS 对象下载失败
这种情况多半是因为 LFS 对象没有推送到远端。先确认本地 LFS 缓存里有没有对象:
bash复制git lfs ls-files
如果文件显示但状态不是 *(表示已上传),执行:
bash复制git lfs push --all origin
如果还是失败,检查一下 LFS 的远端地址是否配置正确:
bash复制git config --list | grep lfs
有些自建 Git 服务需要在服务器端额外配置 LFS 插件,CNB 这类托管平台则默认支持,不需要手动干预。
6.4 推送时还提示其他对象超限,但明明已经处理过目标文件
这说明仓库里还有第二个甚至第三个大文件。回到第 2 节的大对象扫描命令,把 head -20 的结果列全,一次把所有超限文件都处理掉,避免推一次被拦一次,来回消耗时间。
还有种情况是 git gc 没有执行,历史对象虽然不再被引用,但还物理存在。如果确认历史已经移除完,但 .git 目录还是很大,执行一次:
bash复制git reflog expire --expire=now --all
git gc --prune=now --aggressive
这两条命令会把孤儿对象彻底清理掉。
6.5 怎么防止以后再次出现这个问题
预防永远比事后清理省事。我现在的习惯是在仓库里维护一份 .gitignore,把构建产物、日志、压缩包等容易误提交的路径全部忽略掉。同时可以配置一个 pre-push 钩子,提交前自动检测大文件:
bash复制#!/bin/sh
# .git/hooks/pre-push
limit=262144 # 256 MiB,单位 KB
files=$(find . -type f -size +${limit}k -not -path "./.git/*")
if [ -n "$files" ]; then
echo "检测到超过 256 MiB 的文件,禁止推送:"
echo "$files"
exit 1
fi
exit 0
注意这个钩子不会自动生效,需要手动加执行权限:
bash复制chmod +x .git/hooks/pre-push
另外,如果是团队项目,建议在项目文档里明确约定:大文件必须走 Git LFS,且列出哪些文件类型应该被 LFS 接管。规范一旦定下来,后面基本不会再碰到这种服务端拒绝的报错。
写在最后的几个提醒
这次 CNB 的 309 MiB 超限问题,处理过程其实不算复杂,但它把 Git 对象存储、历史重写、LFS 机制这些平时不太注意的底层逻辑都带出来了。我个人最大的体会是:遇到这类报错,别急着“删了再提交”,先弄清楚这个文件到底在仓库里存活了多久、被多少个历史提交引用。定位清楚了,再选 LFS 还是 filter-repo,效率会高很多。
还有一个小建议:处理完这类问题之后,顺手把当时用过的命令和思路记到团队文档里。下次谁再遇到同样的报错,直接把文档甩过去,省得每个人都要从零开始踩一遍坑。毕竟这种大文件问题,在一个团队里往往不会只出现一次。
