# go-cache-lru: кеш сборки Go с ограничением по размеру


go-cache-lru — реализация GOCACHEPROG, которая держит кеш сборки Go в заданном размере[^repo]. Один демон на каталог кеша обслуживает все команды `go`, а когда кеш перерастает лимит, удаляет записи, к которым дольше всего не обращались. Есть второй уровень хранения: каталог на сетевом диске или S3-совместимое хранилище. Сегодня вышла версия 0.1.0[^release]. Ниже — откуда взялась задача, почему первая версия индекса жила в файлах и почему вторая живёт в SQLite.

## Проблема

Я работаю с git worktree: каждая задача живёт в своей копии репозитория, параллельно их бывает несколько, и в каждой постоянно идут сборки и тесты. На ноутбуке это быстро съедает диск. 14 октября кеш сборки дорос до 85 ГБ за пять дней. 20 октября на диске в 460 ГБ кеш занимал 66 ГБ, свободного места оставалось 4.7 ГБ, и `make test` падал на линковке:

```
ld: write() failed, errno=28 (No space left on device)
```

`go clean -cache` вернул 78 ГБ свободного места, но через несколько дней картина повторяется.

Команда `go` чистит свой кеш только по возрасту: удаляет записи, к которым не обращались пять дней. Ограничения по размеру нет. При активной работе почти всё в кеше моложе пяти дней, поэтому чистка не успевает за ростом.

## Попытка с `-trimpath`

Первым подозреваемым был путь к исходникам. Без `-trimpath` абсолютный путь к пакету входит в ключ кеша, поэтому каждый worktree собирает свою копию всего. Я замерил это на рабочем репозитории: вторая копия добавляла в кеш 721 МБ, с флагом — 3 МБ. 14 октября `GOFLAGS=-trimpath` попал в мой конфиг окружения.

Копии между worktree флаг действительно убрал. Но через шесть дней кеш снова занимал 66 ГБ: рост остался, просто медленнее. В ключ входят не только пути. Разные флаги сборки дают разные записи: на том же репозитории холодный кеш после сборки и тестов занимал 1 383 МБ без `-race` и 2 263 МБ, когда `-race` включён только в тестах. Каждая новая ветка, каждое изменение зависимостей и каждый прогон с другими флагами добавляют новые записи, а старые живут свои пять дней. `-trimpath` уменьшает объём, но не ограничивает его.

## GOCACHEPROG

С Go 1.24 команде `go` можно подставить внешнюю программу вместо встроенного кеша: переменная `GOCACHEPROG` указывает на бинарник, `go` запускает его и общается с ним по stdin/stdout простым JSON-протоколом[^cacheprog]. Программа отвечает на `get` и `put` и возвращает путь к файлу на диске, откуда `go` прочтёт объект.

Готовые реализации уже есть, я посмотрел четыре: tailscale/go-cache-plugin, platacard/cacheprog, richardartoul/gobuildcache и bradfitz/go-tool-cache[^others]. Все они про общий удалённый кеш для CI, где каталог живёт одну задачу. Ни одна не ограничивает локальный диск. В README platacard/cacheprog прямо сказано, что сборки мусора на диске нет, в README gobuildcache — что нет никакой автоматической чистки ни локального, ни удалённого кеша. Мне нужно обратное: долгоживущий локальный кеш с жёстким лимитом. Так появился go-cache-lru.

## Как устроено

```
go build ──stdin/stdout──▶ go-cache-lru ──unix socket──┐
go test  ──stdin/stdout──▶ go-cache-lru ──unix socket──┼──▶ daemon ──▶ local dir
go vet   ──stdin/stdout──▶ go-cache-lru ──unix socket──┘      │      (LRU, pins)
                                                              └────▶ remote (optional)
```

