Mybatis 查询结果 ListList修改后再次查询,结果与数据库不一致
使用 Mybatis 查询,结果为对象的 List ,修改List内的参数后,使用相同参数再次查询,发现查询结果与数据库不一致,而是第一次查询结果操作后的对象列表。
根据问题现象可以发现,相同查询条件下,第二次查询使用了第一次的查询结果,而且两次查询是在同一方法的for循环内执行,第一次的对象肯定会被GC回收,所以应该有某种缓存机制存在,那么只可能是 Mybatis 实现了某种缓存机制。

举例:

mysql建表语句

CREATE TABLE `t_user` (
  `rid` bigint NOT NULL COMMENT '主键',
  `username` varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户名',
  PRIMARY KEY (`rid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

实体类

@Data
@TableName("t_user")
public class User {
    @TableId
    private Long rid;
    @TableField("username")
    private String username;
}

mapper类

@Mapper
public interface UserMapper extends BaseMapper<User> {
}

service接口

public interface UserService extends IService<User> {
    List<User> getAllUserList();
    void userList();
    void userListAddr();
}

service接口实现类

@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    @Override
    //@Transactional
    public List<User> getAllUserList() {
        List<User> firstList = list();
        firstList.forEach(item -> item.setUsername("老王头"));
        //修改完成之后,去 dao 中查询用户列表  然后返回
        return list();
    }

    @Override
    public void userList() {
        //查询列表
        final List<User> oneList = list();
        oneList.forEach(item -> log.info("第一次查询的userName:{} \n",  item.getUsername()));

        //修改数据
        oneList.forEach(item -> item.setUsername("猫猫身上有毛毛"));
        oneList.forEach(item -> log.info("修改后的userName:{} \n", item.getUsername()));

        //重新查询
        List<User> secondList = list();
        secondList.forEach(item -> log.info("重新查询的userName:{} \n",  item.getUsername()));

    }

    @Override
    //@Transactional
    public void userListAddr() {
        //查询列表
        final List<User> oneList = list();

        log.info("oneList 第一次查询内存地址:{} \n",System.identityHashCode(oneList));

        //修改数据
        oneList.forEach(item -> item.setUsername("猫猫身上有毛毛"));
        log.info("oneList 将数据进行修改后的内存地址:{} \n",System.identityHashCode(oneList));

        //先声明一个对象
        List<User> secondList = new ArrayList<>();
        log.info("secondList 刚创建后的内存地址:{} \n",System.identityHashCode(secondList));

        //查询数据库 打印 hashcode
        secondList = list();

        log.info("secondList 写入数据后的内存地址:{} \n",System.identityHashCode(secondList));
    }
}

主启动类

@SpringBootApplication
@MapperScan(basePackages = {"com.zhubayi.mybatiscache.mapper"})
public class MybatisCacheApplication {

    public static void main(String[] args) {
        SpringApplication.run(MybatisCacheApplication.class, args);
    }

}

测试类

@SpringBootTest
class MybatisCacheApplicationTests {
    @Autowired
    private UserService userService;
    @Test
    void contextLoads() {
    }
    @Test
    public void test01(){
        System.out.println(userService.getAllUserList());
    }
    @Test
    public void test02(){
        userService.userList();
    }
    @Test
    public void test03(){
        userService.userListAddr();
    }
}

配置文件

spring:
  datasource:
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/learning?serverTimeZone=UTC
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

运行test01方法

发现查询了两次数据库
在这里插入图片描述

加上@Transactional然后再运行
在这里插入图片描述
在这里插入图片描述
只查询了一次数据库。
查询出来的数据先被缓存,然后修改列表时,修改的其实是缓存数据的引用
当再次查询时,取缓存中的数据,由于缓存中的数据已经被修改
取出来的数据理所当然,已经是修改过了
原因:
MyBatis 默认开启了一级缓存,它会缓存查询结果,导致在同一个事务内,从缓存中读取数据而不是从数据库中实际查询。

解决办法:
1.设置mybatis的1级缓存级别为statement
在这里插入图片描述
2.在方法外开启事务:如果可能,可以将查询和更新拆分成不同的方法,然后在需要的地方使用 @Transactional 注解来开启事务,这样可以更好地控制事务的边界。
3.手动清除缓存:在修改数据后,手动调用 MyBatisclearCache 方法,以清除一级缓存,确保后续查询从数据库中重新获取数据。SqlSessionUtils.getSqlSession(sqlSessionFactory).clearCache();

@Autowired
private SqlSessionFactory sqlSessionFactory;

@Transactional
@Override
public List<User> sessionUser(){
    List<User> firstList = list();
    firstList.forEach(item -> item.setUsername("老王头"));
    //修改完成之后,清除session里面额缓存去查询用户列表  然后返回
    SqlSessionUtils.getSqlSession(sqlSessionFactory).clearCache();
    return list();
}

4.Options注解清除1级缓存

@Mapper
public interface UserMapper extends BaseMapper<User> {
    @Options(flushCache= Options.FlushCachePolicy.TRUE)
    @Select("select  * from  t_user")
     List<User> getUsers();
}

UserServiceImpl实现类

    @Override
    @Transactional
    public List<User> getAllUserList() {
        List<User> firstList = baseMapper.getUsers();
        firstList.forEach(item -> item.setUsername("老王头"));
        //修改完成之后,去查询用户列表  然后返回
        return baseMapper.getUsers();
    }

5.声明式事务对代码没有侵入性,方法内只需要写业务逻辑就可以了,帮助我们节省了很多代码,他会自动帮我们进行事务的开启、提交以及回滚等操作,把程序员从事务管理中解放出来。可以说优点很明显,但是这种方式的缺点也很明

问题一:声明式事务的粒度问题
声明式事务有一个局限,那就是他的最小粒度要作用在方法上。也就是说,如果想要给一部分代码块增加事务的话,那就需要把这个部分代码块单独独立出来作为一个方法,在大事务耗时过长需要高并发优化的情况下不建议使用声明式事务,建议采用上面提到的编程式事务方式

问题二: 声明式事务用不对在某些场景下容易失效

  • @Transactional 应用在非 public 修饰的方法上,Transactional将会失效,当用在protected、private 修饰的方法上使用 @Transactional 注解,虽然事务无效,但不会有任何报错,这是我们很容犯错的一点。
  • @Transactional 注解属性 propagation 设置错误。
  • @Transactional 注解属性 rollbackFor 设置错误。
  • 同一个类中方法调用,导致@Transactional失效。
  • 异常被你的 catch“吃了”导致@Transactional失效。

总结:@Transactional 注解的看似简单易用,但如果对它的用法一知半解,还是会踩到很多坑的。

DataSourceTransactionManager 编程式事务

Spring的事务处理中,通用的事务处理流程是由抽象事务管理器AbstractPlatformTransactionManager来提供的,而具体的底层事务处理实现,由PlatformTransactionManager的具体实现类来实现,如 DataSourceTransactionManagerJtaTransactionManagerHibernateTransactionManager等。

	@Autowired
    private TransactionDefinition transactionDefinition;
    @Autowired
    private DataSourceTransactionManager dataSourceTransactionManager;
	public List<User> customUserList() {
        //方式一:使用默认bean对象TransactionDefinition
        TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition);
        //方式二:自己创建,可以设置事务传播机制,但一般都是使用默认即可PROPAGATION_REQUIRED。
        // DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        // def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
        //transactionStatus = dataSourceTransactionManager.getTransaction(def);
        try {
            List<User> firstList = list();
            firstList.forEach(item -> item.setUsername("老王头"));
            //修改完成之后,去查询用户列表  然后返回
            dataSourceTransactionManager.commit(transactionStatus); // 手动提交
            return list();
        } catch (Exception e) {
            System.out.println("异常——————————————————");
            dataSourceTransactionManager.rollback(transactionStatus); // 事务回滚
            e.printStackTrace();
        }
        return new ArrayList<User>();

    }

问题总结

使用 Mybatis 时,要结合具体场景注意缓存使用问题。

Mybatis 缓存机制简介
MyBatis 有一级缓存和二级缓存,并且预留了集成第三方缓存的接口。

一级缓存

定义:一级缓存也叫本地缓存,MyBatis 的一级缓存是在会话(SqlSession)层面进行缓存的。MyBatis 的一级缓存是默认开启的,不需要任何的配置。

一级缓存的缺点:使用一级缓存的时候,由于缓存不能跨会话共享,不同的会话之间对于相同的数据可能有不一样的缓存。在有多个会话、或者分布式环境、或者本地对查询结果进行了增删改(本问题的场景)的情况下,会出现脏数据的问题。

一级缓存级别调整:MyBatis 一级缓存(MyBaits 称其为 Local Cache)无法关闭,但是有两种级别可选,如下所示:

缓存级别处理方式
session 级别的缓存(默认)在同一个 sqlSession 内,对同样的查询将不再查询数据库,直接从缓存中获取
statement 级别的缓存每次查询结束都会清掉一级缓存;将一级缓存的级别设为 statement 级别可避免脏数据问题

二级缓存

  1. 全局开启二级缓存
    在这里插入图片描述
  2. Mapper.xml 文件中添加cache标签
<!--在当前 Mapper.xml文件开启二级缓存-->

<cache
  eviction="FIFO"
  flushInterval="60000"
  size="512"
  readOnly="true"/>
这个更高级的配置创建了一个 FIFO 缓存,每隔 60 秒刷新,最多可以存储结果对象或列表的 512 个引用,
而且返回的对象被认为是只读的,因此对它们进行修改可能会在不同线程中的调用者产生冲突。 
 可用的清除策略有:
    LRU – 最近最少使用:移除最长时间不被使用的对象。
    FIFO – 先进先出:按对象进入缓存的顺序来移除它们。
    SOFT – 软引用:基于垃圾回收器状态和软引用规则移除对象。
    WEAK – 弱引用:更积极地基于垃圾收集器状态和弱引用规则移除对象。
默认的清除策略是 LRU。
flushInterval(刷新间隔)属性可以被设置为任意的正整数,设置的值应该是一个以毫秒为单位的合理时间
量。 默认情况是不设置,也就是没有刷新间隔,缓存仅仅会在调用语句时刷新。
size(引用数目)属性可以被设置为任意正整数,要注意欲缓存对象的大小和运行环境中可用的内存资源。默
认值是 1024。
readOnly(只读)属性可以被设置为 true 或 false。只读的缓存会给所有调用者返回缓存对象的相同实
例。 因此这些对象不能被修改。这就提供了可观的性能提升。而可读写的缓存会(通过序列化)返回缓存对
象的拷贝。 速度上会慢一些,但是更安全,因此默认值是 false。 <cache/>


  1. 在某个查询方法上可以通过useCache单独配置是否启用缓存,默认启用
<select id="selectList" useCache="true">
</select>
  1. 通过@CacheNamespace注解来实现
    @CacheNamespace注解的属性如下:
    implementation:设置缓存的默认实现,默认是PerpetualCache.class
    evicition:默认溢出淘汰策略,默认LruCache.class
    flushInterval:缓存有效期,默认0 即用不失效
    size:缓存容量
    readWrite:是否要序列化和反序列化
    blocking:设置是否需要放置缓存穿透
    properties:为缓存设置属性(为implementation中的类设置属性)

需要注意的是xml和注解如果都用上了的话,需要用@CacheNamespaceRef指定引用的缓存空间(如果需要在命名空间中共享相同的缓存配置和实例,在这样的情况下你可以使用 cache-ref 元素来引用另外一个已经配置好的缓存)
或者在xml文件里面

<cache-ref namespace="com.x.x.x.XXXMapper"/>

二级缓存是用来解决一级缓存不能跨会话共享的问题的,范围是namespace 级别的,可以被多个SqlSession 共享(只要是同一个接口里面的相同方法,都可以共享),生命周期和应用同步,开启了二级缓存后,还需要将要缓存的entity实现Serializable接口。

如果 MyBatis 使用了二级缓存,并且你 Mapperselect 语句也配置使用了二级缓存,那么在执行select查询的时候,MyBatis会先从二级缓存中取输入,其次才是一级缓存,即MyBatis查询数据的顺序是:二级缓存 —> 一级缓存 —> 数据库。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