Команда Go for Devs подготовила перевод статьи о том, как кэширование помогает ускорить API на Go и снизить нагрузку на базу данных. В статье разбираются основные стратегии кэширования — от Write-Through до Write-Back — и показан пример реализации кэша с TTL на чистом Go. Отличный материал, если хотите ускорить свой сервис без перехода на Redis.


Что такое кэш?

Кэш — это хранилище в памяти, предназначенное для хранения часто запрашиваемых данных. В архитектуре систем он выступает посредником между API и базой данных.
Обслуживая запросы из памяти, а не с диска, кэш значительно снижает задержки и повышает производительность.

Помимо роста производительности, кэш полезен для масштабирования системы.
Добавление кэширующего слоя защищает базу данных от повторяющихся, ресурсоемких сетевых вызовов и запросов к БД.

Вот как работает кэш на высоком уровне:

  • Когда поступает запрос, система сначала проверяет кэш.

  • Если данные есть — это cache hit, возвращаются закэшированные данные.

  • Если данных нет — это cache miss, система делает запрос к базе данных, сохраняет результат в кэше и возвращает его.

Такой процесс делает последующие запросы быстрее и снижает нагрузку на базу.

Как выглядит кэш?

Кэш может принимать разные формы в зависимости от сценария использования:

  • In-Memory Stores: простые key-value хранилища вроде Go maps или внешние решения вроде Redis и Memcached.

  • Кэш на уровне приложения: кэш живет в памяти самого API, например map с мьютексом в Go. Он локален для конкретного процесса и исчезает при его перезапуске. Подходит для избежания повторных вычислений, вызовов API или запросов к БД в пределах одной инстанции.

  • Распределенные кэши: общие кэши для масштабных систем, где несколько реплик API должны иметь доступ к общему хранилищу.

На низком уровне кэши управляются структурами данных и политиками вытеснения, которые определяют, какие элементы удаляются при переполнении кэша. Наиболее распространенные стратегии:

  • FIFO (First-In First-Out) — удаляет самый старый элемент (добавленный первым).

  • LRU (Least Recently Used) — удаляет элементы, к которым давно не обращались.

  • LFU (Least Frequently Used) — удаляет элементы, к которым обращаются реже всего, обычно ведется счетчик обращений для каждого элемента.

Инвалидация кэша

Кэш — мощный инструмент, но устаревшие данные могут создать проблемы. Инвалидация нужна, чтобы кэшированные данные не становились неактуальными.

Подходы к инвалидации:

  • TTL (Time To Live) — каждая запись истекает через заданный промежуток времени. Просто, но до момента истечения может возвращаться устаревшая информация.

  • Ручная инвалидация — явная очистка или обновление кэша при изменении данных. Полезно, но перекладывает ответственность за консистентность на разработчика.

Разные стратегии кэширования можно комбинировать с этими методами, например применять политики LRU или LFU, чтобы автоматически управлять обновлением кэша.

Разные стратегии кэширования

Помимо времени жизни записей, существуют стратегии, определяющие, как кэш обновляется при чтении и записи:

  • Write-Through — при каждой записи данные синхронно обновляются и в кэше, и в базе. Обеспечивает сильную консистентность, но увеличивает задержку.

  • Write-Back — сначала запись в кэш, потом асинхронное обновление базы. Даёт низкую задержку и высокую производительность, но снижает надежность (возможна потеря данных при сбое).

  • Write-Around — сначала запись в базу, а кэш обновляется лениво при следующем чтении. Обеспечивает надежность, но увеличивает количество промахов по кэшу.

  • Read-Through — API всегда обращается к кэшу. При промахе сам кэш забирает данные из базы и сохраняет их.

  • Cache-Aside (ленивая загрузка) — сначала проверяется кэш, при промахе приложение само обращается к базе, сохраняет результат в кэше и возвращает его.

Каждый метод по-разному балансирует свежесть данных, сложность реализации и производительность, предлагая свои плюсы и минусы. Выбор зависит от требований системы к консистентности, задержкам и надежности.

Когда и зачем нужен кэш?

