
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
endAs 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
endThen, it can be used like so:
RedisManager.connection_pool_for.with do |redis|
redis.get("mykey")
endExamples
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
endNow 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
...
endThere’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
...
endConfiguring 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) }
endclass 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
...
endWrapping 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/1Finally, 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.