本文正在参加「技术专题19期 漫谈数据库技术」活动
Mysql事务
事务特性
- 原子性:同一个事务中的所有操作要满足原子性要求,要么操作全部成功要么操作全部失败,在同一个事务中不会存在A操作成功,B操作失败的情况,如果B操作失败,那么事务会进行回滚到A,B操作之前的状态;
- 隔离性:并行事务之间互不干扰;
- 持久性:事务提交后,永久生效;
- 一致性:事务从一个一致性的状态到另一个一致性的状态,也就是一个事务执行前和执行后都要处在一致性状态;
不知道大家有没有发现,一致性和原子性看起来感觉没太大差别,那么为什么还会有一致性和原子性两个特性呢?这里一致性主要是指:数据库前后处理结果与客观真实状态保持一致,这种一致性有管理员来定义规则(程序的业务逻辑)。
举个例子: A/B两个账户转账,如果AB账户宗和5000元,那么A就不可以转10000到B,虽然数学上可以这样来加减,但是在业务场景性不可以这样执行,需要确保业务一致,可以这样理解一致性。
我们可以理解为数据库的原子性,隔离性,持久性就是为了保障最终的一致性。
并发问题
Mysql的事务虽然有一致性和原子性等特质,但是当我们在Mysql中有多个事务的时候,多个事务同时操作同一个表或者记录的时候,会引起很多一些并发问题主要有以下几点。
- 脏读:读到其他事务中未提交的数据 示例:当事务Tx1进行查询id=5的记录name=ttt,同时事务Tx2进行id=5记录修改name=ddd,接下来Tx1有进行了数据查询发现name=ddd,然后使用ddd进行处理,结果Tx2没有提交事务或者进行了回滚操作,将name有变成了ttt,那么当Tx1最终提交的时候就出现了脏读。
- 不可重复读:当前事务中两次读取的结果不同 示例:当前事务Tx1第一次读取到数据库记录id=5的值是100,同时事务Tx2对于id=5的值修改到105,当事务Tx1再次读取的时候发现id=5的值变成了105,逻辑上看这种情况没有什么问题,但是从事务隔离级别角度来看,我们需要保证在同一次事务Tx1的生命周期中,最好不要看到其他事务的操作,因此从这个角度来看,对于Tx1两次读取的内容不一样,即出现了不可重复读
- 幻读:当前select操作得到的结果所呈现的数据不足以支撑后续的业务操作 示例:当前事务Tx1查找到表A中的最大记录是100,然后进行插入操作,在Tx1事务执行select之后,另一个事务Tx2对A进行了插入操作,表中的记录最大记录到了105,然后Tx1对表A进行插入操作插入记录101,这时候提示Tx1失败101记录已经存在,这时候对于Tx1来说出现了幻觉,明明最大记录是100但是却无法插入101的数据。
注意:不可重复读和幻读的区别
不可重复读的重点是修改,同样的查询条件前后两次读取的值不一样;
幻读的重点是新增或者删除,同样的条件前后读出来的记录数不一样;从某种程度上来看可能会导致操作失败
隔离级别
在了解到Mysql事务会因为并发问题引起脏读,幻读等操作,这些情况是我们所不能接受的,因此针对这类问题,数据库提供了事务的隔离级别来控制事务用来解决事务的并发问题。
- Read Uncommitted 读未提交内容:最低隔离级别,会读取到其他事务未提交的数据,会出现脏读;
- Read Committed 读取以提交内容:事务过程中可以读取到其他事务已经提交的数据,会出现不可重复读和幻读;
- Reaptable Read 可重复读:每次读取相同结果集,不管其他事务是否提交,会出现幻读
- Serializable 串行化:事务排队,隔离级别最高,性能最差;
实现原理
mysql事务通过MVCC,undo log和redo log来实现。
MVCC
MVCC(Multiversion Concurrency Control)也叫做多版本并发控制,在了解了并发问题和事务隔离级别后,Mysql通过MVCC和锁来解决脏读,不可重复读,幻读的问题。MVCC通过添加隐藏列的方式来实现,通过隐藏列记录当前记录的回滚指针和将当前记录更新为当前状态的事务ID的方式来实现MVCC的控制MVCC实现原理。
undo log
undo log也叫做回滚日志,主要记录逻辑操作用来记录回滚记录到哪个版本。通过undo log记录回滚日志,可以保证原子性和实现MVCC,因为undo log记录了数据的逻辑操作,因此在事务回滚的时候可以根据undo log来操作。
redo log
通常是记录物理日志,记录数据页的物理修改,用来恢复提交后的物理数据页。redo log是以顺序追加的方式来记录日志。其实在我们执行了一条插入/更新语句的时候,并不会再第一时间就去更新数据库在磁盘上的数据文件,而是会先更新内存中的数据,然后在redo log中记录下数据页的状态为prepar状态,等到事务条完成后,再次在redo log的数据页记录改为commit状态并且将内存中数据写入磁盘。如果在内存中的数据写入磁盘之前,服务忽然停掉或者宕机,那么系统恢复之后,可以根据redo log的记录对数据进行恢复。
针对具体的恢复情况要结合数据落盘的策略来决定,mysql针对数据库落盘可以通过‘innodb_flush_log_at_trx_commit’参数控制
Mysql数据写入流程
在了解了undo log和redo log都会记录对于数据库引起改变的相关操作记录时,我们可以看下数据库的数据写入流程,有利于更好的理解undo log和redo log,数据写入流程按照下面顺序进行:
- 客户端执行一条update语句
- server层将修改数据命令发送给存储引擎innodb
- innodb记录修改操作到undo log
- innodb更新内存中的数据
- innodb在redo log中记录下数据页的状态为prepare
- innodb告知server层更新完成
- server层收到完成提交事务,并且记录binlog
- innodb记录事务提交,在redo log中将事务记录为commit状态
对于Mysql binlog,redolog,undolog三者的解释可以参考这篇文章
Mysql锁
数据库为了可以解决并发的问题,以及保证事务的准确性,通过加锁的机制来保障数据的准确和安全。
行级锁
行级锁作用在索引上而不是在记录上,结合这类情况我们可以来分析聚簇索引和二级索引分别加锁的情况。
聚簇索引加锁场景
执行操作delete from user where uid =103的时候,直接对uid=103的索引加锁,那么其实就只是锁定了uid=103的记录
二级索引加锁场景
当根据二级索引phone进行操作的时候,会先锁二级索引中phone=134对应的索引记录,在根据二级索引应的主键uid=103的主键索引进行加锁。
对于唯一索引不管是RC还是RR事务隔离级别,行级锁都可以满足对于事务并发问题的处理;但是对于非唯一索引而言,RC事务级别下可以满足对于并发问题的控制,RR事务级别是无法满足,在RR隔离级别下的非唯一索引,无法使用行级锁来解决并发问题中的幻读。
场景(RR级别下的非唯一索引的幻读)
在user表中有一个二级索引phone,当事务Tx1进行删除phone=134记录操作的时候delete from user where phone =134。
如果这时候有另一事务Tx2来进行数据写入,这时候写phone=134的新纪录(phone=134 uid=102/uid=107),又因为phone并非唯一索引,那么此时数据是可以被写入数据库的,那么试想一下结果当事务Tx1执行完成提示操作完成,影响两条记录(phone=134 uid=103/uid=105),结果后面Tx2进行了事务提交。这时候Tx1再去查询的时候,我们期望发现看不到phone=134的记录,结果发现phone=134的记录还有,那么就出现了幻读。
这种情况非常常见,那么又该怎么保证数据的准确呢?很显然行级锁是无法满足这种情况的,但是mysql提供了间隙锁来解决这类问题。
间隙锁
间隙锁又叫做GAP锁,锁加在不存在的空闲空间,可以是两个索引记录之间,也可能是第一个索引记录之前或最后一个索引之后的空间,锁的不是具体的索引记录而是锁的记录间隙。用来保证两次当前读返回一致的记录(在两次当前读之间,其他事务不会插入新的满足条件的记录)。
场景(RR级别下的非唯一索引的幻读)问题处理
这时候当Tx2的事务再要去进行插入的时候,这时候对于phone=134的记录而言有三个GAP,间隙锁对于3个GAP进行加锁后
我们发现当Tx2再想插入的时候,发现因为有其他锁无法插入,那么我们就解决掉了在RR事务隔离级别下非唯一索引的幻读问题了。
间隙锁虽然可帮助我们解决很多问题,但是设想一下当我们的查询条件范围设置的很大的时候,那这时候间隙锁的GAP也会很大,这样就会导致很多数据被锁住,因此我们还是要很合理的设置查询条件和在做表设计的时候合理设置字段以及索引。
在我们清楚了Mysql的间隙锁会对存在可能的场景进行GAP锁的机制,那么我们可以想象下当我们执行delete from user where uid < 100的时候,mysql使用的是什么锁呢?结果就很明显了mysql为了满足事务的需求防止在同时其他事务插入uid<100的记录出现幻读,那么mysql会选择使用间隙锁而不是行锁。
表级锁
当对数据库表的元数据进行操作,会进行表锁,但是我们一般不会遇到这类问题,另外会经常触发表锁的场景为在没有索引的列查询或者索引失效,从而进行全表扫描的时候,会进行表锁。
思考:有的时候我们确实会因为sql语句不规范导致索引失效进行全表扫描,当表的记录比较多,当本次查询还没结束的时候,有时候操作表的前面一些记录的时候,竟然还可以操作,那这是为什么呢?既然已经进行了全表扫描为什么还能操作其他记录呢?
解析:索引失效全表扫描锁表对所有记录进行加锁然后返回,实际应用中虽然遇到全表扫描,但是我们发现其实表记录还可以使用,没有遇到表被锁,是因为mysql server层对于不符合条件的记录快速过滤和释放锁,所以锁的过程还是有的只是server层做了优化的缘故
另外有时候如果查询语句确实用到索引列,但是当索引列区分度太低,而表的数据有太多,我们会发现查询很慢,而且通过explain查看的时候发现全表扫描了;有时有的表数据记录太少,也会直接全表扫描,这是因为数据库做了优化判断,发现全表扫描会更快的时候,会进行决定选择什么方式。但是这并不是说我们就一定要默认按照数据的选择来进行,例如第一种场景中,区分度低但是确实查询最终返回也慢,那么我们可以选择合理设置索引,或者通过分页来提高区分度从而让索引生效。
总结
数据库的事务和锁可以帮助我们来解决并发的问题,但是我们不可以一成不变的用一套方案来解决所有场景,需要结合实际问题实际分析,而且也要结合锁的特性,以及针对不同的事务隔离级别时再来判断对应的加锁场景,从而来优化我们的查询sql,从而更好的来解决实际的问题。