我们的数据库一般都会并发执行多个事务,多个事务可能会并发的对相同的一批数据进行增删改查操作,可能就会导致我们说的脏写、脏读、不可重复读、幻读这些问题。 这些问题的本质都是数据库的多事务并发问题,为了解决多事务并发问题,数据库设计了事务隔离机制、锁机制、MVCC多版本并发控制隔离机制。

事务 ACID 属性

假设我们有以下场景:现在我们有一个电商系统,该系统中有一个记录商户账户信息的表,里面包括该商户的余额信息;还有一个订单流水表,里面记录着每一笔交易记录,包括流水 ID、交易金额、交易时间戳以及交易双方的系统、账户等信息。

我们先看一下如何来使用 MySQL 的事务,实现一笔交易。比如说,在事务中执行一个充值 100 元的交易,先记录一条交易流水,流水号是 123,然后把账户余额从 100 元更新到 200 元。对应的 SQL 是这样的:

  1. mysql> begin; -- 开始事务
  2. Query OK, 0 rows affected (0.00 sec)
  3. mysql> insert into account_log ...; -- 写入交易流水
  4. Query OK, 1 rows affected (0.01 sec)
  5. mysql> update account_balance ...; -- 更新账户余额
  6. Query OK, 1 rows affected (0.00 sec)
  7. mysql> commit; # 提交事务
  8. Query OK, 0 rows affected (0.01 sec)

使用事务的时候,只需要在之前执行begin,标记开始一个事务,然后正常执行多条 SQL 语句,在事务里面的不仅可以执行更新数据的 SQL,查询语句也是可以的,最后执行commit,提交事务就可以了。

原子性(Atomicity)

使用事务可以保证,记录流水和更新余额这两个操作,要么都成功,要么都失败,即使是在数据库宕机、应用程序退出等等这些异常情况下,也不会出现,只更新了一个表而另一个表没更新的情况。这就是事务的原子性,事务是一个原子操作单位,其对数据的修改,要么全都执行,要么全都不执行

一致性(Consistency)

上述操作中,数据库中的数据总是从一个一致性状态(123 流水记录不存在,余额是 100 元)转换到另外一个一致性状态(123 流水存在,余额是 200 元)。对于其他事务来说,不存在任何中间状态(123 流水存在,但余额是 100 元)。其他事务,在任何一个时刻,如果它读到的流水中没有 123 这条流水记录,它读出来的余额一定是 100 元,这是交易前的状态。如果它能读到 123 这条流水记录,它读出来的余额一定是 200 元,这是交易之后的状态。也就是说,事务保证我们读到的数据(交易和流水)总是一致的。

在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持数据的完整性(一致性更多的是从数据层面来讲,而原子性从操作层面来讲)

隔离性(Isolation)

这个事务的执行过程无论多快,它都是需要时间的,那修改流水表和余额表对应的数据,也会有先后。那一定存在一个时刻,流水更新了,但是余额还没更新,也就是说每个事务的中间状态是事实存在的。数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的

数据库为了实现一致性,必须保证每个事务的执行过程中,中间状态对其他事务是不可见的。比如说我们在事务 A 中,写入了 123 这条流水,但是还没有提交事务,那在其他事务中,都不应该读到 123 这条流水记录。

持久性(Durability)

只要事务提交成功,数据一定会被持久化到磁盘中,后续即使发生数据库宕机,也不会改变事务的结果。它对于数据的修改是永久性的,即使出现系统故障也能够保持。

并发事务处理的问题

有了数据库的事务机制,只要确保每一笔交易都在事务中执行,我们的账户就很容易保证流水和余额数据的一致性。但是,ACID 是一个非常严格的定义,或者说是理想的情况。如果要完全满足 ACID,一个数据库的所有事务和 SQL 都只能串行执行,这个性能肯定是不能满足一般系统的要求的。在并发情况下,对数据操作常见的问题如下:

数据丢失(脏写)

当两个或多个事务选择同一行数据,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,会发生最后的更新覆盖了由其他事务所做的更新。如下图:
image.png

脏读

事务 2 读取到了事务 1 已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果事务 1 回滚,2 读取的数据无效,不符合一致性要求。
image.png

