DEV Community

Cover image for Optimistic Concurrency Control: Alice and Bob Couldn't Sit Together 🙁
Franck Pachot for AWS Heroes

Posted on • Edited on

Optimistic Concurrency Control: Alice and Bob Couldn't Sit Together 🙁

Alice and Bob want to go to the cinema together and sit next to each other. However, each will book their seat. Will they be able to book two adjacent seats?

Let's run that in two Distributed SQL databases:

  • PostgreSQL-compatible behavior (YugabyteDB)
  • Optimistic Concurrency Control (Aurora DSQL)

I created the following table to book seats, identified by the row and seat number:

create table seats (
    seat_row char,    -- row of the seat in the theater
    seat_number int,  -- number of the seat in the row
    booked_by text,   -- null if seat is free, or the name if booked
    primary key (seat_row, seat_number),
    unique (booked_by)
);
Enter fullscreen mode Exit fullscreen mode

Carol, Carlos, Charlie, and Oscar are already booked, but seats A-2, A-4, and A-5 remain available:

insert into seats (seat_row, seat_number, booked_by) values
  ('A', 1, 'Carol'  ),
  ('A', 2,  null    ),
  ('A', 3, 'Oscar' ),
  ('A', 4,  null    ),
  ('A', 5,  null    ),
  ('A', 6, 'Carlos' ),
  ('A', 7, 'Charlie')
;
Enter fullscreen mode Exit fullscreen mode

Here are fully reproducible tests that I've run on YugabyteDB (open-source multi-cloud distributed SQL) and Aurora DSQL (AWS serverless distributed SQL in preview) on the same multi-region configuration.

PostgreSQL-compatible on YugabyteDB

Here is the initial state:

yugabyte=# select * from seats order by seat_row, seat_number
;
 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           1 | Carol
 A        |           2 |
 A        |           3 | Oscar
 A        |           4 |
 A        |           5 |
 A        |           6 | Carlos
 A        |           7 | Charlie
(7 rows)
Enter fullscreen mode Exit fullscreen mode

Bob starts a transaction:

yugabyte=# begin transaction;
BEGIN
Enter fullscreen mode Exit fullscreen mode

Bob checks for two adjacent seats that are free:

yugabyte=*# select seat_row, seat_number, next_seat_number
from (
 select seat_row, seat_number, booked_by
     , lead(seat_number) over row_seats as next_seat_number
     , lead(booked_by  ) over row_seats as next_booked_by
 from seats
 window row_seats as (partition by seat_row order by seat_number)
) seats_with_next
where booked_by is null and next_booked_by is null
;

 seat_row | seat_number | next_seat_number
----------+-------------+------------------
 A        |           4 |                5
(1 row)
Enter fullscreen mode Exit fullscreen mode

Bob is happy. He verifies that his seat is still free, intending to reserve it if Alice can book the other seat. A SELECT FOR UPDATE declares to the database the intent to update:

yugabyte=*# select * from seats
where (seat_row, seat_number)=('A', 4)
for update
;

 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           4 |
(1 row)
Enter fullscreen mode Exit fullscreen mode

Bob calls Alice and tells her to book seat A-5.

Alice starts a transaction:

yugabyte=# begin transaction;
BEGIN
Enter fullscreen mode Exit fullscreen mode

Alice verifies that the seat is still free, intending to reserve it:

yugabyte=*# select * from seats
where (seat_row, seat_number)=('A', 5)
for update
;

 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           5 |
(1 row)

Enter fullscreen mode Exit fullscreen mode

Great, the seat is still free. Alice confirms to Bob that she can reserve it. She declared the intent with a SELECT FOR UPDATE.

During this time, other people may try to book a seat. Mallory checks for available seats, and because the others have not committed yet, he sees three available seats:

yugabyte=# select * from seats where booked_by is null;
;

 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           2 |
 A        |           4 |
 A        |           5 |
(3 rows)
Enter fullscreen mode Exit fullscreen mode

Mallory picks one of those seats, A-4, and tries to book it:

yugabyte=# update seats set booked_by='Mallory'
where (seat_row, seat_number)=('A', 4)
and booked_by is null
;
...
Enter fullscreen mode Exit fullscreen mode

