Rails - Caching with the database
There’s quite a few different ways to cache with Rails, and a lot of information about it in the guides.
Ideally when you cache it’s shared across multiple instances and across many threads. That’s where Redis or Cassandra comes in, but what happens when the data you want to place in the cache store is too large?
The problem#
On Creators we have 400,000 users who have associated balances for their account. User’s balances update regularly, and recently we’ve run into some hiccups with retrieving them. While optimization should happen on the Balance Server, we can add some improvements on our side to help speed things up.
One easy performance gain is to reduce the number of calls we make to the server from one every page load to once every thirty minutes. A simple way to do that is to have some caching code like this
def balance
Rails.cache.fetch("#{user.id}/balance", expires_in: 30.minutes) do
UserBalanceGetter.new(user: user).perform
end
end
However once we did the math we found that this was going to cause our cache store to overflow and run out of memory. If we have 100,000 users logged in we store a list of transactions and balances for each user at 5kb each that’s 500 MB needed in our cache store.
If we run our monthly threaded batch processing on all of our users we’re looking at upwards of 2 GB of usage. Not very scalable for an application which is growing by 1000-2000 users a day.
Enter the database#
So our solution was to introduce a simple database backed cache system.
First we created our migration for the properties.
class AddCachingToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :balance_cache, :jsonb
add_column :users, :balance_cache_updated_at, :datetime
end
end
Then we added the following properties to our models.
class User < ApplicationRecord
def balance_cache
update(balance_cache: BalanceGetter.new(user: user)) if balance_cache_expired?
read_attribute(:balance_cache)
end
def balance_cache=(value)
super(value)
write_attribute(:balance_cache_updated_at, current_time_from_proper_timezone)
end
private
def balance_cache_expired?
balance_cache_updated_at.blank? || 30.minutes.ago > balance_cache_updated_at
end
end
If the cache hasn’t expired we read the attribute from the model with the read_attribute
Rails method.
If the cache has expired, or the updated time is blank we update it. This is where the setter comes in, when we set the balance_cache_updated_at
time to be the current_time_from_property_timezone
. This is implementend in Rails internal ActiveRecord::Timestamp
module.
Finally here’s the pull request where we added it to our application.