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:
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
:
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
:
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
:
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.cache_store = :redis_cache_store, {
url: RedisManager.url_for(:caching),
pool_size: RedisManager.connection_pool_size
}
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
?
production:
adapter: redis
url: <%= RedisManager.url_for(:websockets) %>
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:
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
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.