[英]How to implement locking mechanism for mysql table row to avoide race condition?
[英]MySQL Update query - Will the 'where' condition respected on race condition and row locking? (php, PDO, MySQL, InnoDB)
我正在尝试建立一个先到先得的模型销售页面。 我们有 n 个相同类型的项目。 我们希望将这 n 个项目分配给提出请求的前 n 个用户。 每个项目对应一个数据库行。 当用户按下购买按钮时,系统会尝试查找尚未售出的条目( reservationCompleted = FALSE
)并更新用户 ID 并将reservationCompleted
设置为true。
由于我使用的数据库引擎是 InnoDB,我知道有一个内部锁定机制,不允许两个进程在同一行上同时进行更新。
我的问题是,
如果我使用的语句如下,如果两个请求同时到达,这是否会导致不同的用户被分配到同一行?
$query = "UPDATE available_items
SET assignedPhone=".$user->phone.",
reservationCompleted = TRUE,
assignmentCreatedTimestamp =".time()."
WHERE id=".$itemListing['id']."
AND reservationCompleted=FALSE";
$stmt = $pdo->prepare($query);
$stmt->execute();
考虑以下情况。
两个不同的进程获取同一行(比如 id=5)并尝试更新数据库条目。 但是其中一个得到了锁。 它更新项目并释放锁,下一个进程获得锁。 那么,它会在执行更新之前再次验证 where 条件吗?
在比赛情况下,哪里的条件会受到尊重,但你必须小心检查谁是谁赢得了比赛。
请考虑以下演示如何工作以及为什么必须小心。
首先,设置一些最小的表。
CREATE TABLE table1 (
`id` TINYINT UNSIGNED NOT NULL PRIMARY KEY,
`locked` TINYINT UNSIGNED NOT NULL,
`updated_by_connection_id` TINYINT UNSIGNED DEFAULT NULL
) ENGINE = InnoDB;
CREATE TABLE table2 (
`id` TINYINT UNSIGNED NOT NULL PRIMARY KEY
) ENGINE = InnoDB;
INSERT INTO table1
(`id`,`locked`)
VALUES
(1,0);
id
扮演的角色id
在你的餐桌, updated_by_connection_id
就像assignedPhone
,并locked
像reservationCompleted
。
现在让我们开始比赛测试。 您应该打开2个命令行/终端窗口,连接到mysql并使用创建这些表的数据库。
连接1
start transaction;
连接2
start transaction;
连接1
UPDATE table1
SET locked = 1,
updated_by_connection_id = 1
WHERE id = 1
AND locked = 0;
查询正常,1行受影响(0.00秒)匹配的行数:1已更改:1警告:0
连接2
UPDATE table1
SET locked = 1,
updated_by_connection_id = 2
WHERE id = 1
AND locked = 0;
连接2正在等待
连接1
SELECT * FROM table1 WHERE id = 1;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+
commit;
此时,连接2被释放以继续并输出以下内容:
连接2
查询正常,0行受影响(23.25秒)匹配的行数:0已更改:0警告:0
SELECT * FROM table1 WHERE id = 1;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+
commit;
一切都很好看。 我们看到是的,WHERE子句在竞争情况下得到尊重。
我说你必须小心的原因是因为在一个真实的应用程序中,事情并不总是这么简单。 您可以在事务中进行其他操作,这实际上可以更改结果。
让我们用以下内容重置数据库:
delete from table1;
INSERT INTO table1
(`id`,`locked`)
VALUES
(1,0);
现在,考虑这种情况,其中在UPDATE之前执行SELECT。
连接1
start transaction;
SELECT * FROM table2;
空集(0.00秒)
连接2
start transaction;
SELECT * FROM table2;
空集(0.00秒)
连接1
UPDATE table1
SET locked = 1,
updated_by_connection_id = 1
WHERE id = 1
AND locked = 0;
查询正常,1行受影响(0.00秒)匹配的行数:1已更改:1警告:0
连接2
UPDATE table1
SET locked = 1,
updated_by_connection_id = 2
WHERE id = 1
AND locked = 0;
连接2正在等待
连接1
SELECT * FROM table1 WHERE id = 1;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+ 1 row in set (0.00 sec)
SELECT * FROM table1 WHERE id = 1 FOR UPDATE;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+ 1 row in set (0.00 sec)
commit;
此时,连接2被释放以继续并输出以下内容:
查询正常,0行受影响(20.47秒)匹配的行数:0已更改:0警告:0
好的,让我们看看谁赢了:
连接2
SELECT * FROM table1 WHERE id = 1;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 0 | NULL | +----+--------+--------------------------+
等等,什么? 为什么locked
0并且updated_by_connection_id
空?
这是我提到的小心。 罪魁祸首实际上是因为我们在开始时做了一个选择。 为了获得正确的结果,我们可以运行以下命令:
SELECT * FROM table1 WHERE id = 1 FOR UPDATE;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+
commit;
通过使用SELECT ... FOR UPDATE,我们可以得到正确的结果。 这可能非常令人困惑(因为对我而言,最初),因为SELECT和SELECT ... FOR UPDATE给出了两个不同的结果。
发生这种情况的原因是由于默认隔离级别READ-REPEATABLE
。 当第一个SELECT完成后,就在start transaction;
,创建快照。 所有未来的非更新读取都将从该快照完成。
因此,如果您在执行更新后只是天真地选择SELECT,它将从该原始快照中提取信息,该快照在更新行之前 。 通过执行SELECT ... FOR UPDATE,您可以强制它获取正确的信息。
然而,再次,在实际应用中,这可能是一个问题。 例如,假设您的请求包含在事务中,并且在执行更新后您想要输出一些信息。 收集和输出该信息可以由单独的,可重复使用的代码处理,您不希望使用FOR UPDATE子句“以防万一”。 由于不必要的锁定,这会导致很多挫败感。
相反,你会想要采取不同的轨道。 你有很多选择。
一,是确保在UPDATE完成后提交事务。 在大多数情况下,这可能是最好,最简单的选择。
另一种选择是不要尝试使用SELECT来确定结果。 相反,您可以读取受影响的行,并使用它(更新1行,更新0行)以确定UPDATE是否成功。
另一个选项,也就是我经常使用的选项,因为我希望将单个请求(如HTTP请求)完全包装在单个事务中,这是为了确保在事务中执行的第一个语句是UPDATE或SELECT ...更新 。 这将导致在允许连接继续之前不会拍摄快照。
让我们再次重置我们的测试数据库,看看它是如何工作的。
delete from table1;
INSERT INTO table1
(`id`,`locked`)
VALUES
(1,0);
连接1
start transaction;
SELECT * FROM table1 WHERE id = 1 FOR UPDATE;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 0 | NULL | +----+--------+--------------------------+
连接2
start transaction;
SELECT * FROM table1 WHERE id = 1 FOR UPDATE;
连接2正在等待。
连接1
UPDATE table1
SET locked = 1,
updated_by_connection_id = 1
WHERE id = 1
AND locked = 0;
查询正常,1行受影响(0.01秒)匹配的行数:1已更改:1警告:0
SELECT * FROM table1 WHERE id = 1;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+
SELECT * FROM table1 WHERE id = 1 FOR UPDATE;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+
commit;
连接2现已发布。
连接2
+----+--------+--------------------------+
| id | locked | updated_by_connection_id |
+----+--------+--------------------------+
| 1 | 1 | 1 |
+----+--------+--------------------------+
在这里,您实际上可以让您的服务器端代码检查此SELECT的结果并知道它是准确的,甚至不再继续下一步。 但是,为了完整起见,我会像以前一样完成。
UPDATE table1
SET locked = 1,
updated_by_connection_id = 2
WHERE id = 1
AND locked = 0;
查询正常,0行受影响(0.00秒)匹配的行数:0已更改:0警告:0
SELECT * FROM table1 WHERE id = 1;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+
SELECT * FROM table1 WHERE id = 1 FOR UPDATE;
+----+--------+--------------------------+ | id | locked | updated_by_connection_id | +----+--------+--------------------------+ | 1 | 1 | 1 | +----+--------+--------------------------+
commit;
现在您可以看到在Connection 2中SELECT和SELECT ... FOR UPDATE给出相同的结果。 这是因为SELECT提取的快照直到提交连接1之后才创建。
因此,回到原来的问题:是的,在所有情况下,UPDATE语句都会检查WHERE子句。 但是,您必须小心您可能正在执行的任何SELECT,以避免错误地确定该UPDATE的结果。
(是的另一种选择是改变事务隔离级别。但是,我并没有真正的经验,可能存在任何可能存在的问题,所以我不打算进入它。)
答案是: 它将在更新数据之前验证WHERE
条件 。
好吧,我不得不说这是一个非常有趣的问题。 我以前从未想过这样的问题,它强迫我更好地理解它在MySQL中是如何工作的。 谢谢!
我首先考虑了这种情况。 我知道甚至在我做测试之前它应该像这样工作,但我只是不明白为什么。
最后,我在Index Condition Pushdown部分找到了一些有用的东西。
这是它在MySQL中的工作原理:
MySQL Server
↑ ↑
↓ ↓
Storage Engine(InnoDB here)
WHERE
条件。 如您所见,InnoDB内部发生锁定,MySQL Server在获取行后评估WHERE
条件。 对于您的情况,行(id = 5)被第一个UPDATE
锁定,第二个UPDATE
在获取同一行时被卡住。 并且在获得行的锁定之后,对第二个UPDATE
的WHERE
条件进行评估。
更重要的是,如果您在id
上创建了索引,则会在您的查询中进行索引条件下推。
不,因为reservationCompleted
设置为true true
。不要忘记COMMIT
每个成功的事务。 下一个进程当然会获得锁定,但不会满足WHERE
条件并释放LOCK。如果您希望下一个进程查找另一个可用项目您可以使用Sub Routine
过程包装Update语句来检查是否reservationCompleted
为FALSE
。
因为您正在使用PDO,所以应该考虑在语句中使用参数 。 此外,您在数据库中的时间戳应存储为DATETIME,以便您可以在查找中利用MySQL的搜索功能。
<?php
$sql = "UPDATE available_items SET assignedPhone=:userPhone,
reservationCompleted = TRUE,
assignmentCreatedTimestamp =:time
WHERE id=:id AND reservationCompleted=FALSE";
$stmt = $db->prepare($sql);
$stmt->bindValue(':userPhone', $user->phone, PDO::PARAM_STR);
$stmt->bindValue(':time', time(), PDO::PARAM_INT);
$stmt->bindValue(':id', $itemListing['id'], PDO::PARAM_INT);
$stmt->execute();
$affected_rows = $stmt->rowCount();
总之执行你谈论你需要每次交易之前,如果你希望它是如此接近在一起时做一个查询的行动,否则你可能会疏远你的目标受众。 您还需要利用异步连接(想想AJAX )在潜在事务期间不断检查项目的状态,并可能提供消息传递服务,以便在无法进行事务时让某人知道。 除非您的数据库与Web服务器之间的连接速度较慢,否则数据库锁定的持续时间不足以发生此情况。
你可能也想研究类似SOAP的东西。 然后,您将在系统上编写一个表示每个项目状态的XML文件或JSON文件,并且您的客户端代码只会检查它是否有更新,而不是在页面打开时不断执行异步MySQL查询。 成功执行事务后,还会更新此文件和数据库。 这样您只需要涉及Web服务器而不是Web服务器和数据库服务器。
如果你想要的话,你也可以考虑使用microtime()
来降低微秒。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.