golang

Я написал кэш для API на Go за 120 строк кода — и PostgreSQL перестал быть узким местом (ускорение …

  • воскресенье, 22 марта 2026 г. в 00:00:12
https://habr.com/ru/articles/1012928/

Если API начинает тормозить, первое решение обычно очевидно — добавить Redis. Но иногда оказывается, что проблема гораздо проще. В одном из сервисов PostgreSQL начал упираться в повторяющиеся запросы. Одни и те же данные запрашивались тысячами клиентов. Практически каждый HTTP-запрос заканчивался одинаковым SQL-запросом. Любопытство победило — вместо готового решения был написан небольшой кэш прямо внутри сервиса. На это ушло примерно полчаса.Результат оказался неожиданным: некоторые эндпоинты ускорились почти в 7 раз. Вот, почему это произошло и как работает такая схема.

Базовая версия API

Для примера возьмём простой сервис, который отдаёт пользователя по ID.

type User struct {
    ID   int
    Name string
    Age  int
}

Функция получения данных из базы:

func GetUserFromDB(db *sql.DB, id int) (*User, error) {

    row := db.QueryRow(
        "SELECT id, name, age FROM users WHERE id=$1",
        id,
    )

    user := &User{}

    err := row.Scan(&user.ID, &user.Name, &user.Age)
    if err != nil {
        return nil, err
    }

    return user, nil
}

HTTP-обработчик:

func UserHandler(w http.ResponseWriter, r *http.Request) {

    idParam := r.URL.Query().Get("id")

    id, err := strconv.Atoi(idParam)
    if err != nil {
        http.Error(w, "invalid id", 400)
        return
    }

    user, err := GetUserFromDB(db, id)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }

    json.NewEncoder(w).Encode(user)
}

Работает отлично.Но есть одна проблема. Каждый запрос к API делает новый SQL-запрос, даже если эти данные только что уже запрашивали. Если один и тот же пользователь запрашивается 10 000 раз — база выполняет 10 000 одинаковых операций.

Добавляем простой in-memory кэш

Создадим структуру кэша.

type Cache struct {
    data map[string]CacheItem
    mu   sync.RWMutex
}

type CacheItem struct {
    Value      interface{}
    Expiration int64
}

Инициализация:

func NewCache() *Cache {
    return &Cache{
        data: make(map[string]CacheItem),
    }
}

Метод получения значения

func (c *Cache) Get(key string) (interface{}, bool) {

    c.mu.RLock()
    item, found := c.data[key]
    c.mu.RUnlock()

    if !found {
        return nil, false
    }

    if time.Now().UnixNano() > item.Expiration {
        return nil, false
    }

    return item.Value, true
}

Что происходит:

  • используется RWMutex для безопасного доступа

  • проверяется срок жизни значения

  • если данные ещё актуальны — возвращаем их

Метод записи

func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {

    c.mu.Lock()

    c.data[key] = CacheItem{
        Value: value,
        Expiration: time.Now().Add(ttl).UnixNano(),
    }

    c.mu.Unlock()
}

Каждый элемент получает TTL. Без этого память будет расти бесконечно.

Подключаем кэш к API

Создадим объект кэша.

var userCache = NewCache()

Теперь обновим обработчик.

func UserHandler(w http.ResponseWriter, r *http.Request) {

    idParam := r.URL.Query().Get("id")

    id, err := strconv.Atoi(idParam)
    if err != nil {
        http.Error(w, "invalid id", 400)
        return
    }

    cacheKey := fmt.Sprintf("user:%d", id)

    if cached, found := userCache.Get(cacheKey); found {

        user := cached.(*User)

        json.NewEncoder(w).Encode(user)
        return
    }

    user, err := GetUserFromDB(db, id)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }

    userCache.Set(cacheKey, user, time.Minute)

    json.NewEncoder(w).Encode(user)
}

Теперь схема работы такая: первый запрос → данные читаются из базы

следующие запросы → данные берутся из памяти

Очистка просроченных значений

Если ничего не удалять, память постепенно заполнится. Добавим простой сборщик мусора.

func (c *Cache) StartGC() {

    ticker := time.NewTicker(time.Minute)

    for range ticker.C {

        now := time.Now().UnixNano()

        c.mu.Lock()

        for key, item := range c.data {

            if now > item.Expiration {
                delete(c.data, key)
            }

        }

        c.mu.Unlock()
    }
}

Запускаем его при старте сервиса:

go userCache.StartGC()

Тестируем производительность

Для теста использовалась обычная утилита Apache Bench. Без кэша:

ab -n 10000 -c 100 http://localhost:8080/user?id=1

Результат:

Requests per second: 820

Теперь запускаем ту же нагрузку с кэшем.

Requests per second: 5700

Прирост — примерно 7 раз.

Причина довольно очевидна: чтение из памяти значительно быстрее, чем выполнение SQL-запроса.

Что можно улучшить

Эта реализация максимально простая. В реальных системах обычно добавляют:

  • ограничение памяти

  • LRU-алгоритм

  • шардирование map

  • метрики

  • lock-free структуры

Есть готовые решения, например Ristretto. Но даже такой минимальный вариант может заметно снизить нагрузку на базу.

Когда такой кэш особенно полезен

Этот подход работает лучше всего, если:

  • данные читаются намного чаще, чем изменяются

  • одни и те же объекты запрашиваются снова и снова

  • база данных становится узким местом системы

Итог

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