MySQL 事务 日志 锁
事务是并发控制的基本单位。事务要做到 可靠性 和 并发处理。
ACID
原子性
要么全部都执行,要都不执行。
原子性通过回滚日志实现。
持久性
持久化是为了能应对系统崩溃的情况。
当事务已经被提交之后,就无法再次回滚了,唯一能够撤回已经提交的事务的方式就是创建一个相反的事务对原操作进行『补偿』,这也是事务持久性的体现之一。
事务的持久性通过重做日志(Redo Log)实现。在事务提交后,数据没来得及写会磁盘就宕机时,在下次重新启动后能够成功恢复数据。
隔离性
一个事务在所做的修改最终提交以前,对其他事务时不可见的。
一致性
所有事务对于同一个数据的读取结果都是相同的。通过原子性,持久性,隔离性来实现。
并发一致性问题
- 丢失修改:A、B 两个事务同时对一个数据进行修改,A 先修改,B 后修改,B 的修改覆盖了 A 的修改。
- 脏读:A 修改一个数据,B 读取这个数据,如果 A 撤销了修改,那么 B 读取的数据是脏数据。
- 不可重复读:B 读取一个数据,A 对该数据进行修改,如果 B 再次读取这个数据,此时读取结果和第一次不同。
- 幻读:A 读取某个范围内的数据,B 在这个范围内插入数据,A再次读取这个范围的数据,此时读取的结果和第一次不同。
产生并发不一致的问题主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。
事务的隔离级别
- READ UNCOMMITED (未提交读):事务中的修改,没有提交的时候对其他事务是可见的。会产生脏读。好处是提升并发处理性能,能做到读写并行
- READ COMMITED (提交读):事务的修改在提交前对其他事务不可见。该级别会产生不可重读以及幻读问题。
- REPEATABLE READ (可重复读):保证同一个事务中多次读取同一数据的结果相同,可能发生幻读。
- SERIALIZABLE (可串行化):强制事务串行执行,多个事务互不干扰。解决了幻读的问题。
以上的所有的事务隔离级别都不允许脏写入(Dirty Write),也就是当前事务更新了另一个事务已经更新但是还未提交的数据,大部分的数据库中都使用了 READ COMMITED 作为默认的事务隔离级别,但是 MySQL 使用了 REPEATABLE READ 作为默认配置。MySQL 的 REPEATABLE READ 可以禁止幻读发生,通过临键锁实现。
多版本和快照隔离(MVCC)
多版本并发控制是 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读。
在 MVCC 中事务的增删改操作会为数据行新增一个版本快照,保证事务并行执行时能够不等待互斥锁的释放直接获取数据。
快照存储在 Undo 日志中,该日志通过回滚指针 ROLL_PTR 把一个数据行的所有快照连接起来。
InnoDB 对所有的 row 数据增加三个内部属性
- DB_TRX_ID 记录每一行最近一次修改它的事务 ID
- DB_ROLL_PTR 记录指向回滚段的 undo 日志的指针
- DB_ROW_ID 单调递增的行 ID
隔离级别的实现
未提交读
select 语句不加锁。
提交读
- 读取不加锁,是快照读
- 增删改加锁,加锁的语句,除了在外键约束检查以及重复键检查时会封锁区间,其他时刻只使用记录锁。此时,其他事务的插入依然可以执行,就可能导致读取到幻影记录。
可重复读
- 读取使用快照读,底层用 MVCC 实现。
- 增删改加锁,加锁的语句(select ... in share mode / select ... for update),他们的锁,依赖于他们是否在唯一索引上使用了唯一的查询条件,或者范围查询条件。
- 在唯一索引上使用唯一的查询条件会使用记录锁,不会使用间隙锁和临键锁。
- 范围查询条件会在记录之间加间隙锁和临键锁,索引之间的范围,避免范围间插入记录,以避免产生幻影行记录,以及避免不可重复的读。
串行化
所有 select 语句被隐式转化为 select ... in share mode。
如果有未提交的事务正在修改某些行,所有读取这些行的 select 都会被阻塞住。
两阶段事务提交
MySQL采用了两阶段事务提交(Two-Pha***mit Protocol)协议,当操作完成后,首先 prepare 事务,在 binlog 中实际只是 fake 一下,不做任何事情,而是 innodb 层需要将 prepare 写入 redolog 中。然后执行 commit 事务,首先在 binlog 文件中写入这些操作的 binlog 日志,完成之后在 innodb 的 redolog 写入 commit 日志。
日志
undo log | redo log | binlog |
---|---|---|
逻辑日志 | 物理日志 | 逻辑日志 |
回滚日志(Undo Log)
用于实现事务的原子性,还有多版本并发控制(MVCC)。
- 是逻辑日志,跟据回滚日志做逆向操作,比如 delete 的逆向操作为 insert,insert 的逆向操作为 delete,update 的逆向为 update 等。
- 每条数据变更 (insert/update/delete) 操作都伴随一条 undo log 的生成。
回滚日志除了能够在发生错误或者用户执行 ROLLBACK 时提供回滚相关的信息,它还能够在整个系统发生崩溃、数据库进程直接被杀死后,当用户再次启动数据库进程时,还能够立刻通过查询回滚日志将之前未完成的事务进行回滚,这也就需要回滚日志必须先于数据持久化到磁盘上,是我们需要先写日志后写数据库的主要原因。
重做日志(Redo Log)
重做日志由两部分组成。
- 内存中的重做日志缓冲区(redo log buffer)
- 磁盘上的重做日志文件
缓冲池(Buffer Pool)是为了提升性能的,缓冲池中的数据定期同步到磁盘。
在一个事务中尝试对数据进行修改时的过程:
- 先将数据从磁盘读入内存,并更新内存中缓存的数据。
- 然后生成一条重做日志并写入重做日志。
- 当事务真正提交时,MySQL 会将日志缓存中的内容刷新到重做日志文件。
- 最后再将内存中的日志缓冲区数据更新到磁盘上。
为了保证持久性,数据必须要晚于 redo log 写入持久存储。当系统崩溃时,系统可以根据 Redo Log 的内容,将所有数据恢复到最新的状态。
既然 redo log 也涉及磁盘 IO,为什么还要用?
- Redo Log 会尽量存储在一段连续的空间上,因此在系统第一次启动时就会将日志文件的空间完全分配,以顺序追加的方式记录 Redo Log,而缓存同步是随机操作。
- 缓存同步是以数据页为单位的,每次传输的数据小于 redo log。
- 批量写入日志。日志并不是直接写入文件,而是先写入日志缓冲区。当需要将日志刷新到磁盘时(如事务提交),将许多日志一起写入磁盘。
- 重做日志都是以 512 字节的块的形式进行存储的,同时因为块的大小与磁盘扇区大小相同,所以重做日志的写入可以保证原子性,不会由于机器断电导致重做日志仅写入一半并留下脏数据。
注意点
- 因为批量写入日志的原因,当一个事务将 Redo Log 写入磁盘时,也会将其他未提交的事务的日志写入磁盘。
- Redo Log 上只进行顺序追加的操作,当一个事务需要回滚时,它的 Redo Log 记录也不会从 Redo Log 中删除掉。
崩溃恢复
进行恢复时,重做所有事务包括未提交的事务和回滚了的事务。然后通过 Undo Log 回滚那些提交的事务。
使用这种策略进行恢复就必须要在写 Redo Log 之前将对应的 Undo Log 写入磁盘。Undo 和 Redo Log 的这种关联,使得持久化变得复杂起来。为了降低复杂度,InnoDB 将 Undo Log 看作数据,因此记录 Undo Log 的操作也会记录到 Redo Log 中。这样 Undo Log 就可以像数据一样缓存起来,而不用在 Redo Log 之前写入磁盘了。
二进制日志(binlog)
binlog 是 Mysql sever 层维护的一种二进制日志,与 innodb 引擎中的 redo/undo log 是完全不同的日志。其主要是用来记录对mysql 数据更新或潜在发生更新的 SQL 语句,并以"事务"的形式保存在磁盘中。
作用:
- 主从复制:MySQL Replication 在 Master 端开启 binlog,Master 把它的二进制日志传递给 slaves 并回放来达到master-slave 数据一致的目的。
- 数据恢复:通过mysqlbinlog工具恢复数据。
- 增量备份。
redo log 和 binlog:
- 作用不同:redo log 是保证事务的持久性的,是事务层面的,binlog 作为还原的功能,是数据库层面的(当然也可以精确到事务层面的),虽然都有还原的意思,但是其保护数据的层次是不一样的。
- 内容不同:redo log 是物理日志,是数据页面的修改之后的物理记录,binlog 是逻辑日志,可以简单认为记录的就是sql语句
- 另外,两者日志产生的时间,可以释放的时间,在可释放的情况下清理机制,都是完全不同的。
- 恢复数据时候的效率,基于物理日志的 redo log 恢复数据的效率要高于语句逻辑日志的 binlog。
锁机制
show engine innodb status; 可以查看 InnoDB 的锁情况,也可以调试死锁
锁的粒度
- 表锁:系统性能开销最小,会锁定整张表,MyISAM 使用表锁。
- 行锁:最大程度的支持并发处理,但是也带来最大的锁开销,InnoDB 使用行级锁。
为了支持多粒度锁定,InnoDB 引入了意向锁,意向锁是一种表级锁。
InnoDB 的行锁是实现在索引上的,而不是锁在物理行记录上,如果访问没有命中索引,也无法使用行锁,将退化为表锁。
InnoDB 的七种锁
共享/排他锁
- 多个事务可以拿到一把共享锁,读读并行
- 只有一个事务可以拿到排他锁,写写/读写并行
- 读锁:共享,不堵塞
select ... lock in share mode
- 写锁: 排他,堵塞
select ... for update
- InnoDB 引擎中的 update,delete,insert 语句自动加排他锁
意向锁
意向锁是一个表级的锁。
- 意向共享锁(IS):事务有意向对表中的某些行加 S 锁
- 意向排他锁(IX):事务有意向对表中的某些行加 X 锁
select ... lock in share mode;//要设置 IS 锁 select ... for update;//要设置IX锁
意向锁的意义:
- IX,IS 是表级锁,不会和行级的 X,S 锁发生冲突。只会和表级的 X,S 发生冲突。
- 意向锁是在添加行锁之前添加。
- 如果没有意向锁,当向一个表添加表级 X 锁时,就需要遍历整张表来判断是否存行锁,以免发生冲突。
- 如果有了意向锁,只需要判断该意向锁与表级锁是否兼容即可。
记录锁(Record Lock)
对单条索引记录进行加锁,锁住的是索引记录而非记录本身,即使表中没有任何索引,MySQL会自动创建一个隐式的row_id作为聚集索引来进行加锁。
select * from t where id = 1;
它会在 id = 1
的索引上加锁,以防止其他事务插入、更新、删除 id = 1 的这一行。
间隙锁(GAP Lock)
封锁索引记录中的间隔,如
select * from t where id between 8 and 15 for update;
会封锁区间,阻止其他事务在 8-15 之间插入。
插入意向锁
是间隙锁的一种,所以也是实施在索引上的,专门针对 insert 操作。
多个事务,在同一个索引,同一个范围区间插入记录时,如果插入的位置不冲突,不会阻塞彼此。
临键锁(Next-key Lock)
临键锁是记录锁与间隙锁的组合,会封锁索引记录本身以及索引记录之前的区间。
目的是为了避免幻读,如果把事务的隔离级别降级为提交读,临键锁会失效。
自增锁
是一种表级别的锁,专门针对事务插入 AUTO_INCREAMENT 类型的列。如果一个事务正在往表中插入记录,所有其他事务必须等待,一边第一个事务插入的行,是连续的主键值。
并发控制机制(乐观锁和悲观锁)
- 悲观锁:共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程。传统关系型数据库里边就用到了很多这种锁机制,行锁表锁读锁写锁,都是在操作前先上锁。
- 乐观锁:在更新时判断在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。乐观锁适用于多读的应用类型,这样可以提供吞吐量。
乐观锁实现方式一:版本号机制
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
乐观锁实现方式二:CAS 算法
即compare and swap(比较与交换),是一种有名的无锁算法。
CAS 算法涉及到三个操作数:
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
乐观锁的缺点
- ABA 问题:不能保证一个值 A 被改为 B 后又改为 A,CAS 认为他没有被修改过。
- 循环时间开销大:如果长时间不成功,会给 CPU 带来非常大的执行开销。
- 只能保证一个共享变量的原子操作。
自旋锁
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗 CPU。使用不当会造成 CPU 使用率极高。