Managing Multiple Redis Connection Pools in Rails

Written by Ari Summer·
·Last updated July 29, 2023

In a multi-threaded environment, connection pooling is used to manage multiple connections to data stores. This is what’s being used under-the-hood in many of the most common Ruby gems - ActiveRecord, ActiveSupport cache, ActionCable, and Sidekiq to name a few.

Both ActionCable and Sidekiq (and often Rails.cache) are backed by Redis. When you start out, you can usually get away with a single Redis instance. As your app grows, stability and scalability become more of a concern, and you frequently end up adding more Redis instances and dedicating each to a specific purpose - one for a cache, one for a queue, one for throttling, one for websockets, etc. Many gems will create a connection pool for you. In other cases, you may have to create your own connection pool. One example is if you want to use rack-attack with a dedicated Redis instance.

To my knowledge, there is no standard or conventional way to configure and manage multiple Redis connection pools in Rails, so in this article I’ll detail a simple strategy for doing so. Let’s start by creating a RedisManager class responsible for managing our Redis connections:

lib/redis_manager.rb
class RedisManager
  class_attribute :connection_pool_cache, default: {}
 
  def self.default_url
    ENV["REDIS_URL"]
  end
 
  # Return the URL to a Redis DB for a given purpose
  def self.url_for(purpose = :default)
    case purpose.to_sym
    when :default then default_url
    end
  end
 
  def self.connection_pool_size
    (ENV.fetch("RAILS_MAX_THREADS") { 5 }).to_i
  end
 
  def self.build_connection_pool(purpose)
    ConnectionPool.new(size: connection_pool_size) do
      Redis.new(url: url_for(purpose))
    end
  end
 
  def self.find_or_build_connection_pool(purpose)
    self.connection_pool_cache[purpose] ||= build_connection_pool(purpose)
  end
 
  # Return a memoized ConnectionPool instance for a given purpose
  def self.connection_pool_for(purpose = :default)
    case purpose.to_sym
    when :default then find_or_build_connection_pool(:default)
    end
  end
end

As you can see above, RedisManager memoizes a ConnectionPool instance for a given purpose. It caches the instances in a class attribute Hash, named connection_pool_cache, so that only only a single instance is created and returned.

In order to make use of RedisManager in our Rails app, we’ll need to require it in config/application.rb:

config/application.rb
module MyApp
  class Application < Rails::Application
    # Require RedisManager so that we can use it for
    # configuration and initialization
    require "redis_manager"
  end
end

Then, it can be used like so:

RedisManager.connection_pool_for.with do |redis|
  redis.get("mykey")
end

Examples

Configuring RackAttack

How about a more practical example of using RedisManager to configure rack-attack with a dedicated cache store?

Let’s update RedisManager:

lib/redis_manager.rb
class RedisManager
  class_attribute :connection_pool_cache, default: {}
 
  def self.default_url
    ENV["REDIS_URL"]
  end
 
  def self.url_for_or_default(key) 
    ENV[key].presence || default_url 
  end 
 
  # Return the URL to a Redis DB for a given purpose
  def self.url_for(purpose = :default)
    case purpose.to_sym
    when :throttling then url_for_or_default("REDIS_THROTTLING_URL") 
    when :default then default_url
    end
  end
 
  def self.connection_pool_size
    (ENV.fetch("RAILS_MAX_THREADS") { 5 }).to_i
  end
 
  def self.build_connection_pool(purpose)
    ConnectionPool.new(size: connection_pool_size) do
      Redis.new(url: url_for(purpose))
    end
  end
 
  def self.find_or_build_connection_pool(purpose)
    self.connection_pool_cache[purpose] ||= build_connection_pool(purpose)
  end
 
  # Return a memoized ConnectionPool instance for a given purpose
  def self.connection_pool_for(purpose = :default)
    case purpose.to_sym
    when :throttling then find_or_build_connection_pool(:throttling) 
    when :default then find_or_build_connection_pool(:default)
    end
  end
end

Now we just need to set Rack::Attack.cache.store:

config/initializers/rack_attack.rb
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
  redis: RedisManager.connection_pool_for(:throttling)
)

Configuring Rails Cache

How would we use RedisManager to configure the Rails cache store for Redis? Like this:

config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: RedisManager.url_for(:caching),
  pool_size: RedisManager.connection_pool_size
}
lib/redis_manager.rb
class RedisManager
  ...
 
  # Return the URL to a Redis DB for a given purpose
  def self.url_for(purpose = :default)
    case purpose.to_sym
    when :caching then url_for_or_default("REDIS_CACHING_URL") 
    when :throttling then url_for_or_default("REDIS_THROTTLING_URL")
    when :default then default_url
    end
  end
 
  ...
end

There’s no reason to create your own ConnectionPool for the Rails cache because Rails creates it for you.

Configuring ActionCable

How about ActionCable?

config/cable.yml
production:
  adapter: redis
  url: <%= RedisManager.url_for(:websockets) %>
lib/redis_manager.rb
class RedisManager
  ...
 
  # Return the URL to a Redis DB for a given purpose
  def self.url_for(purpose = :default)
    case purpose.to_sym
    when :websockets then url_for_or_default("REDIS_WEBSOCKETS_URL") 
    when :caching then url_for_or_default("REDIS_CACHING_URL")
    when :throttling then url_for_or_default("REDIS_THROTTLING_URL")
    when :default then default_url
    end
  end
 
  ...
end

Configuring Sidekiq

How about Sidekiq? It’s very similar:

config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  config.redis = { url: RedisManger.url_for(:queue) }
end
 
Sidekiq.configure_client do |config|
  config.redis = { url: RedisManger.url_for(:queue) }
end
lib/redis_manager.rb
class RedisManager
  ...
 
  # Return the URL to a Redis DB for a given purpose
  def self.url_for(purpose = :default)
    case purpose.to_sym
    when :queue then url_for_or_default("REDIS_QUEUE_URL") 
    when :websockets then url_for_or_default("REDIS_WEBSOCKETS_URL")
    when :caching then url_for_or_default("REDIS_CACHING_URL")
    when :throttling then url_for_or_default("REDIS_THROTTLING_URL")
    when :default then default_url
    end
  end
 
  ...
end

Wrapping Up

In addition to providing a single interface for accessing Redis connection strings and pools, one of the nice things about this implementation is that RedisManager falls back to the default REDIS_URL if there is no URL defined for a given purpose. This simplifies local development, where it’s usually unnecessary to run multiple Redis instances. You will need to keep in mind that if you’re using the same Redis instance for multiple purposes, there is a chance of cache key collision. If you’re worried about this, you can always separate your usage by logical database. For example:

REDIS_URL=redis://127.0.0.1:6379/0
REDIS_CACHING_URL=redis://127.0.0.1:6379/1

Finally, it’s worth noting that the above examples aren’t creating connection pools for Rails cache, ActionCable, and Sidekiq. That’s because these gems create and manage their own connection pools - they only expect the user to pass in a connection string and they’ll handle the rest.