找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3112

积分

0

好友

442

主题
发表于 昨天 01:37 | 查看: 0| 回复: 0

我们的系统很快。

但产品不是。

我们只是非常快地给出了错误的数字,然后把这件事叫做“性能”。

如果你曾因为数据库慢而选择加一层缓存,随后却不得不花上数周时间,像讨论哲学一样争论数据过期和一致性问题——那么,你已经为此付出了真实的代价。

这并非基础设施的成本,而是信任的成本

一旦缓存对用户展示过一次错误的数据,此后的每一张图表、每一个数字,都变得更难让人信服。

那个悄悄破坏“真实”的缓存

我们引入缓存的初衷通常很纯粹。有些接口并非简单的单表查询,它们返回的是经过复杂组装后的答案

例如,一个典型的仪表盘汇总接口需要:

  • JOIN 多张表
  • 按时间范围过滤
  • 计算总额或计数
  • 最终拼接成一个结构化的 JSON 响应

每次请求都重新计算这些步骤代价高昂。于是,我们很自然地将最终响应缓存起来。

我们用 userId 和筛选条件组合成缓存 Key,设置一个 TTL(生存时间),并编写一个优雅的包装层来管理这一切。看起来完美无缺。

然后,第一个“不可能”的 Bug 出现了。

同一个用户,在极短的时间内连续刷新两次页面,看到了两个不同的统计总数。关键在于,这两个数字都真实存在——一个来自实时查询的 PostgreSQL,另一个则来自尚未过期的 Redis 缓存。

当意识到这一点时,我们脑海中的系统模型发生了根本变化。我们不再拥有唯一的“真相来源”,而是拥有了两个:数据库的真相,和缓存的真相

当两者不一致时,必须由人来判断哪个才是正确的。这一点,从来不会出现在任何漂亮的架构图中。

我们真正为缓存付出的隐藏成本

理论上,缓存是为了减少重复工作。但在现实中,它往往只是把复杂性转移到了你看不见的地方

我们为缓存付出的成本悄无声息:

  • 新增一个筛选条件时,有人忘了将其纳入 Cache Key 的生成逻辑。
  • 后台任务更新了源数据,但失效(Invalidation)逻辑不清楚应该删除哪些关联的 Key。
  • 为 API 响应增加一个新字段后,突然需要讨论旧的缓存是否还能兼容使用。

每一个修复看似都是小修小补,但每一次修补,都在扩大 “速度很快但答案可能是错的” 的潜在风险范围。

慢慢地,代码中开始充斥具有“仪式感”的组件:

  • Cache warmers(缓存预热器)
  • 定时刷新任务
  • 复杂的 Fallback(回退)分支
  • 那个原本作为兜底机制的“如果缓存未命中则重新计算”的逻辑,在流量高峰时反而成了主要执行路径

此时,缓存已不再是一个简单的性能工具。它演变成了第二个需要进行正确性工程保障的独立系统。我们仍然称之为“优化”,因为承认这是“第二个系统”显得过于诚实了。

我们停止调优Redis参数的那一天

一位同事的一句话,比任何事后复盘都更令人警醒:

“如果缓存的答案可能是错的,我们为什么还要为它的速度感到骄傲?”

那一刻,我们对“如何将 TTL 调整得更精细”失去了兴趣。我们不再追问“如何让缓存变得更聪明?”,而是开始思考一个更根本的问题:“为什么数据库在不断地重复执行相同的工作?

我们意识到,大多数所谓的“昂贵接口”都遵循同一种模式:

  • 一个计算代价高昂的基础数据集合
  • 在这个集合之上,进行多种不同的聚合或计算。

例如,针对同一用户近期数据,可能需要分别计算:

  • 最近30天的交易总金额
  • 最近3天的订单数量
  • 基于近期行为的某个状态标志位

我们原本的做法是,在同一个请求内,重复计算这份基础数据多次;然后试图用 Redis 跨请求 缓存结果来掩盖这部分成本。这个思路似乎是本末倒置的。

于是,我们开始寻找一种方法,能够在不分裂数据真相的前提下,复用这些繁重的计算工作。实际上,PostgreSQL 早已提供了这样的工具。

改变问题本质的那个特性

这个特性就是 物化公共表表达式(Materialized Common Table Expression)

它并非什么全新的服务或框架,只是 SQL 标准中的一句关键字:AS MATERIALIZED

