Categories
Python

Ways to have an atomic counter in Django

This week, I’m back at my tremendously irregular Django tips series, where I share small pieces of code and approaches to common themes that developers face when working on their web applications.

The topic of today’s post is how to implement a counter that isn’t vulnerable to race conditions. Counting is everywhere, when handling money, when controlling access limits, when scoring, etc.

One common rookie mistake is to do it like this:

model = MyModel.objects.get(id=id)
model.count += 1
model.save(update_fields=["count"])

An approach that is subject to race conditions, as described below:

  • Process 1 gets count value (let’s say it is currently 5)
  • Process 2 gets counts value
  • Process 1 increments and saves
  • Process 2 increments and saves

Instead of 7, you end up with 6 in your records.

On a low stakes project or in a situation where precision is not that important, this might do the trick and not become a problem. However, if you need accuracy, you will need to do it differently.

Approach 1

with transaction.atomic():
    model = (
        MyModel.objects.select_for_update()
        .get(id=id)
    )
    model += 1
    model.save(update_fields=["count"])

In this approach, when you first fetch the record, you ask for the database to lock it. While you are handling it, no one else can access it.

Since it locks the records, it can create a bottleneck. You will have to evaluate if fits your application’s access patterns. As a rule of thumb, it should be used when you require access to the final value.

Approach 2

from django.db.models import F

model = MyModel.objects.filter(id=id).update(
    count=F("count") + 1
)

In this approach, you don’t lock any values or need to explicitly work inside a transaction. Here, you just tell the database, that it should add 1 to the value that is currently there. The database will take care of atomically incrementing the value.

It should be faster, since multiple processes can access and modify the record “at the same time”. Ideally, you would use it when you don’t need to access the final value.

Approach 3

from django.core.cache import cache

cache.incr(f"mymodel_{id}_count", 1)

If your counter has a limited life-time, and you would rather not pay the cost of a database insertion, using your cache backend could provide you with an even faster method.

The downside, is the level of persistence and the fact that your cache backend needs to support atomic increments. As far as I can tell, you are well served with Redis and Memcached.

For today, this is it. Please let me know if I forgot anything.

By Gonçalo Valério

Software developer and owner of this blog. More in the "about" page.

Leave a Reply

Your email address will not be published. Required fields are marked *