Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Senior: питання на співбесіді з теми «Великі репозиторії й продуктивність»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Що робить git status:

  1. порівнює індекс з HEAD - швидко, працює з хешами;
  2. порівнює робочий каталог з індексом - для кожного відстежуваного файлу перевіряє метадані (stat: розмір, час зміни, inode);
  3. шукає невідстежувані файли - обходить усі каталоги й застосовує правила .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.

Докладніше в документації: git fsmonitor--daemon

Ці файли - допоміжні індекси поруч з об'єктами. Вони не змінюють дані, а лише прискорюють операції, які інакше вимагали б читання й розпакування тисяч об'єктів.

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.

Докладніше в документації: git commit-graph

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, модулі) - розробка й тестування разом, а публікація окремими пакетами. Для звичайного застосунку вона зайва.

Докладніше в документації: splitsh/lite

Задача: є репозиторії 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