MySQL: Producer/Consumer Model, need atomicity in commands accessing multiple tables
-
22-06-2021 - |
Question
I have the following 2 tables, with sample values:
producer_tbl:
id (auto-inc, PK) producer_id item_id item_added
2 5 3 20
products_available_tbl:
item_id (PK) avail_cnt blocked_cnt
3 9 2
Here is the method how I access them:
When a manufacturer provides me with an item, I insert appropriate data in producer_tbl. I simultaneously increment the avail_cnt for the respective item in products_available_tbl.
When a consumer wants that given item, I first use (avail_cnt - blocked_cnt) to check if the quantity asked for is available. If so, I increment the blocked_cnt with the quantity, but don't update avail_cnt. When the consumer commits to his request, I decrement the blocked_cnt and avail_cnt, both with the same quantity.
Now, when there are multiple producers and consumers touching the same item simultaneously, I need atomicity for the above operations.
I would like to know if can I solve this with triggers? (I don't want to use external mutexes) Anyone can point me to sample examples of how to do this?
Solution
Per your request, below are comments that focus on the performance issues in your code:
Optimizing add_item()
Assuming that the products_available_tbl
has a unique index on item_id
, then
CREATE PROCEDURE add_item(IN in_producer_id INT, IN in_item_id INT,
IN in_item_cnt INT)
BEGIN
DECLARE item INT DEFAULT NULL;
START TRANSACTION;
INSERT INTO producer_tbl (producer_id, item_id, item_cnt)
VALUES (in_producer_id, in_item_id, in_item_cnt);
SELECT item_id FROM products_available_tbl
WHERE item_id=in_item_id INTO item FOR UPDATE;
IF item IS NOT NULL THEN
UPDATE products_available_tbl
SET avail_cnt=avail_cnt + in_item_cnt
WHERE item_id=in_item_id;
ELSE
INSERT INTO products_available_tbl
(item_id, avail_cnt, blocked_cnt)
VALUES (in_item_id, in_item_cnt, 0);
END IF;
COMMIT;
END //
could be rewritten as:
CREATE PROCEDURE add_item(IN in_producer_id INT, IN in_item_id INT,
IN in_item_cnt INT)
BEGIN
START TRANSACTION;
INSERT INTO producer_tbl (producer_id, item_id, item_cnt)
VALUES (in_producer_id, in_item_id, in_item_cnt);
INSERT INTO products_available_tbl SET
item_id = in_item_id,
avail_cnt = in_item_cnt,
blocked_cnt = 0
ON DUPLICATE KEY UPDATE
avail_cnt = avail_cnt + in_item_cnt;
COMMIT;
END //
Optimizing block_item()
The optimization is significant, so let's proceed in stages:
First, let's rewrite
SET out_cnt = var_avail_cnt - var_blocked_cnt;
IF out_cnt >= cnt THEN
SET out_cnt = cnt;
END IF;
as
SET out_cnt = LEAST(var_avail_cnt - var_blocked_cnt, cnt);
Next, let's rewrite
SELECT avail_cnt, blocked_cnt FROM products_available_tbl
WHERE item_id=in_item_id INTO var_avail_cnt, var_blocked_cnt
FOR UPDATE;
SET out_cnt = LEAST(var_avail_cnt - var_blocked_cnt, cnt);
as
SELECT LEAST(avail_cnt - blocked_cnt, cnt) FROM products_available_tbl
WHERE item_id=in_item_id INTO out_cnt
FOR UPDATE;
Finally, let's rewrite
SELECT LEAST(avail_cnt - blocked_cnt, cnt) FROM products_available_tbl
WHERE item_id=in_item_id INTO out_cnt
FOR UPDATE;
UPDATE products_available_tbl
SET blocked_cnt = var_blocked_cnt + out_cnt
WHERE item_id = in_item_id;
as
UPDATE products_available_tbl
SET
blocked_cnt = blocked_cnt + (@out_cnt := LEAST(avail_cnt - blocked_cnt, cnt))
WHERE item_id = in_item_id;
so
CREATE PROCEDURE block_item(IN in_item_id INT, INOUT cnt INT)
BEGIN
DECLARE out_cnt INT DEFAULT cnt;
DECLARE var_avail_cnt, var_blocked_cnt INT DEFAULT 0;
START TRANSACTION;
SELECT avail_cnt, blocked_cnt FROM products_available_tbl
WHERE item_id=in_item_id INTO var_avail_cnt, var_blocked_cnt
FOR UPDATE;
SET out_cnt = var_avail_cnt - var_blocked_cnt;
IF out_cnt >= cnt THEN
SET out_cnt = cnt;
END IF;
UPDATE products_available_tbl
SET blocked_cnt = var_blocked_cnt + out_cnt
WHERE item_id = in_item_id;
SET cnt = out_cnt;
COMMIT;
END //
becomes
CREATE PROCEDURE block_item(IN in_item_id INT, INOUT cnt INT)
BEGIN
UPDATE products_available_tbl
SET
blocked_cnt = blocked_cnt + (@out_cnt := LEAST(avail_cnt - blocked_cnt, cnt))
WHERE item_id = in_item_id;
SET cnt = @out_cnt;
END //
Optimizing commit_item():
Let's rewrite
CREATE PROCEDURE commit_item(IN in_item_id INT, INOUT cnt INT)
BEGIN
DECLARE out_cnt INT DEFAULT cnt;
DECLARE var_avail_cnt, var_blocked_cnt INT DEFAULT 0;
START TRANSACTION;
SELECT avail_cnt, blocked_cnt FROM products_available_tbl
WHERE item_id=in_item_id INTO var_avail_cnt, var_blocked_cnt
FOR UPDATE;
IF cnt > var_blocked_cnt THEN
SET out_cnt = -1; /* Error case: Caller supplied wrong value. */
ELSEIF var_blocked_cnt > var_avail_cnt THEN
SET out_cnt = -2; /* Error case: Bug in block_item proc. */
ELSE
SET out_cnt = cnt;
UPDATE products_available_tbl
SET blocked_cnt = var_blocked_cnt - out_cnt,
avail_cnt = var_avail_cnt - out_cnt
WHERE item_id = in_item_id;
END IF;
SET cnt = out_cnt;
COMMIT;
END //
as
CREATE PROCEDURE commit_item(IN in_item_id INT, INOUT cnt INT)
proc: BEGIN
DECLARE var_avail_cnt, var_blocked_cnt INT DEFAULT 0;
UPDATE products_available_tbl
SET blocked_cnt = blocked_cnt - cnt,
avail_cnt = avail_cnt - cnt
WHERE item_id = in_item_id
AND cnt <= blocked_cnt
AND blocked_cnt <= avail_cnt;
IF ROW_COUNT() > 0 THEN
LEAVE proc;
END IF;
SELECT avail_cnt, blocked_cnt FROM products_available_tbl
WHERE item_id=in_item_id INTO var_avail_cnt, var_blocked_cnt;
IF cnt > var_blocked_cnt THEN
SET cnt = -1; /* Error case: Caller supplied wrong value. */
ELSEIF var_blocked_cnt > var_avail_cnt THEN
SET cnt = -2; /* Error case: Bug in block_item proc. */
ELSE
SET cnt = -3; /* UPDATE failed, reasons unknown. */
END IF;
END //
I hope these help. Let me know what you think!
OTHER TIPS
You can always make sure you are updating the record you just read, by adding a uuid VARCHAR(32)
column to any table. You read the record you want to update, then you update the record with a check that the uuid
field didn't change.
For example, you can increment blocked_cnt
via:
UPDATE products_available_tbl
SET blocked_cnt = blocked_cnt + 1,
uuid = UUID()
WHERE blocked_cnt = 2
AND uuid = '21EC2020-3AEA-1069-A2DD-08002B30309D';
SELECT ROW_COUNT(); -- a 1 indicates the UPDATE was successful, 0 or -1 failure
To decrement the blocked_cnt
and avail_cnt
fields:
UPDATE products_available_tbl
SET blocked_cnt = blocked_cnt - 1,
avail_cnt = avail_cnt - 1,
uuid = UUID()
WHERE blocked_cnt = 3
AND uuid = '3F2504E0-4F89-11D3-9A0C-0305E82C3301';
SELECT ROW_COUNT();
To save 24 bytes per record, you could use a uuid_short BIGINT
field instead, and replace the UUID()
s above with UUID_SHORT()
s.
If you want to make sure no one can change the record between when you read it and update it, you either have to use SELECT ... FOR UPDATE
or SELECT ... LOCK IN SHARE MODE
inside a START TRANSACTION
... COMMIT
, which requires an ENGINE
that supports transactions, such as InnoDB, or LOCK TABLES READ [LOCAL]
/ UNLOCK TABLES
which work on all database engines.
I found that using stored procedures is a good way to solve the problem in this scenario with multiple people accessing/modifying multiple tables here, which also need atomicity. Here are the 3 procedures to add an item, block an item and commit an item. The add_item operation doesn't really need atomicity since it always adds to the item's avail_cnt in products_available_tbl, so the add_item procedure is not really necessary.
DELIMITER //
DROP PROCEDURE IF EXISTS `add_item` //
CREATE PROCEDURE add_item(IN in_producer_id INT, IN in_item_id INT,
IN in_item_cnt INT)
BEGIN
DECLARE item INT DEFAULT NULL;
START TRANSACTION;
INSERT INTO producer_tbl (producer_id, item_id, item_cnt)
VALUES (in_producer_id, in_item_id, in_item_cnt);
SELECT item_id FROM products_available_tbl
WHERE item_id=in_item_id INTO item FOR UPDATE;
IF item IS NOT NULL THEN
UPDATE products_available_tbl
SET avail_cnt=avail_cnt + in_item_cnt
WHERE item_id=in_item_id;
ELSE
INSERT INTO products_available_tbl
(item_id, avail_cnt, blocked_cnt)
VALUES (in_item_id, in_item_cnt, 0);
END IF;
COMMIT;
END //
DELIMITER ;
This 'block_item' gets called when a consumer places a request for an item, but haven't committed to a buy.
DELIMITER //
DROP PROCEDURE IF EXISTS `block_item` //
CREATE PROCEDURE block_item(IN in_item_id INT, INOUT cnt INT)
BEGIN
DECLARE out_cnt INT DEFAULT cnt;
DECLARE var_avail_cnt, var_blocked_cnt INT DEFAULT 0;
START TRANSACTION;
SELECT avail_cnt, blocked_cnt FROM products_available_tbl
WHERE item_id=in_item_id INTO var_avail_cnt, var_blocked_cnt
FOR UPDATE;
SET out_cnt = var_avail_cnt - var_blocked_cnt;
IF out_cnt >= cnt THEN
SET out_cnt = cnt;
END IF;
UPDATE products_available_tbl
SET blocked_cnt = var_blocked_cnt + out_cnt
WHERE item_id = in_item_id;
SET cnt = out_cnt;
COMMIT;
END //
DELIMITER ;
This 'commit_item' gets called when the customer confirms the order.
DELIMITER //
DROP PROCEDURE IF EXISTS `commit_item` //
CREATE PROCEDURE commit_item(IN in_item_id INT, INOUT cnt INT)
BEGIN
DECLARE out_cnt INT DEFAULT cnt;
DECLARE var_avail_cnt, var_blocked_cnt INT DEFAULT 0;
START TRANSACTION;
SELECT avail_cnt, blocked_cnt FROM products_available_tbl
WHERE item_id=in_item_id INTO var_avail_cnt, var_blocked_cnt
FOR UPDATE;
IF cnt > var_blocked_cnt THEN
SET out_cnt = -1; /* Error case: Caller supplied wrong value. */
ELSEIF var_blocked_cnt > var_avail_cnt THEN
SET out_cnt = -2; /* Error case: Bug in block_item proc. */
ELSE
SET out_cnt = cnt;
UPDATE products_available_tbl
SET blocked_cnt = var_blocked_cnt - out_cnt,
avail_cnt = var_avail_cnt - out_cnt
WHERE item_id = in_item_id;
END IF;
SET cnt = out_cnt;
COMMIT;
END //
DELIMITER ;
These have been tested to work fine.
Side note: Since I use python, I need to use cursor.fetchone() after calling these procedures.