一、前言
随着业务发展,对数据库的并发性能要求也越来越高,不仅要做到高并发还需要在保障数据安全,那么今天我们聊一聊 MySQL 在高并发下事务、MVCC、锁机制是如何在高并发情况下维护数据的安全。
二、事务 ACID
- 为什么需要事务:事务是为了
保障
用户的数据操作对数据是安全的
。比如我们的银行卡余额,我们希望对它的操作是稳定准确
的,而且绝对安全
。 ACID
:事务的四大特性原子性
、一致性
、隔离性
、持久性
。原子性
:原子性是指一个事务
要么全部执行
,要么完全不执行
。主要是由 innodb 引擎中的undo
回滚日志来维护
。隔离性
:事务在操作过程
中不会受到其它事务
操作的影响
。主要由事务的隔离级别
和锁机制
共同维护。持久性
:事务操作的结果是具体持久性的,通俗来讲就是提交事务后会持久化存储
(落盘)。主要是由redo log
来维护。一致性
:事务在开始和结束时,数据始终保持一致。由原子性、隔离性、持久性共同维护。
三、多版本并发控制 MVCC
-
介绍:数据库的核心方向就是
高并发
,MySQL 通过并发控制技术
来维护
高并发的环境下数据的一致性
和数据安全
。MySQL 并发控制有两种
技术方案锁机制(Locking)
和多版本并发控制 (MVCC)
。 -
锁机制:通过
锁机制
可以维护数据的一致性
,但是整体业务场景大多是读-读
、读-写
、写-写
,三类并发场景,看似容易融合到业务场景后也比较复杂
。通过锁机制
主要可以帮助我们解决写-写
和读-读
场景下的并发安全
问题 则MVCC
主要帮助解决读-写
问题。 -
MVCC:
多版本并发控制
,侧重优化读-写
业务的高并发环境。可以解决写操作
时堵塞读操作
的并发
问题。 -
一致性非锁定读
:指 innodb 引擎通过多版本并发控制
的方式来读取,当前执行时间数据库中的行数据。读取正在进行 update 或 delete 操作的行,不会等待
锁释放,而是会读取
该行的快照数据
。快照就是指该行之前的版本
数据,主要靠undo
日志来实现,而 undo 是用于数据库回滚
,因此快照读本身是没有开销
的。后台 Purge 线程也会自动清理一些不需要的 undo 数据。 -
MVCC 两类读操作:分为两类读情况
快照读(Snapshot Read)
和当前读(Current Read)
快照读是读取数据的可见版本
而当前读则是读取当前数据的最新版本
需要加锁
从而保障其它事务不会修改当前数据。 -
MVCC 实现策略:我们在设计表过程中通过不会
直接删除
数据而是设定一个字段来标记,从实现逻辑
意义上的删除。MVCC 的实现方式也与此类似。这种数据管理方式叫数据生命周期管理
,其中两个指标就是标记数据的变化
和标记数据可用状态
。 -
MVCC 下的
DML
过程演示:Insert:
进行 insert 操作,事务 id 假设为 1
id name create version delete version 1 test 1 Update:
MVCC 会先将当前记录标记为已删除在 delete version 字段下设置版本号(原来为空),然后新增一行数据,写入相应的版本号,此时为新版本号为 2 和上一条数据的 delete version 一致,比如将 name 修改为 fantasy,如下表:
id name create version delete version 1 test 1 2 1 fantasy 2 Delete:
直接将当前数据的 delete version 打上版本号标记为删除
id name create version delete version 1 test 1 2 1 fantasy 2 3 -
MVCC 解析:刚才只是在
逻辑层面
上介绍 MVCC 的运作方式 create version 和 delete version 维护的是数据的版本信息和数据可用状态
,而实际上
还有一个字段是用户undo
回滚的指针,接下来我们介绍源码
中 MVCC 的实现方式。默认
会给每张表加入三个隐藏
字段(内部属性
)DB_TRX_ID:占 6 个字节,记录每一行最近一次修改它的事务 ID
DB_ROLL_OIR:占 7 个字节,记录指向回滚段 undo 日志的指针
DB_ROW_ID:占 6 个字节,当写入数据时,自动维护自增列将三个字段结合就可以标记数据的周期性和,并定位到对应的事务。这就引出 innodb 中实现 MVCC 两个重要模块 undo 日志用来存储数据的变化 Read View 用来做可见性判断的, 里面保存了
对本事务不可见的其他活跃事务
-
注意:对于 innodb 来讲,无论是
更新
还是删除
,都只是设置行记录上的deldte BIT
来标记,而并不是真正的删除记录
,后续这些记录的清理就需要Purge
线程来做。还需要注意的是 MVCC 只能在RC
和RR
隔离级别下使用,RU
是读未提交
状态,所以不存在版本问题
,而串行化
则会对读取的数据行加锁。
四、隔离级别
-
为什么需要
隔离
级别?
事务之间如果不互相隔离
,那么就会出现脏读
、不可重复读
和幻读
。 -
简单概括脏读、不可重复读和幻读:
写
在前,读
在后:脏读;
读
在前,写
在后:不可重复读;
读
在前,写
在后,再读
:幻读。 -
隔离级别与并发问题的关系如下:
级别 脏读 不可重复读 幻读 读未提交(READ-UNCOMMITTED) ✅ ✅ ✅ 不可重复读(READ-COMMITTED) ❌ ✅ ✅ 可重复读(REPEATABLE-READ) ❌ ❌ 🆚 串行化(SERIALIZABLE) ❌ ❌ ❌ 其中
串行化
隔离级别虽然解决了所有数据问题,但是却带来了并发的性能
问题,而读未提交
的隔离级别违反了基础事务的安全
处理要求,所以我们在选择隔离级别时都会在 RC 和 RR 中选择,MySQL默认隔离级
别为 RR 级别。-- 查询数据库中的隔离级别 select @@transaction_isolation; -- 临时设置MySQL数据库中的隔离级别 set global transaction_isolation='READ-COMMITTED';
-
RC 和 RR 的区别:RC 在事务可以读取到
其它事务
提交的事务数据,而对于 RR 级别来讲,它会保证在一个事务中数据多次的查询
结果是不变的,尽管其它事务已经提交了改动。从锁的角度来讲 RC 的性能
会优于 RR。 -
RC 和 RR 级别下的
快照读
:RC 级别下的快照读总是会读取被锁定
行的最新版本
的一份快照
数据,而 RR 级别下的快照读总是会读取事务开始
时行版本的数据。这个怎么理解呢?请看如下案例:
首先打开 MySQL 会话
Session 1
开启一个事务然后查询一条数据
开启另一个 MySQL 会话
Session 2
模拟并发场景,开启事务修改
id1 = 3 中的 id2 为 8023
在
Session 2
中我们修改了 id2 20170831 为 8023 但是还未提交
,此时 id1 = 3 的行已经加上了一个X 排它锁
,此时再读取Session 1
会话中的 id1 = 3 的记录根据 innodb 引擎的特性,即在 RR 和 RC 事务隔离级别下会使用“非锁定一致性读”
也就是快照读。接者Session 1
未提交的事务再此运行查询 id1 = 3 的 SQL 语句,此时无论
此时隔离级别是 RR 和 RC 结果都如下图:
接者我们提交
Session 2
中的事务。
在
Session 2
中的事务提交后,在Session 1
中再次执行查询 id1 = 3 的 SQL 语句,此时在RR
和RC
级别下运行的结果就不同
了,RC 事务的隔离级别
,总是读取最新版本
的快照
数据,因为Session2
提交了事务更新
了快照版本
,所以 Session 1 在事务中可以读取 Session 2 中已经提交
的改动,结果如下:
因为
RR
级别读取数据快照总是读取开始事务前
的行版本的快照数据,所以尽管Session 2
更新了快照版本,RR
级别下事务未提交之前不会
受到影响,所以RR
级别下两次读取数据的结果都相同
:
五、锁机制
-
什么是锁?
锁是计算机协调
多个进程或线程并发
访问某一资源的机制。 -
innodb 两种锁:
共享锁 S
:允许一个事务去读
一行,阻止其它事务获得相同数据集的排它锁
。通俗来讲就是可以重复读,没读完时不允许写。
排它锁 X
:允许获得排它锁的事务更新
数据,阻止其它事务获得相同数据集的排他锁
和共享锁
。通俗来讲就是写的时候不允许其它事务写和读。 -
发现
问题
:有两个事务 A 和 B,事务 A 锁住了表中的一行数据,加上行锁 S
,即这一行只能读不能写。之后事务 B 又申请整张表
的写锁 (mysql 中可以使用 lock table xxx write 锁表),正常逻辑来将,事务 B 就可以修改
表中任意行数据,包括事务 A 锁住的那一行数据,实际情况则会发生锁冲突
,现在就需要一种机制来判断是否有行锁
,比如锁表前先判断每一行数据是否有行锁
,但是这种方案在随着数据量增大代价
会无限放大,肯定是不取的,而意向锁
就是来解决冲突的协调者
。 -
意向锁工作流程:
事务A
首先需要申请表的意向锁
,成功后申请一行的行锁。
事务B
申请排它锁
,但是发现表中已经有意向共享锁
,说明表中的某行数据已经被锁定,此时申请的写锁会被堵塞
。 -
意向共享锁
IS
:事务给某行数据加入共享锁
前,需要先申请意向共享锁
。通俗来讲,就是一个数据行加共享锁前必须要先取得该表的意向共享锁。 -
意向排它锁
IX
:与上类似,加入排它锁
前需要先获得意向排它锁
。 -
innodb
行锁
:行锁是通过给索引
加锁来实现的,不用担心表中是否创建
了索引,如果有主键 MySQL 会在主键
上创建聚簇索引
用于回表查询
,如果没有主键则考虑unique 约束
的字段,如果前面两种都不满足,则会创建隐藏列 RowID 作为聚簇索引。如果不通过索引
检索数据,那么 innodb 引擎会对表中所有的数据加锁,实际效果与表锁
相同,所以要尽可能让所有数据都通过索引来完成,避免行锁升级为表锁。innodb 下的三种行锁:
行锁(Record Lock)
:对索引加锁,即锁定一行记录。
间隙锁(Gap Lock)
:对索引项之间的间隙、对第一条记录前的间隙或最后一条记录后的间隙加锁,即锁定一个范围的记录,不包含记录本身。
Next-Key Lock
:锁定一个范围的记录幷包含记录本身。