LaunchKit
← All posts
· 13 min read · by The LaunchKit team · 3 views

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.

#rails #caching

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.