How to Prevent Race Conditions in Laravel with Atomic Locks
Time to read:
How to Prevent Race Conditions in Laravel with Atomic Locks
Consider an e-commerce business in which two consumers attempt to purchase the final available unit of a product simultaneously. During processing, the system may enable both customers to buy the item, resulting in an oversell. This might result in unfulfilled orders, inaccurate financial transactions, and frustrated customers.
Otherwise known as race conditions, this is when two or more processes attempt to access and modify shared data. They are particularly problematic in web applications. This is because most web requests are stateless and handled independently, meaning multiple instances of the same operation can run concurrently without awareness of one another.
To prevent such problems, Laravel provides atomic locks through its cache system. These locks ensure that critical code sections execute in a controlled manner, preventing simultaneous access by multiple processes.
In this article, we will examine how to prevent race conditions using Laravel’s atomic locking mechanisms.
Prerequisites
- Prior knowledge of Laravel and PHP
- Familiarity with Docker
- PHP 8.4
- Composer, Docker Compose, and curl installed globally
- Your preferred text editor or IDE
How atomic locks work
Atomic locks ensure that only one process can execute a specific code block at a time. Laravel's cache system provides an easy-to-use locking mechanism that prevents the simultaneous execution of critical operations.
In simple terms, atomic locks in Laravel create a unique key in the cache that serves as a lock. If a process acquires the lock, other processes attempting to access the same resource must wait until the lock is released or expires.
Here's how the process works:
- A process attempts to acquire a lock before executing a critical operation
- If the lock is available, the process obtains it and runs the task
- If another process tries to acquire the same lock, it will either wait or fail, depending on the implementation
- Once the operation completes, the system releases the lock, allowing other processes to proceed
- If a process crashes before releasing the lock, Laravel ensures it expires after a set duration
Remember the e-commerce shop scenario in the introduction, in which two people attempt to purchase the last remaining product simultaneously? Let's simulate it.
Set up the project
To begin, create a new Laravel project using Composer. First, install the Laravel installer via Composer:
After installing the Laravel installer, you can create a new Laravel application. The Laravel installer will prompt you to pick your preferred starter kit, choose "No starter kit". You will also be prompted to choose a database the application will use. When so, choose "MySQL".
Once the application has been created, navigate to the project working directory.
The next step is to create the necessary files to handle the app's business logic; a model, migration, and controller file. To do this, run the following command.
In your IDE, open the newly created migration file in the database/migrations directory, which ends in _create_products_table.php, and replace the existing code with the following:
Next, in the app/Models/Product.php file, add the class property below:
Finally, in the controller file app/Http/Controllers/ProductController.php, replace its contents with the following:
Here, we see a perfect example of a race condition. Why is this problematic? If multiple users execute this function simultaneously, they may both read the same stock value (for example, "stock = 1") before either one updates it. Both processes will decrease the stock
object, leading to an incorrect stock value, resulting in overselling.
Before testing out the code, the database needs to be seeded and a route needs to be set up. Add the following routes to routes/web.php, along with the use
statement.
Then, update database/seeders/DatabaseSeeder.php to match the code below, which will seed a test product with limited stock:
Now, create a bash script called test.sh in the project's top-level directory to handle the test, then paste the following into it.
Furthermore, add the execute permission to the script:
Setup a multi-threaded test server
To make this work, a multi-threaded server, such as Nginx or Apache is needed. PHP's built-in server is single-threaded, meaning it cannot handle true concurrency (processing multiple requests at once). So, we'll build an environment with Docker Compose that provides Apache 2.
Start by creating a new docker/webserver directory and in that directory, a new file named Dockerfile. Then paste the code below into the file.
Next, in your project's top-level directory, create a file named compose.yml. In the new file, paste the configuration below.
Also, you will need to configure the host to receive connections from the "mysql" container. To do that, in the .env file update the DB_HOST variable:
Now that all is set, it's time to start all containers in detached mode:
With the container running, all that remains is to migrate and seed the database and test the application in the container terminal. Run the following commands in sequence to do so:
And finally:
You should see the following output written to the terminal.
Implement a locking mechanism
The message displayed in the terminal shows that, although the product was out of stock, the order was processed, which should not have happened.
To resolve this issue, we need to implement a locking mechanism to ensure that only one process can modify the stock count at a time. To create this mechanism, we use the Cache::lock()
method, which accepts two required arguments:
- name: This is the lock's name. It's crucial to use a unique name for each lock to prevent collisions and ensure their intended purpose.
- seconds: This argument specifies the duration the lock should remain valid. Locks should have an expiration time to prevent deadlocks, ideally 10 seconds
Update the process()
method in the controller app/Http/Controllers/ProductController.php file to match the code below.
Then, add the use statement below to the top of the file.
The code above creates a lock named purchase-lock-{$pId}
that lasts for 10 seconds. The request will be processed if the lock is obtained, and the rest of the operation will be attended to. If the lock is not obtained, an error message will be returned.
Test that the locking mechanism works
To test the new modifications, re-seed the database by running the command below in the docker container.
Then run the bash script outside the docker container:
You should see a message showing that just one order was aborted, while the second request was processed, which you can see below.
You might be wondering why the aborted message appeared first. This is because while the first order was being processed, the cache lock wasn't released yet, causing the second order to fail nearly immediately.
That's how to prevent race conditions in Laravel with atomic locks
Race conditions can create serious problems in Laravel applications, resulting in duplicate transactions, data inconsistencies, and other errors. Laravel’s atomic locks easily prevent these problems by ensuring only one process can execute a given task at a time.
Implementing atomic locks can protect your data and improve the reliability of your Laravel applications. Happy building!
Prosper is a freelance Laravel web developer and technical writer who enjoys working on innovative projects that use open-source software. When he's not coding, he searches for the ideal startup opportunities to pursue. You can find him on Twitter and LinkedIn.
The atom icon in the post's social image was created by Freepik on Flaticon.
Related Posts
Related Resources
Twilio Docs
From APIs to SDKs to sample apps
API reference documentation, SDKs, helper libraries, quickstarts, and tutorials for your language and platform.
Resource Center
The latest ebooks, industry reports, and webinars
Learn from customer engagement experts to improve your own communication.
Ahoy
Twilio's developer community hub
Best practices, code samples, and inspiration to build communications and digital engagement experiences.