Если 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. Но даже такой минимальный вариант может заметно снизить нагрузку на базу.

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

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

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

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

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

Итог

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

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


  1. Dhwtj
    20.03.2026 18:17

    Шо с кешем, шо без - очень мало.

    Нет пула соединений? Сам ab тормозит?


    1. dimonz80
      20.03.2026 18:17

      без http keep alive тестируем скорость открытия tcp сокетов)


  1. Kwisatz
    20.03.2026 18:17

    PostgreSQL тоже умеет читать из памяти. Разница в 7 раз говорит о том что у вас чтото ну очень сильно не так, например памяти pg не дали. Более того много однотипных запросов легко оптимизируются именованными подготовленными запросами, для которых прослойка еще меньше.


  1. iamkisly
    20.03.2026 18:17

    Тяжела и неказиста жизнь go программиста


  1. sdramare
    20.03.2026 18:17

    Это не скалируемое решение


    1. Sunny-s
      20.03.2026 18:17

      Этот кеш можно реализовать внутри пода. Да, он не будет работать между подами, но и так будет прирост. Disposability не страдает - правильный кеш можно обнулять без потери данных


      1. sdramare
        20.03.2026 18:17

        in-memory кэш пода создает read-your-writes problem на ровном месте. Если вы думаете что она просто решается, то нет, это не так.


  1. koleso_O
    20.03.2026 18:17

    Пропущена тема негативного кеша.

    В вашей реализации нагрузка на базу снижается только для адекватных запросов (с вашего фронта, например). Но ничего не мешает вычислить пулл id, которых нет в базе (ну и в кеше соответственно) и начать досить ваш бэкенд такими запросами, кеш мисс 100%, база по прежнему задыхается.


  1. stvoid
    20.03.2026 18:17

    Все так или иначе в зависимости от объема данных к этому приходят... Не очень понятно кто ваш клиент API.
    Вообще, можете пойти дальше и отдавать заголовок Last-Modified, хранить максимально легковесный кэш параллельно (ну, или повесить индекс и дергать БД по max(update_at), если оно того позволяет - это и сам постгрес закэширует на отлично).

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

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


  1. Ak-47
    20.03.2026 18:17

    эмммм.. Сколько у вас запросов, что Вам понадобился кзш?
    Может что-то с архитектурой не так?


  1. idd451289
    20.03.2026 18:17

    Наш род прошел миллионы лет эволюции, чтобы автор на хабре написал статью с кликбейтным названием, про то как обернул мапу в структуру с несколькими простейшими методами, а затем поставил тег "Высоконагруженные системы"

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

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

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

    Очередной болван дропнул какой то нейрослоп и доволен


  1. Farziev
    20.03.2026 18:17

    Ребят, откуда столько отрицательных комментариев?

    Автор написал максимально просто, даже context не использовал. Просто что бы показать концепцию. Вы же сразу закидали «грязными тряпками».

    P.s. согласен что отношение к разработке моб. систем и к высоконагруженным тем более тут опосредованное.


    1. SunchessD
      20.03.2026 18:17

      Алгоритм действий при решении проблемы обычно один - найти тех, кто уже решал подобные проблемы. Тема кеша закрыта на 99%. Не привязываясь к языку и архитектуре видно, что автор пошел по неверному пути, что, в свою очередь, говорит о его уровне.

      Как закрытие задачи для говнокурсов - ок, но в проде это использовать недопустимо, сразу куча проблем всплывёт. Начиная от разных данных в БД и кеше, заканчивая раздутием памяти инстансов.


  1. Elendiar1
    20.03.2026 18:17

    Хз зачем вообще эта статья. Я писал когда-то на джанго, cache.get/set обычное дело если без сторонних зависимостей.

    Я не знаю нюансов на go, но in-memory кеш для сервера уже звучит сомнительно. Разве что там точно один процесс все соединения обрабатывает. Но тогда про какую высоконагруженность идет речь?

    Короче статья уровня "как я округлил число с помощью math.round", очень полезно и интересно (нет)


  1. SunchessD
    20.03.2026 18:17

    Кеш по времени такое себе решение. Кеш нужно чистить по изменению данных в БД.

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

    На мой взгляд решение как временная заплатка "сомнительно, но ОК". А так все делать по нормальному


  1. Dmitry_Shumkov
    20.03.2026 18:17

    В целом, писать свой кеш и добавлять его на уровне приложения нет никакой необходимости при использовании RESTful API. Собственно сам выбор типа API зачастую зависит от того - нужно ли простое кеширование или нет. REST API можно банально NGINXом кешировать это минут за 10 настраивается.


  1. sidorovkv
    20.03.2026 18:17

    Люблю запах велосипедов по утрам. Однажды мы писали кэш на го вместо использования рэдис. И когда все закончилось я посмотрел на скорость запросов. Они ускорились в семь раз. Но запах! Весь код был им пропитан. Это был запах… велосипедов!


  1. Lugburz13
    20.03.2026 18:17

    бытует мнение что если так никто не делает то это неспроста)


  1. scaramancha
    20.03.2026 18:17

    И снова велосипед и снова он кривой ... )