Senior: питання на співбесіді з теми «Великі репозиторії й продуктивність»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Що робить git status:
- порівнює індекс з
HEAD- швидко, працює з хешами; - порівнює робочий каталог з індексом - для кожного відстежуваного файлу перевіряє метадані (
stat: розмір, час зміни, inode); - шукає невідстежувані файли - обходить усі каталоги й застосовує правила
.gitignore.
У репозиторії зі 300 тисячами файлів кроки 2 і 3 - сотні тисяч системних викликів на кожен status. А IDE й промпт оболонки викликають status постійно.
Прискорення:
1. fsmonitor - Git питає не файлову систему, а демон, що стежить за змінами:
git config core.fsmonitor true
Вбудований демон (macOS і Windows) підписується на події файлової системи, і status перевіряє лише файли, змінені з минулого разу. На Linux - через hook з Watchman.
2. Кеш невідстежуваних файлів:
git config core.untrackedCache true
Git запам'ятовує, які каталоги не змінювалися, і не обходить їх повторно. У поєднанні з fsmonitor ефект найбільший.
3. Менше файлів у робочому каталозі:
- sparse-checkout - найдієвіше для монорепозиторію;
- sparse index (
git sparse-checkout init --cone --sparse-index) - індекс зберігає цілі каталоги поза набором одним записом.
4. Версія індексу й багатопотоковість:
git config feature.manyFiles true # index.version=4, untrackedCache
git config index.threads true
5. scalar - утиліта, що постачається з Git і вмикає всі рекомендовані налаштування для великих репозиторіїв разом:
scalar clone https://github.com/acme/monorepo.git
scalar register # для існуючого клону
Вмикає частковий клон, sparse-checkout у режимі cone, fsmonitor, фонове обслуговування й низку налаштувань продуктивності.
Діагностика:
GIT_TRACE2_PERF=1 git status # де витрачається час
git status --untracked-files=no # чи справа в пошуку невідстежуваних
Типові причини поза Git:
- величезні ігноровані каталоги (
node_modules,vendor) - кеш невідстежуваних і fsmonitor допомагають; - антивірус на Windows, що сканує кожне звернення до файлу;
- мережеві файлові системи й bind mount у Docker на macOS.
Ці файли - допоміжні індекси поруч з об'єктами. Вони не змінюють дані, а лише прискорюють операції, які інакше вимагали б читання й розпакування тисяч об'єктів.
Commit-graph (.git/objects/info/commit-graph):
Щоб пройтися історією (git log --graph, merge-base, branch --contains), Git має для кожного коміту знайти батьків - розпакувати об'єкт коміту й розібрати текст. На мільйоні комітів це секунди.
Commit-graph зберігає в компактному бінарному форматі для кожного коміту: батьків, дерево, дату й номер покоління (generation number) - відстань від кореня. Завдяки номеру покоління Git відсікає гілки історії, що не можуть містити шуканий коміт, і не обходить їх.
git commit-graph write --reachable --changed-paths
--changed-paths додає Bloom-фільтри: для git log -- path Git швидко пропускає коміти, які точно не змінювали файл.
Multi-pack-index (.git/objects/pack/multi-pack-index):
Після багатьох fetch у репозиторії десятки pack-файлів, і пошук об'єкта - перебір індексу кожного. Multi-pack-index - один спільний індекс для всіх pack-файлів. Він також дозволяє поступове перепакування: об'єднувати дрібні pack-файли, не переписуючи весь великий.
git multi-pack-index write
Reachability bitmaps (.bitmap поруч з pack-файлом):
Щоб відповісти на clone чи fetch, сервер має визначити, які об'єкти досяжні з потрібних комітів і яких у клієнта ще немає. Без bitmap - обхід усього графа. Bitmap для вибраних комітів зберігає готовий бітовий вектор «які об'єкти досяжні», і відповідь обчислюється операціями над бітами.
git repack -a -d --write-bitmap-index
Саме bitmaps роблять клонування великих репозиторіїв з GitHub швидким - це передусім серверна оптимізація.
Як це використовується на практиці:
- автоматично:
git gcіfetchпишуть commit-graph (gc.writeCommitGraph,fetch.writeCommitGraph),git maintenanceоновлює commit-graph і multi-pack-index у фоні; - scalar вмикає все разом;
- власний Git-сервер (Gitea, GitLab self-hosted) - тут bitmaps і регулярне перепакування на сервері впливають на швидкість клонів у CI.
Вручну це потрібно рідко: для типового Laravel-проєкту різниці не видно. Для монорепозиторію з мільйонами об'єктів - це різниця між секундами й хвилинами в git log і fetch.
laravel/framework - монорепозиторій: усі компоненти (src/Illuminate/Database, Support, Collections...) розвиваються в одному репозиторії з однією історією, одним набором тестів і однією системою CI.
Але користувачі можуть встановити окремий компонент - наприклад, Eloquent поза Laravel:
composer require illuminate/database
Пакети illuminate/* живуть в окремих репозиторіях лише для читання (github.com/illuminate/database), які автоматично отримуються з монорепозиторію розділенням історії (subtree split).
Як працює розділення:
Для каталогу src/Illuminate/Database інструмент проходить історію й будує нову історію, в якій:
- лишаються лише коміти, що змінювали цей каталог;
- файли зміщені в корінь (як
git filter-repo --subdirectory-filter); - результат детермінований: той самий вхід дає ті самі хеші, тож наступне розділення лише додає нові коміти, і push у репозиторій пакета - fast-forward.
SHA=$(splitsh-lite --prefix=src/Illuminate/Database)
git push git@github.com:illuminate/database.git "$SHA:refs/heads/13.x"
Вбудований git subtree split робить те саме, але на великій історії працює дуже повільно - тому Laravel, Symfony й інші використовують швидкий splitsh-lite, що кешує проміжні результати.
Процес у CI: після кожного push у гілку чи тегу монорепозиторію запускається розділення для кожного компонента й push у відповідні репозиторії - разом з тегами, щоб Packagist побачив нові версії.
Що потрібно для такої схеми:
composer.jsonу кожному компоненті з власними залежностями (illuminate/databaseзалежить відilluminate/support,illuminate/collections), а кореневийcomposer.jsonмонорепозиторію оголошуєreplaceдля всіх компонентів, щоб вони не встановлювалися двічі;- pull request лише в монорепозиторій: у репозиторіях пакетів pull request автоматично закриваються з поясненням, куди їх надсилати;
- узгоджене версіонування - усі компоненти отримують однаковий тег при релізі;
- межі між компонентами: залежності між каталогами мають відповідати оголошеним у
composer.json, інакше окремий пакет не працюватиме без решти фреймворку.
Для своїх проєктів схема корисна, якщо ви підтримуєте набір пов'язаних пакетів (SDK, модулі) - розробка й тестування разом, а публікація окремими пакетами. Для звичайного застосунку вона зайва.
Задача: є репозиторії api, admin і shared, і їх треба перенести в один репозиторій з каталогами apps/api, apps/admin, packages/shared - не втративши історію, щоб git log і blame працювали для старого коду.
Крок 1 - у кожному вихідному репозиторії перемістити файли в цільовий каталог через переписування історії (на свіжому клоні):
git clone https://github.com/acme/api.git api-rewrite
cd api-rewrite
git filter-repo --to-subdirectory-filter apps/api
Кожен коміт історії тепер виглядає так, ніби файли завжди лежали в apps/api. Без цього кроку git log -- apps/api/... і blame губили б історію на межі переміщення.
Крок 2 - злити переписані історії в новий репозиторій:
git init monorepo && cd monorepo
git commit --allow-empty -m "Initial monorepo commit"
git remote add api ../api-rewrite
git fetch api
git merge --allow-unrelated-histories --no-edit api/main
--allow-unrelated-histories потрібен, бо історії не мають спільного предка - без нього Git відмовляє: refusing to merge unrelated histories.
Повторити для admin і shared. Результат - коміт злиття, в якому сходяться всі історії.
Що ще врахувати:
- теги конфліктують:
v1.0.0є в кожному репозиторії. Перейменувати при переписуванні:git filter-repo --tag-rename '':'api-'даєapi-v1.0.0; - гілки в роботі: відкриті гілки теж треба перенести - переписати й відновити в монорепозиторії, або домовитися злити їх до міграції;
- pull request і задачі посилаються на старі хеші й репозиторії - старі репозиторії архівувати (не видаляти), лишивши в README посилання на монорепозиторій;
- CI, деплой, права, вебхуки налаштувати заново під каталоги;
- залежності: якщо
apiпідключавsharedяк Composer-пакет за версією - перейти наpath-репозиторій, інакше в монорепозиторії залишаться дві копії коду; .gitignore,.gitattributes, конфіги лінтерів з коренів репозиторіїв опиняться в підкаталогах - вирішити, що лишити локально, а що винести в корінь.
Порядок міграції: заморозити push у старі репозиторії, перенести, перевірити історію (git log --follow, blame на кількох файлах), перемкнути CI - і лише потім відкрити монорепозиторій для роботи.
Зворотна операція - виділення каталогу в окремий репозиторій - git filter-repo --subdirectory-filter packages/shared на свіжому клоні.
Докладніше в документації: git merge: --allow-unrelated-histories