目录
- 事务的四大特性(ACID)
- 事务的隔离级别
- MySQL数据库的四种事务隔离级别
- 解决脏读问题
- 解决不可重复读问题
- 解决幻读问题
- MVCC
- next-key锁
事务的四大特性(ACID)
数据库支持事务操作,必须要具备以下四个特性:
- 原子性(Atomicity)
原子性:事务包含的所有操作要么全部成功,要么全部失败回滚,因此事务的操作如果成功就必须要完全应用到数据库,如果操作失败则不能对数据库有任何影响。 - 一致性(Consistency)
一致性:事务必须使数据库从一个一致性状态变换到另一个一致性状态,即一个事务执行之前和执行之后都必须处于一致性状态。例如转账,A和B两者的钱加起来一共是5000,则不管A和B之间如何转账,转几次账,事务结束后两者的金额相加起来应该是5000,即事务的一致性。 - 隔离性(Isolation)
隔离性:当多个用户并发访问数据库时,如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。即要达到这么一种效果:对于任意两个并发的事务S1和S2,在事务S1看来,S2要么在S1开始之前就已经结束,要么在S1结束之后才开始,即每个事务都感觉不到有其他事务在并发地执行。 - 持久性(Durability)
持久性:一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。
redo log
重做日志用来保证事务的持久性undo log
回滚日志保证事务的原子性undo log+redo log
保证事务的一致性锁(共享、排他)
用来保证事务的隔离性
重做日志 redo log
重做日志 redo log 分为两部分:一部分是内存中的重做日志缓冲(redo log buffer),是易丢失的;二部分是重做日志文件(redo log file),是持久的。InnoDB通过Force Log at Commit机制来实现持久性,当commit时,必须先将事务的所有日志写到重做日志文件进行持久化,待commit操作完成才算完成。InnoDB在下面情况下会将重做日志缓冲的内容写入重做日志文件:
- master thread 每一秒将重做日志缓冲刷新到重做日志文件
- 每个事务提交时
- 当重做日志缓冲池剩余空间小于1/2时
redo log buffer 的刷新到磁盘的时机由参数 innodb_flush_log_at_trx_commit 参数控制:
- 0 由master 线程周期性任务刷新
- 1 在事务 commit 时 redo log buffer 同步写入 disk,伴随 fsync 调用
- 2 将 redo log 日志数据异步写入 disk,即只写到文件系统缓存中,在事务成功提交后并不能保证 redo log 数据一定存储到磁盘上了
回滚日志 undo log
为了满足事务的原子性,在操作任何数据之前,首先将数据备份到Undo Log,然后进行数据的修改。如果出现了错误或者用户执行了 ROLLBACK语句,系统可以利用Undo Log中的备份将数据恢复到事务开始之前的状态。undo log实现多版本并发控制(MVCC)来辅助保证事务的隔离性。
回滚日志不同于重做日志,它是逻辑日志,对数据库的修改都逻辑的取消了。当事务回滚时,它实际上做的是与先前相反的工作。对于每个INSERT,InnoDB存储引擎都会完成一个DELETE;对于每个UPDATE,InnoDB存储引擎都会执行一个相反的UPDATE。
事务提交后并不能马上删除undo log,这是因为可能还有其他事务需要通过undo log 来得到行记录之前的版本。故事务提交时将undo log 放入一个链表中,是否可以删除undo log 根据操作不同分以下2种情况:
- Insert undo log: insert操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该undo log可以在事务提交后直接删除。不需要进行 purge操作
- update undo log:记录的是对 delete和 update操作产生的 undo log。该undo log可能需要提供MVCC机制,在快照读时也需要。因此不能在事务提交时就进行删除。提交时放入undo log链表,等待 purge线程进行最后的删除
purge
从前面的分析可以看出,为了实现InnoDB的MVCC机制,更新或者删除操作都只是设置一下老记录的deleted_bit,并不真正将过时的记录删除。
为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。
为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);
如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。
锁
事务的隔离性的实现原理就是锁,因而隔离性也可以称为并发控制、锁等。事务的隔离性要求每个读写事务的对象对其他事务的操作对象能互相分离。再者,比如操作缓冲池中的LRU列表,删除,添加、移动LRU列表中的元素,为了保证一致性那么就要锁的介入。
锁的分类
按功能分:共享锁(读锁、S锁);排它锁(写锁、X锁)
按锁粒度分:页锁;表锁;行锁
行级锁:
- 共享锁(读锁 S),允许事务读一行数据。事务拿到某一行记录的共享S锁,才可以读取这一行,并阻止别的事务对其添加X锁。共享锁的目的是提高读读并发
- 排它锁(写锁 X),允许事务删除一行数据或者更新一行数据。事务拿到某一行记录的排它X锁,才可以修改或者删除这一行。排他锁的目的是为了保证数据的一致性。
行级锁中,除了S和S兼容,其他都不兼容
意向锁:
- 意向共享锁(读锁 IS ),事务想要获取一张表的几行数据的共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁
- 意向排他锁(写锁 IX),事务想要获取一张表中几行数据的排它锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁
意向锁的主要用途是为了表达某个事务正在锁定一行或者将要锁定一行数据。例如:事务A要对一行记录r进行上X锁,那么InnoDB会先申请表的IX锁,再锁定记录r的X锁。在事务A完成之前,事务B想要来个全表操作,此时直接在表级别的IX就告诉事务B需要等待而不需要在表上判断每一行是否有锁。意向排它锁存在的价值在于节约InnoDB对于锁的定位和处理性能。另外注意了,除了全表扫描以外意向锁都不会阻塞。
InnoDB支持行级锁,有以下三种实现算法:
- Record Lock:单个行记录上的锁
- Gap Lock:间隙锁基于非唯一索引,对索引记录间的范围加锁,它锁定一段范围内的索引记录
- Next-Key Lock:结合Gap Lock和Record Lock,锁定一个范围,并且锁定记录本身。主要解决的问题是REPEATABLE READ隔离级别下的幻读
幻读的问题存在是因为新增或者更新操作,这时如果进行范围查询的时候,会出现不一致的问题,需要对一定范围内的数据进行加锁,间隙锁就是解决这类问题的。在可重复读隔离级别下,数据库是通过行锁和间隙锁共同组成的(next-key lock),来实现的。利用Next-key Lock锁定的不是单个值而是一个范围,目的就是为了阻止多个事务将记录插入到同一范围内从而导致幻读。如果走唯一索引,那么Next-Key Lock会降级为Record Lock,即仅锁住索引本身,而不是范围。即Next-Key Lock前置条件为事务隔离级别为RR且查询的索引走的非唯一索引、主键索引。如果索引唯一的话,仅使用行锁即可,因为不可能插入一条索引字段相同的记录导致幻读问题的发生。
事务的隔离级别
数据库事务:读取事务(SELECT)、修改事务(UPDATE/INSERT)。在没有事务隔离的时候,多个事务在同一时刻对同一数据的操作可能会影响到最终期望的结果,通常有四种情况(并发事务有什么问题):
- 两个更新事务同时修改一条数据时,会造成更新的丢失
- 一个更新事务更新一条数据时,另一个读取事务读取了还没提交的更新,会出现读取到脏数据
- 一个读取事务读取一条数据时,另一个更新事务修改了这条数据,会出现不可重现的读取
- 一个读取事务读取时,另一个插入事务插入了一条新数据,可能多读出一条数据,出现幻读
可总结为:
- 修改时允许修改(丢失更新)
- 修改时允许读取(脏读)
- 读取时允许修改(不可重复读)
- 读取时允许插入(幻读)
脏读
脏读:在一个事务处理过程里读取了另一个未提交的事务中的数据。
当一个事务正在多次修改某个数据,而在这个事务中这多次的修改都还未提交,这时一个并发的事务来访问该数据,就会造成两个事务得到的数据不一致。例如:用户A向用户B转账100元
update account set money=money+100 where name=’B’; //此时A通知B
update account set money=money-100 where name=’A’;
当只执行第一条SQL时,A通知B查看账户,B发现确实钱已到账(此时即发生了脏读),而之后无论第二条SQL是否执行,只要该事务不提交,则所有操作都将回滚,那么当B以后再次查看账户时就会发现钱其实并没有转。
不可重复读
不可重复读:在对于数据库中的某个数据,一个事务范围内多次查询却返回了不同的数据值,这是由于在查询间隔,被另一个事务修改并提交了。例如事务S1在读取某一数据,而事务S2立马修改了这个数据并且提交事务给数据库,事务S1再次读取该数据就得到了不同的结果,发送了不可重复读。
不可重复读和脏读的区别:脏读是某一事务读取了另一个事务未提交的脏数据,而不可重复读则是读取了前一事务提交的数据。
幻读
幻读是事务非独立执行时发生的一种现象。事务S1对一个表中所有的行的某个数据项做了从“1”修改为“2”的操作,这时事务S2又对这个表中插入了一行数据项,而这个数据项的数值还是为“1”并且提交给数据库。而操作事务S1的用户如果再查看刚刚修改的数据,会发现还有一行没有修改,其实这行是事务S2中添加的,就好像产生幻觉一样,即幻读。
幻读和不可重复读都是读取了另一条已经提交的事务,所不同的是不可重复读查询的都是同一个数据项,而幻读针对的是一批数据整体。
MySQL数据库的四种事务隔离级别
- Read Uncommitted(读取未提交内容)
所有事务都可以看到其他未提交事务的执行结果。读取未提交的数据,即脏读 - Read Committed(读取提交内容)
满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别也支持所谓的不可重复读(Nonrepeatable Read),因为同一事务的其他实例在该实例处理其间可能会有新的commit,所以同一select可能返回不同结果 - Repeatable Read(可重读)
MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。但理论上会导致另一个棘手的问题:幻读 (Phantom Read)。InnoDB存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)机制解决该问题 - Serializable(可串行化)
最高的隔离级别,通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。
在MySQL中,实现了这四种隔离级别,分别可能产生如下问题:
① Serializable (串行化):可避免脏读、不可重复读、幻读的发生。
② Repeatable read (可重复读):可避免脏读、不可重复读的发生。
③ Read committed (读已提交):可避免脏读的发生。
④ Read uncommitted (读未提交):最低级别,任何情况都无法保证。
以上四种隔离级别最高的是Serializable级别,最低的是Read uncommitted级别,当然级别越高,执行效率就越低。像Serializable这样的级别,就是以锁表的方式(类似于Java多线程中的锁)使得其他的线程只能在锁外等待,所以平时选用何种隔离级别应该根据实际情况。在MySQL数据库中默认的隔离级别为Repeatable read (可重复读)。
如果是使用JDBC对数据库的事务设置隔离级别的话,应该是在调用Connection对象的setAutoCommit(false)方法之前。调用Connection对象的setTransactionIsolation(level)即可设置当前链接的隔离级别,至于参数level,可以使用Connection对象的字段:
在JDBC中设置隔离级别的部分代码:
Connection conn=null;
Statement st=null;
ResultSet rs=null;
try{
conn=JdbcUtils.getConnection();
conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);//设置该链接的隔离级别,设置数据库的隔离级别一定要是在开启事务之前
conn.setAutoCommit(false);//开启事务
}
隔离级别的设置只对当前链接有效。对于使用MySQL命令窗口而言,一个窗口就相当于一个链接,当前窗口设置的隔离级别只对当前窗口中的事务有效;对于JDBC操作数据库来说,一个Connection对象相当于一个链接,而对于Connection对象设置的隔离级别只对该Connection对象有效,与其他链接Connection对象无关。
解决脏读问题
修改时加排他锁,直到事务提交后才释放
读取时加共享锁,读取完释放事务。读取数据时加上共享锁后(这样在事务读取数据的过程中,其他事务就不会修改该数据),不允许任何事物操作该数据,只能读取,之后如果有更新操作,那么会转换为排他锁,其他事务更无权参与进来读写,这样就防止了脏读问题。
但是当事务1读取数据过程中,有可能其他事务也读取了该数据,读取完毕后共享锁释放,此时事务修改数据,修改完毕提交事务,其他事务再次读取数据时候发现数据不一致,就会出现不可重复读问题,所以这样不能够避免不可重复读问题。
解决不可重复读问题
读取数据时加共享锁,写数据时加排他锁,都是事务提交才释放锁。读取时候不允许其他事物修改该数据,不管数据在事务过程中读取多少次,数据都是一致的,避免了不可重复读问题。
解决幻读问题
在Repeatable Read的隔离级别下,Innodb使用MVCC和next-key locks解决幻读,MVVC解决的是快照读的幻读,next-key locks解决的是当前读情况下的幻读。
MVCC
MVCC(多版本并发控制)目的在于实现读-写冲突不加锁,提高数据库高并发场景下的吞吐性能,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的。不同的事务在并发过程中,SELECT 操作可以不加锁而是通过 MVCC 机制读取指定的版本历史记录,并通过一些手段保证保证读取的记录值符合事务所处的隔离级别,从而解决并发场景下的读写冲突。具体的实现是,在数据库的每一行中,添加额外的三个字段:
DB_TRX_ID – 记录插入或更新该行的最后一个事务的事务 ID
DB_ROLL_PTR – 指向改行对应的 undolog 的指针
DB_ROW_ID – 单调递增的行 ID,他就是 AUTO_INCREMENT 的主键 ID
快照读与当前读
Innodb拥有一个自增的全局事务 ID,每当一个事务开启,在事务中都会记录当前事务的唯一 id,而全局事务 ID 会随着新事务的创建而增长。同时,新事务创建时,事务系统会将当前未提交的所有事务 ID 组成的数组传递给这个新事务。快照读
像不加锁的select操作就是快照读,即不加锁的非阻塞读。每当一个事务更新一条数据时,都会在写入对应 undo log 后将这行记录的隐藏字段 DB_TRX_ID 更新为当前事务的事务 ID,用来表明最新更新该数据的事务是该事务。当另一个事务去 select 数据时,读到该行数据的 DB_TRX_ID 不为空并且 DB_TRX_ID 与当前事务的事务 ID 是不同的,这就说明这一行数据是另一个事务修改并提交的。那么,这行数据究竟是在当前事务开启前提交的还是在当前事务开启后提交的呢?
如上图所示,有了上文提到的 TRX_ID 集合,就很容易判断这个问题了,如果这一行数据的 DB_TRX_ID 在 TRX_ID 集合中或大于当前事务的事务 ID,那么就说明这行数据是在当前事务开启后提交的,否则说明这行数据是在当前事务开启前提交的。
对于当前事务开启后提交的数据,当前事务需要通过隐藏的 DB_ROLL_PTR 字段找到 undo log,然后进行逻辑上的回溯才能拿到事务开启时的原数据。这个通过 undo log + 数据行获取到事务开启时的原始数据的过程就是“快照读”。
当前读
像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读。在读取数据库时,需要读取的是行的当前数据,而不需要通过 undo log 回溯到事务开启前的数据状态。
MVCC与不可重复读、幻读的问题不可重复读与幻读
不可重复读指的是,在一个事务开启过程中,当前事务读取到了另一事务提交的修改。幻读则指的是,在一个事务开启过程中,读取到另一个事务提交导致的数据条目的新增或删除。
可重复读解决不可重复读与幻读问题的原理
对于正常的 select 查询 innodb 实际上进行的是快照读,即通过判断读取到的行的 DB_TRX_ID 与 DB_ROLL_PTR 字段指向的 undo log 回溯到事务开启前或当前事务最后一次更新的数据版本,从而在这样的场景下避免了可重复读与幻读的问题。
针对已存在的数据,insert 和 update 操作虽然是进行当前读,但 insert 与 update 操作后,该行的最新修改事务 ID 为当前事务 ID,因此读到的值仍然是当前事务所修改的数据,不会产生不可重复读的问题。
但如果当前事务更新到了其他事务新插入并提交了的数据,这就会造成该行数据的 DB_TRX_ID 被更新为当前事务 ID,此后即便进行快照读,依然会查出该行数据,产生幻读(其他事务插入或删除但未提交该行数据的情况下会锁定该行,造成当前事务对该行的更新操作被阻塞,所以这种情况不会产生幻读问题)。
Read View(读视图)
Read View是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID,这个ID是递增的,所以最新的事务,ID值越大)。
所以Read View主要是用来做可见性判断的,即当某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本。
RC,RR级别下的InnoDB快照读有什么不同?
正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同:
- 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见
- 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
- 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因
总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
好文推荐
next-key锁
next-key的原理:将当前数据行与上一条数据和下一条数据之间的间隙锁定,保证此范围内读取数据是一致的。next-key锁包含了记录锁(加在索引上的锁)和间隙锁(加在索引之间的锁)。可以理解为X锁(排它锁)+GAP锁。
例如,在RR的情况下,假设使用的是当前读,加锁了的读:select * from table where id>3
锁住的就是id=3这条记录以及id>3这个区间范围,锁住索引记录之间的范围,避免范围间插入记录,以避免产生幻影行记录。
什么是快照读和当前读
快照读:简单的select操作,属于快照读,不加锁。
当前读:加锁的select(S或者X)操作,插入/更新/删除操作,属于当前读,需要加锁。