LaunchKit
← All posts
· 15 min read · by The LaunchKit team · 0 views

Rails and Redis: where it plugs in, and what goes wrong

Rails 8 will not ask you for a Redis. Jobs, cache and Action Cable all have adapters that live in the database you already run, and the case for accepting them is made in Running Rails 8 without Redis. So the interesting question is no longer whether to add one. It is what a Redis is actually wired to when you do add it, and which of those wires fails quietly.

Everything below was run against rails 8.1.3.1 with the redis gem 5.4.1 and redis-client 0.30.1, talking to redis 7.2.4 on 127.0.0.1:16379, on an Apple M2 Max under macOS arm64-darwin25 with ruby 4.0.5. Output blocks are pasted from the terminal.

What Rails 8 actually asks Redis for

Two things in the framework itself, and that is the whole list. ActiveSupport::Cache::RedisCacheStore is a cache store, and ActionCable::SubscriptionAdapter::Redis is a pubsub transport. Neither is required, neither is loaded unless you configure it, and both declare the gem they need at the top of their own file: gem "redis", ">= 4.0.1" on line 4 of active_support/cache/redis_cache_store.rb, and gem "redis", ">= 4", "< 6" on line 5 of action_cable/subscription_adapter/redis.rb. Adding redis-client alone will not satisfy either one; they want the redis gem that sits on top of it.

Everything else people mean when they say Rails and Redis comes from a gem. Sidekiq is the big one, and whether it still earns its Redis is a separate argument. Rack::Attack, Kredis, a hand-rolled leaderboard and a lock you wrote yourself all talk to Redis directly and none of them go through Rails.

The practical consequence is that "we use Redis" is not a configuration. Ask which of these four things it is holding, because the failure modes below apply to one of them at a time.

Wiring the cache store, and reading it back in redis-cli

One line in the environment file, and the store is a RedisCacheStore.

# config/environments/production.rb
config.cache_store = :redis_cache_store, { url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:16379/0") }

The first surprise is that Rails.cache.redis does not return a Redis.

# bin/rails runner
Rails.cache.write("greeting", "hello", expires_in: 90.seconds)
puts Rails.cache.class
puts Rails.cache.redis.class
puts Rails.cache.read("greeting").inspect
ActiveSupport::Cache::RedisCacheStore
ConnectionPool
"hello"

To reach a connection you go through the pool: Rails.cache.redis.with { |c| c.get("greeting") }. Code written against Rails 7.0 that calls Rails.cache.redis.get(...) directly stopped working when pooling became the default, and what it raises reads like a broken install rather than the design change it is:

NoMethodError: undefined method 'get' for an instance of ConnectionPool

On the Redis side the key is stored verbatim, with the TTL Rails computed from expires_in:

$ redis-cli -p 16379 --scan
greeting
$ redis-cli -p 16379 type greeting
string
$ redis-cli -p 16379 ttl greeting
90
$ redis-cli -p 16379 strlen greeting
20

Five bytes of payload, twenty bytes stored. The other fifteen are the Active Support cache entry header, which carries the expiry and the cache_version that Rails caching strategies covers. That header is why a GET in redis-cli shows binary noise rather than your value, and why nothing outside Rails can read the cache.

A key over 250 bytes is not the key you wrote

ActiveSupport::Cache::Store::MAX_KEY_SIZE is 250, and longer keys are truncated and digested. A 333 byte key went in and this came out:

key length asked for: 333
key stored:           250 bytes
reports/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segment/segm:hash:14d1352988e60beb46dd9bc654e13d4d
read back: 1

Reads and writes still agree, because both go through the same truncation in truncate_key at active_support/cache.rb:991, which byteslices the key to 250 minus the suffix and appends ":hash:#{ActiveSupport::Digest.hexdigest(key)}". What breaks is you, grepping redis-cli for a key you can see in the code and finding nothing. Search for the first 200 bytes, or keep keys short enough that the question never comes up.

The connection pool is five, whatever your thread count is

DEFAULT_POOL_OPTIONS = { size: 5, timeout: 5 }.freeze sits at activesupport-8.1.3.1/lib/active_support/cache.rb:192, and nothing wires it to RAILS_MAX_THREADS. The generated config/puma.rb sets threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) on line 28, so a stock app has 3 threads against 5 connections and never notices. Raise RAILS_MAX_THREADS to 16 and the cache pool stays at 5.

Eight threads each holding a connection for six seconds against that pool:

errors = []
t0 = Time.now
threads = 8.times.map do
  Thread.new do
    begin
      Rails.cache.redis.with { |c| c.blpop("no-such-list", timeout: 6) }
    rescue => e
      errors << e
    end
  end
end
threads.each(&:join)
puts "pool size #{Rails.cache.redis.size}, 8 threads each holding a connection 6s"
puts "elapsed #{(Time.now - t0).round(2)}s, failures #{errors.size}"
errors.uniq { |e| e.class }.each { |e| puts "#{e.class}: #{e.message}" }
pool size 5, 8 threads each holding a connection 6s
elapsed 6.06s, failures 3
ConnectionPool::TimeoutError: Waited 5.0 sec, 0/5 available

The same script with a two second hold produced no failures at all and took 4.12 seconds instead of 2: the three extra threads queued for a connection and got one. That intermediate state is the one you will actually meet in production, because Redis operations are fast enough that you reach queueing long before you reach the timeout. A pool of 5 does not fail under 16 threads, it serialises them, and the symptom is tail latency on a cache read that has no business being slow.

Set it explicitly, at the cost of that many more open sockets per Puma worker:

config.cache_store = :redis_cache_store, {
  url: ENV.fetch("REDIS_URL"),
  pool: { size: Integer(ENV.fetch("RAILS_MAX_THREADS", 3)), timeout: 5 }
}

Every Redis failure arrives as a cache miss

RedisCacheStore is fault tolerant by design, and the definition of that is one method:

# activesupport-8.1.3.1/lib/active_support/cache/redis_cache_store.rb:490
def failsafe(method, returning: nil)
  yield
rescue ::Redis::BaseError, ConnectionPool::Error, ConnectionPool::TimeoutError => error
  @error_handler&.call(method: method, exception: error, returning: returning)
  returning
end

Shut the server down under a running process and watch what the application sees:

write  -> nil
read   -> nil
fetch  -> "block ran"
incr   -> nil
raw    -> raised Redis::CannotConnectError: Connection refused - connect(2) for 127.0.0.1:16379 (redis://127.0.0.1:16379)

Four calls through Rails.cache degraded silently. The fifth, Rails.cache.redis.with { |c| c.get(...) }, raised, because the pool is the raw client and the failsafe is not in that path. So any Redis work you do yourself needs its own rescue, and the rescue class you want is Redis::BaseError rather than StandardError, if you want to keep real bugs loud.

The third entry in that rescue list is the one worth stopping on. ConnectionPool::TimeoutError is in there, which means the pool exhaustion from the section above does not surface as an error either. Undersize the pool badly enough and Rails.cache becomes a store that always misses, the application gets slower and stays correct.

The only evidence is one log line per failure, and it is written by DEFAULT_ERROR_HANDLER at line 44 of the same file. Setting ActiveSupport::Cache::Store.logger to stdout and provoking a different failure, an INCRBY against a marshalled value, shows the shape of it:

RedisCacheStore: increment failed, returned nil: Redis::CommandError: ERR value is not an integer or out of range (redis://127.0.0.1:16379)

Same handler reports the exception to ActiveSupport.error_reporter at severity :warning. If your error tracker filters warnings, and most do by default, a Redis outage produces no alert from Rails at all.

increment writes a string that read cannot decode

Rails.cache.increment does not write an Active Support cache entry. It calls INCRBY, so what lands in Redis is a bare integer string with no header, and the ordinary reader cannot make sense of it.

Rails.cache.delete("hits")
400.times { Rails.cache.increment("hits", 1, expires_in: 60.seconds) }
puts "read_counter        #{Rails.cache.read_counter("hits").inspect}"
puts "read(raw: true)     #{Rails.cache.read("hits", raw: true).inspect}"
puts "read                #{Rails.cache.read("hits").inspect}"
puts "fetch { 0 }         #{Rails.cache.fetch("hits") { 0 }.inspect}"
puts "read_counter again  #{Rails.cache.read_counter("hits").inspect}"
puts "increment           #{Rails.cache.increment("hits").inspect}"
read_counter        400
read(raw: true)     "400"
read                nil
fetch { 0 }         0
read_counter again  0
increment           nil

Three things in six lines. read returns nil on a key that exists and holds the right number, which is how a rate limiter ends up never limiting anybody. fetch sees that same nil, decides the key is cold, runs its block and overwrites the counter with a marshalled 0. And from that moment the counter is dead: INCRBY against a marshalled value raises ERR value is not an integer or out of range, the failsafe returns nil, and every subsequent increment is lost until the key expires.

The upside is the part that is genuinely better than the database-backed alternative. INCRBY is atomic, so there is no read-modify-write window for two processes to land in. Eight forked processes doing 50 increments each:

8 processes x 50 increments = 400 expected
read(raw: true) -> "400"
read_counter    -> 400

Exactly 400, on that script and on every run of the test file that pins it. That is the concrete thing Redis buys a counter, and it is worth knowing because the Solid Cache path has a first-increment race documented in Running Rails 8 without Redis. Use read_counter, never read, and never fetch on a key you increment.

Rails.cache.clear is FLUSHDB

RedisCacheStore#clear branches on one option, and the branch nobody configures is the destructive one:

# redis_cache_store.rb:309
def clear(options = nil)
  failsafe :clear do
    if namespace = merged_options(options)[:namespace]
      delete_matched "*", namespace: namespace
    else
      redis.then { |c| c.flushdb }
    end
  end
end

With a Sidekiq queue and a Sidekiq stat sitting in the same database as one cached page:

keys before clear: ["page/home", "queue:default", "sidekiq:stat:processed"]
keys after  clear: []

The same run with namespace: "myapp-cache" on the store:

namespaced key:    ["myapp-cache:page/home", "queue:default"]
after ns clear:    ["queue:default"]

Declare a namespace, or give the cache its own database number, or both. The cost of the namespace is that clear becomes a SCAN over the keyspace instead of one FLUSHDB, which the next section prices.

delete_matched scans the whole keyspace

delete_matched on Redis is SCAN with count: 1000 and a glob, so it costs the size of the database and not the number of matches. Fifty matching keys, timed twice:

dbsize: 100050
delete_matched("posts/*") over that keyspace: 17.7ms for 50 matches
dbsize: 1100050
delete_matched("posts/*") over that keyspace: 297.5ms for 50 matches

Eleven times the keys, seventeen times the wall clock, for identical work. It also refuses anything but a glob string:

ArgumentError: Only Redis glob strings are supported: /posts/

Key-based expiry instead of pattern deletion is the fix, and it is the same fix it always was.

maxmemory-policy defaults to noeviction

Redis 7.2.4 ships maxmemory 0 and maxmemory-policy noeviction, which a managed provider will usually change for you and a Redis you started yourself will not. A 4mb instance under that default, fed 32KB of incompressible bytes per key:

store.write returned nil at page/88
maxmemory 4mb, maxmemory-policy noeviction, 32KB incompressible values
writes accepted: 88
keys in redis:   88
used_memory_human: 3.99M
raw SET raises -> Redis::OutOfMemoryError: OOM command not allowed when used memory > 'maxmemory'. (redis://127.0.0.1:16380)
read of an earlier key: 32000 bytes

The store stopped accepting new entries and returned nil from write, which the failsafe turned into no error. Reads of the 88 keys already there kept working. This is the worst shape a cache failure can take: the hit rate does not drop to zero, it freezes at whatever was warm when the instance filled, every new key misses forever, and the dashboard shows a healthy Redis with healthy reads.

CONFIG SET maxmemory-policy allkeys-lru on the same instance, then 500 writes of the same size:

maxmemory-policy allkeys-lru, 500 more 32KB writes
writes accepted: 500
keys in redis:   88
evicted_keys:    500
read page/0 (oldest): nil

All 500 accepted, 500 evicted, the oldest key gone. That is what a cache is supposed to do. The cost is that allkeys-lru will evict anything, so a Redis holding both cache entries and Sidekiq's queues under that policy will drop jobs. Which is the argument for one Redis per job, or at minimum one database number per job with the cache namespaced, rather than a policy that tries to serve both.

Action Cable on Redis is still the generated default

railties 8.1.3.1 ships exactly one cable.yml template, lib/rails/generators/rails/app/templates/config/cable.yml.tt, and its production block names Redis. Generate an app with --skip-solid and that is the file you get:

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: redisapp_production

Pointed at a real Redis and driven through ActionCable.server.pubsub, a broadcast round trip and the resulting channel list:

received: "{\"body\":\"ping\"}"
redis channels: "redisapp_dev:room_42\n_action_cable_internal"

channel_prefix is what stops two applications sharing a Redis from delivering each other's broadcasts, and it is applied through the ChannelPrefix module prepended on line 13 of the adapter. _action_cable_internal is subscribed on line 93 without going through that prefix, so two applications on one Redis do share it. It only carries adapter housekeeping, so nothing leaks, but it is the reason a PUBSUB CHANNELS on a shared instance shows one channel you did not name.

The dead end: a capacity test that could not fill 4MB

The first version of the eviction test above wrote 5000 keys of "x" * 32_000 into a 4mb Redis, expecting a wall at about 130 keys. All 5000 went in. 160MB of payload fit in 4MB of Redis, dbsize said 5000, and nothing failed.

The cause is a default nobody mentions: RedisCacheStore compresses entries above 1024 bytes, before they ever reach the socket.

default options: {compress: true, compress_threshold: 1024}
32000 bytes of 'x'      -> STRLEN 68
32000 random bytes      -> STRLEN 32015
19790 bytes of JSON  -> STRLEN 2026
same JSON, compress: false -> STRLEN 19805
1023 bytes (under threshold) -> STRLEN 1038
1024 bytes (at threshold)    -> STRLEN 32

32000 repeating bytes became 68. Realistic JSON, 400 rows of id, name and state, went from 19790 bytes to 2026, a factor of 9.8. Random bytes gained 15 bytes of header and were stored raw, because Active Support keeps whichever is smaller. The threshold is exact: 1023 bytes stored 1038, 1024 bytes stored 32.

Two things follow. Any capacity estimate built on repeating test data is meaningless, and the eviction test above only became a test once it switched to SecureRandom.bytes. And sizing a production Redis from the byte size of your objects will overshoot by roughly an order of magnitude on JSON and HTML, which is the same fact pointed the other way.

What a localhost round trip costs

Reading one 40-row hash 20000 times, after 1000 warm-up reads, same process, three consecutive runs:

redis over TCP 127.0.0.1:16379      86.1 us/read   (20000 reads)
redis over unix socket              54.9 us/read   (20000 reads)
memory_store                        24.2 us/read   (20000 reads)

redis over TCP 127.0.0.1:16379      76.8 us/read   (20000 reads)
redis over unix socket              56.7 us/read   (20000 reads)
memory_store                        24.2 us/read   (20000 reads)

redis over TCP 127.0.0.1:16379      73.8 us/read   (20000 reads)
redis over unix socket              54.7 us/read   (20000 reads)
memory_store                        24.2 us/read   (20000 reads)

The loopback TCP stack is about 20 microseconds of the 75, and a unix socket removes it. That is worth taking when Redis is on the same box, with path: instead of url: in the store options, and it is worth nothing at all on Heroku or Upstash where Redis is across a network. What that network hop costs is not measurable from this machine and no number for it appears here.

The honest reading of 75 microseconds is that it is small next to a Rails view render and large next to nothing, so a request that reads forty cache keys has spent three milliseconds on Redis before any of them were useful. read_multi and fetch_multi collapse those into one MGET, and that is the optimisation with the leverage, not the transport.

The position

If you are starting a Rails 8 application today, do not add Redis. The two framework integrations have database-backed replacements that ship configured, the third reason people add it is Sidekiq and Solid Queue now covers most of what Sidekiq was for, and everything on this page is an operating cost you would be taking on voluntarily: a pool to size, an eviction policy to set, a namespace to declare, a failure mode that does not raise, and a second thing in the deployment that can be down.

What would change that: a counter that must be exactly right under concurrency, which Redis gets right with INCRBY and the database-backed cache does not; a pubsub fan-out large enough that Solid Cable's table is the bottleneck; or a working set genuinely too hot for disk, which is a real thing and rarer than it is claimed. One of those is a reason. "It is what we have always used" is not, and neither is speed in the abstract, because the argument against a disk-backed cache is answered in Solid Cache vs Redis and it does not turn on latency.

What this page does not cover

Sidekiq's own use of Redis, which is most of the Redis in most Rails applications and is a different subject with a different failure surface. Sharding across several Redises through Redis::Distributed, which RedisCacheStore builds in build_redis_distributed_client when you pass more than one URL, and which nothing here exercised. Redis Cluster, which is a different thing again. TLS, AUTH and ACLs, none of which were switched on for any measurement above. Redis as a session store through ActionDispatch::Session::CacheStore, which inherits every failure mode in this page and adds logging people out. Persistence, which was off in every instance here (save "", appendonly no): a cache does not need it, and a Redis holding anything but cache does. And Valkey, which nobody ran on this machine, so nothing above should be assumed to hold there.

#rails #caching #infrastructure

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.