Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Rails Solid Cache: Dropping Redis for Database-Backed Caching

The Redis cache bill is a weird tax. You’re paying premium RAM prices for data that is, by definition, disposable — regenerate it and nothing breaks. When our cache footprint crossed a few tens of gigabytes we were maintaining a memory-only store because it was the default, not because the workload demanded it. Modern NVMe drives read a small row in the same ballpark as RAM does, so the real question is whether you can build a cache store that treats disk as a first-class citizen instead of a fallback. Solid Cache, the default cache store in Rails 8 (since November 2024), does exactly that: it’s a cache whose backing store is a plain SQL table, sized in gigabytes on disk instead of megabytes in RAM.

The design that makes disk acceptable

The trap with disk-backed caches is that you solve the storage problem and create a write-path problem. Solid Cache avoids it by structuring the entire thing around a single append-oriented table:

  • key_hash (binary) with a unique index — keys are stored hashed, not raw, so indexes stay small and lookups are cheap.
  • value (binary) plus created_at and byte_size for sizing and eviction.

Writes are batched and asynchronous. Each process keeps an in-memory write buffer and flushes it in batches (the default is every 5 seconds or a few entries, whichever comes first) using an upsert keyed on key_hash, so a repeated write doesn’t deadlock or duplicate. That means a process crash can lose up to one flush window of cache writes — which is fine, because losing a cache write is the cheapest kind of failure there is. Reads go the other way: a single batched SELECT ... WHERE key_hash IN (...) per cache-miss batch, and since rows are tiny, a warm buffer pool serves them in about a millisecond.

Configuration

1
2
3
4
5
6
7
8
9
10
11
12
# config/cache.yml
default:
  store_options: &default_store_options
    max_age: 2.weeks
    namespace: <%= Rails.env %>
  size_estimate_samples: 1000

production:
  database: cache
  store_options:
    <<: *default_store_options
    max_size: 256.gigabytes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config/database.yml
default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

production:
  primary:
    <<: *default
    database: app_production
  cache:
    <<: *default
    database: app_production_cache
    migrations_paths: db/cache_migrate

Pointing it at a dedicated database is not optional politeness: the cache generates constant small SELECTs and the occasional bulk DELETE, and you do not want that traffic sharing the transaction pool with your accounts table. max_size is a soft cap enforced by eviction, max_age is the TTL floor.

Why FIFO, not LRU

Eviction is where Solid Cache makes its most opinionated trade. It’s FIFO, not LRU: entries are deleted by age and ID, from the head of the table, never by last-read timestamp. That choice is what makes the whole thing cheap. LRU requires updating a recency marker on every read — a write on the read path, which is exactly what kills disk-backed stores. FIFO lets the store estimate its size from the min and max IDs in the table (max_id - min_id approximates entry count) and delete in bulk from one end. Expiry runs on a background thread that wakes after a configurable number of writes and deletes a batch (expiry_batch_size, default 100) of entries that are either past max_age or pushing the store past max_size.

The cost is real: a hot-but-old entry can be evicted even while being read constantly. We absorbed that by raising max_age and letting the bigger cache absorb the staleness — a 100GB FIFO cache with a 4-week window evicts far less useful data than a 4GB LRU cache with a tight window. FIFO is also self-defragmenting on MySQL, since deletes happen at one end and inserts at the other.

What the numbers look like

On a 4-vCPU Postgres instance with NVMe storage and a warmed buffer pool, cache reads sit around 1 ms p99 and a single app node pushes tens of thousands of reads per second. That’s slower than Redis’s ~0.5 ms, and it doesn’t matter: the cache read is one hop in a request that also touches the primary database, and it’s dramatically cheaper per byte stored. Our cost comparison at 100GB was roughly a 30GB managed Redis at several hundred dollars a month versus a slice of a Postgres instance we already operated. The cache stopped being a line item.

Production lessons

  • Separate database, separate pool, and tune the connection count. Cache traffic is bursty and shouldn’t contend with app queries. database: cache in cache.yml handles the routing.
  • synchronous_commit = off on the cache database. Writes are disposable; paying fsync latency for a cache flush is pure waste. If your platform supports it, unlogged tables take this further.
  • Watch the expiry threads. The default expiry_method: :thread runs an expiry thread per process; with dozens of web processes that’s dozens of threads occasionally deleting batches. For a large fleet, expiry_method: :job centralizes it.
  • Set size_estimate_samples and keep max_size honest. Size is estimated by sampling byte_size; a too-small sample count lets the cache drift over its budget until eviction thrashes.
  • Account for the write buffer on deploys. Rolling restarts flush buffers and give you a warm-ish cache quickly; a full cache wipe after a deploy change is a stampede risk — pre-warm the hot keys in a rake task instead.

Solid Cache is the rare production dependency where the boring option is also the better option. It’s a cache that fits next to the rest of your data, survives the instance reboot that kills a Redis, and costs what disk costs. Keep Redis if you’re already paying for it for Sidekiq — but if Redis exists solely for the cache, this is the year you delete it.

comments powered by Disqus