不可重复读

一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。
image.png

幻读

一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。一句话,事务 A 读取到了事务 B 提交的新增数据,不符合隔离性。
image.png

MySQL 四种事务隔离级别

读未提交(READ UNCOMMITTED)

事务中的修改,即使没有提交,对其它事务也是可见的。这种隔离级别,会导致脏读、不可重复读以及幻读。

客户端 A 读到了客户端 B 未提交的事务所修改的数据

客户端 B 更新数据:
微信截图_20210626202057.png

客户端 A 看到了客户端 B 未提交的更新:
微信截图_20210626202116.png

读已提交(READ COMMITTED)

在事务 A 读取数据时增加了共享锁,一旦读取,立即释放锁,事务 B 读取修改数据时增加了行级排他锁,直到事务结束才释放锁。也就是说,事务 A 在读取数据时,事务 B 只能读取数据,不能修改。当事务 A 读取到数据后,事务 B 才能修改。这种隔离级别,可以避免脏读,但依然存在不可重复读以及幻读的问题。

读已提交虽然解决了脏读问题,但没有解决可重复读

微信截图_20210626203145.png微信截图_20210626203206.png

可重复读(REPEATABLE READ)

保证在同一个事务中多次读取同一数据的结果是一样的。可以通过加读锁的方式实现,当某个事务读到这行数据时,其他事务不允许修改(MySQL 通过 MVCC 机制实现)

虽然可以解决可重复读,但解决不了幻读

微信截图_20210626205120.png
虽然客户端 A 两次读的数据一样(可重复读)
微信截图_20210626205036.png
但客户端A仍可以感知到客户端B新增的数据
微信截图_20210626205519.png

可串行化(SERIALIZABLE)

强制事务串行执行,这样多个事务互不干扰,不会出现并发一致性问题。select 语句会加读锁(前三种都不会加读锁),update 语句会加写锁。

隔离级别 脏读
(Dirty Read)
不可重复读
(NonRepeatable Read)
幻读
(Phantom Read)
读未提交
(Read uncommitted)
读已提交
(Read committed)
×
可重复读
(Repeatable read)
× ×
可串行化
(Serializeable)
× × ×

锁机制

从性能上分为乐观锁用版本对比来实现)和悲观锁事务之间会等待

乐观锁:通过版本号比对来实现,类似于CAS 悲观锁:当某个事务抢到锁后,其他事务会进行等待锁释放

从对数据库操作的类型分,分为读锁写锁(都属于悲观锁)

读锁(共享锁,S锁(Shared)):针对同一份数据,多个读操作可以同时进行而不会互相影响 写锁(排它锁,X锁(eXclusive)):当前写操作没有完成前,它会阻断其他写锁和读锁

从数据操作的粒度分,分为表锁行锁

表锁:每次操作锁住整张表。开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低,一般用在整表数据迁移的场景。 行锁:每次操作锁住一行数据。开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。

行锁会升级为表锁

InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则都会从行锁升级为表锁

Innodb存储引擎由于实现了行级锁,虽然在锁机制的实现方面所带来的性能损耗可能比表级锁定会要更高一下,但是在整体并发处理能力方面要远远优于MYISAM的表级锁定的。当系统并发量高的时候,Innodb 的整体性能和MYISAM相比就会有比较明显的优势了。 但是,Innodb的行级锁定同样也有其脆弱的一面,当我们使用不当的时候,可能会让Innodb的整体性能表现不仅不能比MYISAM高,甚至可能会更差。

锁分析

通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况

show status like ‘innodb_row_lock%’

对各个状态量的说明如下:
Innodb_row_lock_current_waits: 当前正在等待锁定的数量
Innodb_row_lock_time: 从系统启动到现 在锁定总时间长度
Innodb_row_lock_time_avg: 每次等待所花平均时间
Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花时间
Innodb_row_lock_waits:系统启动后到现在总共等待的次数

锁优化建议

尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁 合理设计索引,尽量缩小锁的范围
尽可能减少检索条件范围,避免间隙锁 尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的sql尽量放在事务最后执行 尽可能低级别事务隔离