一个线上的诡异问题
同事在排查一个线索问题时,发现一个方法获取的数据里会有几个相同的数据。理论上来说,这个方法里一次数据库查询只会是不会相同的数据的。
代码如下:
/**
一个从数据库中查询并返回列表的方法
* @param mobile
* @param group
* @return
*/
protected List<BzClue> getAllInActionClue(String mobile, String group, String column) {
BzFusionSource buildInSource = cacheService.getFusionSources().get(FusionSourceEnum.BUILD_IN.getFlag());
List<BzClue> clues1 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
.eq(column, group)
.eq("fusion_source_id", buildInSource.getId())
.in("lead_status", ClueStatusEnum.LEAD_STATUS_LIVE_LIST));
if (CollectionUtils.isEmpty(clues1)) {
return Lists.newArrayList();
}
List<BzOpportunity> opps = bzOpportunityMapper.selectList(Wrappers.<BzOpportunity>query().eq("contact_phone", mobile)
.eq(column, group)
.eq("fusion_source_id", buildInSource.getId())
.in("stage_name", OpportunityStageNameEnum.STAGE_NAME_LIVE_LIST));
if (CollectionUtils.isEmpty(opps)) {
return clues1;
}
List<String> leadIds = opps.stream().map(BzOpportunity::getLeadId).collect(Collectors.toList());
List<BzClue> clues2 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
.eq(column, group)
.eq("fusion_source_id", buildInSource.getId())
.in("lead_id", leadIds));
clues1.addAll(clues2);
return clues1;
}
按照一般的预期,我们每次调用这个方法,等到的结果都是一样的。可是在多次以相同参数调用方法后,返回结果却不是幂等。难道是数据库的数据更新了?然后查看数据库,并没有发现数据变更。废了很大劲在开发环境断点多次,终于模拟出出错时的场景。
一个遍调用方法时,一切参数变量结果都是正确的,此时clue1是空值,但是clues2
是有值的。但是第二次调用时,方法中的clues1
变量却是上一次方法调用中的列表对象,里面已经存在上一次调用时add
进去的值,导致又再次往同一对象里add
了一次clues2
的值。
我们知道Java是值传递的,对象传递的是引用的值。这里两次方法调用中的clueMapper.selectList()
返回的变量是同一个对象引用,指向堆内存里的同一个对象实例。这个对象实例,在第一次调用getAllInActionClue()
后就存在了,并已经往列表里面放入了数据。当第二次时调用时,clues1
并非新创建的对象,而是第一次调用时已经被修改过的对象。
那么问题来了,为什么clueMapper.selectList()
这个方法返回的值是上一次的引用呢?
产生问题的根源
两次Mybatis查询返回相同的值,马上就能让人联想到缓存,而Mybatis是有一级缓存、二级缓存的。
MyBatis一级缓存问题,返回的对象引用是旧的。
翻看Mybaits源码文章
查了美团技术团队的《聊聊MyBatis缓存机制》,可以知道
Cache
: MyBatis中的Cache接口,提供了和缓存相关的最基本的操作,如下图所示:
有若干个实现类,使用装饰器模式互相组装,提供丰富的操控缓存的能力,部分实现类如下图所示:
BaseExecutor
成员变量之一的PerpetualCache
,是对Cache接口最基本的实现,其实现非常简单,内部持有HashMap,对一级缓存的操作实则是对HashMap的操作。如下代码所示:
public class PerpetualCache implements Cache {
private String id;
private Map<Object, Object> cache = new HashMap<Object, Object>();
是的,MyBatis默认的缓存实现,是直接使用HashMap的,里面保存的对象值,只有在执行了
insert/delete/dpdate
方法后,缓存才会刷新
这时 mybatis的cache不能用,直接都是hashmap!
因为mybatis一级缓存的关系,如果直接对缓存计算会产生问题:
- 同一个session内执行多次相同的查询,第二次开始后续的查库是直接返回缓存中的数据(引用而不是变量的副本)
- 当第一次查询出结果,将结果放入缓存
- 对缓存的引用做计算,直接更新了缓存中的引用
- 后续从缓存去取出结果,此时的结果跟数据库存储的数据已经不一致
解决方案
针对此类问题的解决办法:
- mybatis不支持关闭一级缓存,只能通过一些配置或编码绕过一级缓存,比如每次查询之前都手动清楚缓存
- 将缓存级别设置为statement级别(默认session级别),每次执行statement数据都会被清除(这种方式使用的人不多,会不会引入其他新问题也未知,不太推荐使用)
- 编码时候禁止直接使用mapper返回的对象,应该复制一个副本
- 二级缓存也可有类似的问题
写这段代码的同事的解决方式是,进入方法时,每次都创建一个list,把两个查询的接口都放进去,
/**
* 查询进行中的线索
*
* @param mobile
* @param group
* @return
*/
protected List<BzClue> getAllInActionClue(String mobile, String group, String column) {
List<BzClue> list = Lists.newArrayList();
// 查询进行中的线索
BzFusionSource buildInSource = cacheService.getFusionSources().get(FusionSourceEnum.BUILD_IN.getFlag());
List<BzClue> clues1 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
.eq(column, group)
.eq(FUSION_SOURCE_ID_COLUMN, buildInSource.getId())
.in("lead_status", ClueStatusEnum.LEAD_STATUS_LIVE_LIST));
if (CollectionUtils.isNotEmpty(clues1)) {
list.addAll(clues1);
}
// 查询进行中的商机
List<BzOpportunity> opps = bzOpportunityMapper.selectList(Wrappers.<BzOpportunity>query().eq("contact_phone", mobile)
.eq(column, group)
.eq(FUSION_SOURCE_ID_COLUMN, buildInSource.getId())
.in("stage_name", OpportunityStageNameEnum.STAGE_NAME_LIVE_LIST));
if (CollectionUtils.isEmpty(opps)) {
return list;
}
// 已转化的线索有可能也还是跟进中
List<String> leadIds = opps.stream().map(BzOpportunity::getLeadId).collect(Collectors.toList());
List<BzClue> clues2 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
.eq(column, group)
.eq(FUSION_SOURCE_ID_COLUMN, buildInSource.getId())
.in("lead_id", leadIds));
/**
*
*
*
*/
// clues1.addAll(clues2);
list.addAll(clues2);
return list;
}
另一个同事可能担心,浅拷贝的问题,直接用一个新的map把Mybaits方法传入对象的引用彻底移除。
/**
* 查询进行中的线索
*
* @param mobile
* @param group
* @return
*/
protected List<BzClue> getAllInActionClue(String mobile, String group, String column) {
BzFusionSource buildInSource = cacheService.getFusionSources().get(FusionSourceEnum.BUILD_IN.getFlag());
List<BzClue> clues1 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
.eq(column, group)
.eq("fusion_source_id", buildInSource.getId())
.in("lead_status", ClueStatusEnum.LEAD_STATUS_LIVE_LIST));
// 无进行中线索也必不会有进行中商机
if (CollectionUtils.isEmpty(clues1)) {
return Lists.newArrayList();
}
List<BzOpportunity> opps = bzOpportunityMapper.selectList(Wrappers.<BzOpportunity>query().eq("contact_phone", mobile)
.eq(column, group)
.eq("fusion_source_id", buildInSource.getId())
.in("stage_name", OpportunityStageNameEnum.STAGE_NAME_LIVE_LIST));
if (CollectionUtils.isEmpty(opps)) {
return clues1;
}
// 已转化的线索有可能也还是跟进中
List<String> leadIds = opps.stream().map(BzOpportunity::getLeadId).collect(Collectors.toList());
List<BzClue> clues2 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
.eq(column, group)
.eq("fusion_source_id", buildInSource.getId())
.in("lead_id", leadIds));
// clues1.addAll(clues2);
HashMap<Long,BzClue> buffer = new HashMap<>();
clues1.forEach(clue->buffer.put(clue.getId(),clue));
clues2.forEach(clue->buffer.put(clue.getId(),clue));
return new ArrayList<>(buffer.values());
}
个人认为,以当前的业务逻辑来说,使用第一种改法就好,因为第一种改法已经没有在直接更新Mybatis查出的对象引用,这要保证这一点,就可以让返回值不变。
至此,一个诡异的bug得以查到原因和解决。
以上内容是自己收集的想法,有不正之处,请大家多多指导。
一级缓存
二级缓存
引用资料:
美团技术团队 《聊聊MyBatis缓存机制》https://tech.meituan.com/2018/01/19/mybatis-cache.html