暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

告别Redis!如何在PostgreSQL 18实现无缓存架构的平滑落地?

133

“我在生产环境关掉了 Redis,而且没有回滚。”


这句话现在仍让我心里发紧,因为缓存就像一条安全带。但在 PostgreSQL 18 上经过慎重试验后,p95 降低了,系统变简单了,值班负担也减轻了。


风险是真实的:用户会话、结账流程、我们的口碑。

收益也是真实的:更少的活动部件,更少的告警短信。


下面是我走过的路径、我修复的查询、唯一关键的图,以及几个起决定作用的小配置。



一、我拨动开关的那一天


我们的热路径是一个参数化的产品查询,并带有一点个性化。


缓存为我们带来了命中,但未命中很嘈杂,而且序列化增加了工作量。


我对该路径进行了端到端的分析,然后尝试了一个直接的数据库方案,使用更紧凑的索引和一个预先计算好的投影。


第一次运行很吓人——然后数据稳定下来了。


我让缓存停用了一个完整的周期,看着图表平静下来。


二、Postgres 里到底发生了什么


没有玄学,只有两件落地的事:


1、覆盖度与选择性


一个覆盖索引 + 一个生成列,让优化器只拿我们要的字段,不再碰堆表。


2、预计算形态


物化视图(并发刷新)把原来靠缓存藏起来的昂贵聚合扛了过来。


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 bucket
    ALTER TABLE catalog_item
      ADD 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_lookup
      ON catalog_item (tenant_id, category_id, p_bucket, deleted)
      WHERE deleted = FALSE
      INCLUDE (item_id, price_cents, rating_avg, updated_at, tags);


    -- A precomputed projection for “trending” that used to be cached
    CREATE MATERIALIZED VIEW mv_trending AS
    SELECT 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 rk
    FROM catalog_item
    WHERE deleted = FALSE;


    -- Keep it fresh without blocking writers
    CREATE UNIQUE INDEX CONCURRENTLY mv_trending_pk
      ON 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 Redis
      PREPARE fetch_slice (bigint, bigint, smallint, int) AS
      SELECT item_id, price_cents, rating_avg, tags
      FROM   catalog_item
      WHERE  tenant_id = $1
        AND  category_id = $2
        AND  p_bucket >= $3
        AND  deleted = FALSE
      ORDER  BY rating_avg DESC, updated_at DESC
      LIMIT  $4;


      -- When trending is requested
      PREPARE fetch_trending (bigint, bigint, int) AS
      SELECT item_id, price_cents, rating_avg, tags
      FROM   mv_trending
      WHERE  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 = on
        jit = on
        max_worker_processes = 8
        max_parallel_workers_per_gather = 2
        random_page_cost = 1.1
        default_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 来榨取更多性能,但后来撤销了,因为对于这条路径来说,写入语义的风险不值得去冒。


            八、我保留的可直接复用的更改


            这些是实验结束后保留的两个应用层面的部分。


            1、应用查询形态(SQL 预处理语句),而非 ORM 猜测


            参数顺序稳定的预处理语句使得执行计划稳定且解析快速。它们也明确了需要索引什么。前面的代码块展示了 fetch_slice 和 fetch_trending;那就是实际部署的代码。


            2、适度、常规的刷新节奏


            刷新 mv_trending 的作业每隔几分钟运行一次,使用 CONCURRENTLY 以保持读取端的顺畅。没有复杂的 cron 技巧,没有失效风暴。


            九、这对你的团队意味着什么


            如果你的缓存只是为了掩盖源站慢读,请先尝试修好源站。


            把查询盖全,只预计算真正昂贵的部分。


            度量 p95,别看平均。


            如果直接走数据库能打败缓存未命中的长尾,还能减少运维痛苦,就让更简单的架构赢。


            作者丨The Atomic Architect    编译丨Rio

            来源丨网址: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


            文章转载自杨建荣的学习笔记,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

            评论