掘金 后端 ( ) • 2024-04-17 13:58

背景描述

现在有两个事务,事务A和事务B,他们都需要修改同一行数据,这行数据原始值为100,事务A的操作是数据增加100,事务B的操作也是增加100,预期的最终结果是300,现在如何保证最终的数据是300的?什么时候会出现200的情况?

执行结果

最终数据是300的情况(可重复读) :

在可重复读(REPEATABLE READ)隔离级别下,这种情况较为容易实现。此隔离级别是MySQL默认级别,它可以有效避免脏读、不可重复读和幻读问题。

  • 执行流程:

    • 事务A开始,执行查询操作(SELECT),获取值100。
    • 事务A给这行数据加上读锁(共享锁)或排他锁(根据是否使用SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE)。
    • 事务A在查询结果的基础上增加100,更新这行数据,然后提交事务,释放任何持有的锁。
    • 接着,事务B开始,执行查询操作,获取到更新后的值200(因为事务A已经提交)。
    • 事务B给这行数据加上读锁或排他锁。
    • 事务B在查询结果的基础上增加100,更新这行数据,然后提交事务,释放任何持有的锁。
  • 使用的锁:

    • 根据是否声明FOR UPDATELOCK IN SHARE MODE,可能会使用记录锁(行锁)或next-key锁(next-key锁是行锁和gap锁的组合,防止幻读)。
    • 如果没有使用上述子句,那么InnoDB存储引擎默认会在更新操作时自动给涉及的行加上排他锁(X锁)

最终数据是300的情况(读未提交) :

在读未提交(READ UNCOMMITTED)隔离级别下,事务可以读取到其他事务未提交的更改,也就是这个隔离级别允许发生“脏读”(Dirty Reads)

  • 执行流程:

    • 事务A在读未提交的隔离级别下启动。
    • 事务A执行 SELECT 查询操作,读取数据值为100。
    • 然后事务A增加这个值100,执行 UPDATE 操作,更新该数据行为200。此时,事务A还没有提交,所以更改是未提交的。
    • 在读未提交隔离级别下,事务B也启动。
    • 事务B执行 SELECT 操作,在这个隔离级别下,它可以读取到事务A未提交的更改,也就是它可能会读取到数据值为200。
    • 事务A提交事务后,事务A对数据行的更改变成持久的,这时候数据是200。
    • 基于事务B读到的200的数据值,事务B决定将这个数据增加100,更新操作将这个值更改为300。
    • 然后事务B提交,这时数据行的更新已经持久化为300。

出现200的情况(低级别的隔离级别) :

在较低级别的隔离级别,如读未提交(READ UNCOMMITTED)或读提交(READ COMMITTED),可能会出现最终结果是200的情况。

  • 执行流程:

    • 事务A开始,执行查询操作,获取值100。
    • 事务B几乎同时间开始,并执行查询操作,也读取到值100(尤其在READ UNCOMMITTED级别,或事务B在READ COMMITTED级别下且在事务A提交之前读取数据)。
    • 事务A增加100,更新数据库行为200,然后提交。
    • 事务B没有得到事务A的更新(因为它已经读取了数据),它也增加100,在其本地的值基础上(100),并更新数据库行为200,然后提交。
  • 使用的锁:

    • 在READ UNCOMMITTED级别下,不使用行级锁定,事务可以读取未提交的数据。
    • 在READ COMMITTED级别下,每次查询都会读取最新的已提交数据,但如果事务B在事务A提交之前读取数据,它不会再次查询数据库。
    • 在REPEATABLE READ级别下,第一次读取后,事务内的读操作会在提交前看到同样的数据,即事务B第一次查询后再次查询依然会是100,直到事务提交才会读到新的值。

出现200的情况(并发导致更新丢失) :

  • 执行流程

    • 事务A在可重复读的隔离级别下启动。
    • 事务B也在可重复读的隔离级别下启动。
    • 事务A执行SELECT查询,并且读取到这行数据的初始值100。
    • 事务A在查询结果的基础上增加100,并执行UPDATE操作,将该行的值变为200。
    • 在UPDATE操作过程中,事务A为这行数据加上排他锁。
    • 事务A提交事务,行数据变为200,并释放排他锁。
    • 因为事务B也在可重复读隔离级别下,所以即便事务A已经提交更新,事务B的SELECT操作依然会看到事务B自己快照里的数据,即历史值100。这意味着事务B在这个阶段读取到的是100,而非事务A更新后的200。
    • 此时,事务B尝试基于它自己读取到的那个100的值增加100并且执行UPDATE。
    • 由于事务A已经提交,并释放了排他锁,事务B现在确实能对这行数据进行更新操作,但是基于自己快照里的原始值100,它更新行数为200,而非基于事务A的更新结果。
    • 事务B提交事务,数据的最新状态是200。