Mallory is waiting because a SELECT FOR UPDATE needs to acquire a row lock, and this intent conflicts with the same intent from Bob. He cannot progress until the outcome of the other transaction is known.

Bob and Alice were unaware of this and just continued. As long as their transaction does not time out, what they have read with SELECT FOR UPDATE remains valid, and they can perform the update.

Bob books his seat:

yugabyte=*# update seats set booked_by='Bob'
where (seat_row, seat_number)=('A', 4)
and booked_by is null
;
UPDATE 1
Enter fullscreen mode Exit fullscreen mode

Alice books her seat:

yugabyte=*# update seats set booked_by='Alice'
where (seat_row, seat_number)=('A', 5)
and booked_by is null
;
UPDATE 1
Enter fullscreen mode Exit fullscreen mode

What Alice and Bob did is similar to the the first phase of a Two-Phase Commit. They prepared their booking, checking that everything looked good. They found two free seats near each other and were able to book them. They can COMMIT.

Alice commits:

yugabyte=*# commit;
COMMIT
Enter fullscreen mode Exit fullscreen mode

Bob commits:

yugabyte=*# commit;
COMMIT
Enter fullscreen mode Exit fullscreen mode

In the meantime, if Mallory's session doesn't time out, it will continue automatically, as Bob's commit has released the lock.
Mallory is informed that he wasn't able to book the seat because it didn't verify booked_by is null, and by consequence, no rows were updated:

yugabyte=# update seats set booked_by='Mallory'
where (seat_row, seat_number)=('A', 4)
and booked_by is null
;
UPDATE 0
Enter fullscreen mode Exit fullscreen mode

All transactions are completed, and here is the final state:

yugabyte=# select * from seats order by seat_row, seat_number
;
 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           1 | Carol
 A        |           2 |
 A        |           3 | Oscar
 A        |           4 | Bob
 A        |           5 | Alice
 A        |           6 | Carlos
 A        |           7 | Charlie
(7 rows)
Enter fullscreen mode Exit fullscreen mode

Alice and Bob will go to the movies, and they can sit together. Mallory will be able to book another seat.
Pessimistic Concurrency Control makes everyone happy and limited the frustration.

Optimistic Concurrency Control on Aurora DSQL

There are not many SQL databases using Optimistic Concurrency Control, but the latest AWS announcement includes one of them: Aurora DSQL.
This database is designed for use cases with minimal conflicts. Contrary to other databases that try to detect errors beforehand and make the commit fast with a high probability of success, Optimistic Concurrency Control defers all ACID enforcement to the COMMIT command and returns an OC000 error (with error code 40001 Serialization Failure) if any previous operation in the transaction is not compatible with the current state of the database.

Here is the initial state:

dsql=> select * from seats order by seat_row, seat_number
;
 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           1 | Carol
 A        |           2 |
 A        |           3 | Oscar
 A        |           4 |
 A        |           5 |
 A        |           6 | Carlos
 A        |           7 | Charlie
(7 rows)
Enter fullscreen mode Exit fullscreen mode

Bob starts a transaction:

dsql=> begin transaction;
BEGIN
Enter fullscreen mode Exit fullscreen mode

Bob checks for two available adjacent seats

dsql=*> select seat_row, seat_number, next_seat_number
from (
 select seat_row, seat_number, booked_by
     , lead(seat_number) over row_seats as next_seat_number
     , lead(booked_by  ) over row_seats as next_booked_by
 from seats
 window row_seats as (partition by seat_row order by seat_number)
) seats_with_next
where booked_by is null and next_booked_by is null
;

 seat_row | seat_number | next_seat_number
----------+-------------+------------------
 A        |           4 |                5
(1 row)
Enter fullscreen mode Exit fullscreen mode

Bob is happy and verifies that his seat is still free, intending to reserve it if Alice is available to book the other seat:

dsql=*> select * from seats
where (seat_row, seat_number)=('A', 4)
for update
;

 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           4 |
(1 row)
Enter fullscreen mode Exit fullscreen mode

Bob calls Alice and tells her to book seat A-5.

Alice starts a transaction:

dsql=> begin transaction;
BEGIN
Enter fullscreen mode Exit fullscreen mode

