问题描述
最近几天在忙项目,有个项目是将业务收集到的数据变化,异步同步到一张数据表中。在测验的过程时,收到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 单独拎出来处理?