Если 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)

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

sdramare
20.03.2026 18:17Это не скалируемое решение

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

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

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

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

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

idd451289
20.03.2026 18:17Наш род прошел миллионы лет эволюции, чтобы автор на хабре написал статью с кликбейтным названием, про то как обернул мапу в структуру с несколькими простейшими методами, а затем поставил тег "Высоконагруженные системы"
Когда я слышу рядом слова кэш и высоконагруженные системы я представляю себе, что то типа redis(или своей реализации от автора), которая держит какие то нереальные показатели. Или же работу с кэшем в распределнных системах(к примеру когда у тебя несколько инстансов и между ними синки)
Опять же я жду реальных метрик, а не аналитики уровня "Ну если у вас много запросов на одного юзера то кэш решит вашу проблему"
И при этом фиг бы с ним, можно пережить, если бы это была реальная реализация красивого кэша, начиная с того чем автор закончил, вплоть до того что автор написал в то, что можно улучшить. Но даже этого нет
Очередной болван дропнул какой то нейрослоп и доволен

Farziev
20.03.2026 18:17Ребят, откуда столько отрицательных комментариев?
Автор написал максимально просто, даже context не использовал. Просто что бы показать концепцию. Вы же сразу закидали «грязными тряпками».
P.s. согласен что отношение к разработке моб. систем и к высоконагруженным тем более тут опосредованное.

SunchessD
20.03.2026 18:17Алгоритм действий при решении проблемы обычно один - найти тех, кто уже решал подобные проблемы. Тема кеша закрыта на 99%. Не привязываясь к языку и архитектуре видно, что автор пошел по неверному пути, что, в свою очередь, говорит о его уровне.
Как закрытие задачи для говнокурсов - ок, но в проде это использовать недопустимо, сразу куча проблем всплывёт. Начиная от разных данных в БД и кеше, заканчивая раздутием памяти инстансов.

Elendiar1
20.03.2026 18:17Хз зачем вообще эта статья. Я писал когда-то на джанго, cache.get/set обычное дело если без сторонних зависимостей.
Я не знаю нюансов на go, но in-memory кеш для сервера уже звучит сомнительно. Разве что там точно один процесс все соединения обрабатывает. Но тогда про какую высоконагруженность идет речь?
Короче статья уровня "как я округлил число с помощью math.round", очень полезно и интересно
(нет)

SunchessD
20.03.2026 18:17Кеш по времени такое себе решение. Кеш нужно чистить по изменению данных в БД.
Кеш хранить на сервисе тоже плохое решение, у каждого сервиса, если к примеру это кубер, будет иметь свой кеш. Поэтому использование редиса это необходимомомть , а не прихоть.
На мой взгляд решение как временная заплатка "сомнительно, но ОК". А так все делать по нормальному

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

sidorovkv
20.03.2026 18:17Люблю запах велосипедов по утрам. Однажды мы писали кэш на го вместо использования рэдис. И когда все закончилось я посмотрел на скорость запросов. Они ускорились в семь раз. Но запах! Весь код был им пропитан. Это был запах… велосипедов!
Dhwtj
Шо с кешем, шо без - очень мало.
Нет пула соединений? Сам ab тормозит?
dimonz80
без http keep alive тестируем скорость открытия tcp сокетов)