06 · Performance at Scale¶
Module 6 of Level 3 covered measuring performance. This module covers the most common fix once you've measured and found a bottleneck: caching — storing the result of expensive work so you don't repeat it. The core idea is always the same trade: spend memory (or a cache server's memory) to save CPU time or a round-trip.
Memoization — caching within one object's lifetime¶
The simplest cache is just a hash, keyed by input, checked before doing the real work:
def fib_uncached(n)
n < 2 ? n : fib_uncached(n - 1) + fib_uncached(n - 2)
end
class MemoFib
def initialize
@cache = {}
end
def call(n)
@cache[n] ||= n < 2 ? n : call(n - 1) + call(n - 2)
end
end
require 'benchmark'
t1 = Benchmark.realtime { fib_uncached(28) }
puts "Uncached fib(28): #{(t1 * 1000).round(2)}ms"
memo = MemoFib.new
t2 = Benchmark.realtime { memo.call(28) }
puts "Memoized fib(28) first call: #{(t2 * 1000).round(2)}ms"
t3 = Benchmark.realtime { memo.call(28) }
puts "Memoized fib(28) second call: #{(t3 * 1000).round(4)}ms"
Captured output:
The naive recursive version recomputes the same sub-values
exponentially many times (fib(26) gets computed separately inside both
fib(27) and fib(28)'s call trees). The memoized version's @cache
means each n is computed exactly once ever for that object's lifetime
— nearly 3,000x faster on this input, and the second call is
effectively free since everything needed is already cached.
||= is the idiom doing the work: @cache[n] ||= expensive_thing only
evaluates expensive_thing if @cache[n] is currently nil/false,
otherwise it short-circuits and returns the cached value directly.
TTL caching — for values that go stale¶
Memoization assumes a value never changes once computed. Data that changes over time (a weather API result, an exchange rate) instead needs a time-to-live (TTL): cache it, but only for a bounded window, then recompute:
class TTLCache
Entry = Struct.new(:value, :expires_at)
def initialize
@store = {}
end
def fetch(key, ttl: 60)
entry = @store[key]
return entry.value if entry && entry.expires_at > Time.now
value = yield
@store[key] = Entry.new(value, Time.now + ttl)
value
end
end
cache = TTLCache.new
calls = 0
3.times do
result = cache.fetch("weather", ttl: 100) { calls += 1; "sunny" }
puts result
end
puts "Block executed #{calls} time(s)"
Captured output:
All three .fetch calls return "sunny", but the block (standing in
for an actual API call) only ran once — the other two calls were
served from the still-valid cache entry within the 100-second TTL. In
production, this pattern is exactly what a Redis-backed cache
(Rails.cache.fetch("weather", expires_in: 100) { ... }) does, just
with the cache store living outside the process so multiple app servers
share one cache instead of each having its own.
Where to cache — three common layers¶
- In-process memoization: fastest, but private to one process — a second web worker process has its own separate cache, so it doesn't help under load-balanced multi-process deployment.
- Shared cache (Redis/Memcached): one cache all app processes/servers
read from, essential once you run more than one process — the
TTLCacheabove, but backed by a server instead of an in-process hash. - HTTP/CDN caching: caching a whole response (
Cache-Controlheaders) so a repeat request never even reaches your app process — the cheapest possible cache hit, appropriate for content that's identical for every user (a public blog post, not a personalized dashboard).
Cache invalidation — the hard part¶
Caching is easy; knowing when a cached value is wrong is the hard part ("there are only two hard things in computer science: cache invalidation and naming things"). Two common strategies:
- Time-based (TTL): shown above — simple, always eventually correct, but can serve stale data for up to the TTL window.
- Event-based invalidation: explicitly delete/update the cache entry
the moment the underlying data changes (e.g.
cache.delete("user:#{id}")right afteruser.save) — always fresh, but requires finding every place the underlying data can change and remembering to invalidate there too, which is easy to miss in a large codebase.
Performance-at-scale-specific traps¶
- Caching a value that depends on hidden context. Caching the result
of
current_user.dashboard_datakeyed only by"dashboard"(forgetting to include the user's id in the key) serves one user's data to every other user — always include every input the result actually depends on in the cache key. - A cache that never expires and is never invalidated slowly accumulates stale data as the underlying source changes — always have some eviction strategy (TTL, explicit invalidation, or a bounded size with LRU eviction), even a generous one.
- Caching exceptions/errors accidentally. A memoization pattern like
@cache[key] ||= fetch_from_apiwill re-runfetch_from_apion every call if it raises (good — errors shouldn't be cached), but a variant that catches the error and cachesnilas "the result" silently keeps reporting failure as if it were a valid empty result. - Unbounded in-process memoization on a long-lived process (a background worker running for days) can leak memory if the cache key space is effectively infinite (e.g. keyed by a unique request ID) — memoization needs bounded key space or an LRU cap, not just "cache everything forever."
- Assuming a cache hit is always fast. A remote cache (Redis over the network) still has latency — for extremely hot, small, read-heavy data, an in-process cache in front of the remote cache (a two-tier cache) is sometimes worth the added invalidation complexity.
How It Actually Works¶
Scaling a Ruby service usually means scaling around the GVL rather than
through it: since one process can only execute Ruby bytecode on one core
at a time, throughput under load comes from running multiple worker
processes (each with its own GVL, its own heap, its own copy of loaded
code) behind a load balancer or app-server master (Puma's cluster mode
forks worker processes for exactly this reason) rather than from adding
threads alone, which help mainly with I/O-bound concurrency within a
worker. Caching layers (Rails.cache, Redis) exploit the fact that
recomputation and a network round-trip to another process are both far
slower than a hash lookup in local memory — but a cache shared across
worker processes must live outside any one process's heap (Redis, Memcached)
since separate OS processes can't share Ruby object references. GC tuning
(RUBY_GC_HEAP_GROWTH_FACTOR, etc.) works by changing when MRI's
generational mark-and-sweep collector decides to run a full mark pass
versus a cheaper minor one — trading memory footprint for fewer GC pauses,
a direct lever on the same collector discussed in the profiling module.
Cheat sheet¶
| Pattern | Use when | Ruby idiom |
|---|---|---|
| Memoization | Same input always produces the same output, for this process's lifetime | @cache[key] ||= compute |
| TTL cache | Value can go stale, tolerable staleness window | store expires_at, check before reuse |
| Shared cache (Redis) | Multiple processes need the same cache | Rails.cache.fetch(key, expires_in: n) { ... } |
| Event-based invalidation | Freshness matters more than simplicity | cache.delete(key) at the point of mutation |
| HTTP caching | Same response for everyone, cacheable at the edge | Cache-Control response header |
Exercise¶
- Add a
max_sizeoption toTTLCachethat evicts the oldest entry (by insertion order) once the store exceedsmax_sizekeys — demonstrate inserting 4 keys into a cache withmax_size: 3and show the first key is gone. - Write a
memoized_expensive_query(user_id)method wrapping a simulated slow ActiveRecord call (sleep(0.05)standing in for the query), and benchmark calling it 5 times with the sameuser_idversus 5 times with 5 differentuser_ids — explain in a comment why the timings differ the way they do. - Implement event-based invalidation: a
UserCachewithfetch(id)andinvalidate(id), and a fakeupdate_user(id, attrs)function that callsinvalidate(id)right after making a change — show afetchafterupdate_userrecomputing instead of returning stale data.