一、背景:一个很典型的 Web 技术栈
我原来的 Web 应用架构非常常见:
- PostgreSQL:持久化数据
- Redis:
- 缓存
- Pub/Sub
- 后台任务队列
问题是:
- 两个数据库
- 两套维护
- 两处潜在故障点
后来我意识到一件事:
PostgreSQL 其实能完成我用 Redis 做的大多数事情。
于是我把 Redis 完全移除了。
下面是整个过程与结论。
二、我原来是如何使用 Redis 的
Redis 主要承担三类职责:
1️⃣ 缓存(≈ 70%)
用于缓存 API 响应:
await redis.set(
`user:${id}`,
JSON.stringify(user),
'EX',
3600
);
2️⃣ Pub/Sub(≈ 20%)
用于实时通知:
redis.publish(
'notifications',
JSON.stringify({ userId, message })
);
3️⃣ 后台任务队列(≈ 10%)
使用 Bull / BullMQ:
queue.add('send-email', { to, subject, body });
痛点总结
- 两套数据库都要备份
- Redis 内存成本高(规模一上来就很贵)
- Redis 持久化机制复杂(RDB / AOF)
- Postgres ↔ Redis 存在网络跳数(延迟 + 故障点)
三、为什么考虑替换 Redis
1️⃣ 成本
Redis(原配置)*
-
ElastiCache
-
月成本 ≈ 100 美元
PostgreSQL*
-
AWS RDS 已在使用:≈ 50 美元 / 月(20GB)
-
额外 5GB 数据:≈ 0.5 美元 / 月
👉 潜在节省:≈ 100 美元 / 月****
2️⃣ 运维复杂度
| 场景 | 使用 Redis | 只用 Postgres |
|---|---|---|
| 备份 | Postgres ✅ + Redis ❓ | Postgres ✅ |
| 监控 | Postgres ✅ + Redis ❓ | Postgres ✅ |
| 高可用 | Redis Sentinel / Cluster ❓ | Postgres 原生支持 |
| 系统数量 | 2 套 | 1 套 |
👉 少一套系统,少一堆坑****
3️⃣ 数据一致性
经典问题:
await db.query(
'UPDATE users SET name = $1 WHERE id = $2',
[name, id]
);
// 再删缓存
await redis.del(`user:${id}`);
⚠️ 如果 Redis 在这里挂了?
-
数据库已更新
-
缓存没删
-
数据不一致
如果一切都在 Postgres 中:
👉 事务可以兜底****
四、特性 1:用 UNLOGGED 表做缓存
Redis 写法
await redis.set(
'session:abc123',
JSON.stringify(sessionData),
'EX',
3600
);
PostgreSQL 写法
表结构
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);
写缓存
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;
读缓存
SELECT value
FROM cache
WHERE key = $1
AND expires_at > NOW();
清理过期缓存
DELETE FROM cache WHERE expires_at < NOW();
什么是 UNLOGGED 表?
-
❌ 不写 WAL
-
✅ 写入速度更快
-
❌ 崩溃后数据不保证
👉 非常适合缓存****
性能对比
| 操作 | 延迟 |
|---|---|
| Redis SET | ≈ 0.05ms |
| Postgres UNLOGGED INSERT | ≈ 0.08ms |
✅ 对缓存场景完全可接受
五、特性 2:LISTEN / NOTIFY 做 Pub/Sub
Redis Pub/Sub
redis.publish(
'notifications',
JSON.stringify({ userId: 123, msg: 'Hello' })
);
redis.subscribe('notifications');
redis.on('message', (channel, message) => {
console.log(message);
});
PostgreSQL Pub/Sub
发布
NOTIFY notifications, '{"userId":123,"msg":"Hello"}';
订阅(Node.js)
await client.query('LISTEN notifications');
client.on('notification', (msg) => {
console.log(JSON.parse(msg.payload));
});
性能对比
| 延迟 | |
|---|---|
| Redis Pub/Sub | 1–2ms |
| Postgres NOTIFY | 2–5ms |
实战:实时日志流(原子一致)
PostgreSQL 触发器
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();
👉 插入日志 + 通知 = 一个原子操作****
六、特性 3:用 SKIP LOCKED 做任务队列
表结构
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()
);
出队(Worker)
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 *;
FOR UPDATE SKIP LOCKED 的意义
- 多 worker 不阻塞
- 任务不重复执行
- worker 崩溃不会锁死任务
性能对比
| 延迟 | |
|---|---|
| Redis BRPOP | ≈ 0.1ms |
| Postgres SKIP LOCKED | ≈ 0.3ms |
七、特性 4:限流(Rate Limiting)
Redis 经典写法
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');
}
PostgreSQL 方案一:计数表
CREATE TABLE rate_limits (
user_id INT PRIMARY KEY,
request_count INT DEFAULT 0,
window_start TIMESTAMPTZ DEFAULT NOW()
);
事务内自增 + 判断窗口。
PostgreSQL 方案二:事件表 + 时间窗口
SELECT COUNT(*)
FROM api_requests
WHERE user_id = $1
AND created_at > NOW() - INTERVAL '1 minute';
对比结论
- ✅ Postgres:复杂限流、一致性要求高
- ✅ Redis:极简、超高 QPS
八、特性 5:用 JSONB 存 Session
表结构
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
data JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
JSONB 的额外能力
-- 某用户所有 Session
SELECT *
FROM sessions
WHERE data->>'userId' = '123';
-- 所有 admin Session
SELECT *
FROM sessions
WHERE data->'user'->>'role' = 'admin';
👉 Redis 很难做到
九、实测基准(简表)
| 操作 | Redis | Postgres |
|---|---|---|
| Cache SET | 0.05ms | 0.08ms |
| Cache GET | 0.04ms | 0.06ms |
| Pub/Sub | 1.2ms | 3.1ms |
| Queue pop | 0.12ms | 0.31ms |
👉 单操作慢,但仍 < 1ms*
组合操作反而更快
Redis(3 次网络)*
≈ 4ms
Postgres(一个事务)*
≈ 2.2ms
十、什么时候应该保留 Redis
❌ 不要替换 Redis 的情况:
- 超高吞吐(百万 QPS)
- 重度使用 Sorted Set / HyperLogLog
- 架构上必须独立 cache tier
十一、迁移策略(非常重要)
不要一夜之间拔 Redis*
阶段 1:双写
阶段 2:读优先 Postgres
阶段 3:只写 Postgres
阶段 4:下线 Redis
十二、3 个月后的结论
收益
-
✅ 每月省 ≈ 100 美元
-
✅ 运维复杂度明显下降
-
✅ 架构更简单
-
✅ 数据一致性更好
代价
- ❌ 单次操作慢 ~0.5ms
- ❌ 失去 Redis 高级数据结构
十三、最终总结
如果你的 Redis 只用来做:*
-
简单缓存 / Session
-
普通 Pub/Sub
-
简单队列
-
普通限流
👉 你可以考虑:*
-
UNLOGGED 表 + JSONB
-
LISTEN / NOTIFY
-
FOR UPDATE SKIP LOCKED
-
全部放进一个事务
换来的是:
-
更简单的架构
-
更低的成本
-
更强的一致性
代价是:
-
略慢的单操作
-
更低的极限吞吐
这是一个「用复杂度换极致性能」的 trade-off
对 中小团队 / 中等流量项目 非常友好。