
В интернете есть книги и множество статей, таких как эта, в которых авторы приводят аргументы в пользу использования Postgres для всего. Я решил рассмотреть один из вариантов использования — применение Postgres вместо Redis для кэширования. Я довольно часто работаю с API, поэтому я создал очень простой HTTP-сервер, который отвечает данными из этого кэша. Я начал с Redis, так как часто сталкиваюсь с этим на работе, а затем переключился на Postgres с использованием нежурналируемых таблиц и посмотрел, есть ли разница.
Настройка
Я проведу эксперимент на своём кластере k8s домашней лаборатории. Идея состоит в том, чтобы запустить Postgres или Redis на одном узле, ограничив его двумя процессорами с помощью ограничений k8s, а также 8 ГБ памяти. На другом узле я запущу сам веб-сервер, а затем создам модуль для бенчмарка, который будет выполняться через k6 на третьем узле.
И postgres, и redis используются с готовыми настройками для следующих образов:
Postgres -
postgres:17.6
Редис -
redis:8.2
Я написал простой веб-сервер с двумя конечными точками, кэшем и структурой «Session», которую мы будем хранить в кэше:
Код
var ErrCacheMiss = errors.New("cache miss")
type Cache interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string) error
}
type Session struct {
ID string
}
func serveHTTP(c Cache) {
http.HandleFunc("/get", getHandler(c))
http.HandleFunc("/set", setHandler(c))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Println("Server starting on http://0.0.0.0:" + port)
server := &http.Server{Addr: "0.0.0.0:" + port}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("Error starting server:", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
fmt.Println("Shutting down server...")
if err := server.Close(); err != nil {
fmt.Println("Error shutting down server:", err)
}
}
Для Redis я реализовал кэш с помощью github.com/redis/go-redis/v9
следующим образом:
Код
type RedisCache struct {
client *redis.Client
}
func NewRedisCache() *RedisCache {
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
redisURL = "localhost:6379"
}
fmt.Println("Connecting to Redis at", redisURL)
client := redis.NewClient(&redis.Options{
Addr: redisURL,
Password: "",
DB: 0,
})
return &RedisCache{
client: client,
}
}
func (r *RedisCache) Get(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrCacheMiss
}
if err != nil {
return "", err
}
return val, nil
}
func (r *RedisCache) Set(ctx context.Context, key string, value string) error {
return r.client.Set(ctx, key, value, 0).Err()
}
Кэш Postgres реализован с использованием библиотеки github.com/jackc/pgx/v5
:
Код
type PostgresCache struct {
db *pgxpool.Pool
}
func NewPostgresCache() (*PostgresCache, error) {
pgDSN := os.Getenv("POSTGRES_DSN")
if pgDSN == "" {
pgDSN = "postgres://user:password@localhost:5432/mydb"
}
cfg, err := pgxpool.ParseConfig(pgDSN)
if err != nil {
return nil, err
}
cfg.MaxConns = 50
cfg.MinConns = 10
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
return nil, err
}
_, err = pool.Exec(context.Background(), `
CREATE UNLOGGED TABLE IF NOT EXISTS cache (
key VARCHAR(255) PRIMARY KEY,
value TEXT
);
`)
if err != nil {
return nil, err
}
return &PostgresCache{
db: pool,
}, nil
}
func (p *PostgresCache) Get(ctx context.Context, key string) (string, error) {
var content string
err := p.db.QueryRow(ctx, `SELECT value FROM cache WHERE key = $1`, key).Scan(&content)
if err == pgx.ErrNoRows {
return "", ErrCacheMiss
}
if err != nil {
return "", err
}
return content, nil
}
func (p *PostgresCache) Set(ctx context.Context, key string, value string) error {
_, err := p.db.Exec(ctx, `INSERT INTO cache (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`, key, value)
return err
}
Я заполню Redis и Postgres 30 миллионами записей в каждой, сохраняя записи о вставленных uuids. Затем я сгенерирую подмножество существующих uuids для использования при тестировании. Это позволит имитировать как попадания, так и промахи.
Сначала я проведу несколько тестов для получения данных, затем для их установки, а потом смешанный тест. Каждый тест будет выполняться в течение 2 минут. Я буду следить за количеством операций в секунду, задержками, а также за использованием памяти и процессора в это время.
Чтобы смоделировать более реалистичный сценарий, при котором в кэше существует только подмножество ключей, в тесте на установку вероятность обновления существующего ключа составит 10 %, а в тесте на получение — 80 %. В смешанной рабочей нагрузке вероятность выполнения теста на установку составит 20 %, а теста на получение — 80 %.
Результаты
Получение значений из кэша

Redis показал себя лучше, чем Postgres, что меня совсем не удивило. Узким местом на самом деле был HTTP-сервер. Машина, на которой работал HTTP-сервер, была перегружена по процессору, в то время как Redis комфортно работал с ~1280 mCPU, что меньше установленного лимита в 2000 mCPU. Redis использовал ~3800 МБ оперативной памяти, и этот показатель оставался неизменным во всех запусках.
Для Postgres узким местом был процессор на стороне Postgres. Он постоянно загружал два выделенных ему ядра, а также использовал около 5000 МБ оперативной памяти.
Redis также показал лучшие результаты в плане задержки HTTP-ответов:

