Contact Us

Don't Reinvent the Wheel: Caching Secrets for Faster Data Access

One thing I can say for sure: crutches and reinvented wheels are not the best solution, especially when we're talking about caching — or more precisely, when we need to optimize a data access method so that it performs better than the source. I'll prove it with a few examples in this article, in just five minutes.

Caching in theory and practice

Before anything else: caching is a way to reduce load on the database and, as a result, improve application performance. The core idea of caching is simple — don't send every query to the database. How do we implement that? Let's start with definitions and classification.

Caching is an approach that, when used correctly (!), speeds things up considerably and lowers the load on compute resources. Put even more simply: caching is a method of optimizing data storage and/or access where operations on that data run more efficiently than at the source.

As for classification — this article takes a closer look at two approaches: LRU caching and caching in Redis.

Least Recently Used (evicting items unused the longest)

LRU is an algorithm that evicts the item that has gone unused for the longest time first.

A cache built on the LRU strategy orders elements by their usage chronology. Every time we touch a record, the LRU algorithm moves it to the top of the cache. That way, the algorithm can quickly find the record that hasn't been used for the longest time by checking the end of the list.

The Python standard library module functools provides the @lru_cache decorator, which lets you cache function results using the LRU strategy.

Under the hood, the @lru_cache decorator uses a dictionary. A function's return value is cached under a key that corresponds to the function call and its arguments. What does that tell us? The sharpest readers have already figured it out: for the decorator to work, the arguments must be hashable.

Here's an example:

We need to walk through an event log (audit_log), where each entry has a user_id attribute — a unique user identifier that confirms a specific action performed by the user in the information system. The same user usually performs multiple actions in the system, so there will be more than one event with the same used_id. But a user identifier tells us nothing — it's just a UUID. And unless you're a prodigy who can recite 100 digits of π, it's easier to work with a full name (last name, first name, patronymic). And where does the full name live? Right — in the database, in the users table. So should we query the database for the same user_id every time just to get the full name? Of course not!

Let's apply the LRU decorator to a concrete example:

from functools import lru_cache
from pymongo import MongoClient

# open a connection to MongoDB
# get access to our users collection
client = MongoClient("localhost:27017")
collection = client.users_info

# function that fetches the full name by id
@lru_cache
def get_fio_by_id(id)
    doc = collection.fing_one({"_id": id})
    if not doc:
        return None

    return doc["fio"]

# iterate over the event log
for event in audit_log:
    # get the user_id for this event
    user_id = event.get("user_id")
    if user_id:
        # resolve the id into a full name
        print(get_fio_by_id(user_id))

A single line of code decorates the get_fio_by_id() function — and we've already attached an LRU cache and substantially boosted the application's performance!

Do we really not need to reinvent the wheel?

So what if we go even further? Say we have a large distributed application, and many microservices query the database for the same reference data. Sure, we could bolt LRU caches everywhere, but each service would still have to hit the database for the same data. To avoid that, let's use a centralized fault-tolerant cache built on Redis!

Can we get by with a single line of code, as with LRU? Yes! The most attentive and experienced have probably already guessed it — we need a decorator. Easy, here we go:

import json
from functools import wraps
from redis import StrictRedis

redis = StrictRedis()

def redis_cache(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # build a key from the function's arguments.
        key_parts = [func.__name__] + list(args)
        key = '-'.join(key_parts)
        result = redis.get(key)

        if result is None:
            # nothing in the cache – call the function and store the result.
            value = func(*args, **kwargs)
            value_json = json.dumps(value)
            redis.set(key, value_json)
        else:
            # Hooray, the data is in the cache – use it.
            value_json = result.decode('utf-8')
            value = json.loads(value_json)

        return value
    return wrapper

If none of this makes sense, it's time to brush up on how Python decorators work.

One last step — the cherry on top of our decorator. To use this ingenious creation in the application, just swap the @lru_cache decorator for @redis_cache.

UPDATE: To be fair, there are many ready-to-use libraries for various caching methods and strategies: aiocache, cacheout, python-cache, cachetools. The list goes on...

That's it!