数据库级缓存与物化视图和汇总表:预计算真相的艺术
I’m happy to translate the article for you, but I need the full text of the post (the content you’d like translated). Could you please paste the article’s body here? Once I have the text, I’ll provide a Simplified‑Chinese translation while keeping the source link, formatting, markdown, and any code blocks exactly as they appear.
背景
让我倒回到一个我宁愿忘记的星期二下午。
我们有一个已经运行了五年的 Rails 单体应用,代码臃肿且运行良好。仪表盘——一个充满图表的庞然大物——每当 CEO 点击 “刷新” 时,就会执行一次 12 秒的查询。那是一次跨越百万行 events 表的 GROUP BY、COUNT(DISTINCT) 和 LEFT JOIN 地狱。
CEO 并没有大喊大叫。他只是盯着转动的光标说,“这以前很快啊。” 那种沉默更让人难受。
我已经尝试了所有办法。
- Redis 缓存?首次加载时数据陈旧。
- 计数缓存?计数倒是可以,复杂的汇总就无能为力。
- 分页?仪表盘需要的是总计。
凌晨 2 点,我抬头望着天花板,低声自语:“要不我直接…预先计算答案?”
物化视图和汇总表并不新鲜——它们和数据仓库一样古老。但在 Rails 中,ActiveRecord 的对象关系映射主导了我们的思考方式,以至于我们忘记了数据库本身可以充当 缓存:一个智能的、事务性的、符合 ACID 的缓存,永远不会说谎。
这就是学习用 集合 而非对象思考的旅程。那些已经把 N+1 查询优化到极致的资深 Rails 开发者们:你们的下一个前沿是 预计算列。
我们对自己说的谎:“索引已经足够”
我们把索引砸到所有东西上——复合索引、部分索引、基于表达式的索引。索引看起来像魔法——直到它们不再起作用。当你的查询要聚合数百万行时,数据库仍然必须读取这些行。即使使用覆盖索引,你也在对每一行、每一次请求进行工作。
我记得在那个 CEO 仪表盘上运行 EXPLAIN ANALYZE:
Aggregate (cost=12483.67..12483.68 rows=1 width=32)
-> Seq Scan on events (cost=0.00..10483.33 rows=400068 width=32)
Filter: (created_at > '2024-01-01')
顺序扫描。 四十万行。每一次。每一个请求。
索引把全表扫描改成了索引扫描,但聚合仍然要遍历数十万条索引条目。数据库一次又一次地做同样的工作——就像厨师为每个煎蛋卷都现场刨一块奶酪,而不是早上提前把奶酪刨好放在碗里。
这时我发现了物化视图:那碗预先刨好的奶酪。
Source: …
第一步:物化视图作为重型搬运工
物化视图是一种查询,其结果会实际存储在磁盘上。你可以按计划或在相关数据变更后刷新它。读取是瞬时的——毫秒级而不是秒级。
下面这个视图拯救了我 CEO 的仪表盘:
CREATE MATERIALIZED VIEW daily_sales_summary AS
SELECT
date(created_at) AS day,
product_id,
COUNT(*) AS units_sold,
SUM(amount_cents) AS revenue_cents,
COUNT(DISTINCT user_id) AS unique_buyers
FROM orders
WHERE status = 'completed'
GROUP BY day, product_id;
在 Rails 中:
class DailySalesSummary { where(day: 30.days.ago..Date.today) }
end
# 仪表盘查询变为:
revenue = DailySalesSummary.recent.sum(:revenue_cents)
从 12 秒 降到 42 毫秒。CEO 的光标不再转动,我感觉自己像个巫师。
但物化视图也有一个诅咒:陈旧性。数据只能和你上一次 REFRESH 一样新鲜。我们最初使用每小时运行一次的 cron 任务——对仪表盘来说还行,但对实时排行榜就不够了。
这时我了解到了增量刷新(PostgreSQL 14+ 中的 REFRESH MATERIALIZED VIEW CONCURRENTLY)以及汇总表的技巧。
Source: …
汇总表的艺术:先倾听,再更新
汇总表(又称聚合表)是一个普通的 PostgreSQL 表,使用触发器或 ActiveRecord 回调进行维护。它是一种 增量 更新的物化视图——只更新那些发生变化的行。
我们为一个游戏化功能构建了这样的表:用户积分来源于数十种操作(评论、点赞、分享)。原始的 user_actions 表每天增长约 5 万行。实时排行榜查询让我们苦不堪言。
迁移
# db/migrate/create_user_points_summaries.rb
create_table :user_points_summaries, id: false do |t|
t.integer :user_id, null: false
t.integer :total_points, default: 0
t.integer :daily_points, default: 0
t.integer :weekly_points, default: 0
t.datetime :last_calculated_at
t.timestamps
end
add_index :user_points_summaries, :user_id, unique: true
增量更新
class UserAction < ApplicationRecord
after_create_commit :update_summary
private
def update_summary
summary = UserPointsSummary.find_or_initialize_by(user_id: user_id)
summary.lock!
summary.total_points += point_value
summary.daily_points = calculate_daily_points
summary.weekly_points = calculate_weekly_points
summary.last_calculated_at = Time.current
summary.save!
end
end
注意 lock 吗?是的。如果不小心,同一用户的两个并发操作会导致死锁。我们使用 SELECT … FOR UPDATE 来对每个用户的更新进行串行化。因为单个用户的操作相对少,这种方式还能接受——但如果是全局聚合,就需要采用其他模式(例如队列任务)。
汇总表的魅力何在?它们 始终是最新的。每一次写入都会触发增量更新。读取的复杂度是 O(1)。数据库因此变成了一个物化的流。
挑战:保持原子性
当你维护汇总表时,会打开不一致的可能性。如果 after_create_commit 回调失败怎么办?如果汇总更新成功但原始操作回滚又怎么办?解决办法是将源写入以及汇总更新**放在同一个事务中,或者使用带有失败重试机制的带外处理(例如后台任务)。
实际做法:
class UserAction < ApplicationRecord
after_commit :queue_summary_update, on: :create
private
def queue_summary_update
SummaryUpdateJob.perform_later(id)
end
end
该任务在独立的事务中运行,确保 UserAction 行在尝试进行汇总变更之前已经持久化。如果任务失败,Sidekiq(或你的队列)会进行重试,从而在不冒部分写入风险的前提下保持最终一致性。
要点
- 索引并非重度聚合的灵丹妙药。
- 物化视图 能让你即时读取,但代价是数据可能陈旧。
- 汇总表 在需要时提供实时新鲜度,但需要谨慎的事务处理。
- 首先考虑集合式方法;只有在用尽数据库优势后才降级使用对象级技巧。
下次仪表盘转动数分钟时,记住:数据库也可以充当你的缓存。 🚀
class UserAction < ApplicationRecord
after_create_commit :schedule_summary_refresh
def schedule_summary_refresh
# Non‑critical: use a background job with idempotency key
RefreshUserPointsJob.perform_later(user_id, self.id)
end
end
class RefreshUserPointsJob < ApplicationJob
def perform(user_id, action_id = nil)
# Recalculate from scratch for this user using the raw table
# Idempotent and safe, even if called multiple times
totals = UserAction.where(user_id: user_id)
.group("date(created_at)")
.sum(:point_value)
UserPointsSummary.upsert(
{ user_id: user_id, total_points: totals.values.sum, ... },
unique_by: :user_id
)
end
end
这在实时性与最终一致性之间做了权衡。对于排行榜来说这样没问题,但在 CEO 仪表盘上我们坚持使用每小时更新的物化视图。
艺术在于知道该挑哪场仗。
真正的魔法:结合两种方式
经过两年的打磨,我现在在数据库内部实现了三层缓存策略:
| 层级 | 技术 | 新鲜度 | 使用场景 |
|---|---|---|---|
| L1 | In‑memory (Rails cache) | 秒级 | 用户特定,热点 |
| L2 | Summary table (trigger‑updated) | 毫秒级 | 实时计数器 |
| L3 | Materialized view (scheduled refresh) | 小时/天 | 分析仪表盘 |
最初启动此项目的仪表盘现在使用:
- 一个物化视图用于每日聚合,
- 一个汇总表用于“今日截至目前”,以及
- 一小段 JavaScript 每 30 秒轮询一次实时汇总。
结果: 十二秒降至 80 毫秒。CEO 甚至不再关注加载动画;他只相信这些数字。
人类的教训:缓存是时间的分类法
你不可能把所有东西都缓存。你能做的,是根据数据需要多 新鲜 来对其进行分类。
| 所需新鲜度 | 推荐存储方式 |
|---|---|
| 实时计数器 | 汇总表 |
| 昨天的数据 | 物化视图 |
| 去年的报告 | 带有良好索引的普通表 |
我已经不再把 Redis 当作默认选项。有时最好的缓存就是已经在你的数据库内部的那个——它懂事务、懂一致性、也了解你的数据结构。
物化视图和汇总表听起来有点古老。它们不光鲜亮丽,但却 可靠。对于一位见过太多缓存层因复杂性而崩溃的资深 Rails 工程师来说,这种可靠性就是终极艺术。
现在去预先计算一些漂亮的东西吧。当有新人问:“为什么不直接加个索引?”时,告诉他们关于 CEO 与转动的加载指针的故事。有些教训必须亲身经历。