Каждая команда `go` запускает свой экземпляр `go-cache-lru`, но этот процесс только пересылает байты демону через unix-сокет. Первая команда запускает демон, остальные подключаются к нему, в том числе параллельные сборки из разных worktree. Один демон на каталог гарантирует `flock` на файле блокировки. Блокировка умирает вместе с процессом, поэтому после падения следующий запуск не застревает.

Что делает демон:

- **Держит лимит.** Выше `--max-size` (по умолчанию 30 ГиБ) он удаляет записи, к которым дольше всего не обращались, пока кеш не сожмётся до `--target-size` (25 ГиБ). Удаление идёт в фоне, пачками по 256 записей.
- **Не удаляет файлы в работе.** Протокол требует, чтобы возвращённый файл существовал, пока команда `go` не закроет сессию. Поэтому каждый выданный объект закреплён до конца сессии, и вытеснение его пропускает.
- **Хранит объект один раз.** Файл называется по хешу содержимого, и одинаковые результаты разных действий делят один файл. Перед записью демон проверяет, что тело совпадает с SHA-256 из имени.
- **Понимает `go clean -cache`.** Эта команда GOCACHEPROG не запускает, а просто удаляет подкаталоги `GOCACHE`. Демон держит в одном из них файл-метку и проверяет его раз в две секунды. Пропала метка — значит, была чистка, и демон приводит индекс в порядок.
- **Завершается аккуратно.** По `SIGTERM` или `go-cache-lru stop` он перестаёт принимать подключения, ждёт запущенные команды `go` (по умолчанию минуту), дописывает очередь загрузок и сохраняет статистику.

Удалённое хранилище — второй уровень за локальным диском. Команда `go` читает объекты только из локальных файлов, поэтому диск всегда основной. При промахе демон спрашивает удалённое хранилище (таймаут 10 секунд) и копирует объект на диск, а после локальной записи загружает объект в фоне. Поддерживаются `file://` для каталога, например сетевой шары, и `s3://` для AWS S3, Cloudflare R2, MinIO и других S3-совместимых сервисов. Новый драйвер реализует интерфейс из трёх методов (`Get`, `Put`, `Close`) и регистрирует свою схему URL.

## Первый индекс: файлы

Объекты в любом случае остаются файлами: команда `go` читает их по путям, которые вернул демон. Вопрос был в том, где держать индекс — какому действию соответствует какой объект и когда к нему обращались последний раз.

Сначала я выбрал файлы: на каждую запись индекса отдельный маленький файл, а время последнего обращения — его mtime. Доводы были такие. Каждая запись появляется одним атомарным rename, и каталог всегда согласован сам с собой. База данных дала бы два источника правды: `go clean -cache` удаляет файлы, но не строки, а падение между rename и коммитом оставляет файл без строки или строку без файла. Пишет только демон, так что многопроцессные блокировки SQLite не нужны. И SQLite в Go — это либо cgo, либо заметно более тяжёлый бинарник.

Цена — время запуска: при старте демон читает все записи индекса. Чтобы понять масштаб, я написал тест `TestOpenScale`, который генерирует синтетический кеш и замеряет открытие. Размер взял по первой реальной статистике: 177.9 МиБ в 875 объектах, около 200 КиБ на объект и две записи индекса на объект. Полный кеш в 30 ГиБ — это примерно 150 000 объектов и 300 000 записей индекса, всего 450 000 файлов.

На Mac с Apple Silicon (12 ядер, APFS) открытие такого кеша с прогретым page cache заняло от 6.6 до 15.4 секунды за шесть прогонов. По профилю CPU 97 % времени уходило в системные вызовы, 84 % — в чтение записей индекса по одной: open, read, close и stat на каждый файл. Разброс шёл за нагрузкой на машину. Демон открывает кеш до того, как начинает слушать сокет, поэтому после перезагрузки или `stop` первая команда `go` ждала всё это время. Холодный page cache сразу после загрузки я не замерял: для `purge` нужен root, и там будет медленнее.

## Второй индекс: SQLite