Установка значений в кэше

Redis снова показал лучшие результаты. Загрузка процессора осталась примерно на том же уровне, что и в случае с GET-запросом, а использование оперативной памяти выросло до ~4300 МБ из-за добавления новых ключей. Узким местом по-прежнему оставался HTTP-сервер, а Redis снова использовал ~1280 mCPU.
Postgres снова столкнулся с проблемой нехватки ресурсов процессора, постоянно используя 100 % из 2 доступных ядер. В ходе выполнения теста использование памяти выросло до ~5500 МБ.
Тест на задержку:

Скорость чтения/записи

Смешанный бенчмарк также показал предсказуемый результат: Redis лидирует. Как и в предыдущих тестах, загрузка ЦП составила ~1280 mCPU, а использование оперативной памяти немного выросло из-за добавления новых ключей.
Postgres задействовал оба ядра и использовал около 6 ГБ памяти.
При использовании Redis задержки снова сократились:

Нежурналируемые таблицы
В тесте я использовал такую таблицу для Postgres, но это, похоже, не помогло, или всё-таки помогло? Если я повторю тот же тест с обычной таблицей, мы сможем посмотреть на результаты.

Как видим есть влияние на результаты теста записи и в меньшей степени, но всё же влияет на результаты теста смешанной нагрузки. Это связано с тем, что в таблицах без журнала транзакций не используется журнал упреждающей записи, что значительно ускоряет запись. Однако разница в производительности при чтении незначительна, и я ожидаю, что при большем количестве запусков результаты двух тестов совпадут.
Заключение - выбираю PostgreSQL
Когда дело доходит до кэширования, Redis работает быстрее postgres, в этом нет никаких сомнений.
К тому же у него есть множество других полезных функций, которые можно было бы ожидать от кэша, таких как TTL.
Узким местом при тестирование были используемое аппаратное обеспечение, сам сервис. Если их улучшить тестирование, безусловно, могло бы показать лучшие показатели.
Тогда, конечно, мы все должны использовать Redis для наших нужд кэширования, не так ли? Что ж, я думаю, что все равно буду использовать postgres. И вот почему:
а) Почти всегда моим проектам нужна база данных
б) Отсутствие необходимости добавлять ещё одну зависимость имеет свои преимущества
в) Если мне нужно, чтобы срок действия моих ключей истёк, я добавлю для этого столбец и задание cron для удаления этих ключей из таблицы.
г) Что касается скорости, то 7425 запросов в секунду — это всё равно много. Это более полумиллиарда запросов в день. И всё это на оборудовании, которому 10 лет, с процессорами для ноутбуков. Немногие проекты достигают такого масштаба, а если и достигают, то я могу просто обновить экземпляр Postgres или, если потребуется, запустить Redis.
д) Если нужно будет сменить используемую СУБД благодаря наличию интерфейса для кэша я легко смогу это сделать.
Спасибо за чтение!
Подробней про нежурналируемые таблицы.
Мой блог про System Design, Архитектурные каты, паттерны - @System_Design_World
Комментарии (4)
Dhwtj
27.09.2025 08:18Можно и в приложении кешировать L1 write back cache, то есть на каждый инстанс свой. Пишешь в потокобезопасный вариант хешмапы и отгребаешь в БД его изменения батчами, что быстрее чем по одному. И быстрее чем редис: никаких сетевых запросов.
Минус: при рестарте сервиса кеш сбрасывается. Больше памяти, потому что на каждый инстанс свой кеш.
Второй минус при падении сервиса потеря данных что не успели записаться, нужно мягкое гашение сервиса. Если критично, то можно писать свой WAL fallback: критичные данные дублировать в append-only лог перед батчингом. Тоже надёжно и очень быстро.
Но если жесткое IO и годный балансер чтобы не утопить свеже стартовавший (или вообще один инстанс) то штука хорошая
Для балансера:
- Readiness probe с задержкой после старта
- Slow start в nginx/envoy — постепенно увеличивать трафик
- Или прогрев кеша перед переключением в ready
В C# удобно через IHostedService + Channel для батчинга. В Rust — tokio::sync::mpsc + graceful shutdown через токены
daniil_kulikov
Не понял пункта про зависимости. В проектах число зависимостей зачастую как у соседа в Мурино. Неужели наличие ещё одной критичнее, чем прожорливость и скорость?
А вообще, было бы любопытно глянуть на перфоманс кэшей без упора в железо. Чисто ради интереса
noavarice
Про зависимости - проще администрировать один постгрес, чем постгрес и редис (настраивать, следить за обновлениями и уязвимостями и т.д.). Редис и постгрес более похожи, чем скажем постгрес и рэббит - можно очередь через БД реализовать и отказаться от рэббита, но это намного сложнее, чем отказаться от редиса и реализовать его функции в постгресе
avovana7 Автор
Даниил, я бы раскрыл со стороны технологического ландшафта. Если у нас есть PostgreSQL и мы умеем её готовить, то доп сущность/СУБД привносит дополнительные издержки на поддержку/нужду в наработке экспертизы.
Автор исходил из фокуса использования кэша для условно небольших проектов. Где PostgreSQL вполне может хватить. Тем более, если он уже есть.
Про перформанс кэшэй без упора в железо - имеется ввиду как-то абстрагироваться от тестовых стендов?