我用 PostgreSQL 替代了 Redis

5 阅读2分钟

原文:dev.to/polliog/i-r…


一、背景:一个很典型的 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/Sub1–2ms
Postgres NOTIFY2–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 很难做到


九、实测基准(简表)

操作RedisPostgres
Cache SET0.05ms0.08ms
Cache GET0.04ms0.06ms
Pub/Sub1.2ms3.1ms
Queue pop0.12ms0.31ms

👉 单操作慢,但仍 < 1ms*


组合操作反而更快

Redis(3 次网络)*

≈ 4ms

Postgres(一个事务)*

≈ 2.2ms


十、什么时候应该保留 Redis

❌ 不要替换 Redis 的情况:

  1. 超高吞吐(百万 QPS)
  2. 重度使用 Sorted Set / HyperLogLog
  3. 架构上必须独立 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

中小团队 / 中等流量项目 非常友好。