concurrency problems.

In this article I’m going to present two approaches for managing concurrency in Django models.

 
Photo by Denys Nevozhai

The Problem

To demonstrate common concurrency issues we are going to work on a bank account model:

class Account(models.Model):
    id = models.AutoField(
primary_key=True,
)
user = models.ForeignKey(
User,
)
balance = models.IntegerField(
default=0,
)

withdrawmethods for an account instance:

def deposit(self, amount):
self.balance += amount
self.save()
def withdraw(self, amount):
if amount > self.balance:
raise errors.InsufficientFunds()
    self.balance -= amount
self.save()

what happens when two users perform actions on the same account at the same time?

  1. User A fetches the account — balance is 100$.
  2. User B fetches the account — balance is 100$.
  3. User B withdraws 30$ — balance is updated to 100$ — 30$ = 70$.
  4. User A deposits 50$ — balance is updated to 100$ + 50$ = 150$.

What happened here?

User B asked to withdraw 30$ and user A deposited 50$ — we expect the balance to be 120$, but we ended up with 150$.

Why did it happen?

At step 4, when user A updated the balance, the amount he had stored in memory was stale (user B had already withdrawn 30$).

make sure the resource we are working on is not altered while we are working on it.


Pessimistic approach

lock the resource exclusively until you are finished with it. If nobody else can acquire a lock on the object while you are working on it, you can be sure the object was not changed.

database lock for several reasons:

  1. databases are very good at managing locks and maintaining consistency.
  2. protect the data from other processesmodifying the data as well. For example, direct updates in the DB, cron jobs, cleanup tasks, etc.
  3.  can run on multiple processes (e.g workers). Maintaining locks at the app level will require a lot of (unnecessary) work.

select_for_update.

Let’s use the pessimistic approach to implement a safe deposit and withdraw:

@classmethod
def deposit(cls, id, amount):
with transaction.atomic():
account = (
cls.objects
.select_for_update()
.get(id=id)
)

account.balance += amount
account.save()
    return account
@classmethod
def withdraw(cls, id, amount):
with transaction.atomic():
account = (
cls.objects
.select_for_update()
.get(id=id)
)

if account.balance < amount:
raise errors.InsufficentFunds()
       account.balance -= amount
account.save()

return account

What do we have here:

  1. lock the object until the transaction is done.
  2. transaction.atomic() to scope the transaction.
  3. self the object is already fetched and we don’t have any guaranty that it was locked.
  4. All the operations on the account are executed within the database transaction.

Let’s see how the scenario from earlier is prevented with our new implementation:

  1. User A acquires a lock on the account.
    - Balance is 100$.
  2. User B waits for the lock to release.
  3. Lock of user A on account is released.
  4. User B acquires a lock on the account.
    - Balance is 70$.
    - New balance is 70$ + 50$ = 120$.
  5. Lock of user B on account is released, balance is 120$.

Bug prevented!

select_for_update:

  • …select_for_update(nowait=True).
  • explicitly state which of the tables in the query to lock.

I used the bank account example in the past to demonstrate common patterns we use in Django models. You are welcome to follow up in this article:

We recently added a bank account like functionality into one of our products. During the development we encountered…medium.com

Optimistic Approach

collisions are not very common, and dictates that one should only make sure there were no changes made to the object at the time it is updated.

How can we implement such a thing with Django?

First, we add a column to keep track of changes made to the object:

version = models.IntegerField(
default=0,
)

Then, when we update an object we make sure the version did not change:

def deposit(self, id, amount):
updated = Account.objects.filter(
id=self.id,
version=self.version,
).update(
balance=balance + amount,
version=self.version + 1,
)
   return updated > 0
def withdraw(self, id, amount):       
if self.balance < amount:
raise errors.InsufficentFunds()

updated = Account.objects.filter(
id=self.id,
version=self.version,
).update(
balance=balance - amount,
version=self.version + 1,
)

return updated > 0

Let’s break it down:

  1. directly on the instance (no classmethod).
  2. version is incremented every time the object is updated.
  3. object will not be updated.
  4. Django returns the number of updated rows. If `updated` is zero it means someone else changed the object from the time we fetched it.

How is optimistic locking work in our scenario:

  1. User A fetch the account — balance is 100$, version is 0.
  2. User B fetch the account — balance is 100$, version is 0.
  3. Version is incremented to 1.
  4. nothing is updated.

What you need to know about the optimistic approach:

  • django-optimistic-lock seem to do the same. We haven’t used any of these packages but we’ve taken some inspiration from them.
  • wasteful.
  • does not protect from modifications made to the object outside the app. If you have other tasks that modify the data directly (e.g no through the model) you need to make sure they use the version as well.
  • function can fail and return false. In this case we will most likely want to retry the operation. Using the pessimistic approach with nowait=False the operation cannot fail — it will wait for the lock to release.

Which one should I use?

it depends”:

  • If your object has a lot of concurrent updates you are probably better off with the pessimistic approach.
  • If you have updates happening outside the ORM (for example, directly in the database) the pessimistic approach is safer.
  • If your method has side effects such as remote API calls or OS calls make sure they are safe. Some things to consider — can the remote call take a long time? Is the remote call idempotent (safe to retry)?
 
Like what you read? Give Haki Benita a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.

相关文章:

  • 2022-12-23
  • 2022-12-23
  • 2021-05-18
  • 2022-02-14
  • 2021-11-24
  • 2021-11-24
  • 2021-10-15
猜你喜欢
  • 2021-04-17
  • 2021-05-18
  • 2022-12-23
  • 2021-05-17
  • 2021-08-15
  • 2021-11-17
  • 2021-05-30
相关资源
相似解决方案