Кэш — это инструмент в системном дизайне, который улучшает производительность и снижает нагрузку на базу данных, уменьшая количество повторяющихся операций, например получение данных пользователя.

Однако добавление кэширующего слоя вносит дополнительную сложность: нужно управлять обновлением и инвалидацией кэша. Для небольшого API с низким количеством ежедневных активных пользователей (DAU) затраты на поддержку кэша могут быть неоправданными.

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

  • Высокая нагрузка на CPU/IO базы данных — запросы используют более 70–80% доступных ресурсов.

  • Рост задержек — среднее время ответа API для простых запросов превышает 200–300 мс, что сказывается на пользовательском опыте.

  • Нагрузка на пропускную способность — база обслуживает тысячи запросов в секунду или трафик растет быстрее, чем система может масштабироваться вертикально.

  • Частые повторяющиеся запросы — одни и те же запросы (например, “топ товаров” или “профиль пользователя”) выполняются сотни раз в секунду.

В таких случаях добавление кэша помогает разгрузить систему, ускорить ответы и предотвратить превращение базы данных в узкое место.

Как реализовать кэш в Golang?

Существует много способов реализовать кэширующий слой: от использования сторонних библиотек до написания собственного решения, которым вы полностью управляете и можете развивать.

В этой статье мы сосредоточимся на базовом нативном решении, используя встроенные пакеты Golang и map.

Этот подход простой, легко интегрируется и идеально подходит для изучения основ кэширования перед тем, как переходить к более продвинутым инструментам, таким как Redis, или реализовывать политики приоритетного вытеснения вроде LRU или LFU.

Определяем структуру кэша

Предположим, у нас есть RESTful API под названием TransactionsHistoryAPI.
Мы можем создать базовый пакет cache внутри TransactionsHistoryAPI, чтобы хранить данные в памяти.

type ICache interface {
 Set(key string, value any) error
 Get(key string) (any, bool)
 Del(key string) bool
 cleanup()
 StopCleanup()
}

type item struct {
 value any
 ttl   int64 // время жизни в формате unix ns timestamp
}

type Cache struct {
 cacheMap   map[string]item
 mx         sync.Mutex
 quit       chan struct{}
 defaultTTL time.Duration
}

func NewCache(defaultTTL, cleanupInterval time.Duration) *Cache {
 c := &Cache{
  cacheMap:   make(map[string]item),
  quit:       make(chan struct{}),
  defaultTTL: defaultTTL,
 }

 // фоновый очиститель
 go func() {
  ticker := time.NewTicker(cleanupInterval)
  for {
   select {
   case <-ticker.C:
    c.cleanup()
   case <-c.quit:
    ticker.Stop()
    return
   }
  }
 }()

 return c
}

func (c *Cache) Set(key string, value any) error {
 c.mx.Lock()
 defer c.mx.Unlock()

 if key == "" || value == nil {
  return fmt.Errorf("cache key/value is invalid")
 }

 var expiry int64
 if c.defaultTTL > 0 {
  expiry = time.Now().Add(c.defaultTTL).UnixNano()
 }

 c.cacheMap[key] = item{value: value, ttl: expiry}
 return nil
}

func (c *Cache) Get(key string) (any, bool) {
 c.mx.Lock()
 defer c.mx.Unlock()

 it, exists := c.cacheMap[key]
 if !exists {
  return nil, false
 }

 // проверка на истечение TTL
 if it.ttl > 0 && time.Now().UnixNano() > it.ttl {
  delete(c.cacheMap, key)
  return nil, false
 }

 return it.value, true
}

func (c *Cache) Del(key string) bool {
 c.mx.Lock()
 defer c.mx.Unlock()

 _, exists := c.cacheMap[key]
 if !exists {
  return false
 }
 delete(c.cacheMap, key)
 return true
}

func (c *Cache) cleanup() {
 c.mx.Lock()
 defer c.mx.Unlock()

 now := time.Now().UnixNano()
 for k, it := range c.cacheMap {
  if it.ttl > 0 && now > it.ttl {
   delete(c.cacheMap, k)
  }
 }
}

