site logo

Marico's space

数据库级缓存之道:物化视图与汇总表的预计算

前端技术 2026-05-04 17:35:02 15

这篇文章让我感同身受。作为一名在甲方企业干了多年数据库优化的工程师,我见过太多类似的场景:业务方天天催报表性能,工程师拼命加索引、加缓存,问题却像打地鼠一样按下葫芦浮起瓢。作者用“给奶酪刨丝”的比喻讲透了物化视图的本质——不是数据库太慢,而是我们在重复做同样的计算。这套从物化视图到汇总表、再到多级缓存的演进思路,其实就是 DBA 日常优化的一个缩影,很有参考价值。

先说说背景吧。

那是一个周二下午,我现在想起来还心有余悸。

当时我们有个 Rails 单体应用,五年时间功能越积越多。仪表盘——一个图表丰富、看着挺唬人的大家伙——每次 CEO 点“刷新”,都要跑一条耗时 12 秒的查询。12 秒的全表扫描,带 GROUP BY、COUNT(DISTINCT) 和 LEFT JOIN地狱般的关联,在一张上百万行数据的 events 表上翻来覆去。

CEO 没有发火。他只是盯着那个转圈的加载图标,淡淡说了句:“以前这玩意儿挺快的啊。”那种沉默比挨骂还难受。

我该试的都试过了。Redis 缓存?首次加载数据就过期了。计数器缓存?统计个数还行,复杂的汇总报表根本指望不上。分页?仪表盘需要的是总计数据。我凌晨两点盯着天花板,突然灵光一闪:能不能把答案提前算好?

物化视图和汇总表其实不是什么新东西,数据仓库领域早就玩烂了。但在 Rails 的世界观里,ActiveRecord 的对象映射思维根深蒂固,大家都快忘了数据库本身就能当缓存用。一个智能、支持事务、ACID(原子性、一致性、隔离性、持久性)合规的缓存,而且永远不会给你返回过期数据。

这整个故事,就是我学会用“集合思维”替代“对象思维”的过程。如果你是 Rails 老兵,优化 N+1 查询已经轻车熟路,那下一个该攻克的前沿就是预计算列。

我们自欺欺人的谎言:“有索引就够了”

遇到性能问题,我们第一反应就是加索引。复合索引、部分索引、表达式索引,一股脑往上怼。索引确实神奇——但只限于某些场景。当查询要对上百万行做聚合运算时,数据库还是得把这些行读一遍。就算用了覆盖索引,每行每请求还是得算一遍。

我还记得当时跑 EXPLAN ANALYZE 看 CEO 仪表盘的执行计划:

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')

全表扫描,40 万行。每次请求都扫 40 万行。

加索引确实把扫描方式从全表变成了索引扫描,但聚合操作还是要遍历几十万条索引项。数据库在重复做同样的工作。就像一个厨师做每个煎蛋卷都要现磨奶酪,而不是早上提前磨好一碗备用。

那一刻,我发现了物化视图这个好东西:就是那个提前磨好的奶酪。

第一步:让物化视图当主力

物化视图的本质,是一个查询的结果直接落地到磁盘。你可以定时刷新,也可以在数据变化后刷新。读取速度是毫秒级——从原来的几秒直接降到几十毫秒。

下面这个 SQL 就是拯救 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 < ApplicationRecord
  self.primary_key = %i[day product_id]
  belongs_to :product

  scope :recent, -> { where(day: 30.days.ago..Date.today) }
end

# Dashboard query becomes:
revenue = DailySalesSummary.recent.sum(:revenue_cents)

从 12 秒降到 42 毫秒。CEO 的加载图标终于不再转圈了。我当时感觉自己像个魔法师。

但物化视图也有它的诅咒:数据陈旧。数据新鲜度取决于你上次 REFRESH 的时间。最初我们用定时任务每小时刷新一次,对仪表盘来说勉强够用,但对实时排行榜就不行了。

这时候我学到了增量刷新(PostgreSQL 14+ 支持 CONCURRENTLY 选项)和汇总表的核心玩法。

汇总表的艺术:监听变化,按需更新

汇总表(也叫聚合表)本质上就是一张普通的 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

每创建一个 UserAction 记录,都会触发一次增量更新:

class UserAction < ApplicationRecord
  after_create_commit :increment_summary

  private

  def increment_summary
    UserPointsSummary.transaction do
      summary = UserPointsSummary.lock.find_or_initialize_by(user_id: user_id)
      summary.total_points += point_value
      summary.daily_points += point_value if created_at.today?
      summary.weekly_points += point_value if created_at > 1.week.ago
      summary.last_calculated_at = Time.current
      summary.save!
    end
  end
end

注意到那个 lock 了吗?没错,两个并发操作改同一个用户的积分,不加锁就会死锁。我们用 SELECT FOR UPDATE 来序列化同用户的更新操作。好在单个用户并发操作不频繁,所以问题不大——但如果要更新全局聚合数据,就得换一种模式,比如走队列。

汇总表的优势在哪里?数据永远是最新的。每次写入都触发增量更新,读取是 O(1) 的简单查询。数据库变成了一个实时聚合的数据流。

难点:保持原子性

这里才是技术和艺术的交汇点。维护汇总表会引入数据不一致的风险。比如 after_create_commit 失败了怎么办?汇总表更新成功了,但原始操作回滚了怎么办?

你需要幂等性和原子性。我们的方案是这样的:

class UserAction < ApplicationRecord
  after_create_commit :schedule_summary_refresh

  def schedule_summary_refresh
    # 非关键路径:用后台任务 + 幂等 key
    RefreshUserPointsJob.perform_later(user_id, self.id)
  end
end

class RefreshUserPointsJob < ApplicationJob
  def perform(user_id, action_id = nil)
    # 从源头表重新计算该用户的所有数据
    # 幂等设计,就算重复调用也没问题
    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 内存缓存(Rails cache) 秒级 用户级热点数据
L2 汇总表(触发器更新) 毫秒级 实时计数器
L3 物化视图(定时刷新) 小时/天级 分析报表

开头那个让 CEO 抓狂的仪表盘,现在用物化视图算每日汇总,用汇总表算“今日实时”,再加一点点 JavaScript 每 30 秒轮询一下汇总表数据。

12 秒变成了 80 毫秒。CEO 根本不再盯着加载图标了。他只信数字。

人的教训:缓存是对时间的分类

你不可能把什么都缓存起来。要做的是按数据的新鲜度需求分类。实时计数器?用汇总表。昨天汇总?用物化视图。去年报表?普通表加索引就够了。

我已经不再把 Redis 当成默认方案了。有时候,最好的缓存就在你的数据库里——它懂事务、懂一致性、懂你的数据结构。

物化视图和汇总表听起来土气,也不酷炫。但它们靠谱。对于一个见过太多缓存层因为太复杂而崩掉的老 Rails 工程师来说,靠谱才是终极艺术。

去吧,去预计算点有价值的东西。等哪天新人问你“为啥不直接加索引”,就把 CEO 和那个转圈图标的故事讲给他听。有些教训,得自己踩过坑才能懂。