Кеш в памяти в Go: чек-лист перед выкатом в прод
Что проверить в собственном in-memory кеше на Go, прежде чем он утечёт по памяти, встанет под конкурентным доступом или отдаст протухшие данные. Разбор по пунктам с рабочим кодом.

Собственный кеш в памяти в Go пишется за десять минут: map[string]T, sync.RWMutex, готово. Проблемы начинаются на второй неделе в проде: RSS процесса растёт линейно по времени, под нагрузкой появляются гонки на границе истечения TTL, а после деплоя половина инстансов дружно идёт в базу за одним и тем же ключом. Ниже — чек-лист из шести пунктов, которые ломаются в самодельном кеше чаще всего, с примерами на Go 1.22+. Формат — практика: не пишем свою библиотеку с нуля, а проверяем то, что уже есть или планируется.
Пункт 1. Кто чистит протухшие записи
Самая частая ошибка — TTL проверяется только при чтении. Ключ записали, обращений к нему больше не было, запись живёт в map до конца жизни процесса. Для 100 уникальных ключей неважно, для миллиона одноразовых токенов — это утечка.
Два рабочих подхода к вытеснению просроченного:
- Ленивое (lazy) — проверяем срок только на чтении, удаляем при попадании в истёкший ключ. Память не освобождается, пока ключ не запросят.
- Активное (janitor) — фоновая горутина раз в N секунд проходит по map и удаляет протухшее. Освобождает память, но раз в интервал держит блокировку и создаёт нагрузку на GC.
Комбинация обоих — то, что делает популярная библиотека patrickmn/go-cache: ленивое чтение плюс janitor с настраиваемым интервалом.
// Go 1.22, схематичный janitor поверх своей map
func (c *Cache) startJanitor(interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
now := time.Now().UnixNano()
c.mu.Lock()
for key, item := range c.items {
if item.expiresAt > 0 && now > item.expiresAt {
delete(c.items, key)
}
}
c.mu.Unlock()
}
}()
}
Слабое место такого janitor — полный проход под Lock(). На map в миллион ключей это заметная пауза, во время которой встают все читатели. Если ключей много, дробите проход: собирайте кандидатов на удаление под RLock, удаляйте пачками под коротким Lock.
Пункт 2. Что мешает памяти течь без ограничения
TTL не спасает от роста, если ключи приходят быстрее, чем протухают. Нужен потолок по числу записей и политика вытеснения при его достижении. Голая map такого не умеет — delete по количеству придётся писать самому либо брать библиотеку с LRU.
hashicorp/golang-lru/v2 даёт типизированный LRU с фиксированным размером из коробки:
import lru "github.com/hashicorp/golang-lru/v2"
// Кеш не более чем на 10 000 записей, старое вытесняется автоматически
cache, err := lru.New[string, []byte](10000)
if err != nil {
log.Fatal(err)
}
cache.Add("user:42", payload)
val, ok := cache.Get("user:42")
Ограничение по числу ключей — не то же самое, что ограничение по памяти. 10 000 записей по 2 КБ и 10 000 записей по 5 МБ — разница в три порядка. Если значения переменного размера, считайте байты, а не элементы.
Для контроля именно памяти смотрите в сторону ristretto от Dgraph: он вытесняет по стоимости (cost) записи, которую вы задаёте сами, а не по их количеству.
Пункт 3. Как ведёт себя под конкурентным доступом
sync.RWMutex вокруг одной map — корректно, но при высокой доле записей превращается в узкое горлышко: все горутины сериализуются на одном замке. Варианты, когда упёрлись в контеншн:
- sync.Map — оптимизирована под сценарий «пишем один раз, читаем много раз» и под непересекающиеся наборы ключей у разных горутин. Для равномерной смеси чтений и записей часто проигрывает обычной map с мьютексом.
- Шардирование — N независимых map, ключ распределяется по шарду через хеш. Замок держится только на своём шарде, контеншн падает в N раз. Так устроен
bigcache.
Отдельная ловушка — гонка на промахе кеша, cache stampede: при истечении популярного ключа сотня горутин одновременно видит промах и идёт в базу. Решается через singleflight: первый запрос выполняется, остальные ждут его результат.
import "golang.org/x/sync/singleflight"
var group singleflight.Group
func getUser(id string) (*User, error) {
// Для одного ключа loadFromDB выполнится один раз,
// остальные вызовы дождутся того же результата
v, err, _ := group.Do(id, func() (interface{}, error) {
return loadFromDB(id)
})
if err != nil {
return nil, err
}
return v.(*User), nil
}
Пункт 4. Не отдаёт ли кеш ссылку на изменяемые данные
Если в кеше лежит *User или срез, Get возвращает ссылку на тот же объект, что лежит внутри. Один вызывающий поменял поле — изменение увидели все, включая тех, кто читает параллельно, без всякого замка. Это гонка данных, которую go build -race поймает не всегда, потому что формально доступ к самой map защищён.
Правила безопасного хранения:
- Кладите и возвращайте значения, а не указатели, если структура небольшая и копируется дёшево.
- Для указателей и срезов договоритесь, что содержимое иммутабельно после записи в кеш. Меняете — создавайте новый объект и перезаписывайте ключ.
- Не кладите в кеш то, что содержит
sync.Mutexвнутри: копирование такой структуры при возврате значения ломает замок.
Пункт 5. Сравнение готовых решений
Свой кеш оправдан, когда логика вытеснения нестандартная. В остальных случаях берите проверенное. Ориентир по выбору:
| Решение | Вытеснение | Когда брать | Чем платите |
|---|---|---|---|
| map + RWMutex | своё | десятки–сотни ключей, простая логика | сами следите за памятью и контеншном |
| sync.Map | нет из коробки | read-heavy, редкие записи | нет TTL и лимита, тип interface{} |
| golang-lru/v2 | LRU по числу ключей | нужен предсказуемый потолок | лимит в штуках, не в байтах |
| bigcache | по TTL, шардирование | миллионы записей, минимум GC | значения — только []byte, нужна сериализация |
| ristretto | TinyLFU по стоимости | контроль памяти в байтах, высокий hit ratio | сложнее API, вытеснение вероятностное |
Строку про GC у bigcache стоит пояснить: он хранит данные в предвыделенных байтовых буферах, чтобы миллионы записей не порождали миллионы указателей, которые сборщику мусора приходится обходить. За это платите тем, что всё сериализуется в []byte.
Пункт 6. Что произойдёт при рестарте
In-memory кеш умирает вместе с процессом. Это не проблема сама по себе, но у неё есть последствия, которые надо осознавать до, а не после инцидента:
- Холодный старт. Первые секунды после деплоя все запросы промахиваются и идут в источник. Если источник — база под нагрузкой, деплой может её положить. Прогрев критичных ключей на старте или постепенный ввод инстанса в балансировку снимают пик.
- Рассинхрон между инстансами. При нескольких репликах у каждой свой кеш. Ключ, инвалидированный на одном инстансе, живёт на остальных до истечения TTL. Если нужна согласованная инвалидация — это уже распределённый кеш (Redis, Memcached), а не память процесса.
Инвалидация по событию между инстансами — отдельная тема, здесь её не разворачиваем; для in-memory обычно ограничиваются коротким TTL и мирятся с рассинхроном.
Что мерить после выката
Кеш без метрик — чёрный ящик. Минимальный набор, который стоит завести до первого инцидента:
- Hit ratio — доля попаданий. Падает ниже ожидаемого — либо TTL слишком короткий, либо лимит вытесняет нужное. У
golang-lruиristrettoесть встроенные счётчики. - Число записей и оценка байтов — чтобы поймать рост до OOM, а не после.
- Время под замком — если janitor или медленные читатели держат
Lock, это видно по хвостам латентности запросов. - RSS процесса в разрезе времени — линейный рост без плато означает, что вытеснение не работает.
Следующий шаг: прогоните нагрузочный тест с go test -race и профилем памяти (go tool pprof по heap), сравните RSS через час и через сутки под ровной нагрузкой. Если график вышел на плато — вытеснение работает. Если растёт — возвращайтесь к пунктам 1 и 2.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: hashicorp/golang-lru — репозиторий на GitHub, golang.org/x/sync/singleflight — документация пакета
Частые вопросы
sync.Map или map с RWMutex — что быстрее?
А если у меня несколько инстансов сервиса — in-memory кеш подойдёт?
Race detector не ругается — значит, гонок в кеше нет?
Указанные версии библиотек актуальны? У меня другая.
Кеш растёт по памяти, хотя TTL стоит. Почему?
Нужен ли вообще потолок по размеру, если есть TTL?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.