其含义非常明确:这份繁重的计算,在整个查询生命周期内只执行一次,后续步骤都复用其结果。

关键在于,这一切发生在同一个查询内在同一个事务快照下,因此保证了唯一的真相

以下就是利用物化 CTE 重构后,取代原有缓存方案的查询示例:

WITH base AS MATERIALIZED (
  SELECT o.user_id, o.total, o.created_at
  FROM orders o
  WHERE o.user_id = $1
    AND o.created_at >= now() - interval '30 days'
),
sum30 AS (
  SELECT user_id, sum(total) AS amt
  FROM base
  GROUP BY user_id
),
last3 AS (
  SELECT user_id, count(*) AS cnt
  FROM base
  WHERE created_at >= now() - interval '3 days'
  GROUP BY user_id
)
SELECT s.amt, l.cnt
FROM sum30 s
LEFT JOIN last3 l USING (user_id);

其中,base 部分是查询中最耗时的基础数据获取。如果不使用 MATERIALIZED 提示,查询优化器可能会根据成本估算决定多次执行它。而 AS MATERIALIZED 清晰地表达了我们的意图:

  • 计算一次。
  • 复用多次。
  • 给出始终一致的答案。

这里没有复杂的 Cache Key,没有脆弱的失效逻辑,也没有第二个“真相”来源。

架构重新变得简单而可靠

此前,我们的系统架构类似于:

Client
  |
  v
API
  |-----> Redis (缓存JSON)
  |
  v
PostgreSQL(仅在缓存未命中时查询)

在应用物化 CTE 进行优化后,我们直接移除了这类用于“组装答案”的缓存层。

Client
  |
  v
API
  |
  v
PostgreSQL(在查询内部复用工作)

Redis 并没有从我们的技术栈中消失,它只是回归到了更适合它的岗位

  • 限流(Rate Limiting)
  • 短期会话令牌(Session Token)存储
  • 跨请求的临时状态共享

而不是用于存储:

  • 核心的业务真相数据
  • 用户可能复制到 Excel 中或汇报给上级的关键业务数字

一个简单的架构并非缺乏追求。它意味着你主动停止了引入额外的、不必要的故障模式

关键的性能与正确性对比

我们针对一个原先必须依赖缓存的仪表盘汇总接口进行了测试。使用相同的数据集、相同的流量回放脚本以及相同的硬件环境。

唯一的变量是:查询结构优化 + 物化基础数据集合

方案 p95 延迟 (ms) p99 延迟 (ms) 数据库 CPU 负载 可能返回错误结果
Redis 缓存命中 12 22
缓存未命中 + 数据库实时组装 180 310
物化 CTE(仅数据库) 34 68

最值得关注的是最后一列。因为一个无法保证数据正确性的“高性能”方案,犹如一笔附带高额利息的技术债务

这也澄清了一个常见误区:物化 CTE 并非 要替代所有缓存。它精准替代的是那些对正确性要求极高,却被我们误用缓存来加速的、复杂的“组装答案”式查询

这一调整,直接消除了整整一类由缓存一致性引发的 Bug。不是减少,是消除。并且,是通过删除代码而非增加代码来实现的。

最终让我们清醒的设计原则

如果你的系统能够针对同一个问题给出两个不同的答案,那么你拥有的不是高性能,而是被放大了的混乱。

我们依然关注延迟,依然会创建合适的索引,依然进行性能剖析,依然紧盯 p95 和 p99 分位数。但现在,我们遵循一条在高压下也能清晰记得的简单规则:

如果你无法用一句话清晰地解释缓存失效(Invalidation)的逻辑,那么你根本就没有一个可靠的缓存,你只有一个等待出错的时间炸弹。

在追求性能的道路上,有时最有效的优化不是增加一层,而是减少一层;不是让错误的结果出现得更快,而是确保每一次计算都忠于唯一的真相。这正是优秀 系统架构 设计的精髓所在。如果你对这类深入的技术实践探讨感兴趣,欢迎来 云栈社区 与更多开发者交流。




上一篇:从Clawdbot到Moltbot:为什么说提示词正在取代应用界面?
下一篇:深入剖析CPU指令调度:现代CPU架构中控制单元的工作原理与RISC、CISC对比
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-1 00:15 , Processed in 1.423323 second(s), 47 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表