Redis больше не нужен?! Реализуем реактивный кэш на чистом PostgreSQL и Go
- пятница, 6 февраля 2026 г. в 00:00:08
Привет, Хабр! 👋
В современной разработке мы привыкли решать проблемы производительности стандартным набором инструментов. "База не тянет? Поставь Redis!" — это стало почти рефлексом. Но всегда ли оправдано тащить в инфраструктуру лишний сервис, настраивать сетевые хопы и следить за инвалидацией, если ваша задача — это всего лишь быстрый доступ к небольшому справочнику?
В нашем Open Source проекте BMSTU-ITSTECH/SSO мы столкнулись именно с таким кейсом. И решение оказалось элегантнее, чем просто "поднять Redis". Рассказываю, как мы сэкономили на инфраструктуре и получили мгновенный отклик, используя скрытую мощь PostgreSQL LISTEN/NOTIFY.
Мы пишем SSO (систему единого входа). В сердце системы есть таблица apps — реестр подключенных приложений.
Вводные данные такие:
Таблица маленькая: Вряд ли там будет больше 100 строк (сервисов в экосистеме не тысячи).
Читается постоянно: Почти каждый запрос на аутентификацию т��ебует проверки: "А есть ли такое приложение? А какие у него права?".
Меняется редко: Новые сервисы добавляются не каждый день.
Лезть в БД диском на каждый чих — дорого.
Ставить Redis ради 100 строчек — это классическая стрельба из пушки по воробьям (плюс сетевые задержки, плюс сериализация).
Решение: Хранить данные прямо в памяти Go-приложения (map), а о любых изменениях узнавать мгновенно через нативный механизм подписок Postgres.
Postgres умеет работать как брокер сообщений. Механизм LISTEN/NOTIFY позволяет базе данных "крикнуть" подписчикам, что что-то произошло.
Вот SQL, который мы накатили в миграции. Он делает две вещи:
Определяет функцию, которая отправляет уведомление в канал update_cache.
Вешает триггер на любые изменения (INSERT, UPDATE, DELETE) в таблице 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 мы реализуем простую структуру: RWMutex для потокобезопасного чтения и мапу с данными. Плюс горутина-слушатель.
Весь код можно найти в ветке dev_v2 нашего репозитория, но вот сама суть:
Обычная мапа, защищенная мьютексом. Чтение из неё — это наносекунды, быстрее любого 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 }
Используем библиотеку 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() — тут.
Давайте честно сравним подходы для задачи "Справочник на 100 строк".
Характеристика | Redis / Memcached | In-Memory + PG Notify |
|---|---|---|
Скорость чтения | Быстро (~0.5 - 1 ms). Требует сериализации/десериализации (JSON/Protobuf) и похода в сеть. | Мгновенно (ns). Данные уже в куче Go в нужном формате. |
Инфраструктура | Нужен отдельный контейнер/инстанс. Нужно мониторить память, настраивать персистентность. | 0 затрат. Используется уже существующее подключение к БД. |
Актуальность данных | Нужно вручную инвалидировать кэш при записи в БД (pattern "Cache-Aside"). Есть риск рассинхрона. | ��еактивно. База сама сообщает об изменении. Рассинхрон минимален (время доставки сигнала). |
Сложность кода | Средняя. Нужен клиент Redis. | Низкая. Стандартные средства SQL и драйвера. |
У этого подхода есть границы применимости. Не делайте так, если:
Данных много. Держать гигабайты в RAM приложения — плохая идея (OOM Killer не дремлет).
Частые обновления. Если таблица обновляется 100 раз в секунду, вы замучаете приложение постоянными Reload() и локами на запись.
Распределенная система без Sticky Sessions. Каждая реплика вашего сервиса будет хранить свою копию кэша. Для 100 строк это ОК, для больших данных — дублирование памяти.
Для задачи кэширования небольших, редко изменяемых справочников (конфиги, списки приложений, права ролей, feature-flags) связка PostgreSQL LISTEN/NOTIFY + Go RWMutex работает идеально.
Мы получили:
Нулевой Latency при чтении.
Отсутствие лишней зависимости (Redis).
Гарантированную консистентность (кэш обновляется сразу после коммита транзакции в БД).
Иногда лучшие инструменты — это те, которые у вас уже есть, просто нужно уметь их готовить.
Посмотреть, как это работает в живом проекте, можно в нашем репозитории: github.com/bmstu-itstech/sso. Заходите, ставьте звёздочки, предлагайте PR! ⭐