DEV Community

Alex Golubenko
Alex Golubenko

Posted on • Updated on

Rails Cache with Redis Hashes

Pretty interesting title of the article, isn’t it?🤔

My name is Alex and I’m Senior Software Engineer at Mrsool.co

Before start, for the last 6 years in Ruby / Ruby on Rails, all that I knew about caching in Rails was that it somehow uses Redis, that’s all.

But some time ago on our project, we faced the problem: #delete_matched, who work on high load projects know what it means…
Pain, pain for CPU and Redis Cluster(to be clear, for cluster it’s not… because in Redis Cluster it’s scan only one node /we have 3 for example/)
So, just to clarify what for we need #delete_matched: let’s imagine we have some SQL table banks and it has many branches and we need to show nearest branches of some bank by the current location of the user, but we have hundreds of thousands of users in many cities and countries, and we don’t want overload our DB each time for 2–3–4–100 users that locates in few meters from each other.

What we should do in this way? Right, caching it:

def nearest_brances(id, latitude, longitude)
  bank = ... # get bank by id

  Rails.cache.fetch("nearest_brances_#{id}_#{latitutde}_#{longitude}") do
    # some logic to get nearest open branches
  end
end

On the other hand, if any of this branch is, for example, decided to be closed for some time? In this case, we need to update the cache to avoid confusion of users that decided to visit the closed branch:

def clear_nearest_bank_branches(id)
  Rails.cache.delete_matched("nearest_brances_#{id}_")
end

Pros:

  1. It removes all the values for the given pattern

Cons:

  1. High CPU load for because it needs to scan all the keys from Redis.
  2. In case if you use Redis Cluster with a few nodes it will scan only for one of them.

So.. is it worth it? - Definitely NO.

But, how to solve it?

Answer: “Redis Hashes”, you can read more about it here: https://redis.io/topics/data-types.
It’s very similar to Ruby hashes:

{ key1 => { key2 => value } }

From this structure we can see the answer to our question of how to solve this problem:

key1 = "nearest_brances_#{id}"
key2 = "#{latitude}_#{longitude}"
key3 = "#{latitude2}_#{longitude2}"
value = some_list_of_branches
value2 = some_another_list_of_branches

So now we can code it in such a way:

redis = Rails.cache.redis
redis.hset(key1, key2, value)
redis.hset(key1, key3, value2)

After, when we will need to clear cache for some bank, all that we need to do is:

redis.del(key1)

But, it’s not that simple: How to store objects in the hash (by default it’s possible to store only strings)? Like ActiveRecord in our case.

I will try to show you.
First of all, we need some service(I decided to use lib instead but it’s up to you) to work with our new “cache”:

# lib/redis_hash_store.rb
module RedisHashStore
  extend self

  def write(key1, key2, value)
    redis.hset(key1, key2, value)
  end

  def read(key1, key2)
    redis.hget(key1, key2)
  end

  def delete(key1, key2)
   redis.hdel(key1, key2)
  end

  def delete_hash(key)
    redis.del(key)
  end

  private

  def redis
    Rails.cache.redis
  end
end

Now it’s a bit easier to work with hashes:

RedisHashStore.write(key1, key2, value)
RedisHashStore.read(key1, key2)
RedisHashStore.delete(key1, key2)
RedisHashStore.delete_hash(key1)

But we still can’t store objects… Let’s change it!
After some investigations of ActiveSupport source I decided to use some light version of their implementation:

class Entry
  attr_reader :value

  def initialize(value, expires_in:)
   @value = value
   @created_at = Time.now.to_f
   @expires_in = expires_in
  end

  def expired?
    @expires_in && @created_at + @expires_in <= Time.now.to_f
  end
end

You might ask: “What for we need expired? logic here?” — good question!
Let me try to explain: Unfortunately Redis doesn’t have expire for Redis Hashes…(There are a lot of discussions about it: https://github.com/redis/redis/issues/1042 )
That’s why we need it here.
Let’s move back to our RedisHashStore, now it has such look:

module RedisHashStore
  extend self

  class Entry
    attr_reader :value

    def initialize(value, expires_in:)
      @value = value
      @created_at = Time.now.to_f
      @expires_in = expires_in
    end

    def expired?
      @expires_in && @created_at + @expires_in <= Time.now.to_f
    end
  end

  def write(key1, key2, value)
    redis.hset(key1, key2, value)
  end

  def read(key1, key2)
    redis.hget(key1, key2)
  end

  def delete(key1, key2)
    redis.hdel(key1, key2)
  end

  def delete_hash(key)
    redis.del(key)
  end

  private

  def redis
    Rails.cache.redis
  end
end

But wait a minute.. something missed here? Right!

Now we need somehow to convert Entry to string and then safely move it back to the object. For this purpose we need Marshal. (You can read more about it here: https://ruby-doc.org/core-2.6.3/Marshal.html).
In our case we need 2 methods: #dump and #load, let’s add it to our RedisHashStore:

module RedisHashStore
  extend self

  class Entry
    attr_reader :value

    def initialize(value, expires_in:)
      @value = value
      @created_at = Time.now.to_f
      @expires_in = expires_in
    end

    def expired?
      @expires_in && @created_at + @expires_in <= Time.now.to_f
    end
  end

  def write(key1, key2, value, **options)
    entry = Entry.new(value, expires_in: options[:expires_in])
    redis.hset(key1, key2, serialize_value(entry))
    entry.value
  end

  def read(key1, key2)
    entry = deserialize_value(redis.hget(key1, key2))
    return if entry.blank?
    if entry.expired?
      delete(key1, key2)
      return nil
    end
    entry.value
  end

  def delete(key1, key2)
    redis.hdel(key1, key2)
  end

  def delete_hash(key)
    redis.del(key)
  end

  private

  def serialize_value(value)
    Marshal.dump(value)
  end

  def deserialize_value(value)
    return if value.nil?
    Marshal.load(value)
  end

  def redis
    Rails.cache.redis
  end
end

About benchmarks, some preparations:

indexes = 1..1_000_000
indexes.each do |index|
  Rails.cache.write("some_data_#{index}", index)
  RedisHashStore.write("some_data", index, index)
end

..and let's go!

Benchmark.bm do |x|
  x.report("delete_matched") { Rails.cache.delete_matched("some_data_*") }
  x.report("delete_hash") { RedisHashStore.delete_hash('some_data') }
end
user             system      total        real
delete_matched  0.571040   0.244962   0.816002 (3.791056)
delete_hash     0.000000   0.000225   0.000225 (0.677891)

Nise result, isn’t it? 😜
To avoid thoughts like: “But first one was running on Redis with 1_000_001 records and second with only 1!”
Oh, got it! Let’s try in a different order:

Benchmark.bm do |x|
  x.report("delete_hash") { RedisHashStore.delete_hash('some_data') }
  x.report("delete_matched") { Rails.cache.delete_matched("some_data_*") }
end
user             system      total        real
delete_hash     0.000478   0.000000   0.000478 (0.648363)
delete_matched  0.744732   0.187648   0.932380 (3.957141)

So as you can see, there are almost the same result here.
Enjoy 😜

That’s it 🤗 🎉
That’s my first article but I tried to describe it as much clear and understandable as it’s possible, thank you if you read it to the end 🙇

Top comments (0)