golang

Redis больше не нужен?! Реализуем реактивный кэш на чистом PostgreSQL и Go

  • пятница, 6 февраля 2026 г. в 00:00:08
https://habr.com/ru/articles/992990/

Привет, Хабр! 👋

В современной разработке мы привыкли решать проблемы производительности стандартным набором инструментов. "База не тянет? Поставь Redis!" — это стало почти рефлексом. Но всегда ли оправдано тащить в инфраструктуру лишний сервис, настраивать сетевые хопы и следить за инвалидацией, если ваша задача — это всего лишь быстрый доступ к небольшому справочнику?

В нашем Open Source проекте BMSTU-ITSTECH/SSO мы столкнулись именно с таким кейсом. И решение оказалось элегантнее, чем просто "поднять Redis". Рассказываю, как мы сэкономили на инфраструктуре и получили мгновенный отклик, используя скрытую мощь PostgreSQL LISTEN/NOTIFY.

Контекст: Зачем нам кэш?

Мы пишем SSO (систему единого входа). В сердце системы есть таблица apps — реестр подключенных приложений.
Вводные данные такие:

  1. Таблица маленькая: Вряд ли там будет больше 100 строк (сервисов в экосистеме не тысячи).

  2. Читается постоянно: Почти каждый запрос на аутентификацию т��ебует проверки: "А есть ли такое приложение? А какие у него права?".

  3. Меняется редко: Новые сервисы добавляются не каждый день.

Лезть в БД диском на каждый чих — дорого.
Ставить Redis ради 100 строчек — это классическая стрельба из пушки по воробьям (плюс сетевые задержки, плюс сериализация).

Решение: Хранить данные прямо в памяти Go-приложения (map), а о любых изменениях узнавать мгновенно через нативный механизм подписок Postgres.

Магия SQL: Настраиваем "Уши" 👂

Postgres умеет работать как брокер сообщений. Механизм LISTEN/NOTIFY позволяет базе данных "крикнуть" подписчикам, что что-то произошло.

Вот SQL, который мы накатили в миграции. Он делает две вещи:

  1. Определяет функцию, которая отправляет уведомление в канал update_cache.

  2. Вешает триггер на любые изменения (INSERTUPDATEDELETE) в таблице apps.

-- 1. Функция-глашатай
CREATE OR REPLACE FUNCTION notify_table_update() RETURNS TRIGGER AS $$
BEGIN
    -- Отправляем сигнал в канал 'update_cache'.
    -- В качестве payload можно передать имя таблицы или ID записи.
    PERFORM pg_notify('update_cache', TG_TABLE_NAME);
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- 2. Триггер, который дергает функцию
CREATE TRIGGER secrets_changed
    AFTER INSERT OR UPDATE OR DELETE ON apps
    FOR EACH ROW EXECUTE FUNCTION notify_table_update();

Теперь, как только админ поменяет конфиг приложения в админке, Postgres сам пнёт наше Go-приложение.

Реализация на Go: In-Memory Cache

На стороне Go мы реализуем простую структуру: RWMutex для потокобезопасного чтения и мапу с данными. Плюс горутина-слушатель.

Весь код можно найти в ветке dev_v2 нашего репозитория, но вот сама суть:

1. Структура кэша

Обычная мапа, защищенная мьютексом. Чтение из неё — это наносекунды, быстрее любого Redis.

type AppCache struct {
    mu   *sync.RWMutex
    data map[int32]models.AppRepos
    db   *sqlx.DB
}

// Потокобезопасное получение данных
func (c *AppCache) App(ctx context.Context, appId int32) (models.AppRepos, error) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    app, ok := c.data[appId]
    if !ok {
       return models.AppRepos{}, storage.ErrAppNotFound
    }
    return app, nil
}

2. Слушатель (Listener)

Используем библиотеку lib/pq (можно и pgx, суть та же). Мы подписываемся на канал и ждем событий.

func UpdateListener(connString string, cash *AppCache) {
    reportProblem := func(ev pq.ListenerEventType, err error) {
       if err != nil { fmt.Println("Listener error:", err) }
    }
    
    // Настраиваем подключение и реконнекты
    listener := pq.NewListener(connString, 10*time.Second, time.Minute, reportProblem)
    
    // ПОДПИСЫВАЕМСЯ НА КАНАЛ
    if err := listener.Listen("update_cache"); err != nil {
       panic(err)
    }

    fmt.Println("Start monitoring PostgreSQL...")

    go func() {
       for {
          select {
          // Как только прилетел сигнал от триггера...
          case <-listener.Notify:
             fmt.Println("PostgreSQL updated! Reloading cache...")
             // ...мы просто перечитываем всю таблицу
             _ = cash.Reload()
          
          // Пинг, чтобы соединение не отвалилось по таймауту
          case <-time.After(1 * time.Minute):
             go listener.Ping()
          }
       }
    }()
}

Полная реализация инициализации и метода Reload() — тут.

Почему это круче Redis (в нашем случае)? 🥊

Давайте честно сравним подходы для задачи "Справочник на 100 строк".

Характеристика

Redis / Memcached

In-Memory + PG Notify

Скорость чтения

Быстро (~0.5 - 1 ms). Требует сериализации/десериализации (JSON/Protobuf) и похода в сеть.

Мгновенно (ns). Данные уже в куче Go в нужном формате.

Инфраструктура

Нужен отдельный контейнер/инстанс. Нужно мониторить память, настраивать персистентность.

0 затрат. Используется уже существующее подключение к БД.

Актуальность данных

Нужно вручную инвалидировать кэш при записи в БД (pattern "Cache-Aside"). Есть риск рассинхрона.

��еактивно. База сама сообщает об изменении. Рассинхрон минимален (время доставки сигнала).

Сложность кода

Средняя. Нужен клиент Redis.

Низкая. Стандартные средства SQL и драйвера.

Почему не делаем так всегда?

У этого подхода есть границы применимости. Не делайте так, если:

  1. Данных много. Держать гигабайты в RAM приложения — плохая идея (OOM Killer не дремлет).

  2. Частые обновления. Если таблица обновляется 100 раз в секунду, вы замучаете приложение постоянными Reload() и локами на запись.

  3. Распределенная система без Sticky Sessions. Каждая реплика вашего сервиса будет хранить свою копию кэша. Для 100 строк это ОК, для больших данных — дублирование памяти.

Итог

Для задачи кэширования небольших, редко изменяемых справочников (конфиги, списки приложений, права ролей, feature-flags) связка PostgreSQL LISTEN/NOTIFY + Go RWMutex работает идеально.

Мы получили:

  • Нулевой Latency при чтении.

  • Отсутствие лишней зависимости (Redis).

  • Гарантированную консистентность (кэш обновляется сразу после коммита транзакции в БД).

Иногда лучшие инструменты — это те, которые у вас уже есть, просто нужно уметь их готовить.

Посмотреть, как это работает в живом проекте, можно в нашем репозитории: github.com/bmstu-itstech/sso. Заходите, ставьте звёздочки, предлагайте PR! ⭐


Полезные ссылки