Привет! 👋
В современной разработке мы привыкли решать проблемы производительности стандартным набором инструментов. "База не тянет? Поставь Redis!" — это стало почти рефлексом. Но всегда ли оправдано тащить в инфраструктуру лишний сервис, настраивать сетевые хопы и следить за инвалидацией, если ваша задача — это всего лишь быстрый доступ к небольшому справочнику?
В нашем Open Source проекте BMSTU-ITSTECH/SSO мы столкнулись именно с таким кейсом. И решение оказалось элегантнее, чем просто "поднять Redis". Рассказываю, как мы сэкономили на инфраструктуре и получили мгновенный отклик, используя скрытую мощь PostgreSQL LISTEN/NOTIFY.
Контекст: Зачем нам кэш?
Мы пишем SSO (систему единого входа). В сердце системы есть таблица apps — реестр подключенных приложений.
Вводные данные такие:
-
Таблица маленькая: Вряд ли там будет больше 100 строк (сервисов в экосистеме не тысячи).
-
Читается постоянно: Почти каждый запрос на аутентификацию требует проверки: "А есть ли такое приложение? А какие у него права?".
-
Меняется редко: Новые сервисы добавляются не каждый день.
Лезть в БД диском на каждый чих — дорого.
Ставить Redis ради 100 строчек — это классическая стрельба из пушки по воробьям (плюс сетевые задержки, плюс сериализация).
Решение: Хранить данные прямо в памяти Go-приложения (map), а о любых изменениях узнавать мгновенно через нативный механизм подписок Postgres.
Магия SQL: Настраиваем "Уши" 👂
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: 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 и драйвера. |
Почему не делаем так всегда?
У этого подхода есть границы применимости. Не делайте так, если:
-
Данных много. Держать гигабайты в RAM приложения — плохая идея (OOM Killer не дремлет).
-
Частые обновления. Если таблица обновляется 100 раз в секунду, вы замучаете приложение постоянными
Reload()и локами на запись. -
Распределенная система без Sticky Sessions. Каждая реплика вашего сервиса будет хранить свою копию кэша. Для 100 строк это ОК, для больших данных — дублирование памяти.
Итог
Для задачи кэширования небольших, редко изменяемых справочников (конфиги, списки приложений, права ролей, feature-flags) связка PostgreSQL LISTEN/NOTIFY + Go RWMutex работает идеально.
Мы получили:
-
Нулевой Latency при чтении.
-
Отсутствие лишней зависимости (Redis).
-
Гарантированную консистентность (кэш обновляется сразу после коммита транзакции в БД).
Иногда лучшие инструменты — это те, которые у вас уже есть, просто нужно уметь их готовить.
Посмотреть, как это работает в живом проекте, можно в нашем репозитории: github.com/bmstu-itstech/sso. Заходите, ставьте звёздочки, предлагайте PR! ⭐
Полезные ссылки
Автор: BOBAvov
