事故描述

某商品的照片分两种类型:A商品外观照片 和 B商品配件照片两个相册 ,它们保存在同一张picture表中。
在一个事务内按照片类型批量更新商品照片,但操作人只有保存A类型照片的权限,因需要将该商品A照片清空,然后插入新A类照片,然后取商品所有照片,仅发现B类照片,未发现新插入的A类照片。

事务的隔离性

  1. start transaction;
  2. ## 插入id=1数据
  3. INSERT INTO
  4. # 当前会话内 查询id=1的数据可见
  5. select * from where id='1';
  6. commit ;
  7. # 其他事务查询id=1的数据可见
  8. select * from car_picture where id='1';

因此,在同一个事务内,删除数据a,再插入数据b,查询得到的应该是b,但就结果没有拿到b. 导致在同步第三方数据同台时出现少数据的线上问题。

问题分析

事务的传播行为

会不会是因为插入行为在另一事务内?

  • 查阅代码发现事务传播行为为默认属性:required ,也就是不会创建新事务,而是加入调用者的事务。

  • 况且即使发起新事务,只要事务B提交,就能查到数据b(在没用使用多线程的情况下,事务的隔离级别默认为readCommited) .

image.png

一级缓存

会不会是一级缓存的问题?

image.png
每一个sqlsession有自己的Executor,每一个executor有一个local cache.
当用户发起查询时,mybatis会根据当前statement生成一个key,去localcache中查询,如果缓存命中直接返回,未命中,访问db,写入localcache然后返回
信息量:

  • 一级缓存默认开启
  • 一级缓存是session级别的
  • sqlsession执行dml (insert/update/delete)、close、clearCache等方法,会释放localcache中的对象(引用),一级缓存不可用

综上,删除再插入,然后重新获取时不会使用一级缓存。因此不应该是一级缓存的锅。

  1. debug sqlSession.selectList()

image.png
但事实上在第二次selectList的过程中,发现控制台没有打sqlLog 并且debug到sqlSession.selectList方法上,手动执行前调用sqlSession.clearCache(), 发现获取到了最新数据(不调用clearCache控制台不打sqlLog,取到脏数据),这也就是说缓存还是生效了,尽管对图片表delete和insert过,那么问题在哪?

  1. 难道是因为一个事务开启了多个sqlSession?

debug事务内部所有sql操作,查看sqlSession的内存地址
理论上在一个事务内,一个mapper对应开启一个sqlSession。
打印:update和selectList的sqlSession的内存地址
意外发现mybaits-plus在updateBatch的时候和update用的不是同一个sqlSession,这实在太坑了。

  1. /**
  2. com.baomidou.mybatisplus.extension.service.impl.ServiceImpl
  3. */
  4. public class ServiceImpl<M extends BaseMapper<T>, T> implements IService<T> {
  5. @Transactional(rollbackFor = Exception.class)
  6. @Override
  7. public boolean updateBatchById(Collection<T> entityList, int batchSize) {
  8. Assert.notEmpty(entityList, "error: entityList must not be empty");
  9. String sqlStatement = sqlStatement(SqlMethod.UPDATE_BY_ID);
  10. try (SqlSession batchSqlSession = sqlSessionBatch()) {
  11. int i = 0;
  12. for (T anEntityList : entityList) {
  13. MapperMethod.ParamMap<T> param = new MapperMethod.ParamMap<>();
  14. param.put(Constants.ENTITY, anEntityList);
  15. batchSqlSession.update(sqlStatement, param);
  16. if (i >= 1 && i % batchSize == 0) {
  17. batchSqlSession.flushStatements();
  18. }
  19. i++;
  20. }
  21. batchSqlSession.flushStatements();
  22. }
  23. return true;
  24. }
  25. @Override
  26. public boolean updateById(T entity) {
  27. return retBool(baseMapper.updateById(entity));
  28. }
  29. // 其他
  30. }

如上代码片断,mybatis-plus在updateBatch时的处理逻辑 使用Serivice内部打开的sqlSession ,而普通的updateById则走的mapper更新,mapper更新用的则是另一套session. 这也就是说,
image.png
如前文所说,sqlSessionA未监听到update/delete句柄,因此未执行移除缓存的操作,这使得第二次selectList的时候未执行sql语句,直接从缓存中取。

总结

  1. mytabis一级缓存在表被删除更新操作时缓存对象引用会被移除
  2. 一级缓存是会话级别的
  3. mybatis-plus selectList和updateBatchBy方法使用了两个不同的sqlSession.

因第3条的缘故,使得一级缓存没有在理想状态下被移除从而引发事故。

至于mybatis-plus为什么selectList和updateBatchBy方法使用了两个不同的sqlSession,感觉是在偷懒,后面可以再另出文章专门探讨。
image.png

参考文献