После этого замера индекс переехал в SQLite. Драйвер — `modernc.org/sqlite` на чистом Go, без cgo. В базе одна строка на действие: идентификатор объекта, размер, время записи и время последнего обращения.

Чтобы кеш не ждал базу, горячий путь не изменился. `get` и `put` работают с индексом в памяти под одним мьютексом. Изменения встают в очередь, а фоновая горутина раз в 100 мс пишет их одной транзакцией. При завершении демон дописывает остаток очереди.

Расхождение базы и файлов никуда не делось, его пришлось обрабатывать явно. `go clean -cache` удаляет файлы, а падение теряет до 100 мс изменений из очереди. Поэтому при каждом старте и после каждой обнаруженной `go clean -cache` демон сверяет одно с другим. Он перечисляет файлы объектов по именам через `ReadDir`, не открывая их и не вызывая stat. Строки без объекта удаляются, объекты без строки тоже. Падение стоит не больше последних 100 мс записей в кеш, но согласованность не теряется.

Замер на той же машине, тот же синтетический кеш на 300 000 записей и параллельный бенчмарк пакета кеша (12 горутин, объекты по 4 КиБ, три прогона по 3 секунды):

![Индекс в файлах и в SQLite: запуск демона, Put и Get](https://static.juev.org/2026/10/go-cache-lru-index.svg)

| | Индекс в файлах | Индекс в SQLite |
|---|---|---|
| Открытие, прогретый page cache | 6.6–15.4 с за 6 прогонов | 0.55–0.57 с за 9 прогонов |
| Put | 251–265 мкс | 110–117 мкс |
| Get, попадание | 4.5–4.9 мкс | 1.43–1.47 мкс |
| Память под индекс | около 150 МиБ | около 100 МиБ |
| Индекс на диске | 300 000 файлов | `index.db` на 54 МБ |
| Размер бинарника | 11.6 МиБ | 17.4 МиБ |

Запуск стал быстрее в 12–28 раз и перестал зависеть от нагрузки на машину. Горячий путь тоже ускорился: `put` больше не пишет и не переименовывает файл индекса под блокировкой, а `get` не обновляет mtime файла. Платой стали 5.8 МиБ бинарника и требование Go 1.26 для сборки из исходников — его просит драйвер SQLite. Самой команде `go`, которая пользуется кешем, достаточно 1.24.

Старый индекс в файлах импортируется в базу при первом запуске. На кеше в 300 000 записей импорт один раз занял 42 секунды. Если перед переходом выполнить `go clean -cache`, импортировать нечего.

Вся история от кодека протокола до релиза уложилась в один день: 15 коммитов 27 октября, с 16:00 до 17:40. Замер с индексом в файлах я записал в README в 17:01 вместе с объяснением, почему не SQLite. Через 13 минут индекс уже был в SQLite.

## Как подключить

Готовые бинарники для macOS и Linux (amd64, arm64) лежат на странице релизов. Можно собрать из исходников:

```sh
go install github.com/juev/go-cache-lru/cmd/go-cache-lru@latest
```

Дальше указать бинарник в `GOCACHEPROG`. Флаги идут в ту же переменную:

```sh
export GOCACHEPROG="$HOME/go/bin/go-cache-lru \
  --dir=$HOME/.cache/go-lru \
  --max-size=30GiB \
  --target-size=25GiB"
```

То же для fish:

```fish
set -gx GOCACHEPROG "$HOME/go/bin/go-cache-lru --dir=$HOME/.cache/go-lru --max-size=30GiB --target-size=25GiB"
```

`GOFLAGS=-trimpath` стоит оставить: без него каждый worktree по-прежнему заполняет кеш своими копиями, и до лимита кеш дойдёт быстрее.

Отдельный каталог через `--dir` лучше, чем `GOCACHE`. Команда `go` всё равно открывает `GOCACHE` для корпуса фаззинга и отметки устаревания тестового кеша, и остатки её собственного кеша там не смешаются с файлами go-cache-lru. Перед переходом эти остатки стоит один раз удалить через `go clean -cache`.

Каждый флаг можно задать и переменной окружения: `GOCACHELRU_DIR`, `GOCACHELRU_MAX_SIZE`, `GOCACHELRU_REMOTE` и так далее. Флаг важнее переменной. Удалённое хранилище подключается одним флагом:

```sh
--remote=file:///Volumes/share/go-cache
--remote='s3://my-bucket/go-cache?region=eu-central-1'
--remote='s3://my-bucket/go-cache?endpoint=<account-id>.r2.cloudflarestorage.com&region=auto'
```

Ключи S3 берутся из `AWS_ACCESS_KEY_ID` и `AWS_SECRET_ACCESS_KEY`, из файла учётных данных или из метаданных инстанса. Удалённые объекты go-cache-lru никогда не удаляет, для этого нужно правило жизненного цикла бакета.

Две оговорки. Демон запоминает лимиты, с которыми запущен, поэтому после их изменения нужен `go-cache-lru stop`: следующая команда `go` поднимет демон с новыми значениями. То же после обновления бинарника. И программы, запущенные не из шелла, например IDE из Dock, переменную не видят и пользуются встроенным кешем.

## Как следить

Управляющим командам нужен тот же `--dir`, что и в `GOCACHEPROG`. Главная из них — `stats`. Так выглядит мой кеш через час после перехода:

```
$ go-cache-lru --dir=$HOME/.cache/go-lru stats
daemon          pid 12299, version v0.0.0-20261027142116-62b694c9a6b6 62b694c9a6b6, up 25m49s, 0 active sessions
directory       ~/.cache/go-lru
size            4.4 GiB of 30.0 GiB (target 25.0 GiB), 14.7%
entries         17740 actions, 6020 objects, 0 pinned
counters since  2026-10-27 16:38:51
hits            25955 (73.8%), 5.4 GiB served
misses          9199
puts            22017, 5.1 GiB written
evictions       0 entries, 0 B
cleans          0 explicit, 1 by go clean -cache
sessions        21
```

Счётчики переживают перезапуск демона. `stats -json` отдаёт то же для скриптов, `stats -reset` обнуляет счётчики. Остальные команды:

```sh
go-cache-lru --dir=$HOME/.cache/go-lru trim    # сжать до --target-size сейчас
go-cache-lru --dir=$HOME/.cache/go-lru clean   # удалить всё, что не используется
go-cache-lru --dir=$HOME/.cache/go-lru stop    # аккуратно остановить демон
go-cache-lru --dir=$HOME/.cache/go-lru serve   # запустить демон на переднем плане
```

Журнал демона пишется в `<dir>/gocacheprog/daemon.log`.

## Что не проверено

Вытеснение в реальной работе ещё не срабатывало: в статистике выше 4.4 ГиБ из 30 и ноль вытеснений. Его покрывают тесты, в том числе сквозные с настоящей командой `go`, но на живом кеше лимит я ещё не набрал. Запуск с холодным page cache не замерен. Windows пока не поддерживается.

[^repo]: [juev/go-cache-lru](https://github.com/juev/go-cache-lru), репозиторий с README, где описаны устройство и замеры.
[^release]: [go-cache-lru v0.1.0](https://github.com/juev/go-cache-lru/releases/tag/v0.1.0), бинарники для macOS и Linux.
[^cacheprog]: [cmd/go/internal/cacheprog](https://pkg.go.dev/cmd/go/internal/cacheprog), описание протокола GOCACHEPROG.
[^others]: [tailscale/go-cache-plugin](https://github.com/tailscale/go-cache-plugin), [platacard/cacheprog](https://github.com/platacard/cacheprog), [richardartoul/gobuildcache](https://github.com/richardartoul/gobuildcache), [bradfitz/go-tool-cache](https://github.com/bradfitz/go-tool-cache); README смотрел в октябре 2026.

