Я написал кэш для API на Go за 120 строк кода — и PostgreSQL перестал быть узким местом (ускорение …
- воскресенье, 22 марта 2026 г. в 00:00:12
Если API начинает тормозить, первое решение обычно очевидно — добавить Redis. Но иногда оказывается, что проблема гораздо проще. В одном из сервисов PostgreSQL начал упираться в повторяющиеся запросы. Одни и те же данные запрашивались тысячами клиентов. Практически каждый HTTP-запрос заканчивался одинаковым SQL-запросом. Любопытство победило — вместо готового решения был написан небольшой кэш прямо внутри сервиса. На это ушло примерно полчаса.Результат оказался неожиданным: некоторые эндпоинты ускорились почти в 7 раз. Вот, почему это произошло и как работает такая схема.
Для примера возьмём простой сервис, который отдаёт пользователя по 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 одинаковых операций.
Создадим структуру кэша.
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. Без этого память будет расти бесконечно.
Создадим объект кэша.
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. Но даже такой минимальный вариант может заметно снизить нагрузку на базу.
Этот подход работает лучше всего, если:
данные читаются намного чаще, чем изменяются
одни и те же объекты запрашиваются снова и снова
база данных становится узким местом системы
Иногда кажется, что без отдельного сервиса кэширования не обойтись. Но на практике бывает, что десятки строк кода внутри приложения решают проблему быстрее и проще. Это не замена полноценному распределённому кэшу, но для многих сервисов может стать неожиданно эффективным первым шагом.