DEV Community

Cover image for Database concurrency as simple as possible
Fabio Hiroki
Fabio Hiroki

Posted on • Updated on

Database concurrency as simple as possible

In this article I'll show basic concepts for handling concurrent requests by building a banking web application. When coding there are some traps we need to pay attention specially because it's not a scenario easy to test.

Bank account application scope

atm losing money

Our example application will contain bank accounts and will enable money transfer from one account to another. It has been built using PHP, Symfony and Doctrine ORM, but you don't need to be familiar with these technologies, only with Postgres database.

Account entity

The bank account will store the name of the owner and the final amount.

CREATE TABLE "public"."bank_account" (
    "id" int4 NOT NULL,
    "name" varchar(255) NOT NULL,
    "amount" int4 NOT NULL,
    PRIMARY KEY ("id")
);
Enter fullscreen mode Exit fullscreen mode

Money transfer API

The endpoint for transferring money between two accounts will receive 3 variables by query parameters:

  • from: source account id
  • to: destination account id
  • amount: amount to be transferred

Dus to transfer 100 amount from account 1 to 2, we can use the request:

http://localhost:8000/move?from=1&to=2&amount=100
Enter fullscreen mode Exit fullscreen mode

Account repository

To support the endpoint above we will use the following repository:

class BankAccountRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, BankAccount::class);
    }

    public function transferAmount($from, $to, $amount): void
    {
        // Fetches both account entities to be update
        $fromAccount = $this->find($from);
        $toAccount = $this->find($to);

        // Updates the amount on each of them
        $fromAccount->setAmount($fromAccount->getAmount() - $amount);
        $toAccount->setAmount($toAccount->getAmount() + $amount);

        // Persist both entities
        $this->getEntityManager()->persist($fromAccount);
        $this->getEntityManager()->persist($toAccount);
        $this->getEntityManager()->flush();
    }
}
Enter fullscreen mode Exit fullscreen mode

On SQL it does the following (edited the Doctrine generated SQL for readability):

SELECT * FROM bank_account WHERE id = 1; # source
SELECT * FROM bank_account WHERE id = 2; # destination
START TRANSACTION;
UPDATE bank_account SET amount = ? WHERE id = 1; # source
UPDATE bank_account SET amount = ? WHERE id = 2; # destination
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Account controller

On controller side we then just need to parse the query parameters and call the repository accordingly.

