PostgreSQL Field Guide

把 PostgreSQL 当键值存储用

在 PostgreSQL 里做键值负载的三条路线(hstore、jsonb、UNLOGGED 两列表)、它替代不了 Redis 的部分,以及各自的适用边界

PostgreSQL 没有内置 Redis 式的服务器:没有 GET/SET 协议,没有内存数据结构引擎,也没有按键过期的 TTL。它有的是在关系型数据库内部建模键值数据的三条成熟路线——对一大类"缓存性质"的负载来说,这足以让你少运维一个系统。

2026-08 对照官方文档核对

本页的行为陈述以 2026 年 8 月核对的 PostgreSQL 官方文档为准,并与 pg_cron 仓库Redis 许可证页面交叉核对。涉及具体版本的细节请在依赖前复核。

PostgreSQL 里的三条 KV 路线

hstore:经典的键值类型

hstore 是一个 contrib 扩展,把一组键值对存进单个值里——键和值都是文本。它配有一套紧凑的运算符(-> 取键、? 测键存在、@> 包含匹配),并可用 GIN 或 GiST 索引,避免按键查找时全表扫描:

CREATE EXTENSION hstore;

CREATE TABLE session_attrs (
  session_id uuid PRIMARY KEY,
  attrs      hstore NOT NULL
);

CREATE INDEX ON session_attrs USING gin (attrs);

-- 取单个键
SELECT attrs -> 'theme' FROM session_attrs WHERE session_id = $1;

-- 键存在 / 键值匹配的行
SELECT * FROM session_attrs WHERE attrs ? 'theme';
SELECT * FROM session_attrs WHERE attrs @> 'theme => "dark"';

hstore 早于 jsonb 出现,对扁平的字符串映射仍然够用。它没有嵌套、没有数字和布尔——一切皆是文本。

jsonb:可索引的文档

只要涉及嵌套结构,jsonb 就是现代答案。它以分解后的二进制形式存储,支持包含匹配(@>)、存在性判断(??|?&)与路径运算符,全部由 GIN 索引支撑:

CREATE TABLE kv_docs (
  key  text PRIMARY KEY,
  doc  jsonb NOT NULL
);

-- jsonb_path_ops:索引更小,支持 @>(以及 @?、@@)——KV 访问的常见情形
CREATE INDEX ON kv_docs USING gin (doc jsonb_path_ops);

SELECT * FROM kv_docs WHERE doc @> '{"user_id": 42}';

默认的 jsonb_ops GIN 操作类索引每个键和值、支持的运算符更全;jsonb_path_ops 只按路径索引值的哈希,索引通常小几倍——纯键值访问模式下它是合理的默认选择。完整的索引权衡见 JSONB 存储与检索

普通两列表,可选 UNLOGGED

对最纯粹的 KV 形态——一个键、一个值、别无所求——普通表往往是最佳工具。主键上的 B-tree 点查是 PostgreSQL 优化得最充分的路径:

CREATE UNLOGGED TABLE cache_entries (
  key        text PRIMARY KEY,
  value      jsonb NOT NULL,
  expires_at timestamptz
);

把表标记为 UNLOGGED 换来的是诚实的缓存语义:写入不走 WAL,代价明显更低,但崩溃或不正常关闭后表会被自动清空,其内容也不会复制到备机。这正是缓存想要的持久性交换——写得快、数据可丢弃——前提是每一行都能从权威数据源重建。

缓存负载在 PostgreSQL 里缺什么

在宣布 Redis 多余之前,有三处缺口需要正视。

没有内建 TTL 与驱逐。 PostgreSQL 没有按键过期,也没有 maxmemory 式的驱逐策略;行在你删除之前一直存在。标准的替代手段是定时清扫(pg_cron,见下文示例),或按时间窗口做分区、到期 DROP 分区而非大规模 DELETE。两者都可行,但过期粒度是"下一次清扫的间隔"而非毫秒级精确,内存吃紧时也不会有任何东西被自动逐出。

没有 Redis 的数据结构。 list、set、sorted set、stream、bitmap、HyperLogLog——这些让 Redis 不止是个哈希表的运算(LPUSHZADDXADD、对成员的原子自增)在 PostgreSQL 里没有直接对应物。计数器可以用 UPDATE ... RETURNING 近似,队列可以用 SELECT ... FOR UPDATE SKIP LOCKED 近似,但用 SQL 实现排行榜或扇出流是重新开发,不是配置。

LISTEN/NOTIFY 不等于 Redis 的 pub/sub。 PostgreSQL 的 LISTEN/NOTIFY 只在发送事务提交时才投递通知,载荷上限不足 8000 字节,且不留历史——当时不在线的客户端会彻底错过事件。它是一个唤醒信号("有变化,去重读"),不是消息代理。

诚实的性能画面

Redis 与 Valkey 从内存中应答单键操作,延迟在亚毫秒级——这本来就是它们的全部设计中心。PostgreSQL 每条语句都要付出 SQL 解析、规划以及(在 logged 表上)WAL 的开销,即便数据已在 shared buffers 里,单次点读写的开销也更高。换来的则是少一个需要部署、复制、加固、值班的组件——而且你的"缓存"可以与其余数据 join、加约束、放进同一个事务。

一份务实的决策清单:

以下情形 PostgreSQL 够用:

  • 你已经在跑 PostgreSQL,KV 负载规模适中——会话、功能开关、应用层缓存条目、任务元数据。
  • 缓存行必须与持久表保持事务一致(一次提交同时写两边)。
  • 你希望用 SQL 查询"缓存",而不只是按键取值。
  • 驱逐可以懒散:每几分钟清扫一次可以接受。

以下情形上真正的 Redis/Valkey:

  • 热键上高 ops/sec 且延迟预算在亚毫秒级。
  • 需要在内存压力下按 TTL 自动驱逐(maxmemory 策略)。
  • 负载建立在 Redis 数据结构之上——限流器、排行榜、stream、扇出队列。
  • 缓存是挡在数据库前面的共享高流量层,它全部的工作就是替 PostgreSQL 吸收读请求。

关于这个选择背后的许可证背景(Redis 2024 年的许可证变更、Valkey 分叉),见选型 FAQ

可用示例

一张带惰性过期的最小缓存表、一条 UPSERT,以及一个 pg_cron 清扫任务。pg_cron 是基于 cron 的作业调度器,以扩展形式运行在 PostgreSQL 内部:

CREATE EXTENSION pg_cron;

CREATE UNLOGGED TABLE cache_entries (
  key        text PRIMARY KEY,
  value      jsonb NOT NULL,
  expires_at timestamptz NOT NULL
);

CREATE INDEX ON cache_entries (expires_at);

-- UPSERT,TTL 一小时
INSERT INTO cache_entries (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_entries
WHERE key = $1 AND expires_at > now();

-- 每 5 分钟清扫过期行
SELECT cron.schedule(
  'expire-cache',
  '*/5 * * * *',
  $$DELETE FROM cache_entries WHERE expires_at < now()$$
);

注意这个模式:过期在读路径上强制执行(expires_at > now()),pg_cron 只是垃圾回收器。这样清扫延迟或失败造成的只是磁盘上的陈旧数据,永远不会是读到陈旧结果。

相关页面

Last updated on

On this page