24. Кеширование
Фил Карлтон однажды заметил: «В информатике есть только две по-настоящему сложные проблемы: инвалидация кэша и придумывание названий».
Кеширование — это всегда сделка с дьяволом: вы покупаете микросекундную задержку ответа сегодня ценой сложнейшей борьбы за консистентность данных завтра. В таких динамических средах, как PHP или Python, разработчики привыкли полагаться на черные ящики вроде Memcached или расширения фреймворков. В Go подход к кешированию требует предельной честности перед физической машиной: как данные ложатся в память, сколько указателей придется обойти сборщику мусора (GC mark phase), и что произойдет с базой данных, когда у популярного ключа внезапно истечет срок жизни.
Философия кеширования в Go
Кеширование в Go — это не просто map[string]any для ускорения запросов. Это архитектурный паттерн, который радикально снижает задержку (latency) и нагрузку на БД, но вводит фундаментальную сложность: управление согласованностью данных (consistency). В отличие от PHP или Python, где кеши часто делегируются внешним расширениям или фреймворкам, Go требует явного контроля над аллокациями, эвикцией и конкурентным доступом, чтобы не превратить кеш в причину утечек памяти или GC-пауз.
Основные стратегии кеширования
В production-микросервисах де-факто стандартом является Cache-Aside (Lazy Loading). Другие паттерны применяются в специфичных сценариях:
- Cache-Aside: Приложение читает из кеша. При промахе (miss) запрашивает БД, сохраняет результат в кеш с TTL и возвращает клиенту. Устойчив к падениям кеша, не требует синхронизации записи.
- Write-Through: Запись происходит синхронно в кеш и БД. Гарантирует консистентность, но увеличивает задержку
PUT/POSTзапросов. - Write-Behind (Async): Запись сразу в кеш, асинхронный batch-сброс в БД. Максимальная пропускная способность, но риск потери данных при крахе процесса.
flowchart LR
A["Запрос от клиента"] --> B["Проверка кеша<br>Hit или Miss"]
B -->|"Hit"| C["Возврат данных<br>из кеша"]
B -->|"Miss"| D["Запрос к БД<br>Чтение данных"]
D --> E["Запись результата<br>в кеш с TTL"]
E --> C
C --> F["Ответ клиенту"]
classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
classDef decision fill:#334155,stroke:#f59e0b,color:#f8fafc;
classDef hit fill:#0f172a,stroke:#10b981,color:#f8fafc;
class A,D,E,F normal;
class B decision;
class C hit;
Идиоматичная реализация In-Memory кеша
Стандартная библиотека Go не предоставляет готового кеша с TTL и автоматической очисткой. Идиоматичная реализация использует дженерики для типобезопасности, sync.RWMutex для конкурентного доступа и фоновый воркер для удаления истекших записей.
package cache
import (
"sync"
"time"
)
type Item[V any] struct {
Value V
ExpiresAt time.Time
}
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]Item[V]
ttl time.Duration
done chan struct{}
}
func New[K comparable, V any](ttl, cleanupInterval time.Duration) *Cache[K, V] {
c := &Cache[K, V]{
items: make(map[K]Item[V]),
ttl: ttl,
done: make(chan struct{}),
}
go c.startCleanup(cleanupInterval)
return c
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
c.items[key] = Item[V]{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
c.mu.Unlock()
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
item, ok := c.items[key]
c.mu.RUnlock()
if !ok {
var zero V
return zero, false
}
if time.Now().After(item.ExpiresAt) {
return item.Value, false
}
return item.Value, true
}
func (c *Cache[K, V]) startCleanup(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.mu.Lock()
now := time.Now()
for k, v := range c.items {
if now.After(v.ExpiresAt) {
delete(c.items, k)
}
}
c.mu.Unlock()
case <-c.done:
return
}
}
}
func (c *Cache[K, V]) Close() {
close(c.done)
}
Проблема Cache Stampede (Thundering Herd)
Когда ключ истекает (TTL) или вытесняется (eviction), тысячи горутин одновременно обнаруживают промах и идут в БД. Это вызывает мгновенный скачок нагрузки, блокировки таблиц и может уронить базу данных.
Решение: Singleflight. Пакет golang.org/x/sync/singleflight гарантирует, что тяжелая операция (loader) выполнится только один раз для одинакового ключа, а остальные горутины получат тот же результат.
package cache
import (
"fmt"
"golang.org/x/sync/singleflight"
)
type SafeCache[K comparable, V any] struct {
*Cache[K, V]
group singleflight.Group
}
func (sc *SafeCache[K, V]) GetOrLoad(key K, loader func() (V, error)) (V, error) {
if val, ok := sc.Get(key); ok {
return val, nil
}
// singleflight ожидает строковый ключ: сериализуем generic-ключ в строку
v, err, _ := sc.group.Do(fmt.Sprint(key), func() (interface{}, error) {
return loader()
})
if err != nil {
var zero V
return zero, err
}
result := v.(V)
sc.Set(key, result)
return result, nil
}
Производительность и Mechanical Sympathy
Кеширование в памяти должно быть дружелюбным к иерархии кэшей процессора и планировщику Go:
- Sharding (Шардирование): Разделите один
mapна 16-64 независимых сегментов с собственнымиRWMutex. Это распределяет блокировки по разным кэш-линиям CPU (64 байта), снижая false sharing и contention на системной шине. Hash функцииfarmhashилиxxhashминимизируют коллизии. - Локальность данных: Указатели на разбросанные объекты в куче вызывают cache misses (
dTLB-load-misses). Для value-heavy кешей храните структуры inline, а не указатели на них. Если в кеше хранится миллион указателей*User, сборщик мусора Go во время фазы mark обязан обойти каждый указатель, что приводит к многомиллисекундным паузам GC. Использование срезов байт или структур без указателей полностью освобождает кеш от внимания GC! - Аллокации: Каждый
Setсоздает новую структуруItem. Для ultra-low-latency используйтеsync.Poolдля переиспользования буферов сериализации или pre-allocated массивы.
Итог
- Кеширование снижает latency и нагрузку на БД, но вводит сложность управления согласованностью и лимитами памяти.
- Cache-Aside с TTL — стандарт для микросервисов на Go.
- Избегайте cache stampede с помощью
singleflightили локальных блокировок. - Ограничивайте размер кеша (LRU/эвикция) для предотвращения OOM.
mapне возвращает память ОС мгновенно. - Для production используйте
sharded maps,ristrettoилиgroupcacheвместо самописных реализаций. - Всегда применяйте negative caching для защиты от сканирования БД несуществующими ключами.
Следующая статья: 25. Работа с Redis