class BankAccountController extends AbstractController
{
    #[Route('/move', name: 'bank_account')]
    public function transfer(Request $request, BankAccountRepository $repository): Response
    {
        $from = $request->query->get('from');
        $to = $request->query->get('to');
        $amount = $request->query->get('amount');

        $repository->transferAmount($from, $to, $amount);

        return new Response(sprintf('from %s to %s amount %s', $from, $to, $amount));
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's test!

Now, we create some test accounts in the database:

INSERT INTO "public"."bank_account" ("id", "name", "amount") VALUES
(1, 'Alice', 1000),
(2, 'Bob', 0);
Enter fullscreen mode Exit fullscreen mode

And transfer 100 from Alice to Bob:

curl http://localhost:8000/move?from=1&to=2&amount=100
Enter fullscreen mode Exit fullscreen mode

Checking the database, everything is fine:

| id | name  | amount |
|----|-------|--------|
|  1 | Alice |    900 |
|  2 | Bob   |    100 |
Enter fullscreen mode Exit fullscreen mode

Easy, right? We could stress out this implementation and write unit tests, integration tests and everything will be working correctly.

What's wrong, then?

Someone asking what is the problem

To identify the problem let's use the Apache HTTP server benchmarking tool (ab) to perform several requests to your application.

The first test will have the following scenario:

  • Alice starts with 1000 amount
  • Bob starts with 0 amount
  • Alice makes 10 transfers of 100 to Bob, one request per time
  • Final result expected:
    • Alice: 0
    • Bob: 1000

We can use the following command, where parameters n is the total number of requests and c is the number of concurrent requests:

ab -n 10 -c 1 'http://localhost:8000/move?from=1&to=2&amount=100'
Enter fullscreen mode Exit fullscreen mode

You will have to trust me now, but I can assure you that after running the command above, Alice has 0 and Bob has 1000.

The second scenario will be similar but we will do 10 concurrent requests:

  • Alice starts with 1000 amount
  • Bob starts with 0 amount
  • Alice makes 10 simultaneous transfers of 100 to Bob
  • Final result expected:
    • Alice: 0
    • Bob: 1000

The parameter c is changed to 10:

ab -n 10 -c 10 'http://localhost:8000/move?from=1&to=2&amount=100'
Enter fullscreen mode Exit fullscreen mode

The not so good result:

| id | name  | amount |
|----|-------|--------|
|  1 | Alice |    300 |
|  2 | Bob   |    700 |
Enter fullscreen mode Exit fullscreen mode

the results are not good

But why? Basically there are processes updating the amount while there others that read and kept the old amount in memory. Let's imagine two concurrent processes A and B updating only Alice's account:

1 - Process A reads 1000 on Alice's account
2 - Process B reads 1000 on Alice's account
3 - Process A writes 900 on Alice's account
4 - Process B writes 900 on Alice's account (it should've been 800, shame on you!)

What's the fix then?

Someone trying to discover a bugfix

There's more than one solution, but I'll show you the one using Pessimistic Locking for read and writes. That means the database will only allow one read or write per resource, that in this case is the account entity.

On Doctrine we can achieve this using the following code:

public function transferAmountConcurrently($from, $to, $amount): void
{
    $this->getEntityManager()->beginTransaction();
    $fromAccount = $this->find($from, LockMode::PESSIMISTIC_WRITE);
    $toAccount = $this->find($to, LockMode::PESSIMISTIC_WRITE);

    $fromAccount->setAmount($fromAccount->getAmount() - $amount);
    $toAccount->setAmount($toAccount->getAmount() + $amount);

    $this->getEntityManager()->persist($fromAccount);
    $this->getEntityManager()->persist($toAccount);
    $this->getEntityManager()->flush();
    $this->getEntityManager()->commit();
}
Enter fullscreen mode Exit fullscreen mode

We have now to explicitly begin a transaction before acquiring a lock, which makes sense because Doctrine can't know when the transaction should've started.

Finally, on SQL:

START TRANSACTION;
SELECT * FROM bank_account WHERE id = 1 FOR UPDATE; # source
SELECT * FROM bank_account WHERE id = 2 FOR UPDATE; # destination
UPDATE bank_account SET amount = ? WHERE id = 1; # source
UPDATE bank_account SET amount = ? WHERE id = 2; # destination
COMMIT;
Enter fullscreen mode Exit fullscreen mode

For testing, I'll create a new endpoint on previously created BankAccountController:

#[Route('/move-concurrently', name: 'bank_account_concurrent')]
public function transferConcurrently(Request $request, BankAccountRepository $repository): Response
{
    $from = $request->query->get('from');
    $to = $request->query->get('to');
    $amount = $request->query->get('amount');

    $repository->transferAmountConcurrently($from, $to, $amount);

    return new Response(sprintf('from %s to %s amount %s', $from, $to, $amount));
}
Enter fullscreen mode Exit fullscreen mode

And now we can test using the Apache benchmarking tool:

ab -n 10 -c 10 'http://localhost:8000/move-concurrently?from=1&to=2&amount=100'
Enter fullscreen mode Exit fullscreen mode

Trust me, it's working now: Alice has 0 and Bob has 1000.

someone celebrating because it's working

The end

By using the lock strategy, we guarantee the process that acquired lock is reading the most updated value and then updating consistent data based on the last read. Final code is on Github.

Thank you for reading and I hope you enjoyed!

Top comments (0)