Nitseg
Nitseg

Reputation: 1277

Concurrency scenarios with INSERTs

I'm designing a booking system in PHP + PostgreSQL. I'm not able to find a clean solution to a concurrency problem based on INSERTs operations.

The DB system is mainly made of these tables:

CREATE TABLE booking (
booking_id INT,
user_id INT,
state SMALLINT,
nb_coupons INT
);

CREATE booking_state_history (
booking_state_history_id INT,
timestamp TIMESTAMP,
booking_id INT,
state SMALLINT);

CREATE TABLE coupon_purchase(
coupon_purchase_id,
user_id INT,
nb INT,
value MONEY)

CREATE TABLE coupon_refund(
coupon_refund_id INT,
user_id,
nb INT,
value MONEY)

CREATE TABLE booking_payment(
booking_payment_id INT,
user_id,
booking_id,
nb INT,
value MONEY)

A booking must be paid with coupons that have been previously purchased by the user. Some coupons may have been refund. All these operations are stored in the two corresponding tables to keep an history and be able to compute the coupon balance. Constraint: the coupon balance cannot be negative at any time.

A booking is finalized when it is paid with coupons.

Then the following operations happen:

BEGIN;    
(1) Check there are enough coupons remaining to pay the booking. (SELECT)    
(2) Decide which coupons (number and value) will be used to pay the booking 
(mainly, higher cost coupon used first. But that is not the issue here.)    
(3) Add records to booking_payment (INSERTs)    
(4) Move the booking to state="PAID" (integer value representing "PAID") (UPDATE)
(5) Add a record to booking_state_history (INSERT)    
COMMIT;

These operations need to be atomic to preserve DB information coherency.

Hence the usage of transactions that allow to COMMIT or ROLLBACK in case of failure, DB exception, PHP exception or any other issue in the middle of the operations.

Scenario 1

Since I'm in a concurrent access environment (web site) nothing prevents the user from (for instance) asking for a coupon refund while doing a booking payment at the same time.

Scenario 2

He can also trigger two concurrent booking payments at the same time in two different transactions.

So the following can happen:

Scenario 1 After (1) is done, the coupon refund is triggered by the user and the subsequent coupon balance is not enough to pay the booking any more. When it COMMITs the balance becomes negative. Note: Even if I do a recheck of coupon balance in a new (6) step, there is a possibility for the coupon refund to happen in the meantime between (6) and COMMIT.

Scenario 2

Two concurrent booking payment transactions for which total number of coupons for payment is too much for the global balance to stay positive. Only one of them can happen. Transaction 1 and transaction 2 are checking for balance and seeing enough coupons for their respective payment in step (1). They go on with their operations and COMMIT. The new balance is negative and conflicting with the constraint. Note: Even if I do a coupon balance recheck in a new (6) step the transactions cannot see the operations not yet commited by the other one. So they blindly proceed to COMMIT.

I guess this is an usual concurrency case but I cannot find a pattern to solve this on the internet.

I thought of rechecking the balance after the COMMIT so I can manually UNDO all the operations. But it is not totally safe since if an exception happen after the commit the UNDO won't be done.

Any idea to solve this concurrency problem?

Thanks.

Upvotes: 2

Views: 3467

Answers (2)

Nitseg
Nitseg

Reputation: 1277

Below is the solution I've implemented.

Note: I just treated the coupon transfer part below but it is the same with booking state change and booking_state_history.

The main idea is to preserve this part of the processing as a critical section. When an INSERT into booking_payment, coupon_purchase or coupon_refund is to be done I prevent other transactions from doing the same by putting a lock on a dedicated table through an UPDATE for the given user_id.

This way, only transactions impacting this given user_id for the same kind of treatment will be locked.

Intialization

DROP TABLE coupon_purchase;
DROP TABLE coupon_refund;
DROP TABLE booking_payment;
DROP TABLE lock_coupon_transaction;

CREATE TABLE coupon_purchase(
coupon_purchase_id SERIAL PRIMARY KEY,
user_id INT,
nb INT);

CREATE TABLE coupon_refund(
coupon_refund_id SERIAL PRIMARY KEY,
user_id INT,
nb INT);

