I'm working on a project similar to ecommerce in which I have models that represent a product with a certain amount in the storage, and users can buy some quantity of the product as long as it doesn't exceed the stored amount. I want to avoid a race condition taking place when the server receives multiple requests to buy the same product.
class Product(models.Model):
amount_in_storage = models.PositiveIntegerField() # for design reasons, this amount is unchangeable, it must be only evaluated once during initialization like constants in C++
@property
def amount_available_for_purchase(self):
return self.amount_in_storage - Purchase.objects.filter(product=self.id).aggregate(models.Sum('amount'))["sum__amount"]
class Purchase(models.Model):
product = models.ForeignKey(Product, ...)
amount = models.PositiveIntegerField()
payment_method = ...
Let's assume that this is the block of code that's responsible for creating a purchase.
@atomic_transaction
def func(product_id, amount_to_purchase):
product = Product.objects.get(...)
if product.amount_available_for_purchase > amount_to_purchase:
# do payment related stuff
Purchase.objects.create(product=product, amount=amount_to_purchase)
# do more stuff
I would like to restrict concurrent access to this block of code, Ideally I would like to obtain a read
lock access at the if
condition, so that if multiple threads try to see if the amount available is greater than the amount to purchase, one of the threads will have to wait until the transaction is done, and then the read request will be evaluated, so I thought leveraging Django's select_for_update
and a version
field like so:
class Product(models.Model):
amount_in_storage = models.PositiveIntegerField()
version = models.PositiveIntegerField() # we use this field just to write to it, no reading will take place
@property
def amount_available_for_purchase(self):
return self.amount_in_storage - Purchase.objects.filter(product=self.id).aggregate(models.Sum('amount'))["sum__amount"]
And I use this field to obtain a lock like so:
@atomic_transaction
def func(product_id, amount_to_purchase):
product = Product.objects.select_for_update.get(...)
# acquiring a lock
product.version += 1
product.save()
if product.amount_available_for_purchase > amount_to_purchase:
# do payment related stuff
Purchase.objects.create(product=product, amount=amount_to_purchase)
# do more stuff
Using select_for_update
if multiple threads reach the version modification line, only the first will evaluate, and the rest will have to wait until the whole transaction is completed, hence acquiring a read lock for the line at the if condition. In other words, only 1 thread at a time will have access to this block of code.
My questions are:
Yes, your approach is pretty much right and it will acquire the lock for the first query and the rest will have to wait until the transaction is complete.
If you are not using the version field for anything other than acquiring the lock, you can forcefully acquire a lock by forcefully evaluating the QuerySet using a list()
. See the code below.
@atomic_transaction
def func(product_id, amount_to_purchase):
# Forcefully acquiring a lock
product = list(Product.objects.select_for_update.filter(...))[0]
if product.amount_available_for_purchase > amount_to_purchase:
# do payment related stuff
Purchase.objects.create(product=product, amount=amount_to_purchase)
# do more stuff
Here you are acquiring the lock forcefully by evaluating the QuerySet like mentioned here in the docs.