[Ruby on Rails (RoR)] Speed Up Your Rails App RESTful API by cache

Rails Cache

Caching means to store content generated during the request-response cycle and to reuse it when responding to similar requests.

Caching is often the most effective way to boost an application’s performance. Through caching, web sites running on a single server with a single database can sustain a load of thousands of concurrent users.

Rails provides a set of caching features out of the box. This guide will teach you the scope and purpose of each one of them. Master these techniques and your Rails applications can serve millions of views without exorbitant response times or server bills.

Enable caching

By default, caching is only enabled in your production environment. To play around with caching locally you’ll want to enable caching in your local environment by setting config.action_controller.perform_caching to true in the relevant config/environments/*.rb file:

1
config.action_controller.perform_caching = true

Changing the value of config.action_controller.perform_caching will only have an effect on the caching provided by the Action Controller component. For instance, it will not impact low-level caching, that we address below.


Fragment caching

Fragment caching is supported within rails/jbuilder: Jbuilder: generate JSON objects with a Builder-style DSL - https://github.com/rails/jbuilder, it uses Rails.cache and works like caching in HTML templates:

1
2
3
json.cache! ['v1', @person], expires_in: 10.minutes do
json.extract! @person, :name, :age
end

You can also conditionally cache a block by using cache_if! like this:

1
2
3
json.cache_if! !admin?, ['v1', @person], expires_in: 10.minutes do
json.extract! @person, :name, :age
end

If you are rendering fragments for a collection of objects, have a look at yonahforst/jbuilder_cache_multi - https://github.com/yonahforst/jbuilder_cache_multi gem. It uses fetch_multi (>= Rails 4.1) to fetch multiple keys at once.

Renders the given block for each item in the collection. Accepts optional ‘key’ attribute in options (e.g. key: ‘v1’).

Note: At the moment, does not accept the partial name as an argument (#todo)

Examples:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
json.cache_collection! @people, expires_in: 10.minutes do |person|
json.partial! 'person', :person => person
end

# Or with optional key

json.cache_collection! @people, expires_in: 10.minutes, key: 'v1' do |person|
json.partial! 'person', :person => person
end

# Or with a proc as a key

json.cache_collection! @people, expires_in: 10.minutes, key: proc {|person| person.last_posted_at } do |person|
json.partial! 'person', :person => person
end

Last thing: If you are using a collection for the cache key, may I recommend the ‘scope_cache_key’ gem? (check out my fork for a Rails 4 version: https://github.com/joshblour/scope_cache_key). It very quickly calculates a hash for all items in the collection (MD5 hash of updated_at + IDs).

You can also conditionally cache a block by using cache_collection_if! like this:

1
2
3
json.cache_collection_if! do_cache?, @people, expires_in: 10.minutes do |person|
json.partial! 'person', :person => person
end

Low-Level Caching

Sometimes you need to cache a particular value or query result instead of caching json or view fragments. Rails’ caching mechanism works great for storing any kind of information.

The most efficient way to implement low-level caching is using the Rails.cache.fetch method. This method does both reading and writing to the cache. When passed only a single argument, the key is fetched and value from the cache is returned. If a block is passed, that block will be executed in the event of a cache miss. The return value of the block will be written to the cache under the given cache key, and that return value will be returned. In case of cache hit, the cached value will be returned without executing the block.

Consider the following example. An application has a Product model with an instance method that looks up the product’s price on a competing website. The data returned by this method would be perfect for low-level caching:

1
2
3
4
5
6
7
8
9
# app/models/product.rb

class Product < ApplicationRecord
def competing_price
Rails.cache.fetch("#{cache_key_with_version}/competing_price", expires_in: 12.hours) do
Competitor::API.find_price(id)
end
end
end

SQL Caching

Query caching is a Rails feature that caches the result set returned by each query. If Rails encounters the same query again for that request, it will use the cached result set as opposed to running the query against the database again.

For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# app/controllers/product_controller.rb

class ProductsController < ApplicationController

def index
# Run a find query
@products = Product.all

...

# Run the same query again
@products = Product.all
end

end

The second time the same query is run against the database, it’s not actually going to hit the database. The first time the result is returned from the query it is stored in the query cache (in memory) and the second time it’s pulled from memory.

However, it’s important to note that query caches are created at the start of an action and destroyed at the end of that action and thus persist only for the duration of the action. If you’d like to store query results in a more persistent fashion, you can with low level caching.

Cache Stores

Rails provides different stores for the cached data (apart from SQL and page caching).

  • ActiveSupport::Cache::MemoryStore

  • ActiveSupport::Cache::FileStore

  • ActiveSupport::Cache::MemCacheStore

  • ActiveSupport::Cache::RedisCacheStore

  • ActiveSupport::Cache::NullStore

  • Or Custom ActiveSupport::Cache

Configuration

You can set up your application’s default cache store by setting the config.cache_store configuration option. Other parameters can be passed as arguments to the cache store’s constructor:

1
2
3
# config/environment.rb or config/environments/*.rb

config.cache_store = :memory_store, { size: 64.megabytes }

Alternatively, you can call ActionController::Base.cache_store outside of a configuration block.

ActiveSupport::Cache::Store

This class provides the foundation for interacting with the cache in Rails. This is an abstract class and you cannot use it on its own. Rather you must use a concrete implementation of the class tied to a storage engine. Rails ships with several implementations documented below.

The main methods to call are read, write, delete, exist?, and fetch. The fetch method takes a block and will either return an existing value from the cache, or evaluate the block and write the result to the cache if no value exists.

There are some common options that can be used by all cache implementations. These can be passed to the constructor or the various methods to interact with entries.

  • :namespace - This option can be used to create a namespace within the cache store. It is especially useful if your application shares a cache with other applications.

  • :compress - Enabled by default. Compresses cache entries so more data can be stored in the same memory footprint, leading to fewer cache evictions and higher hit rates.

  • :compress_threshold - Defaults to 1kB. Cache entries larger than this threshold, specified in bytes, are compressed.

  • :expires_in - This option sets an expiration time in seconds for the cache entry, if the cache store supports it, when it will be automatically removed from the cache.

  • :race_condition_ttl - This option is used in conjunction with the :expires_in option. It will prevent race conditions when cache entries expire by preventing multiple processes from simultaneously regenerating the same entry (also known as the dog pile effect). This option sets the number of seconds that an expired entry can be reused while a new value is being regenerated. It’s a good practice to set this value if you use the :expires_in option.

Connection Pool Options

By default the MemCacheStore and RedisCacheStore use a single connection per process. This means that if you’re using Puma, or another threaded server, you can have multiple threads waiting for the connection to become available. To increase the number of available connections you can enable connection pooling.

First, add the connection_pool gem to your Gemfile:

1
2
3
# Gemfile

gem 'connection_pool'

Next, pass the :pool_size and/or :pool_timeout options when configuring the cache store:

1
config.cache_store = :mem_cache_store, "cache.example.com", { pool_size: 5, pool_timeout: 5 }
  • :pool_size - This option sets the number of connections per process (defaults to 5).

  • :pool_timeout - This option sets the number of seconds to wait for a connection (defaults to 5). If no connection is available within the timeout, a Timeout::Error will be raised.

Custom Cache Stores

You can create your own custom cache store by simply extending ActiveSupport::Cache::Store and implementing the appropriate methods. This way, you can swap in any number of caching technologies into your Rails application.

To use a custom cache store, simply set the cache store to a new instance of your custom class.

1
config.cache_store = MyCacheStore.new

ActiveSupport::Cache::RedisCacheStore

The Redis cache store takes advantage of Redis support for automatic eviction when it reaches max memory, allowing it to behave much like a Memcached cache server.


Deployment note: Redis doesn’t expire keys by default, so take care to use a dedicated Redis cache server. Don’t fill up your persistent-Redis server with volatile cache data! Read the Redis cache server setup guide in detail.


For a cache-only Redis server, set maxmemory-policy to one of the variants of allkeys. Redis 4+ supports least-frequently-used eviction (allkeys-lfu), an excellent default choice. Redis 3 and earlier should use least-recently-used eviction (allkeys-lru).

Set cache read and write timeouts relatively low. Regenerating a cached value is often faster than waiting more than a second to retrieve it. Both read and write timeouts default to 1 second, but may be set lower if your network is consistently low-latency.

By default, the cache store will not attempt to reconnect to Redis if the connection fails during a request. If you experience frequent disconnects you may wish to enable reconnect attempts.

Cache reads and writes never raise exceptions; they just return nil instead, behaving as if there was nothing in the cache. To gauge whether your cache is hitting exceptions, you may provide an error_handler to report to an exception gathering service. It must accept three keyword arguments: method, the cache store method that was originally called; returning, the value that was returned to the user, typically nil; and exception, the exception that was rescued.

To get started, add the redis gem to your Gemfile:

1
2
3
# Gemfile

gem 'redis'

You can enable support for the faster hiredis connection library by additionally adding its ruby wrapper to your Gemfile:

1
2
3
# Gemfile

gem 'hiredis'

Redis cache store will automatically require & use hiredis if available. No further configuration is needed.

Finally, add the configuration in the config/environment.rb or relevant config/environments/*.rb file:

1
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }

A more complex, production Redis cache store may look something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# config/environment.rb or config/environments/*.rb

cache_servers = %w(redis://cache-01:6379/0 redis://cache-02:6379/0)
config.cache_store = :redis_cache_store, { url: cache_servers,

connect_timeout: 30, # Defaults to 20 seconds
read_timeout: 0.2, # Defaults to 1 second
write_timeout: 0.2, # Defaults to 1 second
reconnect_attempts: 1, # Defaults to 0

error_handler: -> (method:, returning:, exception:) {
# Report errors to Sentry as warnings
Raven.capture_exception exception, level: 'warning',
tags: { method: method, returning: returning }
}
}

ActiveSupport::Cache::NullStore

This cache store implementation is meant to be used only in development or test environments and it never stores anything. This can be very useful in development when you have code that interacts directly with Rails.cache but caching may interfere with being able to see the results of code changes. With this cache store, all fetch and read operations will result in a miss.

1
config.cache_store = :null_store

Cache Keys

The keys used in a cache can be any object that responds to either cache_key or to_param. You can implement the cache_key method on your classes if you need to generate custom keys. Active Record will generate keys based on the class name and record id.

You can use Hashes and Arrays of values as cache keys.

1
2
# This is a legal cache key
Rails.cache.read(site: "mysite", owners: [owner_1, owner_2])

The keys you use on Rails.cache will not be the same as those actually used with the storage engine. They may be modified with a namespace or altered to fit technology backend constraints. This means, for instance, that you can’t save values with Rails.cache and then try to pull them out with the dalli gem. However, you also don’t need to worry about exceeding the memcached size limit or violating syntax rules.


See Caching with Rails: An Overview — Ruby on Rails Guides - https://guides.rubyonrails.org/caching_with_rails.html to learn more about Rails caching.

References

[1] Caching with Rails: An Overview — Ruby on Rails Guides - https://guides.rubyonrails.org/caching_with_rails.html

[2] How to Improve Website Performance With Caching in Rails ― Scotch.io - https://scotch.io/tutorials/how-to-improve-website-performance-with-caching-in-rails

[3] rails/jbuilder: Jbuilder: generate JSON objects with a Builder-style DSL - https://github.com/rails/jbuilder

[4] yonahforst/jbuilder_cache_multi - https://github.com/yonahforst/jbuilder_cache_multi

[5] yonahforst/scope_cache_key: Add cache_key functionality to ActiveRecord scopes - https://github.com/yonahforst/scope_cache_key - https://github.com/yonahforst/scope_cache_key