这句话现在仍让我心里发紧,因为缓存就像一条安全带。但在 PostgreSQL 18 上经过慎重试验后,p95 降低了,系统变简单了,值班负担也减轻了。
风险是真实的:用户会话、结账流程、我们的口碑。
收益也是真实的:更少的活动部件,更少的告警短信。
下面是我走过的路径、我修复的查询、唯一关键的图,以及几个起决定作用的小配置。
一、我拨动开关的那一天
我们的热路径是一个参数化的产品查询,并带有一点个性化。
缓存为我们带来了命中,但未命中很嘈杂,而且序列化增加了工作量。
我对该路径进行了端到端的分析,然后尝试了一个直接的数据库方案,使用更紧凑的索引和一个预先计算好的投影。
第一次运行很吓人——然后数据稳定下来了。
我让缓存停用了一个完整的周期,看着图表平静下来。
二、Postgres 里到底发生了什么
没有玄学,只有两件落地的事:
一个覆盖索引 + 一个生成列,让优化器只拿我们要的字段,不再碰堆表。
物化视图(并发刷新)把原来靠缓存藏起来的昂贵聚合扛了过来。
PostgreSQL 18 用更理智的执行计划 + 可预测的 I/O 完成了其余工作。
三、那个让 Redis 变得多余的查询
下面的表结构反映了一条常见的 Feed 或目录切片:商品、软删除、新鲜度、个性化键。
重点不是字段名,而是“生成列 + 覆盖索引”的组合正好匹配返回结果。
-- Schema and plan helpers (run in a maintenance window)CREATE TABLE catalog_item (item_id BIGSERIAL PRIMARY KEY,category_id BIGINT NOT NULL,tenant_id BIGINT NOT NULL,price_cents INTEGER NOT NULL,rating_avg NUMERIC(3,2) NOT NULL DEFAULT 0.0,tags TEXT[] NOT NULL DEFAULT '{}',updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),deleted BOOLEAN NOT NULL DEFAULT FALSE);-- Generated column to precompute a simple personalization bucketALTER TABLE catalog_itemADD COLUMN p_bucket SMALLINT GENERATED ALWAYS AS ((rating_avg * 10)::smallint) STORED;-- Covering index that matches the query (note the INCLUDE list)CREATE INDEX CONCURRENTLY idx_catalog_lookupON catalog_item (tenant_id, category_id, p_bucket, deleted)WHERE deleted = FALSEINCLUDE (item_id, price_cents, rating_avg, updated_at, tags);-- A precomputed projection for “trending” that used to be cachedCREATE MATERIALIZED VIEW mv_trending ASSELECT tenant_id, category_id,item_id, price_cents, rating_avg, tags,row_number() OVER (PARTITION BY tenant_id, category_id ORDER BY rating_avg DESC, updated_at DESC) AS rkFROM catalog_itemWHERE deleted = FALSE;-- Keep it fresh without blocking writersCREATE UNIQUE INDEX CONCURRENTLY mv_trending_pkON mv_trending (tenant_id, category_id, item_id);-- In a job runner or cron:-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_trending;
应用程序调用随后变成了单次往返:
-- Serve top N without touching RedisPREPARE fetch_slice (bigint, bigint, smallint, int) ASSELECT item_id, price_cents, rating_avg, tagsFROM catalog_itemWHERE tenant_id = $1AND category_id = $2AND p_bucket >= $3AND deleted = FALSEORDER BY rating_avg DESC, updated_at DESCLIMIT $4;-- When trending is requestedPREPARE fetch_trending (bigint, bigint, int) ASSELECT item_id, price_cents, rating_avg, tagsFROM mv_trendingWHERE tenant_id = $1 AND category_id = $2 AND rk <= $3;
简而言之:规划器停留在索引上,堆保持"冷却",视图从热路径中移除了沉重的聚合操作。
四、从源头削减延迟
两个小的配置调整起到了作用。它们并非万能药;但它们确实让 I/O 保持稳定,并让规划器能够可靠地利用索引和并行性。
# postgresql.conf (16 GB VM example)shared_buffers = '4GB'effective_cache_size = '11GB'work_mem = '64MB'maintenance_work_mem = '1GB'track_io_timing = onjit = onmax_worker_processes = 8max_parallel_workers_per_gather = 2random_page_cost = 1.1default_statistics_target = 500
如果你在更快的本地 NVMe 上运行,较低的 random_page_cost 能保持索引扫描的吸引力。track_io_timing 会在你对自己磁盘性能判断有误时告诉你。
五、我是如何测量的
我运行了一个简单的客户端,它发出了我们通常缓存的那组参数。下表来自我的环境,3 次预热运行,显示的是中位数。网络跳数和序列化开销比人们预期的要多。
+--------------------------------------+-------+-------+-------+-------------------------------+| Path | p50 | p95 | p99 | Notes |+--------------------------------------+-------+-------+-------+-------------------------------+| Redis (hit) | 6 ms | 18 ms| 28 ms| fast but extra hop || Redis (miss → DB) | 24 ms | 80 ms| 120 ms| hop + serialization + origin || Postgres 18 direct (covering index) | 18 ms | 55 ms| 95 ms| fewer hops, stable tail || Postgres 18 via mv_trending (warm) | 12 ms | 38 ms| 70 ms| precomputed hot slice |+--------------------------------------+-------+-------+-------+-------------------------------+
在我们的信息流端点上,直接的数据库路径击败了缓存未命名的尾部延迟,并消除了命中与未命中之间的断崖式差距。那个差距过去常常出现在用户追踪记录和支持工单中。
六、前后对比,用 ASCII 图绘制
一个说服团队的流程图:
BEFORE┌────────┐ ┌──────────────┐ ┌───────────┐│ Client │───▶│ Redis/Codec │───▶│ Postgres │└────────┘ └──────────────┘ └───────────┘▲ │ ▲└──── misses ──┘ └── invalidates ─┘AFTER┌────────┐ ┌──────────────────────────────────────────┐│ Client │─────▶│ Postgres 18 │└────────┘ │ • Covering index (tenant, category, p) ││ • Materialized view for trending ││ • Parallel plan where it helps │└──────────────────────────────────────────┘
我们仍然保留了一个只读副本以确保安全,但热路径现在只有一个依赖项。
七、何时缓存仍然占优
我并不是反对缓存。如果你需要跨请求协调、速率窗口,或者跨服务的扇出扇入操作,缓存或消息总线仍然有其用武之地。我仅在它掩盖了规划器错误并增加了方差的地方移除了它。一个诚实的承认:我曾尝试通过降低 synchronous_commit 来榨取更多性能,但后来撤销了,因为对于这条路径来说,写入语义的风险不值得去冒。
八、我保留的可直接复用的更改
这些是实验结束后保留的两个应用层面的部分。
参数顺序稳定的预处理语句使得执行计划稳定且解析快速。它们也明确了需要索引什么。前面的代码块展示了 fetch_slice 和 fetch_trending;那就是实际部署的代码。
刷新 mv_trending 的作业每隔几分钟运行一次,使用 CONCURRENTLY 以保持读取端的顺畅。没有复杂的 cron 技巧,没有失效风暴。
九、这对你的团队意味着什么
如果你的缓存只是为了掩盖源站慢读,请先尝试修好源站。
把查询盖全,只预计算真正昂贵的部分。
度量 p95,别看平均。
如果直接走数据库能打败缓存未命中的长尾,还能减少运维痛苦,就让更简单的架构赢。
来源丨网址:https://medium.com/@the_atomic_architect/postgresql-18-killed-my-cache-layer-and-i-am-not-bringing-it-back-764496b2a9a5
*本文为dbaplus社群编译整理,如需转载请取得授权并标明出处!dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
如果字段的最大可能长度超过255字节,那么长度值可能…
只能说作者太用心了,优秀
感谢详解
一般干个7-8年(即30岁左右),能做到年入40w-50w;有…
230721