问题描述
最近几天在忙项目,有个项目是将业务收集到的数据变动,异步同步到一张数据表中。在测试的过程时,收到QA的反馈,说有订单的数据同步时好时坏。我怀着疑惑的表情打开了那段代码,它的逻辑大概是这样的:
如果用简单的代码实现的话,会是这样的:
public void updateAndQuery(Example example, int diff){
List<ProductPO> productPOS = productMapper.selectByExample(example);
ProductPO productPO = productPOS.get(0);
System.out.println("一次查询内容" + JSONObject.toJSONString(productPO));
//二次插入并查询
Integer oldNumber = productPO.getNumber();
productPO.setNumber(oldNumber + diff);
//首先先更新,再进行查询
System.out.println("更新内容:"+ JSONObject.toJSONString(productPO));
productMapper.updateByPrimaryKey(productPO);
//异步执行
CompletableFuture.runAsync(() -> {
Example example1 = new Example(ProductPO.class);
example1.createCriteria().andEqualTo("skuId", productPO.getSkuId());
List<ProductPO> select = productMapper.selectByExample(example1);
System.out.println("二次查询结果:"+JSONObject.toJSONString(select));
if (oldNumber != select.get(0).getNumber() - diff) {
throw new NrsBusinessException("查询出错");
}
});
}
起初我左看看,右看看,也没有想到这个是什么原因造成的。直到我看到了这个…
@Transactional(rollbackFor = Exception.class)
事务执行原理
在了解的问题原因前,我们需要了解事务是如何实现的。首先假设现在我们要设计一个mysql事务,最简单的方案其实是这样:每执行一次SQL,写一次数据库。大致流程图如下所示:
但是这个方案好么?显然不是。因为我们如果采用这样的方式,存在两个显著的问题:
- 数据库写入为磁盘读写,速度很慢。
- 数据库存在锁机制,难支持高并发。
对于问题一,了解到存储器读写速度如下所示(图源网络):
可以看到内存的存储速度是纳秒级别(10-9次方),而硬盘的存储速度是毫秒级别(10-3次方)。由此,为加快读写速度,可以将修改的内容写入内存,而后再异步写入磁盘。
同时,由于内存本身并没对不同线程做锁控制机制,可以支持多个线程同时访问。对于高并发的问题也能更好支持。由此,上述的实现方案就改为了下面的流程:
事务隔离级别
在修改为优先写内存后续再异步同步的情况后,又带来了新的问题:在一个事务尚未确认提交时,新事务从缓存中应该读取什么数据呢?
对于这种不同事务间数据读取的策略就被称为事务隔离级别。根据读取策略的不同,事务隔离级别被划分为四种:读未提交、读已经提交、可重复读、序列化。
读未提交(Read Uncommitted)
读未提交的策略比较简单,即默认读取内存中的内容,而不必管这个数据是否已经写入到了数据。但是这个策略会带来一些问题:
存在问题:
如图所示,若设置为读未提交,那么此时事务可能读到尚未提交的数据,即脏读。因此会造成数据A在前一时刻尚且可以读取到,但想二次更新的时候,mysql数据库却因为回滚导致数据A被回退了。这种错误会导致系统的无法正常运行,是不可容忍的。
读已提交(Read Committed)
既然读未提交的事务带来的错误是不可容忍的,那么我只读已提交的数据就可以避免读到脏数据了呀!那么应如何实现只读已提交数据呢?对问题进行分析,要获取到最新已提交的数据,必然要将数据的版本关系体现出来。为此,InnoDB设计了一个版本链的概念。对每行记录会新增两个隐藏列:trx_id、roll_pointer。
- trx_id:用于保存每次对该记录进行修改的事务id。
- roll_pointer:存储一个指针,指向这条数据记录上一个版本的地址,可以通过它获取到该记录上一个版本的数据信息。
由此一来,就可以通过最新记录(可能未提交)进行回溯,直到找到已提交的记录。
当然,仅有版本链的概念明显不够,我们还无法判断哪个数据是已提交的。为此InnoDB又新增了一个ReadView的解决方案,ReadView保存了一个写入了但未提交的事务ID列表。依据这个列表,我们就可以判断哪些事务还未写入。
以上图为例,由于此时trx_id=20、trx_id=40的事务均未提交,InnoDB会生成一个ReadView:{20,40}。由此可能出现三种情况的事务访问:
- 若预期访问事务ID=10的记录,由于其小于最小的事务Id20,证明事务已提交,允许访问。
- 若预期访问事务ID=30的记录,由于其介于最大最小的事务ID之间,就需要逐一判断ReadView中是否包含事务ID=30的记录
- 若预期访问事务ID=50的记录,由于其大于ReadView最大的事务Id,必然是在生成ReadView后生成的,也必然没有提交,不允许访问。
结合版本链和ReadView,基本就可以实现只读取已经提交的内容。
存在问题:
由于ReadView是每次查询才新生成的,因此不免存在以下情况:
在事务中首先读了一次数据A,期间事务发生了提交,导致二次查询出来的数据A同第一次出现了差异。由此难免让人发问:“两次相同的条件,查询到的结果却不一致,我是出现了幻觉了嘛?” 因此,这种情况也被形象称做:幻读。
幻读同脏读不同,幻读造成的问题是会破坏数据一致性。假设我们有一张表 user(id, name, age),已经有两条数据 (1, “Jack”, 20), (2, “Tom”, 18),同时我们执行以下流程:
三个事务执行完成后,主库数据库内的数据应该是:(1, “Jack”, 10), (2, “Jack”, 18),(3, “Jack”, 18)。然而,此时binlog内的写入的SQL语句却是:
//事务二
update user set name = "Jack" where id = 2
update user set age = "40" where id = 2
//事务三
insert into user values(3, "Jack", 30) /*(3, Jack, 30)*/
//事务一
update user set name = "Tom" where name = "Jack"
那么此时,从库收到了主库同步的binLog数据,并按照顺序执行。得到的结果却是:(1, “Jack”, 10), (2, “Jack”, 10),(3, “Jack”, 10)。不难发现,数据行2和3发生了主从不一致,这个是无法容忍的。
可重复读(Repeatable Read)
要解决幻读,主要是解决两个问题:1、确保一次事务内看到的数据一致;2、确保生成的binLog数据顺序正确。
对于问题1,其实相对比较简单。同一次事务内看到的数据不一致是由于每次ReadView都实时生成(也被称为实时读)。因此,只要确保同一次事务内只生成一次ReadView(也被称为快照读),就可以避免多次查询会出现不一致数据的情况。
然而,仅保持自己看不到是不够的,如果无法解决binLog的SQL写入顺序问题,数据不一致的问题就无法得到解决。那其实对上述现象进行分析,导致SQL写入顺序混乱的原因,其实是因为违背了事务一对于"where name = “Jack” 的原子性。即事务操作期间还有别的符合条件数据能被修改。
那么,很朴素的一个思想就是,只要对这些都符合条件的数据都加锁不就可以了嘛?为此,mysql提出了间隙锁的概念。假设当前我们的数据对name字段配置了一个索引,那么此时事务一运行的时候,我们需要将其索引临近的一行及其间隙都锁上,不允许其余事务进行更新插入的操作。由此一来,索引被锁上,没法插入新的数据,也就不会出现SQL语句混乱的情况了。
那么这个时候肯定有人会说:“你没索引的字段咋办啊?”,对于没有索引的字段,mysql会做全表的扫描。由此一来,相当于会把整张表的数据都给锁上。从而避免无索引的情况出现数据不一致的问题。
序列化(Serializable)
对于可序列化来说,实现就相对粗暴些。本着“爷才不考虑那么多,直接将表锁了,肯定不会有问题”的思想出发进行设计:
1、首先针对每次事务读操作的时候加表级共享锁,确保多个事务可以读。
2、事务写操作的时候则加表级别的排它锁,只允许自己事务操作。
这些锁都维持到事务结束再释放,从而完美避免了上述问题的出现。然而,粗暴的方法一般性能都不太好,在高并发的情况下,常常只有一个线程可以操作数据,因此不建议使用。
总结
介绍了这么多有关事务隔离的内容,我们终于可以回归到我们的问题上来了。那么其实对于开头提到的问题,原因就是在异步线程中,会新开一个事务,这两个事务是并行的。由于mysql默认的事务隔离级别是可重复读,会导致事务A异步的情况下,数据可能未提交,事务B执行较快而获取到了旧数据,造成了同步数据错误的问题。
知道了问题,那么解决方案就比较简单了,可以不通过异步的方式发送,而是采用kafka消息的机制。这样就给事务A留足了事务提交的时间,从而确保数据的准确同步。
参考文献
深入浅出Mybatis系列(五)Mybatis事务篇
从因到果看懂事务隔离级别的实现原理
innodb存储引擎中一条sql写入的详细流程
MySQL的两阶段提交(数据一致性)
MySQL是如何实现读已提交和可重复读的——MVCC原理
幻读为什么会被 MySQL 单独拎出来解决?