Если 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. Но даже такой минимальный вариант может заметно снизить нагрузку на базу.
Когда такой кэш особенно полезен
Этот подход работает лучше всего, если:
-
данные читаются намного чаще, чем изменяются
-
одни и те же объекты запрашиваются снова и снова
-
база данных становится узким местом системы
Итог
Иногда кажется, что без отдельного сервиса кэширования не обойтись. Но на практике бывает, что десятки строк кода внутри приложения решают проблему быстрее и проще. Это не замена полноценному распределённому кэшу, но для многих сервисов может стать неожиданно эффективным первым шагом.
Автор: Nik_way_88
