1.什么是事务
事务就是进行多个操作,要么同时执行成功,要么同时执行失败。
2.事务的特性 - ACID特性
2.1原子性Atomicity
原子性(Atomicity):当前事务的操作要么同时成功,要么同时失败。原子性由undo log日志来实现。
在事务中,如果SQL进行了一个减库存的操作,例如由5扣减为2,在执行这个SQL语句时会使用undo log日志进行记录一个库存2到5的日志,如果失败抛出异常就会执行这条SQL语句。
2.2一致性Aconsistency
一致性(Consistency):使用事务的最终目的,由其它3个特性以及业务代码正确逻辑来实现。
2.3隔离性Isolation
2.3.1什么是隔离性
隔离性(Isolation):在事务并发执行时,他们内部的操作不能互相干扰,隔离性由MySQL的各种锁以及MVCC机制来实现。
在InnoDB引擎中,定义了四种隔离级别供我们使用,级别越高事务隔离性越好,但性能就越低,而隔离性是由MySQL的各种锁以及MVCC机制来实现的。
1.read uncommit(读未提交):脏读。
2.read commit(读已提交):不可重复读。
3.repeatable read(可重复读):幻读。
4.serializable(串行):解决上面所有问题,包括脏写。
MySQL的事务隔离性默认使用的是repeatable read(可重复读)
2.3.2读未提交
在read uncommit(读未提交)的隔离级别下,会产生脏读现象。
脏读:事务A读取到事务B已经修改但是尚未提交的数据。
一般不会使用这种隔离级别,这种隔离级别危机太多了。
2.3.3读已提交
在read commit(读已提交)的隔离级别下,会产生不可重复读的现象。
不可重复读的意思是,A事务在多次读取一个数据时,如果B事务在过程中对数据进行修改,并提交,A事务读到的数据会发生变化。
2.3.4可重复读
在repeatable read(可重复读)的隔离级别下,会产生幻读的现象。
执行的机制:在事务开启后,当执行了事务中第一条查询语句之后,MySQL中所有记录在事务中都有一条快照了,读取数据时都是读取快照中的数据,由此来实现的可重复读。
还需要注意的是,如果在当前事务对数据进行了修改,下次再读取的时候就会使用修改后的值,其他未修改的数据还是读取快照中的数据。并且修改的时候,不会用快照的数据,而是真实的数据。
由于该机制,A事务在执行完第一天查询语句后,读取的数据都是当时快照的数据,无论其他数据如何修改,在事务中读取的都是快照里的数据。
而且还会出现幻读的现象,幻读现象就是事务A读取到了事务B刚新增的数据。
有人说可重复读解决了幻读现象,也有人说可重复读没有解决幻读现象,这个是需要去分情况讨论的。现在同时启动A和B两个事务,假设A进行了一次查询操作,B事务在该表中进行了添加数据操作并提交了事务,这样A事务再去读取该表的数据时,由于A事务可重复读的机制,读取的数据是快照数据,则不会读取到B事务添加的数据,就不会产生幻读现象。但是如果A在开启事务后,查询了数据后,B事务去再去添加数据的时候,A事务再次去查询就会查询到B事务新增的数据,这样就会产生幻读现象。
2.3.5脏写现象
读未提交、读已提交、可重复读都可能会出现脏写现象。
脏写的意思是,由于隔离级别的问题,读取的数据是有问题的数据,但是还是在Java中对该数据进行处理后,并写入到了数据库中,此时写入的数据是个错的数据。
2.3.5.1读未提交出现脏写的原因
读未提交会出现脏写现象是因为,事务A和事务B一起开启了,事务B进行对数据修改了多次,但是事务B并没有进行提交,事务A此时读到了事务B未提交的数据,但是在事务A读取后,事务B进行了回滚,但是事务A用错误的数据,进行了操作,并更新到数据库,由此就会发生一次脏读事件。
2.3.5.2读已提交出现脏写的原因
读已提交会出现脏写现象是因为,事务A和事务B一起开启了,事务B进行对数据修改了多次,但是由于事务B没有进行提交,事务A读取到的数据永远是原始的数据,事务A对数据进行了操作,并提交,事务B此时用事务A未提交之前的数据进行修改,并更新到数据库,由此会发生一次脏读事件。
2.3.5.3可重复读出现脏写的原因
可重复读会出现脏写现象是因为,事务A和事务B一起开启了,事务A查询了一次数据,事务B对该数据进行了多次修改,并提交,事务A又读取了一次数据,读取到的数据是快照数据,并没有读取到事务B提交后的数据,并且事务A对该次读取的数据进行了修改,并提交到数据库,由此会发生一次脏读事件。
2.3.5.4解决脏写的方案:悲观锁
悲观锁解决方案,可以解决读未提交、读已提交和可重复读三种隔离级别的情况。
解决脏写可以采用的方法是采用悲观锁,每次更新的时候,使用SQL语句进行更新,而不是使用查询到的数据,在业务代码中进行修改后直接更新替换数据。
代码如下:
UPDATE account SET balance = balance + 500 WHERE id = 1;
2.3.5.5解决脏写的方案:乐观锁
乐观锁的解决方案,可以解决读未提交和读已提交两种隔离级别的方案。
解决脏写可以采用的方法是,给表格额外加一个version字段,每次更新的时候,WHERE筛选时加上自己上次查询出的版本号,如果更新成功就没事,更新失败就继续重新查询再更新,做一个自旋操作。
代码如下:
UPDATE account SET balance = balance WHERE id = 1 AND version = 1;
2.3.6串行化读取
在串行(serializable)情况下,这是最高的隔离级别,解决了以上所有的问题。
串行化的机制就是,当A事务开启后,其隔离级别是serializable时,A事务只要对某个数据表进行了查询操作,其他任务对该表的增加/更新操作都会被卡住,直到A事务提交/回滚。
这种方式解决了脏写和幻读的问题了,因为串行化对某个表进行读取操作后,其他针对该表的新增/删除/修改操作在串行化事务提交之前都会被阻塞住。
串行化的实现原理:
串行化的实现原理其实十分简单,在串行化事务中,执行是查询SQL语句会自动加一个读锁,由于读锁是共享的,当添加上这个读锁之后,对这个表再进行读取操作时,是没问题的,但是如果进行增加/修改/删除操作,就会被卡住,知道读锁释放。
查询的语句:
SELECT * FROM account WHERE id = 1;
该语句实际执行的时候,执行的SQL语句如下:
SELECT * FROM account WHERE id = 1 lock in share mode;
一般不会去使用串行化这种方案,因为这个方案的性能太差了,整体并发量低。
2.3.7读写锁
刚刚提到串行化隔离级别是使用读写锁实现的,所以现在具体介绍一下读写锁。
读锁(共享锁、S锁):SELECT ... LOCK IN SHARE MODE;
读锁是共享的,多个事务可以同时读取同一个资源,但是不允许其它事务修改。
写锁(排它锁、X锁):SELECT ... FOR UPDATE;
写锁是排他的,会阻塞其他的写锁和读锁,UPDATE、DELETE、INSERT都会加写锁。
2.4持久性Durability
一旦提交了事务,它对数据库的改变就应该是永久性的。持久性由redo log日志来实现。
MySQL保证持久性是通过redo log日志机制实现的,先来看MySQL更新一条数据的整体流程:
InnoDB引擎在执行一条写数据的操作的时候(假设现在进行的是更新操作),首先会从磁盘中加载需要修改的数据,再将数据写入到undo日志中(方便进行回滚),下一步并不是直接去修改表中的数据,而是将数据写入到redo日志中,最后才通过异步IO线程将数据更新到磁盘文件中。
之所以MySQL要做这个操作,完全是因为将数据写入到redo日志中进行的是磁盘顺序写的操作,而写入到表中的时候,由于多个表分布在多个文件中,无法进行磁盘顺序写,所以MySQL才会先将数据写入到redo日志中,对于机械硬盘来说,磁盘顺序写的性能提升很高,写入速度特别快。之所以要利用磁盘顺序写快速写入数据,是因为MySQL为了保证持久性,由于将数据写入到磁盘中是一个比较耗时的操作,如果中间出现突发情况,MySQL崩掉了,数据就会丢失,无法保证持久性,但是redo log是磁盘顺序写,性能非常高,所以可以保证持久性。
3.初识MVCC机制
3.1什么是MVVC机制
事务分为RNC,RC,RR,Serializable四种,一般来说不会使用Serializable隔离级别,因为该隔离级别虽然安全性很高,但是其性能堪忧,所以一般在真正的项目落地时,都会采用RC和RR这两种隔离级别,因为这两种隔离级别可以保障读写的并发,所有操作并非都是串行的,可以提高整体的并发量。
疑问点:RC和RR事务隔离级别在操作同一条数据的时候,是如何做到读写并发的呢?需不需要串行化呢?读写操作到底先执行读呢还是先执行写呢?
以上提到的疑问点就是读写并发问题,MySQL使用MVVC机制解决了读写并发问题。
MVVC(Multi-Version Concurrency Control)多版本并发控制,可以做到读写不阻塞,且避免了类似脏读这样的问题,主要是通过undo日志链来实现的。
SELECT操作时快照读(读取历史版本)
INSERT、UPDATE和DELETE是当前读(读取当前版本)
Read Commit(读已提交):语句级快照。
Repeatable Read(可重复读):事务级快照。
3.2MVVC机制的执行流程
MySQL在每张数据表中维护了两个字段,其中一个字段是trx_id,即最后操作这条数据的事务ID,还有一个字段是roll_pointer,这个字段是回滚指针,该指针指向的是回滚日志,如果事务进行了回滚操作,就会通过数据行中的roll_pointer回滚指针,找到回滚日志,完成回滚操作。
假设现在有一个数据表是account表,首先对account表进行一个插入操作,进行了插入操作之后,会讲操作account表的事务ID存储到数据行的trx_id字段中,并且会生成一个undo log日志,roll_pointer会指向该undo log。
又有一个事务对数据行进行了操作,将数据行中的balance进行修改为500,此时会将数据行的trx_id进行替换,并又生成了一个undo log,并将roll_pointer指向该undo log,最后事务完成了提交,完成提交后,会有一个commited标志指向该数据行。
假设现在启动了两个事务,事务A和事务B,事务A是RR可重复读隔离级别,事务B是RC读已提交隔离级别,此时两个事务同时执行查询该数据行的操作,最终查询出的数据均为500。
此时又有事务对该数据行进行了操作,将balance修改为800,并进行了提交。事务A再进行查询的时候,由于RR的机制导致器读取到的数据是500,事务B读取数据时,由于其永远读取的是已提交的数据,所以读取到的数据是800。
事务对该数据行再次进行了操作,将balance更新为1000但是没有提交,所以事务A和事务事务B再进行查询时保持原有数据不变。
3.3undo日志链
从整个MVVC机制的执行流程中可以发现,无论是查询,回滚,是否提交等操作都是通过该链完成的,这样不仅节省了空间(不是每一个日志都要生成一个undo log),还保证了读写的并发性,提高了整体的性能。
4.如何选择适合隔离级别
对于MySQL中四种隔离界别,RNC读未提交,RC读已提交,RR可重复读,Serilizable串行化,这四种隔离级别,性能从高到低,安全性从低到高,一般在实际生产落地的环境中不会使用到RNC读未提交,因为这种方式虽然性能很高,但是安全性特别差,会出现脏读现象,所以一般不会使用这种隔离级别。对于Serializable串行化隔离级别,一般在生产环境中也不会使用这种隔离级别,因为这种隔离级别安全性虽然很高,但是性能实在是太差了。
在开发时,一般只会采用RC读已提交和RR可重复读这两种隔离级别。对于一些并发量要求不是巨大的系统,一般都是直接采用MySQL默认的隔离级别RR可重复读即可,但是对于一些互联网公司而言,如果需要很高的性能,会考虑采用RC读已提交这种隔离级别,因为这种隔离级别只会对写进行加事务,RR这种隔离级别会对读写都会加事务,所以相对来说RC的性能会更高一些。
在实际事务选型时,需要根据当前业务场景进行分析选型。需要对读操作加事务的时候,可以采用RR事务隔离级别来保证事务内读数据时,读取出的数据在同一时间维度。如果不需要对读操作进行加事务的操作,可以根据需要考虑使用RC事务级别。
5.大事务的影响及事务优化手段
并发情况下,数据库连接池容易被撑爆。
由于事务在使用的时候,会占据着MySQL的一个链接,如果此时的并发特别高,且事务都非常大,每个事务需要占据着数据库连接很长一段时间,那么就会导致数据库连接池的连接全部被用完,最终数据库的连接池被撑爆。
锁定太多的数据,造成大量的阻塞和锁超时。
如果事务锁定了太多的数据,比如事务进行了多次update操作,UPDATE是会加锁的,加的锁会阻塞其他的事务对数据进行操作,其他事务会一直等待,但是如果事务特别大,让其它事务等待的时间过久,最终就会导致大量的阻塞和锁超时的事件发生。
执行时间长,容易造成主从延迟。
由于大事务的执行时间比较长,如果此时MySQL是主从多节点,就可能会出现主从延迟。
回滚所需要的时间比较长。
由于大事务中对数据库的操作比较多,所以一旦其中发生的错误,需要回滚的操作也会更多,这就导致了回滚所需要花费的时间比较长。
undo log膨胀。
大事务中对数据库的操作比较多,很多操作都会记录undo log日志,所以整条undo log日志链会膨胀,导致占用的空间较大。
容易导致死锁。
大事务中对数据库的操作比较多,很有可能引发死锁的出现。
5.2事务的优化实践原则
将查询等数据准备操作放到事务外。
如果查询操作需要使用RR隔离级别保证查询到的数据在同一时间维度时,是需要将查询操作放在事务内,但是如果查询操作不需要保证查询的数据在同一时间维度,使用RC隔离级别时,是可以将查询准数据的操作放到事务外的。
事务中避免远程调用,远程调用要设置超时,防止事务等待时间太久。
事务中进行执行业务代码的时候,是需要避免远程调用的,因为远程调用的接口的时间控制是不好控制的,如果等待时间过长,事务会膨胀为大事务,所以不能将远程调用放置在事务内,或者为远程调用设定超时时间。
事务中避免一次性处理太多数据,可以拆分为多个事务分次处理。
事务中如果一次性处理太多数据,事务可能会成为大事务,导致出现各种各样的问题,所以建议将事务拆分为多个事务分批次处理。
更新等涉及加锁的操作尽可能放在事务靠后的位置。
更新加锁操作是需要放在最后的,并且要放在Insert的后面,虽然Update和Insert两个操作都会加锁,但是Update加锁的时候会导致其他操作改行数据的操作卡住,但是Insert不会,因为没有插入的数据是不可能被其他事务操作的,自然不会出现卡住的现象
- 能异步处理的尽量异步处理。
- 应用侧(业务代码)保证数据一致性,非事务执行。
如果系统对于性能的要求非常高,可以考虑使用try...catch等操作替代事务,使用业务代码实现事务的回滚功能,这样会显著提升性能。