CREATE TABLE booking_payment(
booking_payment_id SERIAL PRIMARY KEY,
user_id INT,
booking_id INT,
nb INT);

CREATE TABLE lock_coupon_transaction (
user_id INT,
timestamp TIMESTAMP); 

INSERT INTO coupon_purchase
(user_id, nb) VALUES
(1, 1),
(1, 5);

INSERT INTO coupon_refund
(user_id, nb) VALUES
(1, 3);

INSERT INTO lock_coupon_transaction
(user_id, timestamp) VALUES
(1, current_timestamp);

Transaction 1

BEGIN;

UPDATE lock_coupon_transaction SET timestamp=current_timestamp WHERE user_id='1';

WITH coupon_balance AS (
SELECT
  t1.nb_purchased_coupons -
  t2.nb_refunded_coupons -
  t3.nb_booking_payment_coupons AS total
FROM 
(SELECT COALESCE(SUM(nb),0) AS nb_purchased_coupons FROM coupon_purchase WHERE user_id='1' ) t1,
(SELECT COALESCE(SUM(nb),0) AS nb_refunded_coupons FROM coupon_refund WHERE user_id='1' ) t2,
(SELECT COALESCE(SUM(nb),0) AS nb_booking_payment_coupons FROM booking_payment WHERE user_id='1' ) t3
)
INSERT INTO booking_payment
(user_id, booking_id, nb)
SELECT 1::INT, 1::INT, 3::INT
FROM coupon_balance
WHERE (total::INT >= 3::INT);
INSERT 0 1

Transaction 2

BEGIN;

UPDATE lock_coupon_transaction SET timestamp=current_timestamp WHERE user_id='1';

// Transaction is locked waiting for a COMMIT or ROLLBACK from transaction 1.

Transaction 1

COMMIT;
COMMIT

Transaction 2

// Transaction 1 lock has been released so transaction 2 can go on

WITH coupon_balance AS (
SELECT
  t1.nb_purchased_coupons -
  t2.nb_refunded_coupons -
  t3.nb_booking_payment_coupons AS total
FROM 
(SELECT COALESCE(SUM(nb),0) AS nb_purchased_coupons FROM coupon_purchase WHERE user_id='1' ) t1,
(SELECT COALESCE(SUM(nb),0) AS nb_refunded_coupons FROM coupon_refund WHERE user_id='1' ) t2,
(SELECT COALESCE(SUM(nb),0) AS nb_booking_payment_coupons FROM booking_payment WHERE user_id='1' ) t3
)
INSERT INTO coupon_refund
(user_id, nb)
SELECT 1::INT, 3::INT
FROM coupon_balance
WHERE (total::INT >= 3::INT);
INSERT 0 0 

COMMIT;
COMMIT

INSERT couldn't be done since not enough money on the account. This is the expected behavior.

The previous transaction was commited when the second one proceeded. So transaction 2 could see all changes made by transaction 1.

This way there is not risk to have concurrent access to coupons handling.

Upvotes: 0

Pavel Horal
Pavel Horal

Reputation: 18194

Your problem boils down to the question of "what should be the synchronization lock". From your question it seems that the booking is not booking of a specific item. But lets assume, that a user is booking a specific hotel room so you need to solve two problems:

  • prevent overbooking (e.g. booking the same thing for two people)
  • prevent parallel account state miscalculation

So when a user gets to a point when he/she is about to hit confirm button, this is a possible scenario you can implement:

  1. begin transaction

  2. lock the user entry so that parallel processes are blocked

    SELECT * FROM user FOR UPDATE WHERE id = :id

  3. re-check account balance and throw exception / rollback if there are insufficient funds

  4. lock the item to be booked to prevent overbooking

    SELECT * FROM room FOR UPDATE WHERE id = :id

  5. re-check booking availability and throw exception / rollback if the item is already booked

  6. create booking entry and subtract funds from user's account

  7. commit transaction (all locks will be released)

If, in your case, you don't need to check for overbooking, just skip / ignore steps 4 and 5.

Upvotes: 2

Related Questions