更新丢失问题

当两个或多个事务读取同一数据,并基于此数据进行更新时,其中一个事务的更新可能会由于另一个事务的更新而被覆盖,导致第一个事务的更改丢失。什么场景下会出现这种问题呢?

假设一个场景,在事务中涉及到先查询后更新的操作,那么使用排他锁时,是在事务开始时加锁,还是说在更新数据时加锁?

这有两种常见的策略,各有不同的问题:

1、 查询时不加锁,更新时加锁: 在这种策略中,事务在查询数据时不会对数据行加锁。只有当事务执行更新操作时,才会对相关数据行加上排他锁。这样做的好处是可以减少锁的持有时间,从而提高并发性能。但是,这种策略可能会导致“丢失更新”(Lost Update)问题,即两个事务可能同时读取相同的数据,然后几乎同时更新,导致一个事务的更新覆盖另一个事务的更新。

2、查询时就加锁,保持到事务结束: 在这种策略中,事务在查询数据时就会对数据行加上排他锁,并且保持锁直到事务结束(提交或回滚)。这样可以确保在事务执行期间,其他事务无法修改被锁定的数据行,从而避免了“丢失更新”问题。然而,这种策略会增加锁的持有时间,可能会降低系统的并发性能。

在MySQL的InnoDB存储引擎中,默认的隔离级别是可重复读(REPEATABLE READ)。在这个隔离级别下,InnoDB会使用以下策略,查询时不加锁,更新时加锁

  • 当执行普通的SELECT查询时,InnoDB不会加排他锁。
  • 当进行写操作,如UPDATE、DELETE、INSERT时,会对涉及的数据行加上排他锁。

这里是两种不同SELECT语句的例子:

  • 普通查询(不加锁) :

    SELECT * FROM your_table WHERE id = 1;
    
  • 查询并锁定(加排他锁) :

如果你希望在读取数据时立即加锁以确保数据不会被其他事务修改,你可以使用SELECT ... FOR UPDATE语句。这会对选中的行加上排他锁,直到当前事务结束。

SELECT * FROM your_table WHERE id = 1 FOR UPDATE;

那么MySQL是不是存在“更新丢失”问题呢?它是怎么解决的呢?

如何解决“更新丢失”问题呢?

我们先来看看解决“更新丢失”有哪些办法:

  1. 使用合适的隔离级别:可重复读或串行化(SERIALIZABLE)隔离级别可以防止这种问题。
  2. 悲观锁定:通过在读取数据时立即加上SELECT ... FOR UPDATE,以获取对相关数据行的排他锁,防止其他事务并发修改。
  3. 乐观锁定:通过版本号或时间戳来检查在读取数据后和更新数据前是否有其他事务对数据作了修改,如果检测到了变化,可以拒绝更新或重新读取最新数据再尝试更新。乐观锁定一般需要在数据表中使用一个额外的字段来控制版本(version)或者保存时间戳(timestamp)。

下面是如何在MySQL中使用乐观锁定的方法和步骤:

  1. 修改表结构: 在你的数据表中增加一个版本号字段或时间戳字段。例如:
ALTER TABLE your_table ADD COLUMN version INT DEFAULT 0;
  1. 读取数据: 在进行数据操作时,你需要读取数据和它当前的版本号。例如:
SELECT value, version FROM your_table WHERE id = 1;
  1. 更新数据: 更新数据时,除了更改数据之外,还需要增加版本号,并检查更新时的版本号是否跟读取时的一致。如果不一致,说明数据在读取之后有过其他更新,当前更新应该被拒绝。
