数据库事务是访问可能操作各种数据项的一个数据库操作序列,这些操作要么全部成功,要么全部失败。提起事务,大家都知道ACID属性,这些特性在前边的文章里都有详细的讲解,感兴趣的可以通过历史文章查看。
在Java中有并发编程,可以多线程并发执行,并发可以提高程序执行的效率,也会带来线程安全的。数据库事务和多线程一样,为了提高数据库处理事务的吞吐量,数据库也支持并发事务,在并发处理数据的过程中,也存在着安全问题。
我们本文将从并发事务可能引发的问题、解决并发问题、MySQL的锁机制、锁的实现等方面逐渐深入,探讨高并发场景下的事务调优问题。
并发事务可能引发的问题
1.数据丢失
2.脏读、
3.幻读
4.不可重复读
事务隔离解决的并发问题
数据丢失可以基于数据库中的悲观锁来避免发生,即在查询时通过在事务中使用 select xx for update 语句来实现一个排他锁,保证在该事务结束之前其他事务无法更新该数据。
我们也可以基于乐观锁来避免,即将某一字段作为版本号,如果更新时的版本号跟之前的版本一致,则更新,否则更新失败。剩下3 个问题,其实是数据库读一致性造成的,需要数据库提供一定的事务隔离机制来解决。
MySQL 的锁机制
InnoDB实现了两种类型的锁机制:共享锁(S)和排他锁(X)。共享锁允许一个事务读数据,不允许修改数据,如果其他事务要再对该行加锁,只能加共享锁;排他锁是修改数据时加的锁,可以读取和修改数据,一旦一个事务对该行数据加锁,其他事务将不能再对该数据加任务锁。
不同的锁机制会产生不同的事务隔离级别,不同的隔离级别分别可以解决并发事务产生的问题,如读未提交、读已提交、可重复读、可序列化等。(1号发的《MySQL的事务隔离级别和长事务,看这一篇就够了》一文中有介绍过)
InnoDB中的读已提交和可重复读隔离事务是基于多版本并发控制(MVCC)实现高性能事务。一旦数据被加上排他锁,其他的事务将无法加入共享锁,且处于阻塞等待状态,如果一张表有大量的请求,这样的性能将是无法支持的。
MVCC对普通的Select 不加锁,如果读取的数据正在执行delete或者update操作,这时读取操作不会等待排他锁的释放,而是直接利用MVCC读取该行的数据快照。MVCC避免了对数据重复加锁的过程,大大提高了毒草在的性能。(数据快照是指在该行的之前版本的数据,而数据快照的版本是基于undo实现的,undo是用来做事务回滚的,记录了回滚的不同版本的行记录)
锁的具体实现算法
InnoDB既实现了行锁,也实现了表锁,行锁是通过索引实现的,如果不通过索引条件检索数据,那么InnoDB将表中所有的记录进行加锁,其实就是升级为表锁。
行锁的具体实现算法有三种:record lock、gap lock和next-key lock。record lock是专门对索引项加锁;gap lock是对索引项之间的间隙加锁,next-key lock则是前面两种的组合,对索引项及其之间的间隙加锁。
只在可重复读或以上隔离级别下的特定操作才会取得 gap lock 或 next-key lock,在 Select 、Update 和 Delete 时,除了基于唯一索引的查询之外,其他索引查询时都会获取 gap lock 或 next-key lock,即锁住其扫描的范围。
优化高并发事务
上边的讲解,都是为了对事务、锁和隔离级别更加深入了解,下边将聊聊高并发场景下的事务是如何调优的。
- 结合业务场景,使用低级别事务隔离
在高并发业务中,为了保证业务数据的一致性,操作数据库时往往会使用不同级别的事务隔离,隔离等级越高,并发性能就越低。
那在实际的业务中,我们要如何选择呢,下边举两个例子:
在修改用户的最后登录时间,或者用户的个人资料等数据时,这些数据都只有用户自己登录和登陆后才会修改,不存在一个事务提交的信息被覆盖的可能,所以这样的业务我们就最低的隔离级别。
如果账户的余额或者积分的消费,就可能存在多个客户端同事消费一个账户的情况,此时我们应该选择可重复读隔离级别,来保证当一个客户端在操作的时候,其他客户端不能对该数据进行操作。
- 避免行锁升级表锁
我们知道,InnoDB中行锁是通过索引实现的,当不通过索引条件检索数据时,行锁就会升级成表锁,我们知道表锁会严重影响我们对整张表的操作,应该避免这种情况。
- 控制事务的大小,减少锁定的资源和锁定的时间
下边这个SQL异常相比很多并发比较高的系统里都会遇见,比如抢购系统的日志中:
MySQLQueryInterruptedException: Query execution was interrupted
由于抢购系统中,提交订单业务开启了事务,在并发环境中对一条记录进行更新操作的情况下,由于更新记录所在的事务还可能存在其他操作,导致一个事务比较长,当大量请求进入时,就可能导致一些请求同时进入事务中,由于锁的竞争是不公平的,当多个事务同时对一条记录进行更新时,极端情况下,一个更新操作进去排队系统后,可能会一直拿不到锁,最后因超市被系统中断,就会抛出上边这个异常。
提交订单需要创建订单和扣减库存,两种不同顺序的执行方式,结果都一样,但是性能确实不一样的:
这两种不同的执行方式,虽然这些操作都在一个事务中,但是锁的申请不在同一时间,锁只有当其他操作都执行完成才会释放锁。扣减库存是更新操作,属于行锁,如果先扣减库存会影响到其他操作该数据的事务,所以我们应该尽可能的避免长时间持有该锁,尽快的释放锁。
因为创建订单和扣除库存不管先执行哪一步都不影响业务,所以我们可以先执行新增操作,把扣除库存放到最后,也就是使用执行顺序1 ,来减少锁的持有时间。
总结
MySQL 的并发事务调优和 Java 的多线程编程调优非常类似,都是可以通过减小锁粒度和减少锁的持有时间进行调优。在 MySQL 的并发事务调优中,我们尽量在可以使用低事务隔离级别的业务场景中,避免使用高事务隔离级别。
在功能业务开发时,我们往往会为了追求开发速度,习惯使用默认的参数设置来实现业务功能。例如,在 service 方法中,你可能习惯默认使用 transaction,很少再手动变更事务隔离级别。但要知道,transaction 默认是 RR 事务隔离级别,在某些业务场景下,可能并不合适。因此,我们还是要结合具体的业务场景,进行考虑。