Mysql二级缓存线程不安全_两个Mybatis二级缓存的脏数据问题分析

Mysql 快照读引起的缓存脏数据

场景

Mysql使用默认的事务隔离级别;表A的某行数据值为X,有两个线程,均通过Mybatis操作数据库,Mapper文件中开启二级缓存且查询方法使用缓存;线程1开启事务,读取该行数据,线程2开启事务,修改该行值为Y并提交事务,当两个线程的操作顺序如下图所示时,Mybatis二级缓存中缓存的该行数据为修改前的X,出现脏数据。

快照读导致的数据不一致.png

原因

出现上述问题的原因是线程1查询数据的方式为快照读,SQL示例如下:

select * from TableA where id = 'abc'

快照读读的是该行数据的快照,由于线程1的数据库事务开启在线程2之后,所以快照读读不到线程2更新后的数据版本。但是线程2在提交事务时,已经将Mybatis二级缓存清空,线程1此时查询数据,缓存必然不会命中,从而触发数据库查询,将快照的旧数据放入缓存中。

详细分析

MVCC

Mysql的MVCC实现机制在每一行数据中额外保存两个隐藏的列:当前行创建时的版本号和删除时的版本号。这里的版本号并不是实际的时间值,而是系统版本号。每开始新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询每行记录的版本号进行比较。

初始状态时,数据库快照如下:

id

value

创建版本号

删除版本号

abc

X

V0

在更新操作的时候,采用的是先标记旧的那行记录为已删除,并且删除版本号是事务版本号,然后插入一行新的记录,记录的版本号即当前事务的版本号。

线程2更新值为Y后,数据库快照如下:

id

value

创建版本号

删除版本号

abc

X

V0

V2

abc

Y

V2

查询时要符合以下两个条件的记录才能被事务查询出来:1) 删除版本号未指定或者大于当前事务版本号,即查询事务开启后确保读取的行未被删除。2) 创建版本号 小于或者等于 当前事务版本号,就是说记录创建是在当前事务中(等于的情况)或者在当前事务启动之前的其他事务插入的。

所以当线程1去查询数据库时,第一行快照的创建版本号和删除版本号均符合要求,可以被查出来。第二行快照的创建版本号不符合要求,不能被查询出来。

快照读

Mysql中的数据查询分为快照读和当前读两种。

快照读:

简单的select操作,属于快照读,不加锁。

select * from table where ?;

当前读:

特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。

select * from table where ? lock in share mode;

select * from table where ? for update;

insert into table values (…);

update table set ? where ?;

delete from table where ?;

所有以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。

快照读的实现机制就是MVCC

解决方法

使用当前读

线程1使用当前读的方式,避免读到的是历史版本

新事务

线程1在查询时新起一个事务,新事务版本号大,就能读到最新的快照

Mybatis事务提交顺序引起的缓存脏数据

场景

场景1:

两个线程同时访问一个Mybatis Mapper的查询方法,此Mapper开启二级缓存且查询方法使用缓存。若两个线程查询时二级缓存均未命中,在触发数据库查询时同时还有线程3在做更新,线程1查询返回的是旧数据库,线程2查询返回的是最新的数据,若线程2比线程1事务先提交,则缓存中出现脏数据。

场景2:

线程1查询,线程2更新,线程1查询时二级缓存未命中,触发数据库查询,线程2更新,清空缓存,若线程2比线程1事务先提交,则缓存中出现脏数据。

原因

Mybatis中二级缓存是全局性的,那么当多个线程操作同一份缓存时,如何在线程之间做隔离,保证相互间不影响呢?

答案是Mybatis在二级缓存的Cache外面进行了一层封装,就是TransactionalCache,在同一个事务期间,所有对于Cache的操作(包括往缓存中放值,清空缓存)都会先暂存在TransactionalCache中,当事务提交时,将暂存的内容提交到二级缓存Cache中,当事务回滚时,丢弃掉暂存的内容。

详细分析

场景1:

查询时Mybatis的处理流程如下:

查询操作的处理流程.png

上图为了更清晰的说明问题,与真实代码相比,省略了很多中间层次,只列出了与缓存相关的类;Client泛指客户端类;上图只列出了缓存未命中时的处理流程,若缓存命中,则可省略一些步骤

问题出现在上图中标红的两个步骤之间,线程1将查询返回的旧数据暂存在TransactionalCache中,此时线程3更新数据,接着线程2将查询返回的最新数据也暂存在TransactionalCache中,线程2接下来先做事务提交,最新的数据被提交到二级缓存中,而后线程1也做事务提交,旧数据也会被提交到二级缓存中,覆盖线程2提交过来的最新数据,出现脏数据。

场景2:

更新时Mybatis的处理流程如下:

更新操作的处理流程.png

问题同样出现在标红的两个步骤之间,线程1将查询返回的旧数据暂存在TransactionalCache中,接着线程2做更新操作在TransactionalCache设置了清空缓存的标识。线程2接下来先做事务提交,二级缓存被清空,而后线程1也做事务提交,旧数据被提交到二级缓存中,出现脏数据。

通过上面两个场景的分析可以看出,Mybatis仅仅在事务间做了缓存操作的隔离,却并未对缓存操作加锁,导致缓存中数据依赖于事务的提交顺序,出现脏数据。

解决方法

目前来看并没有太好的解决方法

结论

要求查询实时数据时,不要使用快照读。

Mybatis二级缓存对于多事务并发读写的处理策略太过简陋,对于缓存脏数据库容忍度低的应用不用使用二级缓存。

你可能感兴趣的:(Mysql二级缓存线程不安全)