UPDATE your_table 
SET value = value + 100, version = version + 1 
WHERE id = 1 AND version = read_version;
  1. 操作结果处理: 执行更新后,需要检查操作是否成功。你可以根据更新影响行数来判断,如果影响行数为0,则说明在读取数据和尝试更新之间,数据已经被其他地方更新过,此时可以选择重新读取再尝试更新或返回错误提示。
int affectedRows = preparedStatement.executeUpdate();
if (affectedRows == 0) {
    // Handle the optimistic lock failure (e.g., throw an exception or retry)
}

在Java代码中,可以通过检查数据库操作返回的受影响行数来实现乐观锁定逻辑。如果受影响行数为0,则说明更新并没有成功,很可能是因为版本号不一致导致的更新冲突。应用程序可以根据业务场景,选择是重新尝试、放弃操作,还是抛出异常来通知用户。

乐观锁定在并发不是特别高,冲突概率较低的系统中是非常有用的,并且相对于悲观锁,它提供了更好的性能和用户体验。但在高冲突环境下,乐观锁可能会导致大量的冲突和重试,从而可能影响系统的整体性能。

MySQL怎么解决“更新丢失”问题?

在MySQL默认的事务隔离级别(REPEATABLE READ)下,解决并发事务中的“更新丢失”的关键流程主要包括:

  1. Next-Key锁: 当事务对数据进行修改时,InnoDB会使用Next-Key锁锁定这些数据,并且锁定它们的索引记录以及索引记录之间的间隙,防止并发事务插入新的记录。
  2. 检查当前版本: 在更新数据前,事务会检查此行数据的当前版本,确保没有其他事务已经修改过这个数据。如果数据已经被修改,事务会等待或得到更新冲突的通知。
  3. 行锁定: 当执行UPDATE或DELETE操作时, InnoDB会对涉及的每行数据加上排它锁(X锁),这使得其他事务不能同时对这些行进行写操作。
  4. 使用Undo日志保持一致性读: 即便有事务正在对数据行进行修改,其他事务因为Undo日志的存在,仍然可以读取到修改前的数据快照,保证了在更新过程中可以进行一致性非锁定读取。
  5. 版本控制: InnoDB通过为每个事务维护一个版本链来实现MVCC。事务在开始时记录下系统版本号,只查询版本早于当前事务版本的数据记录,避免了读取到其他事务修改后的数据,从而防止了更新丢失。
  6. COMMIT前确认: 在事务提交前,系统会检查所有修改是否与其他事务有冲突。如果发现冲突,当前事务需要重新执行或等待其他事务完成。
  7. REDO日志: 为了保证即便数据库发生故障,已经提交的修改也不会丢失,所有的更改操作都会记录在REDO日志中。
  8. 隔离和序列化操作: 当需要修改数据时,InnoDB通过锁定机制对相关的数据行进行隔离,确保操作的序列化,防止更新丢失。

排他锁

排他锁(Exclusive Lock),通常简称为X锁,是数据库中用于控制并发访问的一种锁机制。当一个事务对数据行加上排他锁时,它可以确保在这个事务完成并释放锁之前,其他事务不能对该数据行进行任何读取或修改操作。

排他锁的主要特点如下:

  1. 写操作保护:排他锁主要用于写操作,确保在事务执行更新、删除等操作时,数据不会被其他并发事务干扰。
  2. 互斥性:当一个事务持有排他锁时,其他事务必须等待直到锁被释放。这种互斥性保证了数据的一致性和完整性。
  3. 防止死锁:通过合理的锁策略,排他锁可以帮助防止死锁的发生。例如,事务可以按照一致的顺序获取锁,避免循环等待。
  4. 锁的释放:一旦事务完成了对数据的修改并提交,排他锁就会被释放,其他事务可以开始对数据行进行操作。

在MySQL中,当执行INSERT、UPDATE或DELETE操作时,InnoDB存储引擎会自动为涉及的数据行加上排他锁。这些锁在事务提交或回滚后自动释放。排他锁是实现事务隔离的关键机制之一,它有助于维护事务的原子性和一致性。

需要注意的是,排他锁可能会导致性能问题,特别是在高并发的场景下。如果事务长时间持有锁,可能会导致其他事务等待,从而降低系统的并发性能。因此,在设计数据库操作时,应该尽量减少锁的持有时间,合理地使用锁,以及考虑使用其他并发控制机制,如乐观锁等,来平衡性能和数据一致性的需求。