Hello! Welcome back.
In our last lesson, we mastered transaction.atomic, ensuring that multi-step database operations are an "all-or-nothing" affair. We saw that this prevents data inconsistency if our application crashes mid-operation. However, we also briefly touched on a limitation: atomicity alone doesn't solve problems that arise when multiple transactions run at the same time.
This lesson dives straight into that challenge. We'll explore a critical concurrency problem known as a race condition and compare the two fundamental strategies used to solve it: pessimistic and optimistic locking. Understanding this is vital for building robust applications that can handle simultaneous user requests without corrupting data—a core responsibility of a backend engineer.
1. The Problem: Race Conditions
A race condition occurs when the behavior of a system depends on the unpredictable sequence of concurrent operations. In the context of a database, this usually happens when two or more transactions read the same piece of data, perform some logic based on it, and then try to write an updated value back. The last one to write "wins," potentially overwriting the work of the other and leading to an incorrect state.
This is often called a read-modify-write problem. Let's see a classic example.
Database Locks Explained | With Real-World E-Commerce Example
This video from the Redhwan Nacef channel provides an excellent, easy-to-follow illustration of a race condition using an e-commerce stock management scenario. Pay close attention to how two users are able to purchase the last item in stock.
Watch from the beginning to 03:04. The video clearly lays out the sequence of events: both users' requests read the stock count, both verify that stock is available, and then both proceed to decrement the stock, leading to an oversell.
As the video demonstrated, the sequence of events that leads to the error is:
- Request A (Alice): Reads product stock. Stock is 1.
- Request B (Bob): Reads product stock. Stock is 1.
- Request A (Alice): Checks
stock (1) >= quantity (1). The check passes. - Request B (Bob): Checks
stock (1) >= quantity (1). The check also passes. - Request A (Alice): Creates the order and updates stock to 0. Commits the transaction.
- Request B (Bob): Creates another order and updates stock to 0. Commits the transaction.
The final stock count is 0, which seems correct, but two orders were processed when only one item was available. The database is now in an inconsistent state relative to the real-world inventory. transaction.atomic on its own doesn't prevent this because each transaction, when viewed in isolation, is perfectly valid. The problem is their interleaving.
Implementing Concurrent Control with ORM
For a concise definition of a race condition and other key terms, please review the introductory sections of this article from Leapcell.
Read the sections 'Introduction' and 'Core Concepts of Concurrent Control'. This will solidify your understanding of terms like 'Race Condition' and introduce the two locking strategies we are about to discuss.
2. Concurrency Control Strategies: Locking
The solution to race conditions is to introduce locking, which prevents conflicting operations from happening at the same time. This forces a degree of serialization on access to shared resources.
However, locking introduces a fundamental trade-off:
- Data Integrity: More aggressive locking ensures data is never corrupted.
- Performance & Concurrency: Aggressive locking can slow down your application, as processes have to wait for locks to be released.
There are two primary philosophies for balancing this trade-off: pessimistic and optimistic locking.
3. Pessimistic Locking: Assume Conflicts are Likely
The pessimistic strategy is named because it assumes the worst: that a conflict is probable. Therefore, to be safe, it locks the data resource before performing any modifications.
- Principle: When a transaction needs to modify a row (or rows), it first acquires an exclusive lock on it. No other transaction can modify (or sometimes even read) that row until the first transaction is committed or rolled back, releasing the lock. If another transaction tries to access the locked row, the database forces it to wait.
- Mechanism: This is typically implemented at the database level using a
SELECT ... FOR UPDATESQL statement. When you query for the data you intend to modify, you tell the database to lock those specific rows for you. - Analogy: You go to a library to edit a rare manuscript. You ask the librarian to lock the room you're in, preventing anyone else from entering and trying to edit it at the same time. Others have to wait outside until you're done.
4. Optimistic Locking: Assume Conflicts are Rare
The optimistic strategy, as the name implies, assumes the best: that conflicts are infrequent. It allows operations to proceed without acquiring locks upfront, maximizing concurrency.
- Principle: Instead of locking, the system checks for conflicts only at the very end, right before committing the changes. It allows a transaction to read a row and perform modifications in memory. When it's time to save, it checks if the row has been changed by another transaction since it was first read.
- Mechanism: This is usually implemented in the application layer using a version number or a timestamp column in the database table.
- When you read a row, you also get its current
version(e.g.,5). - When you're ready to write your changes, you issue an
UPDATEstatement that includes a crucial condition:... WHERE id = ? AND version = 5. You also increment the version number in theSETclause:SET ..., version = 6. - If the update affects one row, you know it succeeded because the version number was still
5. - If the update affects zero rows, it means another transaction already updated the row and incremented its version to
6. Your update failed. The application must then handle this "collision" by rolling back and usually retrying the entire operation.
- When you read a row, you also get its current
- Analogy: You and a colleague are both given a copy of a document to edit. You both make your changes independently. When you go to save your version, the system checks if the original has been modified by your colleague. If it has, it alerts you to a conflict that you now have to resolve (e.g., by re-doing your changes on the new version).
5. Comparing the Strategies
The choice between pessimistic and optimistic locking depends entirely on the nature of your application and the specific data you are protecting.
Database Locks Explained | With Real-World E-Commerce Example
Let's return to the Redhwan Nacef video. The next segment provides a clear, concise comparison of the two locking strategies.
Watch from 03:04 to 04:47. This part effectively summarizes the core assumptions and trade-offs of both approaches.
Here is a summary table comparing the two approaches:
| Feature | Pessimistic Locking | Optimistic Locking |
|---|---|---|
| Core Assumption | Conflicts are frequent and likely. | Conflicts are infrequent and rare. |
| Mechanism | Database-level locks (SELECT FOR UPDATE). |
Application-level version checking (version column). |
| Concurrency | Lower. Transactions must wait for locks to be released. | Higher. No blocking; transactions don't wait for each other. |
| Application Logic | Simpler. The database handles the waiting. | More complex. The application must handle conflict detection and retries. |
| Performance | Good if contention is high (avoids wasted work). | Good if contention is low (no locking overhead). |
| Typical Use Cases | Financial transactions, inventory, booking systems. | Wikis, CMS, user profiles, configuration settings. |
For a more detailed breakdown with code previews, the Leapcell article is an excellent resource.
Implementing Concurrent Control with ORM
Now, let's read the sections of the Leapcell article that dive into each strategy. As you read, notice how the Django ORM provides methods to implement these database-level concepts.
Read the sections 'Pessimistic Locking with ORMs (SELECT FOR UPDATE)', 'Optimistic Locking with ORMs (Versioning)', and the 'Conclusion'. Focus on the principles and application scenarios for each. Don't worry about the implementation details of the code just yet; we will cover that in future lessons. The goal here is to solidify your understanding of the 'what' and 'why' for each strategy.
Test your understanding!
For each of the following scenarios, would you choose Pessimistic or Optimistic locking? Explain your reasoning.
- A seat reservation system for a popular airline. Multiple agents and web users are simultaneously trying to book a limited number of seats on the same flight.
- A user settings page. Users can update their profile information, like their bio, profile picture URL, or contact details. It's very unlikely that the same user will be editing their profile from two different browser tabs at the exact same moment.
- A banking application processing a money transfer. A transaction must deduct money from a source account and add it to a destination account. The source account balance must not go below zero.
Show answer
-
Pessimistic Locking. This is a classic high-contention scenario. Conflicts are not only likely, but expected. The cost of a conflict (selling the same seat twice) is very high. It's better to make users wait a few moments for a lock to be released than to have to resolve a double-booking. Pessimistic locking ensures that once an agent starts the booking process for a seat, that seat is locked and cannot be sold by anyone else.
-
Optimistic Locking. This is a low-contention scenario. Conflicts are extremely rare. Using pessimistic locking would add unnecessary overhead and complexity. Optimistic locking provides high performance, and in the rare event of a conflict (e.g., the user does have two tabs open), the application can simply inform the user that their changes couldn't be saved and ask them to reload and try again.
-
Pessimistic Locking. This is another high-contention, high-integrity scenario. Like the inventory problem, this is a read-modify-write operation on the account balance. To prevent an account from being overdrawn by two simultaneous withdrawal attempts, you must lock the account row when you first read its balance to perform the check. This guarantees that no other transaction can alter the balance until your transfer is complete.
Conclusion
You've now learned about one of the most common and tricky problems in concurrent systems—the race condition—and the two primary philosophies for solving it. This theoretical foundation is essential for writing code that is not just correct in a single-user context, but robust and reliable in a real-world, multi-user environment.
Key Takeaways:
- A race condition occurs when the result of an operation depends on the unpredictable timing of concurrent events, often leading to data inconsistency in a read-modify-write cycle.
- Pessimistic locking assumes conflicts are likely, locks data upfront using
SELECT FOR UPDATE, and forces other transactions to wait. It prioritizes data integrity over concurrency. - Optimistic locking assumes conflicts are rare, avoids initial locks, and instead checks for conflicts before committing using a
versioncolumn. It prioritizes concurrency and requires the application to handle failed attempts. - The choice between them is a critical design decision based on the likelihood of conflict (contention) and the cost of data inconsistency for a given resource.
Preview of the Next Lesson:
We've covered the theory. Now, it's time to put it into practice. In our next lesson, we will implement pessimistic locking in Django. You will learn how to use the select_for_update() QuerySet method to acquire database locks within a transaction, effectively preventing the exact stock inventory race condition we examined today.