Solid Cache vs Redis: the case for a cache on disk
Rails 8 ships a cache store that keeps entries as rows in a SQL database. The obvious objection writes itself: RAM is faster than disk, a cache exists to be fast, so a cache on disk is a cache that missed the point.
The objection is answerable, and the answer is not "disks got fast". Solid Cache is slower than Redis on every individual operation and its own authors say so. Its README also says it "is configured by default in new Rails 8 applications", so this is a trade most people take without being asked. What follows is the argument behind it, checked against solid_cache 1.0.10, the version pinned in the boilerplate this site sells.
The claim: a bigger cache beats a faster one
Solid Cache's README states the trade in its first paragraph: the store "lets you keep a much larger cache than is typically possible with traditional memory-only Redis or Memcached stores", because "you're now usually better off keeping a huge cache on disk rather than a small cache in memory". Size is the product. Speed is the price.
The reasoning behind it has two halves, and the 37signals announcement post from 3 October 2023 puts both plainly. First, "while memory access is many times faster than disk, it only accounts for a fraction of cache operation time", the rest being network time, serialization and compression. Second, "usually we won't need to go to disk, as databases contain built-in memory caches", which is the buffer pool doing the work you were paying Redis to do.
Then the part that actually decides it. A cache hit and a cache miss are not two slightly different costs. A hit returns a rendered fragment in about a millisecond; a miss re-runs whatever produced that fragment, which is queries and template rendering and probably more of both than you think. So the comparison a reader should run is not "Redis read versus Solid Cache read". It is a fraction of a millisecond added to every read, set against a render avoided on some of them, and a render costs tens of milliseconds where the latency difference costs a fraction of one.
Their own published numbers let you do that arithmetic, which the next section does.
What 37signals measured
Donal McBreen's Rails World 2023 talk, delivered on 5 October 2023, carries the operation numbers. On Redis a cache read took 0.7ms and a write 0.9ms. On Solid Cache the same operations took about 1ms and 1.4ms, which he described on stage as "in the region of 50% slower", noting that the database read underneath is roughly 250 microseconds and the rest is Active Record overhead. The announcement post published two days earlier says "about 40% slower", and the talk explains the gap: the numbers were redone afterwards and came out lower.
The hit rate is the other half. McBreen showed a graph of 37signals' cache miss rate holding steady around 10% on Redis and settling at about 7.5% after the move, with the caveat from the stage that "these numbers are going to be highly dependent on your application". On hardware, 1.1TB of RAM for Redis became 80GB for Solid Cache, and the cost of storing a million cache entries came out roughly 20 times cheaper.
Now the arithmetic. Per hundred reads, Solid Cache costs you about 0.3ms extra each, so 30ms; it also saves you 2.5 misses. Break even sits at 12ms per miss. Any fragment more expensive than 12ms to render, which is most of them, puts you ahead. That is the whole argument, and it is why the 95th percentile request duration at Basecamp fell from 375ms to 225ms, which the announcement post sums up as "no magic here, just the effect of a bigger cache".
Two caveats travel with those figures. They are one application's, on 37signals' own hardware, and Basecamp "heavily uses fragment caching so was primed to benefit". An application that caches little has little hit rate to win back and inherits the latency anyway.
How Solid Cache stores an entry
The table is five columns, and db/cache_schema.rb is short enough to read whole.
create_table "solid_cache_entries", force: :cascade do |t|
t.binary "key", limit: 1024, null: false
t.binary "value", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "key_hash", limit: 8, null: false
t.integer "byte_size", limit: 4, null: false
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
end
Lookups never touch the key column. SolidCache::Entry.key_hash_for takes the first eight bytes
of a SHA256 digest and unpacks them as a signed 64 bit integer, via
Digest::SHA256.digest(key.to_s).unpack("q>").first, and the unique index on key_hash is what
every read and write addresses. Sixty four bits of hash means collisions exist in principle, and
they resolve as misses rather than as wrong answers: read_multi returns rows keyed by the stored
key, the caller looks up its own normalized key in that hash, and a colliding row simply never
matches.
Keys longer than max_key_bytesize, default 1024 to match the index, are not rejected. truncate_key
cuts the key and appends :hash:#{ActiveSupport::Digest.hexdigest(key)} so the truncated form stays
unique. Writes go through upsert_all in slices of MULTI_BATCH_SIZE, which is 1000.
byte_size is computed rather than measured: key bytesize plus value bytesize plus
ESTIMATED_ROW_OVERHEAD, a constant of 140 that the source describes as "a bit high for SQLite
(more like 90 bytes), but about right for MySQL/Postgresql". Every size limit you set is enforced
against that estimate, not against what the database actually occupies.
Trimming by age, size and count
Expiry in Solid Cache is a background trim triggered by writes, not a deletion scheduled at
expires_in. SolidCache::Store::Expiry sets EXPIRY_MULTIPLIER = 2 and a default
expiry_batch_size of 100, giving an expires_per_write of
(1 / expiry_batch_size.to_f) * EXPIRY_MULTIPLIER, which is 0.02. Roughly one trim task per fifty
writes, each deleting up to 100 rows, so the cache can shrink twice as fast as it grows. The
comment in the source is explicit that this is the point: "ensures there is downward pressure on
the cache size while there is valid data to delete".
Three limits govern what a trim deletes. max_age defaults to 2.weeks.to_i. max_entries and
max_size both default to nil, meaning no limit, and Entry::Expiration.cache_full? compares
them against estimates rather than counts: id_range is max(id) - min(id) + 1, and
estimated_size is a sampled sum of the byte_size column. When the cache is full, any oldest rows
go. When it is not, only rows whose created_at is older than max_age go.
The Rails 8 generator, and therefore config/cache.yml in the boilerplate, ships
max_size: <%= 256.megabytes %> with max_age commented out. So a stock install is a 256MB cache
holding entries for up to two weeks, and the generated comment next to max_age calls it what it is: a
retention policy, not a performance knob.
One detail worth knowing before you reason about which rows survive. expiry_candidate_ids fetches
three times as many ids as it intends to delete and then calls candidate_ids.sample(count), so
concurrent trims overlap less. Trimming is approximately oldest first, not exactly.
Why a hot key still ages out
FIFO is the eviction policy, and the README is upfront that this is a compromise: Solid Cache "is a FIFO (first in, first out) cache", and "while this is not as efficient as an LRU (least recently used) cache, it is mitigated by the longer cache lifespan". Reads are not tracked, so nothing knows which entry is popular.
The consequence is sharper than "FIFO is worse than LRU", and it lives in one keyword argument.
upsert_all \
add_key_hash_and_byte_size(payload_batch),
unique_by: upsert_unique_by, on_duplicate: :update, update_only: [ :key, :value, :byte_size ]
update_only lists three columns, and created_at is not among them. Neither is id. So
overwriting an existing key leaves both untouched: the row keeps the timestamp and the primary key
it got on first insert. A fragment rewritten every ten minutes for a fortnight is, as far as
trimming is concerned, a fortnight old, and at max_age it goes.
Rewriting it does not save it, and reading it certainly does not. What saves it is the write that
happens after the trim, which is a miss you paid for. On a cache sized in months this is the
mitigation the README claims and you will never notice. On a 256MB default with heavy traffic,
cache_full? will be true most of the time, trims will be deleting the oldest rows by id
regardless of age, and your most-read fragments will churn out alongside everything else.
Raising max_size is the fix, and it is the fix Solid Cache is designed around. The README's own
example configuration uses 256.gigabytes, a thousand times what the generator writes into a new
application.
What encryption covers and what it does not
Turning encryption on is one line, encrypt: true in config/cache.yml or
config.solid_cache.encrypt = true, and it requires Active Record Encryption configured in the
application. What it produces is a single encrypts call in SolidCache::Entry::Encryption:
encrypts :value, **SolidCache.configuration.encryption_context_properties
The value column is encrypted. The key column is not. Cache keys in Rails are built from model
names, ids and cache versions, so anyone reading solid_cache_entries can see which records you
cached and when, just not what the fragments contain. That may be fine for your threat model. It
should be a decision rather than a surprise.
The default context is tuned for this table rather than borrowed from Active Record.
Configuration#default_encryption_context_properties sets
ActiveRecord::Encryption::Encryptor.new(compress: false), since the cache already compresses, and
ActiveRecord::Encryption::MessagePackMessageSerializer.new, which the README describes as
binary-column-only and able to "store about 40% more data than the standard serializer". Encrypted
rows also pay ESTIMATED_ENCRYPTION_OVERHEAD, 170 bytes added to the 140 byte row estimate, so an
encrypted cache accounts itself as fuller for the same content.
One hard stop is worth naming. The engine raises at boot for Rails 7 on PostgreSQL with encryption on, because Active Record Encryption there does not support encrypting binary columns. Rails 8 is fine.
Where Redis is genuinely better
Redis is a datastore that happens to make a good cache. Solid Cache is an
ActiveSupport::Cache::Store and nothing else, so sorted sets, lists, pub/sub, Lua scripts,
SCAN-based key inspection and every other Redis primitive are simply absent. If Redis is already
in your stack for a leaderboard or a sliding window, Solid Cache does not remove it and was never
going to.
Atomic counters are the specific gap, and this site has already taken it apart. Solid Cache
increments under SELECT ... FOR UPDATE, which locks nothing when the row does not exist yet, so
two concurrent first increments on a new key can collapse into one. For a cache that is a lost write
on a cold key and nobody cares. For Rails 8 rate_limit, which defaults its store: to your cache
store, the abuse counter is the thing losing the write, and
running Rails 8 without Redis works through exactly how far that goes
and what to do instead.
Three smaller ones. cleanup raises NotImplementedError with the message "does not support
cleanup", so the ActiveSupport::Cache::Store API is not fully implemented. Per gigabyte you fit
less in a database than in Redis, because of indexes and deliberately reserved free space. And every
cache write is an upsert, which means WAL, replication traffic and vacuum pressure on a database
that also serves your users, on the same connection pool.
Latency remains the honest one. Redis answers in 0.7ms and Solid Cache in 1ms on 37signals' hardware, and if your workload is small, hot and already fits in RAM, the bigger cache buys you a hit rate you already had.
Failures that arrive looking like a cache miss
SolidCache::Store::Failsafe catches six Active Record error classes and returns a benign value
instead of raising.
TRANSIENT_ACTIVE_RECORD_ERRORS = [
ActiveRecord::AdapterTimeout,
ActiveRecord::ConnectionNotEstablished,
ActiveRecord::Deadlocked,
ActiveRecord::LockWaitTimeout,
ActiveRecord::QueryCanceled,
ActiveRecord::StatementTimeout
]
A saturated connection pool, a lock wait, a statement timeout: each one reaches your application as
nil from a read, which is indistinguishable from a cache miss. The default error handler logs at
error level, opening with SolidCacheStore: #{method} failed, returned #{returning.inspect} and
then the exception class and message, and reports to ActiveSupport.error_reporter with
severity: :warning. So the information exists, in
logs, under a warning, while your dashboards show a hit rate quietly collapsing and your database
getting slower for reasons nobody connects to the cache. Grep for SolidCacheStore: before you go
looking anywhere else.
A second one hides in the connection configuration. If database, databases and connects_to are
all unset, the README says Solid Cache "will use the ActiveRecord::Base connection pool", and
that "cache reads and writes will be part of any wrapping database transaction". Write to the cache
inside a transaction that later rolls back and the cache write rolls back with it. Setting
database: cache in config/cache.yml, which the generator does for production only, is what
prevents that.
What the boilerplate on this site actually caches
Scope, stated plainly. The Rails boilerplate this site sells sets config.cache_store = :solid_cache_store in
config/environments/production.rb and ships the generated config/cache.yml unmodified. It calls
Rails.cache nowhere and has no fragment caching in any view. The only thing reaching that store is
Rails 8 rate_limit, used in four controllers at to: 10, within: 3.minutes.
So none of the hit rate argument above applies to it yet. Which is the ordinary state of a new Rails 8 application: Solid Cache is configured, and the first thing that exercises it is the rate limiter rather than a fragment.
The test suite will not tell you any of this
config/environments/test.rb sets config.cache_store = :null_store, as new Rails applications do.
Every read is a miss, every write is a no-op, and no test anywhere touches a solid_cache_entries
row. Trimming, max_size, the FIFO timestamp behaviour, the failsafe swallowing a timeout: none of
it has a green or red state in CI. The suite passes identically whether your production cache works
or is dropping every write.
The boilerplate works around exactly one piece of this, and the comment says why:
# Back `rate_limit` with a real (in-memory) store so throttling can be tested; the general
# cache stays a null store.
config.action_controller.cache_store = :memory_store
Rate limiting is testable because it was given a separate store. Everything else about the cache is verified in production or not at all.
The position, and what would change it
Take the default. A new Rails 8 application should run Solid Cache and skip Redis, because the argument holds on the numbers its authors published and the operational simplification is real: one datastore to back up, one to restore, one to monitor.
Two changes to those defaults are worth making on day one rather than after an incident. Raise
max_size well past 256.megabytes, since the entire case for this store is that the cache is
large and a quarter gigabyte is not large. And give rate_limit a store of its own, for the
atomicity reason in the linked post and because eviction pressure from page fragments should not be
deciding how long your abuse counters live.
What would move the position: a workload that is small and hot rather than large and long-tailed, where the cache already fits in RAM and the extra 0.3ms per read buys no hit rate back. Redis already present for something Solid Cache cannot express, since a second datastore you are keeping anyway costs nothing more to also cache in. Or a database whose write capacity is the constraint, where cache upserts competing with user traffic for the same pool is the problem rather than the simplification.
Comments
No comments yet. Be the first.