func (c *Cache) StopCleanup() {
 close(c.quit)
}

Сначала мы создаем интерфейс ICache, чтобы объявлять кэш в слабо связанной стиле.
Затем создаем структуру Cache, в которой:

  • cacheMap — map[string]item для хранения данных кэша.

  • mx — sync.Mutex, защищающий map от конкурентного доступа.

  • quit — неблокирующий канал, который используется методом StopCleanup() для остановки горутины, выполняющей фоновую очистку в NewCache.

  • ttl — время жизни кэшированных записей, задается в NewCache.

Функция NewCache инициализирует структуру Cache и запускает горутину, которая работает бесконечно до вызова StopCleanup().

Ее основная задача — каждые cleanupInterval срабатывать по каналу <-ticker.C и вызывать cleanup(), удаляя записи, у которых истек TTL.

Основные методы:

  • Get(key string) (any, bool) — проверяет, существует ли ключ в кэше. Если да, проверяет его TTL и, если он не истёк, возвращает значение (cache-hit). Иначе запись считается невалидной, и это cache-miss.

  • Set(key string, value error) error — добавляет пару ключ/значение в нашу map.

  • Del(key string) bool — если ключ существует, удаляет его из кэша.

Выбор стратегии кэширования

Далее нужно определить стратегию кэширования. Как обсуждалось в разделе «разные стратегии кэширования» выше, стратегий много, у каждой свои плюсы, минусы и компромиссы.

В этом разделе разберём примеры Write-Through и Write-Back.

Пример Write-Through:

Каждая запись синхронно обновляет и кэш, и базу данных.

  • Пользователь вызывает хендлер TransactionsHistoryAPI для обновления данных.

  • Хендлер сразу обновляет кэш.

  • То же обновление затем записывается в базу данных.

Так обеспечивается сильная консистентность между кэшем и базой. Компромисс — повышенная задержка при записи из-за синхронных операций.

Покажем это на примере хендлера в TransactionsHistoryAPI, который называется UpdateTransactionHandler.

// Write-Through: update cache and DB synchronously
func (m *TransactionModel) UpdateTransactionHistory(h *data.TransactionHistory) error {
 if err := m.cache.Set(h.UserID, h); err != nil {
  return err
 }
 if err := m.db.Update(h); err != nil {
  return err
 }
 return nil
}

В этом фрагменте мы просто вызываем cache.Set, а затем обновляем БД, чтобы записать в кэш UserID и данные о транзакции.

Но здесь есть недостаток.

Если транзакция в БД завершится ошибкой, кэш и база окажутся в несогласованном состоянии.

Можно поменять порядок: сначала обновлять БД, потом кэш — тогда БД остаётся источником истины. Но если обновление кэша не удастся, кэш может устареть. Поэтому можно применить подход «инвалидация при сбое».

// Write-Through: update DB and Cache synchronously
func (m *TransactionModel) UpdateTransactionHistory(h *data.TransactionHistory) error {
    // 1. Сначала пишем в БД
    if err := m.db.Update(h); err != nil {
        // гарантируем, что кэш не новее БД
        _ = m.cache.Del(h.UserID)
        return err
    }

    // 2. Обновляем кэш (best-effort; при сбое удаляем, чтобы избежать устаревших данных)
    if err := m.cache.Set(h.UserID, h); err != nil {
        _ = m.cache.Del(h.UserID) // удаляем потенциально устаревшую запись
        // log.Printf("cache update failed for user %s: %v", h.UserID, err)
    }

    return nil
}

Теперь, если запись в БД провалится, мы удаляем запись из кэша, чтобы не возвращать устаревшие данные. Если не удалось обновить кэш, запись тоже удаляется. Следующие запросы на чтение получат свежие данные из БД, обновят кэш и сохранят консистентность. В худшем случае кэш пуст, и потребуется запрос к БД.

Пример Write-Back:

Запись сначала идёт в кэш, а затем БД обновляется асинхронно.

  • Пользователь вызывает хендлер TransactionsHistoryAPI для обновления данных.

  • Хендлер немедленно обновляет кэш.

  • База данных обновляется фоново в горутине.

