数据库常见并发冲突详解

关系型数据库常见并发冲突详解与总结

为什么会有冲突

为什么保证数据库的 AICD 特性, 通常数据库调度的时候希望事务的执行是一个串行调度, 但是实际中, 由于事务是并发执行的, 因此会出现各种冲突, 而事务的并发控制协议, 并发控制算法的目的则是处理并发事务中的冲突以实现并发冲突的可串行化. 并发冲突的可串行化在 传送门 这篇博客中已经解释了.

我们总结并举例常见的冲突类型如下:

不可重复读(Non-Repeatable Read)

定义

读-写(R-W)冲突是指一个事务(T1)读取某个数据后, 另一个事务(T2)对该数据进行了修改并提交, 导致 T1 在同一事务中再次读取该数据时, 发现其值已经发生了变化.
这种现象也称为 不可重复读(Non-Repeatable Read), 因为 T1 在事务执行过程中, 无法保证对同一数据的两次读取返回相同的值.

示例

假设数据库中有一张 账户表(Accounts), 初始数据如下:

+----+--------+
| ID | Balance |
+----+--------+
| 1  | 100    |
+----+--------+

事务 T1(读取账户余额):

BEGIN TRANSACTION;
SELECT Balance FROM Accounts WHERE ID = 1;  -- 读取结果:100

事务 T2(更新余额并提交):

BEGIN TRANSACTION;
UPDATE Accounts SET Balance = 50 WHERE ID = 1;
COMMIT;

事务 T1 再次读取:

SELECT Balance FROM Accounts WHERE ID = 1;  -- 读取结果:50
COMMIT;

问题:
T1 在事务执行过程中, 两次读取同一行数据, 结果却不一致(从 100 变成了 50). 这种现象违背了事务的 隔离性(Isolation), 导致事务执行过程中数据视图不稳定.

写-读(W-R)冲突 / 脏读(Dirty Read)

定义

写-读(W-R)冲突, 也称为 脏读(Dirty Read), 指的是: 一个事务(T1)修改了某个数据, 但 未提交. 另一个事务(T2)读取了这个未提交的修改.
如果 T1 最后回滚(Rollback), T2 读取的数据就变成了无效的脏数据. 这会导致 T2 依赖了一个可能不存在或不稳定的状态, 违反了事务的一致性(Consistency)

示例

假设数据库中有一张 账户表(Accounts), 初始数据如下:

+----+--------+
| ID | Balance |
+----+--------+
| 1  | 100    |
+----+--------+

事务 T1(未提交的写入):

BEGIN TRANSACTION;
UPDATE Accounts SET Balance = 50 WHERE ID = 1;  -- 账户余额修改为 50
-- 但还没有提交(COMMIT)

事务 T2(读取未提交数据):

BEGIN TRANSACTION;
SELECT Balance FROM Accounts WHERE ID = 1;  -- 读取结果: 50
COMMIT;

此时, T2 读取到了 T1 未提交的数据(50), 这就是脏读.
事务 T1 回滚(撤销更改):

ROLLBACK;  -- 取消修改, 账户余额恢复为 100

现在, T2 读到的 50 实际上从未正式存在过, 但它已经基于这个错误数据进行了决策或计算, 导致数据不一致.

写-写(W-W)冲突 / 丢失更新(Lost Update)

定义

写-写(W-W)冲突, 也叫 丢失更新(Lost Update), 指的是: 两个并发事务(T1 和 T2)同时修改 同一行数据.
其中一个事务的修改 被另一个事务覆盖, 导致更新丢失. 最终, 数据库中的数据状态 不符合任何串行化调度的结果.

示例: 丢失更新

假设数据库中有一张 账户表(Accounts), 初始数据如下:

+----+--------+
| ID | Balance |
+----+--------+
| 1  | 100    |
+----+--------+

事务 T1(读取并更新账户余额):

