Почему Git ломается на больших файлах
В предыдущих модулях мы говорили “Git хранит snapshots”. Это правда, но не вся правда — Git делает много умного, чтобы snapshots занимали мало места. Для текстовых файлов это работает отлично. Для больших бинарных — катастрофически плохо. Закоммитил CSV на 50MB пять раз — внезапно у тебя репо размером 250MB, и git clone идёт минуты вместо секунд.
Для Data Engineer это важно понимать. CSV-снимки, parquet файлы, дампы баз, ML-модели, картинки для визуализации — всё это тянет тебя в зону, где Git становится не помощником, а врагом. В этом уроке: как именно Git хранит объекты, почему delta compression не спасает для бинарей, как репо разрастается, и где грань “уже плохо”.
Напоминание: что такое objects в .git
Когда ты делаешь git add + git commit, Git создаёт три типа объектов в .git/objects/:
Главное свойство — Git хранит содержимое целиком в blob-е. Если ты изменил один байт в файле, Git создаёт новый blob (новый SHA), а старый остаётся в .git/objects/ навечно (пока не запустится garbage collector, и то с условиями).
Посмотри своими глазами:
# В любом репо
$ ls .git/objects/
00 03 05 07 ... ff info pack
$ ls .git/objects/00/
b3f5c8d9e2a1... ← это blob-ы, tree-ы или commit-ы
# Сколько занимает .git/?
$ du -sh .git/
12M .git
Каждый коммит — это новые объекты. Со временем .git/ накапливается.
Delta compression: магия для текста
Git не такой наивный, чтобы хранить каждый blob независимо. После определённого порога (обычно при git gc или при push) Git упаковывает объекты в packfiles (.git/objects/pack/*.pack).
В packfile похожие blob-ы хранятся как delta — разница относительно базового blob-а. Это называется delta compression.
blob v1: "hello world" (11 bytes)
blob v2: "hello mighty world" (18 bytes)
В packfile:
base: "hello world"
delta для v2: "+ ' mighty' at position 5" ← всего ~15 bytes
Для текста это работает фантастически. Изменил одну строку в Python файле — blob v2 хранится как несколько байт описания изменений. Тысячи commit-ов в Python-репо могут занимать всего несколько MB на диске.
# Команда показывает упакованные vs неупакованные объекты
$ git count-objects -vH
count: 32
size: 128.00 KiB
in-pack: 5421
packs: 1
size-pack: 8.42 MiB
prune-packable: 0
garbage: 0
size-garbage: 0
in-pack: 5421, size-pack: 8.42 MiB — пять тысяч объектов в одном packfile, всего 8MB благодаря delta compression. Без deltas это были бы сотни MB.
Почему бинарные файлы — катастрофа
Теперь представь: у тебя CSV с миллионом строк, 50MB. Ты внёс в него одно изменение — обновил одну строку. Создаётся новый blob, тоже 50MB. Изменил ещё раз — третий blob, ещё 50MB.
Можно надеяться, что delta compression спасёт. И иногда спасает — для текстовых CSV есть надежда найти common chunks. Но для сжатых бинарных форматов (parquet, sqlite, zip, jpg, png) — нет.
Причина: алгоритмы сжатия (gzip, snappy, zstd, deflate) распределяют изменения по всему выходному файлу. Один новый байт во входе -> переписаны целиком блоки сжатия. Delta compression не находит общих байт между v1 и v2.
Для DE-форматов это особенно болезненно:
| Формат | Delta-friendly? | Почему |
|---|---|---|
| .csv (несжатый) | Иногда | Текстовый, локальные изменения находятся |
| .json (несжатый) | Иногда | Текстовый |
| .parquet | Нет | Сжатие colonar, переупаковка при изменении |
| .duckdb | Нет | Бинарный формат таблиц |
| .sqlite | Нет | Бинарный B-tree |
| .h5 / .hdf5 | Нет | Бинарный научный формат |
| .pkl (pickle) | Нет | Бинарная сериализация Python |
| .joblib | Нет | Часто содержит compressed numpy arrays |
| .png / .jpg | Нет | Сжатие изображения |
| .zip / .tar.gz | Нет | Архив |
Правило: всё, что выглядит как “бинарь с внутренним сжатием” — Git не может эффективно хранить.
Реальный сценарий: что происходит с репо
Возьмём типичный DE-сценарий — версионируешь маленький parquet с features:
# Старт
$ git init demo && cd demo
$ python -c "
import pandas as pd
import numpy as np
df = pd.DataFrame(np.random.rand(1_000_000, 5))
df.to_parquet('features.parquet')
"
$ ls -lh features.parquet
40M features.parquet
$ git add features.parquet
$ git commit -m "add features v1"
$ du -sh .git
38M .git ← parquet файл в blob-е
# Обновили: добавили 100 новых строк
$ python -c "
import pandas as pd
import numpy as np
df = pd.read_parquet('features.parquet')
new = pd.DataFrame(np.random.rand(100, 5))
df = pd.concat([df, new])
df.to_parquet('features.parquet')
"
$ git add features.parquet
$ git commit -m "add features v2"
$ du -sh .git
76M .git ← Git хранит ОБА файла, нет дельты
# Ещё пять итераций...
$ du -sh .git
228M .git ← 6 версий по ~40MB. Тяжёлый репо за 5 минут работы
После 10 итераций — репо 400MB. После года активной работы с моделями и фичами — гигабайты. git clone для нового члена команды — несколько минут (на хороших сетях) или 30 минут (на корпоративном VPN). Каждый git push — отправка десятков MB на сервер.
GitHub limits — где граница
GitHub имеет конкретные лимиты:
Что произойдёт реально:
# Попытка запушить 150MB файл
$ git push origin main
remote: error: File big.parquet is 150.00 MB; this exceeds GitHub's file size limit of 100.00 MB
remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com.
To https://github.com/user/repo.git
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to 'https://github.com/user/repo.git'
push отклонён. GitHub советует Git LFS (об этом следующий урок). 50-100MB файлы push-ятся, но получаешь warning. И ты сам должен понимать — даже 30MB файл уже подозрителен для Git.
Для DE: 50MB csv = много. 200MB parquet = недопустимо. Перед git add любого data-файла спрашивай себя: “это код или данные?”. Если данные — почти всегда не Git.
GitLab и Bitbucket имеют похожие лимиты. Self-hosted Gitea / GitLab можно настроить выше, но это не решение — клиенту всё равно придётся всё это скачивать.
Что происходит с clone и push
Допустим, репо разросся до 1GB из-за бинарей. Конкретные последствия:
1. git clone — медленно.
$ git clone https://github.com/acme/heavy-repo.git
Cloning into 'heavy-repo'...
remote: Enumerating objects: 4521, done.
remote: Counting objects: 100% (4521/4521), done.
remote: Compressing objects: 100% (2103/2103), done.
Receiving objects: 23% (1040/4521), 234.42 MiB | 1.23 MiB/s
При 1 MB/s на корпоративном VPN — 15 минут на клон. На хорошем home-интернете — пара минут, но всё равно ощутимо. У DE может быть 5 таких репо для разных проектов — экстраполируй.
2. CI/CD job startup — медленно.
GitHub Actions при каждом запуске делает clone. Если репо 1GB — каждый CI job начинается с 5-минутного clone-а. При 50 PR в день — 250 минут CI-времени уходит впустую.
3. Pack-files раздуваются — операции медленные.
git log сканирует объекты. git status сравнивает working tree с indexed blobs. На 50MB blob-ах это секунды вместо миллисекунд.
4. Disk usage — гигабайты на клиенте.
Каждый разработчик носит копию репо. 10 разработчиков × 1GB = 10GB размазано по машинам. Плюс по 1GB на каждый клон одного и того же репо (если кто-то клонирует в разные папки).
Что считается “большим” в DE
Шкала:
Это эвристика. Точнее: главное не размер файла, а частота изменений × размер. Один 50MB файл, который не меняется — терпимо в обычном Git. Один 5MB файл, который меняется каждый день — за год превратит репо в гигабайт.
Самый частый ляп: коммит venv или pycache
Junior, не настроивший .gitignore, может закоммитить .venv/ целиком — это 500MB-1GB Python packages с бинарными .so файлами. Или node_modules/ — десятки тысяч файлов на сотни MB.
Такая ошибка превращает репо в монстра. Удалить из истории — сложно (git filter-repo, см. модуль 18). Поэтому профилактика через .gitignore критична.
# Защита от коммита больших файлов (опциональный pre-commit hook)
# В .pre-commit-config.yaml:
hooks:
- id: check-added-large-files
args: ['--maxkb=1024'] # 1MB
Это hook из pre-commit-hooks, который кричит при попытке закоммитить файл больше указанного размера. См. модуль 16.
Diagnostic команды
Если ты не знаешь, насколько ваш репо плох, вот команды для аудита:
# 1. Общий размер .git/
$ du -sh .git/
1.2G .git
# 2. Объекты по типу
$ git count-objects -vH
count: 0
in-pack: 12453
size-pack: 1.20 GiB
prune-packable: 0
garbage: 0
# 3. Топ-10 самых больших объектов в истории
$ git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '/^blob/ {print $3, $4}' \
| sort -rn \
| head -10
# Вывод:
524288000 data/dump.sqlite ← 500MB!
167772160 models/lgbm_v3.pkl ← 160MB
83886080 data/features.parquet ← 80MB
Эта команда — золото для аудита. Видишь топ-10 самых больших файлов в истории (не только сейчас). Если в топе — твои текстовые исходники, всё хорошо. Если — dump.sqlite, model.pkl, dataset.parquet — нужно чистить (модуль 18 — git filter-repo).
# 4. Сколько объектов в репо
$ git rev-list --all --count
1247 ← коммитов
$ git rev-list --objects --all | wc -l
15823 ← всего объектов (blobs + trees + commits)
Куда идём дальше
Если в твоей работе есть большие файлы, у тебя три стратегии:
.gitignoreвсё data/ — данные не в Git вообще. Хранятся в S3/GCS, скачиваются скриптом. Самый чистый подход для production ETL.- Git LFS — большие файлы в Git, но фактическое содержимое в отдельном LFS-storage. См. урок 2 этого модуля.
- DVC — внешнее хранилище data, метаданные (хеши) в Git. Идеально для ML/DE. См. урок 3.
Никогда: не пускай большие бинари в обычный Git. Это технический долг, который сам по себе не исчезнет.
Попробуй сам: посмотри свой репо
# В любом своём репо
cd ~/some-project
# Размер
du -sh .git/
# Топ-10 больших файлов в истории
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '/^blob/ {print $3, $4}' \
| sort -rn \
| head -10
# Если в топе - бинари размером >10MB, есть проблема
Если репо чистый — поздравляю, .gitignore работает. Если в топе — питон-пакет, parquet или sqlite, то ты в положении большинства DE: история репо засорена, надо разбираться. Следующие уроки — три способа жить с большими данными в Git-инфраструктуре.
Форматы хранения данных: Parquet, ORC, Avro