Alice verifies that the seat is still free, with the intention to reserve it:

dsql=*> select * from seats
where (seat_row, seat_number)=('A', 5)
for update
;

 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           5 |
(1 row)

Enter fullscreen mode Exit fullscreen mode

The seat is still free. Alice confirms to Bob that she can reserve it.

During this time, Mallory checks for available seats, and because the others have not committed yet, he sees three free seats:

dsql=> select * from seats where booked_by is null
;

 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           2 |
 A        |           4 |
 A        |           5 |
(3 rows)
Enter fullscreen mode Exit fullscreen mode

Mallory picks one of them, A-4, and tries to book it:

dsql=> update seats set booked_by='Mallory'
where (seat_row, seat_number)=('A', 4)
and booked_by is null
;
UPDATE 1
Enter fullscreen mode Exit fullscreen mode

Mallory did not have to wait, and his transaction was successful. Because the database is in Optimistic Concurrency Control mode, he ignored others' intents and was able to book it. Aurora DSQL doesn't synchronize with others before committing and reads from a stale state (snapshot isolation defined at the beginning of the transaction).
This provides good performance for reads, but application developers must understand its consequences: what you have read is valid only if your transaction can commit, and any decision that depends on what you have read must be reverted if the commit fails. This includes the SQL commands, part of the rollback, and any non-transaction action the application did (write to a file, send to queue, increment a counter).

Mallory's transaction is committed. I've run it in auto-commit here to keep it simple, as it is a single-statement transaction.

Bob and Alice were not aware of this. Their transaction runs in Repeatable Read, so they can query again and will always see the same result with 3 free seats. They did a SELECT FOR UPDATE, which in other databases declares the intent to update and reserves the row for it. This is not true with Optimistic Concurrency Control.

Bob books his seat:

dsql=*> update seats set booked_by='Bob'
where (seat_row, seat_number)=('A', 4)
and booked_by is null
;
UPDATE 1
Enter fullscreen mode Exit fullscreen mode

Alice books her seat:

dsql=*> update seats set booked_by='Alice'
where (seat_row, seat_number)=('A', 5)
and booked_by is null
;
UPDATE 1
Enter fullscreen mode Exit fullscreen mode

Alice and Bob prepared their bookings, checking everything to ensure they had two seats near each other. If anything was incorrect, they could cancel their transaction, but from what they saw, it all looked good so that they could commit.

Alice commits:

dsql=*> commit;
COMMIT
Enter fullscreen mode Exit fullscreen mode

Bob commits:

dsql=*> commit;
ERROR:  change conflicts with another transaction, please retry: (OC000)
Enter fullscreen mode Exit fullscreen mode

Bob gets an optimistic concurrency control error, which tells him everything he has read is a phantom state that isn't true. The seat he selected is not free, and he cannot book it. His transaction is canceled.
He checks the status of the bookings:

dsql=> select * from seats order by seat_row, seat_number
;
 seat_row | seat_number | booked_by
----------+-------------+-----------
 A        |           1 | Carol
 A        |           2 |
 A        |           3 | Oscar
 A        |           4 | Mallory
 A        |           5 | Alice
 A        |           6 | Carlos
 A        |           7 | Charlie
(7 rows)
Enter fullscreen mode Exit fullscreen mode

Alice will sit with Mallory, not Bob. They tried to synchronize their booking, but the database was designed to avoid such synchronization.


This illustrates what happens with Optimistic Concurrency Control: the application developer must understand and code around it, and the tests must include race conditions to validate the behavior.
This behavior is different from that of PostgreSQL and PostgreSQL-compatible databases. Aurora DSQL made this trade-off to improve performance. YugabyteDB distributes the transaction intents like the committed data (each tablet has an IntentsDB and a RegularDB, which are maintained strongly consistent across zones or regions with a Raft consensus) with additional synchronization during the transaction (read Write Buffering to Reduce Raft Consensus Latency in YugabyteDB to know when). Aurora DSQL does not synchronize the transaction intents for ongoing transactions to synchronize only once at COMMIT time. The application developer familiar with PostgreSQL must keep this in mind and adapt his code.

Top comments (0)