我们的系统很快。
但产品不是。
我们只是非常快地给出了错误的数字,然后把这件事叫做“性能”。
如果你曾因为数据库慢而选择加一层缓存,随后却不得不花上数周时间,像讨论哲学一样争论数据过期和一致性问题——那么,你已经为此付出了真实的代价。
这并非基础设施的成本,而是信任的成本。
一旦缓存对用户展示过一次错误的数据,此后的每一张图表、每一个数字,都变得更难让人信服。
那个悄悄破坏“真实”的缓存
我们引入缓存的初衷通常很纯粹。有些接口并非简单的单表查询,它们返回的是经过复杂组装后的答案。
例如,一个典型的仪表盘汇总接口需要:
- 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)的逻辑,那么你根本就没有一个可靠的缓存,你只有一个等待出错的时间炸弹。
在追求性能的道路上,有时最有效的优化不是增加一层,而是减少一层;不是让错误的结果出现得更快,而是确保每一次计算都忠于唯一的真相。这正是优秀 系统架构 设计的精髓所在。如果你对这类深入的技术实践探讨感兴趣,欢迎来 云栈社区 与更多开发者交流。