PostgreSQL místo Redis: Jak nahradit Redis čistě PostgreSQL – a proč to funguje

Redis je běžně používán pro cache, fronty a realtime notifikace, ale přidává další infrastrukturu. Tento článek ukazuje, jak lze tyto funkce nahradit čistě PostgreSQL, s praktickými příklady kódu a zachováním rychlosti i transakční konzistence.
Nálepky:
V tradiční webové aplikaci se často používají dvě databáze:
- PostgreSQL pro trvalá data (například uživatele a business logiku),
- Redis pro cache, realtime messaging a fronty úloh.
To znamená dvojnásobnou infrastrukturu: dva různé systémy, které je nutné zálohovat, monitorovat a udržovat. Autor původního experimentu tedy položil zásadní otázku: Je možné Redis nahradit PostgreSQL tak, že výsledná aplikace bude stále stejně funkční a dostatečně rychlá? Odpověď zní ano – pro mnoho scénářů stačí PostgreSQL samotná.
Problémy se samostatným Redis
Než se podíváme na řešení, shrneme důvody, proč se autor rozhodl Redis odstranit:
- Náklady – provoz AWS ElastiCache stál kolem $45/měsíc pro 2 GB paměti, škálování na 5 GB stálo kolem $110/měsíc. PostgreSQL na stejném hostingu s 5 GB navíc stál centy.
- Provozní složitost – Redis má složitou persistenci s možností RDB nebo AOF, o Sentinel/Cluster režimu nemluvě. PostgreSQL má nastavení robustní replikace a zálohování.
- Konzistence dat – při Redis + PostgreSQL architektuře je běžný scénář, kdy databáze se uloží, ale cache se neinvaliduje (např. kvůli chybě v Redis), což vede ke nekonzistentním datům.
1. Caching pomocí PostgreSQL UNLOGGED tabulek
Redis je extrémně populární pro caching (v původní aplikaci tvořil ~70 % použití Redis), typicky takto:
// Cache API responses
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);Code language: JavaScript (javascript)
V PostgreSQL lze cache implementovat pomocí UNLOGGED tabulek, které nepíšou do WAL (write‑ahead log), což zrychluje zápisy a je ideální pro dočasná data.
Definice cache tabulky
CREATE UNLOGGED TABLE cache (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_cache_expires ON cache(expires_at);Code language: PHP (php)
Vložení nebo aktualizace cache
INSERT INTO cache (key, value, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '1 hour')
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
expires_at = EXCLUDED.expires_at;Code language: PHP (php)
Čtení cache
SELECT value FROM cache
WHERE key = $1 AND expires_at > NOW();
Čištění expirovaných záznamů
DELETE FROM cache WHERE expires_at < NOW();
UNLOGGED tabulky skrz absenci WAL zapisů výrazně zrychlují operace, a i když jsou o něco pomalejší než Redis (např. ~0.08 ms vs ~0.05 ms), pro běžné použití je to přijatelné.
2. Pub/Sub pomocí PostgreSQL LISTEN/NOTIFY
Redis je běžně používán pro realtime notifikace:
// Publisher
redis.publish('notifications', JSON.stringify({ userId: 123, msg: 'Hello' }));
// Subscriber
redis.subscribe('notifications');
redis.on('message', (channel, message) => {
console.log(message);
});Code language: JavaScript (javascript)
To lze nahradit PostgreSQL nativním pub/sub mechanizmem.
Odeslání notifikace
NOTIFY notifications, '{"userId": 123, "msg": "Hello"}';Code language: JavaScript (javascript)
Poslech v Node.js
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query('LISTEN notifications');
client.on('notification', (msg) => {
const payload = JSON.parse(msg.payload);
console.log(payload);
});Code language: JavaScript (javascript)
Výkon PostgreSQL NOTIFY je mírně pomalejší (~2‑5 ms) oproti Redis (~1‑2 ms), ale výhody jsou zásadní:
- není třeba další služby,
- pub/sub lze použít přímo ve stejné transakci jako jiné SQL operace,
- výsledky lze kombinovat se selecty nebo jinými kroky bez síťového hopu.
Reálný příklad: realtime log streaming
Redis řešení:
await db.query('INSERT INTO logs ...');
await redis.publish('logs:new', JSON.stringify(log));
redis.subscribe('logs:new');Code language: JavaScript (javascript)
Tady je problém, že insert a publish nejsou atomické – pokud publish selže, frontend nikdy nedostane notifikaci.
PostgreSQL řešení – s triggerem
CREATE FUNCTION notify_new_log() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('logs_new', row_to_json(NEW)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER log_inserted
AFTER INSERT ON logs
FOR EACH ROW EXECUTE FUNCTION notify_new_log();Code language: JavaScript (javascript)
Frontend (např. pomocí Server‑Sent Events):
app.get('/logs/stream', async (req, res) => {
const client = await pool.connect();
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
});
await client.query('LISTEN logs_new');
client.on('notification', (msg) => {
res.write(`data: ${msg.payload}\n\n`);
});
});Code language: JavaScript (javascript)
Tímto způsobem je publish zabudován do databázové logiky a je atomický s datovým zápisem.
3. Job Queues pomocí SKIP LOCKED
V původním systému byl Redis použit společně s knihovnami jako Bull/BullMQ:
queue.add('send-email', { to, subject, body });
queue.process('send-email', async (job) => {
await sendEmail(job.data);
});Code language: JavaScript (javascript)
Pro PostgreSQL lze implementovat frontu úloh tabulkou s logikou SKIP LOCKED, která umožňuje bezpečné paralelní zpracování:
Definice tabulky jobů
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
queue TEXT NOT NULL,
payload JSONB NOT NULL,
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 3,
scheduled_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_jobs_queue ON jobs(queue, scheduled_at)
WHERE attempts < max_attempts;Code language: PHP (php)
Vložení jobu
INSERT INTO jobs (queue, payload)
VALUES ('send-email', '{"to": "user@example.com", "subject": "Hi"}');Code language: JavaScript (javascript)
Získání jobu pro zpracování
WITH next_job AS (
SELECT id FROM jobs
WHERE queue = $1
AND attempts < max_attempts
AND scheduled_at <= NOW()
ORDER BY scheduled_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET attempts = attempts + 1
FROM next_job
WHERE jobs.id = next_job.id
RETURNING *;Code language: PHP (php)
SKIP LOCKED zajistí, že:
- více workerů může přistupovat ke frontě současně,
- žádný job nebude zpracován dvakrát,
- pokud worker spadne, job se může zpracovat později.
Výkon je sice opět pomalejší než Redis BRPOP (~0.3 ms vs ~0.1 ms), ale rozdíl je pro běžné použití zanedbatelný.
4. Rate limiting v PostgreSQL
Redis velice často slouží i pro rate limiting:
const key = `ratelimit:${userId}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 60);
}
if (count > 100) {
throw new Error('Rate limit exceeded');
}
Code language: JavaScript (javascript)
V PostgreSQL lze stejnou logiku zvládnout strukturou tabulky a SQL, bez externí služby:
CREATE TABLE rate_limits (
user_id INT PRIMARY KEY,
request_count INT DEFAULT 0,
window_start TIMESTAMPTZ DEFAULT NOW()
);
WITH current AS (
SELECT
request_count,
CASE
WHEN window_start < NOW() - INTERVAL '1 minute'
THEN 1
ELSE request_count + 1
END AS new_count
FROM rate_limits
WHERE user_id = $1
FOR UPDATE
)
UPDATE rate_limits
SET
request_count = (SELECT new_count FROM current),
window_start = CASE
WHEN window_start < NOW() - INTERVAL '1 minute'
THEN NOW()
ELSE window_start
END
WHERE user_id = $1
RETURNING request_count;
Code language: PHP (php)
Nebo alternativně pomocí tabulky s jednotlivými requesty a jednoduchým SELECT COUNT(*).
5. Sessions s JSONB
Redis se často používá pro session storage:
await redis.set(`session:${sessionId}`, JSON.stringify(sessionData), 'EX', 86400);Code language: JavaScript (javascript)
PostgreSQL nebo SQL lze využít JSONB tabulku:
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
data JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
INSERT INTO sessions (id, data, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '24 hours')
ON CONFLICT (id) DO UPDATE
SET data = EXCLUDED.data,
expires_at = EXCLUDED.expires_at;
SELECT data FROM sessions
WHERE id = $1 AND expires_at > NOW();
Code language: PHP (php)
JSONB navíc umožňuje dotazovat se uvnitř session objektů (např. podle uživatelského ID či role), což Redis nativně neumožňuje.
Výsledky a benchmarky
Autor provedl benchmarky na AWS RDS instanci s 1 000 000 cache položek a 10 000 session záznamy. Mezi výsledky patří:
| Operace | Redis | PostgreSQL |
|---|---|---|
| Cache SET | 0.05 ms | 0.08 ms |
| Cache GET | 0.04 ms | 0.06 ms |
| Pub/Sub | 1.2 ms | 3.1 ms |
| Queue push | 0.08 ms | 0.15 ms |
| Queue pop | 0.12 ms | 0.31 ms |
PostgreSQL je tedy o něco pomalejší, ale stále velmi rychlá – a navíc bez nutnosti sítě mezi aplikací a Redisem.
Kombinované operace
V praxi je často potřeba provést více kroků najednou (např. uložit nový post, invalidovat cache a publikovat notifikaci). Redis verze typicky vypadá takto:
await db.query('INSERT INTO posts ...'); // ~2 ms
await redis.del('posts:latest'); // ~1 ms
await redis.publish('posts:new', data); // ~1 ms
// Celkem ~4 msCode language: JavaScript (javascript)
V PostgreSQL lze totéž zahrnout do jediné transakce:
BEGIN;
INSERT INTO posts ...; // ~2 ms
DELETE FROM cache WHERE key = 'posts:latest'; // ~0.1 ms
NOTIFY posts_new, '...'; // ~0.1 ms
COMMIT;
// Celkem ~2.2 msCode language: JavaScript (javascript)
Díky tomu je skutečný výkon v reálném workflow často rychlejší než Redis.
Shrnutí rozhodovacích kritérií
Autor uvádí praktickou matici:
Kdy nahradit Redis PostgreSQL:
- Redis slouží jen pro jednoduchou cache, sessions nebo fronty,
- cache hit rate není extrémně vysoká,
- je důležitá transakční integrita,
- chcete snížit počet provozovaných služeb.
Kdy Redis ponechat:
- potřebujete 100 000+ operací za sekundu,
- využíváte Redis datové struktury (sorted sets, streams, geospatial, atd.),
- máte dedikovaný ops tým,
- vyžadujete sub-milisekundovou latenci.
Závěr
Pro malé až střední aplikace je nahrazení Redis PostgreSQL nejen možné, ale často i výhodné z hlediska cen, konzistence a provozní jednoduchosti. Redis je stále nezastupitelný tam, kde je vyžadován extrémní výkon nebo pokročilé datové struktury.
Zdroj: https://dev.to/polliog/i-replaced-redis-with-postgresql-and-its-faster-4942