惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

雷峰网
雷峰网
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
Tailwind CSS Blog
F
Fortinet All Blogs
Microsoft Azure Blog
Microsoft Azure Blog
Jina AI
Jina AI
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
N
Netflix TechBlog - Medium
B
Blog RSS Feed
Blog — PlanetScale
Blog — PlanetScale
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
T
The Blog of Author Tim Ferriss
D
Docker
博客园 - 聂微东
博客园 - 【当耐特】
博客园 - 三生石上(FineUI控件)
L
LangChain Blog
量子位
宝玉的分享
宝玉的分享
博客园 - 司徒正美
The Cloudflare Blog
G
Google Developers Blog
Microsoft Security Blog
Microsoft Security Blog
腾讯CDC

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Database-Level Caching with Materialized Views and Summar...
Alex Aslam · 2026-05-04 · via DEV Community

Let me rewind to a Tuesday afternoon I’d rather forget.

We had a Rails monolith that had grown fat and happy over five years. The dashboard—a beautiful, chart-filled monster—was running a 12-second query every time the CEO clicked “refresh.” Twelve seconds of GROUP BY, COUNT(DISTINCT), and LEFT JOIN hell across a million-row events table.

The CEO didn’t yell. He just stared at the spinning cursor and said, “This used to be fast.” That silence was worse.

I’d already tried everything. Redis caching? Stale data on first load. Counter caches? Fine for counts, useless for complex rollups. Pagination? The dashboard needed totals. I was staring at the ceiling at 2 AM when I whispered: “What if I just… precompute the answer?”

Materialized views and summary tables aren’t new. They’re as old as data warehouses. But in Rails, with ActiveRecord’s object‑relational map guiding our every thought, we forget that the database itself can be a cache. A smart, transactional, ACID‑compliant cache that never lies.

This is the journey of learning to think in sets, not objects. Senior Rails devs who’ve optimized N+1 queries to death: your next frontier is the precomputed column.

The Lie We Tell Ourselves: “Indexes Are Enough”

We throw indexes at everything. Composite, partial, expression‑based. And indexes are magic—until they aren’t. When your query aggregates millions of rows, the database still has to read those rows. Even with a covering index, you’re doing work per row, per request.

I remember running EXPLAIN ANALYZE on that CEO dashboard:

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

Enter fullscreen mode Exit fullscreen mode

Seq scan. Four hundred thousand rows. Every. Single. Request.

Indexes reduced the scan to an index scan, but the aggregate still looped over hundreds of thousands of index entries. The database was doing the same work over and over. Like a chef who grates a block of cheese for every single omelet, instead of pre‑grating a bowl in the morning.

That’s when I discovered materialized views: the pre‑grated cheese bowl.

The First Step: Materialized Views as Heavy‑Lifters

A materialized view is a query whose results are stored physically on disk. You refresh it on a schedule, or after relevant data changes. Reads are instantaneous—milliseconds instead of seconds.

Here’s the one that saved my CEO’s dashboard:

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;

Enter fullscreen mode Exit fullscreen mode

Then in 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)

Enter fullscreen mode Exit fullscreen mode

From 12 seconds to 42 milliseconds. The CEO’s cursor stopped spinning. I felt like a wizard.

But materialized views come with a curse: staleness. The data is only as fresh as your last REFRESH. We started with a cron job running every hour. Fine for a dashboard. Not fine for a real‑time leaderboard.

That’s when I learned about incremental refresh (PostgreSQL 14+ with REFRESH MATERIALIZED VIEW CONCURRENTLY) and the art of the summary table.

The Art of the Summary Table: Listen, Then Update

A summary table (a.k.a. aggregation table) is a regular PostgreSQL table you maintain with triggers or ActiveRecord callbacks. It’s a materialized view you update incrementally—only the rows that change.

We built one for a gamification feature: user points from dozens of actions (comments, likes, shares). The raw user_actions table grew by 50k rows/day. Real‑time leaderboard queries were killing us.

Here’s the pattern that lived through Black Friday:

# 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

Enter fullscreen mode Exit fullscreen mode

Then, every action that creates a UserAction record triggers an incremental update:

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

Enter fullscreen mode Exit fullscreen mode

Notice the lock? Yes. Two concurrent actions on the same user will deadlock if you’re not careful. We used SELECT FOR UPDATE to serialize updates per user. It’s fine because a single user’s actions are rare—but for global aggregates, you’d need a different pattern (like a queued job).

The beauty of summary tables? They’re always fresh. Every write triggers an incremental update. Reads are O(1). The database becomes a materialized stream.

The Challenge: Keeping It Atomic

Here’s where art meets science. When you maintain summary tables, you open the door to inconsistency. What if the after_create_commit fails? What if the summary update succeeds but the original action rolls back?

You need idempotency and atomicity. Our pattern:

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

Enter fullscreen mode Exit fullscreen mode

This trades real‑time for eventual consistency. For a leaderboard, that’s fine. For the CEO dashboard, we stuck with hourly materialized views.

The art is knowing which battles to fight.

The Real Magic: Combining Both Worlds

After two years of refinement, I now have a three‑tier caching strategy inside the database:

Tier Technique Freshness Use case
L1 In‑memory (Rails cache) Seconds User‑specific, hot
L2 Summary table (trigger‑updated) Millisecond‑level Real‑time counters
L3 Materialized view (scheduled refresh) Hour/Day Analytics dashboards

The dashboard that started this journey? It now uses a materialized view for daily aggregates, a summary table for “today so far,” and a small bit of JavaScript to poll the real‑time summary every 30 seconds.

Twelve seconds became 80ms. The CEO doesn’t even look at the spinner anymore. He just trusts the numbers.

The Human Lesson: Caching Is a Taxonomy of Time

You can’t cache everything. What you can do is classify your data by how fresh it needs to be. Real‑time counters? Summary table. Yesterday’s numbers? Materialized view. Last year’s reports? Just a regular table with good indexes.

I’ve stopped reaching for Redis as the default. Sometimes the best cache is the one already inside your database—the one that understands transactions, consistency, and the shape of your data.

Materialized views and summary tables feel archaic. They aren’t shiny. But they’re reliable. And for a senior Rails engineer who’s seen too many caching layers collapse under their own complexity, that reliability is the ultimate art.

Now go precompute something beautiful. And when a junior asks, “why not just add an index?”, tell them about the CEO and the spinning cursor. Some lessons need to be lived.