BEGIN TRANSACTION;
SELECT Balance FROM Accounts WHERE ID = 1;  -- 读取到 Balance = 100
UPDATE Accounts SET Balance = Balance - 30 WHERE ID = 1;  -- 余额修改为 70
-- 但还没有提交(COMMIT)

事务 T2(并发执行, 也读取并更新账户余额):

BEGIN TRANSACTION;
SELECT Balance FROM Accounts WHERE ID = 1;  -- 读取到 Balance = 100
UPDATE Accounts SET Balance = Balance - 20 WHERE ID = 1;  -- 余额修改为 80
COMMIT;

事务 T1 提交:

COMMIT;  -- 余额变为 70

问题:
T1 和 T2 都读取了初始余额 100, 然后各自执行了扣款操作.T2 先提交, 数据库余额更新为 80.T1 再提交, 余额变为 70, 覆盖了 T2 的修改! T2 的扣款 20 元的操作丢失了, 最终余额错误!
如果按正确的 串行化调度, 事务 T1 和 T2 应该依次执行, 即: T2 先执行完, 则余额应该是 80, 再由 T1 执行后变成 50.
但现在由于 W-W 冲突, 余额变成了 70, 不符合任何正确的串行调度.

Write Skew(写偏差)

定义

写偏差(Write Skew) 是指两个事务并发执行时, 分别读取某个数据集, 并基于该数据集的状态 做出不同的更新, 导致最终结果不符合串行化调度.
这种情况通常发生在 多个独立对象 但具有某种约束(constraint)的情况下.
例如在 MVCC 中由于事务是基于快照进行操作, 因此无法检测到数据已经被修改.
示例: 黑白弹珠问题
假设数据库中有一张 marbles 表:

CREATE TABLE marbles (
    id INT PRIMARY KEY,
    color TEXT  -- 'white' 或 'black'
);

初始状态:

id | color  
---+------
1  | white  
2  | black  

两个事务并发执行
T1(事务 1)修改白色弹珠变黑:

BEGIN;
SELECT * FROM marbles;  -- 看到 (1, 'white') 和 (2, 'black')
UPDATE marbles SET color = 'black' WHERE color = 'white';

T2(事务 2)修改黑色弹珠变白:

BEGIN;
SELECT * FROM marbles;  -- 也看到 (1, 'white') 和 (2, 'black')
UPDATE marbles SET color = 'white' WHERE color = 'black';

T1 和 T2 提交事务

COMMIT; -- T1 先提交
COMMIT; -- T2 也提交

最终状态:

id | color  
---+------
1  | black  
2  | white  

问题: 这个最终状态不是任何串行调度的结果!
如果先执行 T1, 再执行 T2, 所有弹珠应该都是白色.
如果先执行 T2, 再执行 T1, 所有弹珠应该都是黑色.
但这里的最终状态是 (1, black), (2, white), 完全违背了串行化规则.
WVCC 仅根据快照隔离实现版本控制无法解决该问题, 因为 T1 和 T2 事务在开始执行的时候可能读取到的是同一个快照, 但是可以看到执行结果却不正确.

Phantom Read(幻读)

定义

幻读(Phantom Read) 发生在事务执行两次相同的查询, 但是两次查询没有正确的读取到数据库中的数据.

示例: 银行账户最低余额

假设数据库中有一张 账户表(User):

+----+--------+
| id | balance |
+----+--------+
| 1  | 50    |
| 2  | 60    |
+----+--------+

假设管理员A想在数据库中新增一个用户 (10, 40), 管理员 B 想新增一个用户 (10, 50), 如果按照下列的执行顺序会发生什么呢?
img
上图的结果很好的解释了幻读是怎么回事, 步骤 4 实际上就是幻读, 上述是使用 MVCC 的例子说明的, Txn_B commit 修改了数据库之后, 对 Txn_A 是不可见的, 因此 Txn_A 发生了幻读, 导致反馈的结果不准确.

posted @ 2025-02-15 10:42  虾野百鹤  阅读(224)  评论(0)    收藏  举报