Такой подход повышает производительность и снижает задержку при записи.

Компромисс — риск потери данных, если кэш «упадёт» до того, как изменения попадут в БД.

Модифицируем функцию UpdateTransactionHandler из примера выше для реализации Write-Back:

func (m *TransactionModel) UpdateTransactionHistory(h *data.TransactionHistory) error {
 if err := m.cache.Set(h.UserID, h); err != nil {
  return err
 }
 go func() {
  if err := m.db.Update(h); err != nil {
   // логируем/обрабатываем ошибку при необходимости
  }
 }()
 return nil
}

В этом фрагменте кэш обновляется первым, а функция сразу возвращает управление, чтобы обеспечить низкую задержку записи. Затем база данных обновляется асинхронно в горутине — это компромисс в пользу скорости за счёт надежности.

Однако этот компромисс по надежности можно смягчить, добавив немного сложности.
Например, можно отправлять обновление в БД через надежную очередь сообщений, такую как Kafka, чтобы исключить потерю данных при сбое системы.

func (m *TransactionModel) UpdateTransactionHistory(h *data.TransactionHistory) error {
 // 1. Немедленно записываем в кэш
 if err := m.cache.Set(h.UserID, h); err != nil {
  return err
 }

 // 2. Асинхронно отправляем событие в Kafka для записи в БД
 go func(h *data.TransactionHistory) {
  // Сериализуем историю транзакций (например, в JSON)
  msg, err := json.Marshal(h)
  if err != nil {
   // log.Printf("failed to marshal transaction history: %v", err)
   return
  }

  kafkaMsg := &m.kafka.Message{
   Topic: "transaction-history-updates",
   Key:   []byte(h.UserID),
   Value: msg,
  }

  if err := m.kafkaProducer.WriteMessages(context.Background(), *kafkaMsg); err != nil {
   // log.Printf("failed to push update to Kafka for user %s: %v", h.UserID, err)
  }
 }(h)

 return nil
}

В этом примере мы по-прежнему сразу обновляем кэш. Но в горутине асинхронно отправляем транзакцию в топик Kafka transaction-history-updates. Затем сервис-консюмер обновляет БД.

Такой подход сочетает низкую задержку Write-Back-кэширования с гарантиями надежности Kafka.

Компромисс — дополнительная сложность при внедрении и сопровождении очереди сообщений и сервиса-консюмера.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Итоги

Мы разобрали, что такое кэш, зачем он нужен, какие существуют стратегии кэширования и как реализовать кэширующий слой в API на Golang.

Кэш — это хранилище в памяти для часто запрашиваемых данных. В архитектуре системы он играет роль посредника между API и базой данных.

Его основная задача — повышать производительность, обслуживая частые запросы из памяти, и защищать базу от повторяющихся, дорогих по времени обращений.

При этом кэш добавляет сложность в управлении, обновлении и инвалидации данных. Он больше подходит для API с высокой нагрузкой. Признаки, что кэш пора внедрять: высокая нагрузка на CPU/IO базы, рост задержек запросов, перегрузка БД по количеству обращений и большое число повторяющихся запросов.

Мы показали пример простой реализации кэша с использованием sync.Map, защищённой sync.Mutex, а также TTL для автоматической инвалидации данных по истечении заданного времени. Разобрали стратегии кэширования: Write-Through, где кэш и база обновляются синхронно, и Write-Back, где кэш обновляется сразу, а база — асинхронно.

Эта база знаний позволит вам расширить реализацию кэша, добавив политики вытеснения вроде Least Recently Used (LRU) или Least Frequently Used (LFU). При масштабировании можно заменить локальное хранилище на Redis, чтобы реализовать распределённый кэш, доступный для всех реплик API.

х

Комментарии (0)


  1. mcferden
    26.09.2025 11:08

    Хотите, я продолжу и переведу финальную часть текста с примером реализации кэша на Go?

    Авторы совсем обленились вычитывать текст после ИИ?