Mybatis 中同一个mapper文件方法重载问题引发的生产事故
生产上遇到的问题
问题描述:关于一个数量统计的问题,生产上给客户发送消息,统计发送失败的数量,结果发现发送失败数量往往大于消息数量的几倍甚至几十倍。
问题排查:经过各种代码流程,消息触发日志等手段的分析排查,都没什么问题,头发即将掉光的时候,突然发现了更新消息发送失败数量的方法竟然有重载,终于抓住了一线希望,觉得重载似乎和这个问题没多少关系,但是这是唯一的突破点。
所以闷着头,本地来一把调式,看看mapper重载的情况下,在加载和方法调用的时候到底有哪些猫腻。下来就开始长达几个小时的代码调试过程。。。
一. 首先看下MP同一个mapper文件方法能不能重载?
在我们印象中,使用xml定义方法是,每个select,update等标签都会有一个id,mybatis会拼接上当前mapper文件的全路径包名 + id来生成一个MappedStatement, 放入mappedStatements的map中。所以mybatis中同一个mapper文件方法是不能重载的,在一些版本中如果重载,在项目启动时候会报错,但是此次说的版本并没有报错,而且生产上运行正常。使用版本:mybatis-plus-boot-starter 版本3.0.7.1。
二. 基于mybatis-plus-boot-starter 版本3.0.7.1,剖析下方法重载时候MappedStatement的解析问题
那就从mapper文件的加载入手,MybatisMapperRegistry类中的addMapper方法,就是在项目启动时候加载解析mapper文件的。找到解析mapper文件的地方,即MybatisMapperAnnotationBuilder中的parse方法:
@Override
public void parse() {
String resource = type.toString();
if (!configuration.isResourceLoaded(resource)) {
loadXmlResource();
configuration.addLoadedResource(resource);
assistant.setCurrentNamespace(type.getName());
parseCache();
parseCacheRef();
// 1.通过反射,查询mapper文件中定义的方法,此处会有重载的方法
Method[] methods = type.getMethods();
if (GlobalConfigUtils.getSuperMapperClass(configuration).isAssignableFrom(type)) {
GlobalConfigUtils.getSqlInjector(configuration).inspectInject(assistant, type);
}
for (Method method : methods) {
try {
if (!method.isBridge()) {
//2. 通过method构建出MappedStatement,并加入mappedStatements中
parseStatement(method);
}
} catch (IncompleteElementException e) {
configuration.addIncompleteMethod(new MethodResolver(this, method));
}
}
}
parsePendingMethods();
}
- 在上面的代码进行了两个注释,注释1的地方,一看就是利用反射获取方法中定义的方法数组,此处是关键,获取到的方法在数组中的顺序不一样。
- type.getMethods();在第一次获取时候,会调用一个native方法getDeclaredMethods0获取,由jvm决定顺序(ps:这个具体怎么决定的,暂时没研究,自己感觉会与class对象加载时候分配的内存排序有关),后面获取会从缓存中获取。
- 注释2的地方就是根据反射获取的方法数组遍历,去解析MappedStatemen,此时已经解析出SqlSource(ps:是在mapper方法上加的@Update注解)。
- 下面的代码是将MappedStatement 加入到mappedStatements中,下面代码是加入的方法,可以看到,mappedStatements中存在id(mapper全路径+id,此处就是方法名)时, 直接打日志警告返回,不会抛异常阻止项目启动。到此,mapper文件的加载解析已完成。
@Override
public void addMappedStatement(MappedStatement ms) {
logger.debug("addMappedStatement: " + ms.getId());
if (GlobalConfigUtils.isRefresh(ms.getConfiguration())) {
/*
* 支持是否自动刷新 XML 变更内容,开发环境使用【 注:生产环境勿用!】
*/
mappedStatements.remove(ms.getId());
} else {
if (mappedStatements.containsKey(ms.getId())) {
/*
* 说明已加载了xml中的节点; 忽略mapper中的SqlProvider数据
*/
logger.error("mapper[" + ms.getId() + "] is ignored, because it exists, maybe from xml file");
return;
}
}
super.addMappedStatement(ms);
}
三. 方法调用时参数组装问题
- 经过上面的MappedStatement解析后,SqlSource已经解析出来了,看下方法调用时候的参数组装,调用的过程不进行解析,只看参数组装地方,MybatisDefaultParameterHandler类中的setParameters方法。代码如下:
@Override
@SuppressWarnings("unchecked")
public void setParameters(PreparedStatement ps) {
ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings != null) {
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
TypeHandler typeHandler = parameterMapping.getTypeHandler();
JdbcType jdbcType = parameterMapping.getJdbcType();
if (value == null && jdbcType == null) {
jdbcType = configuration.getJdbcTypeForNull();
}
try {
typeHandler.setParameter(ps, i + 1, value, jdbcType);
} catch (TypeException | SQLException e) {
throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
}
}
}
}
}
- 上面代码中能看出,大概过程是从解析好的boundSql中获取对应的sql需要的参数信息,然后进行遍历,从parameterObject(调用方法时候传入的实际参数值)中获取对应值进行设置参数值。
- parameterObject 是在PageMapperMethod.MethodSignature.convertArgsToSqlCommandParam中进行解析的,(ps: 此处是根据实际调用的方法进行参数个数和参数值解析), 一个参数,解析成一个参数,多个参数则放进map中。
剖析生产上事故产生原因
经过上面的反射获取方法数组,mapper方法重载时候MappedStatement的解析,方法调用参数组装的三大方面的分析,此时来看看生产上事故发生的原因。
下面是重载的代码:
@Update("update A set fail_count = fail_count + 1 where id = #{id}")
public int updateFailCountById(long id);
@Update("update A set fail_count = fail_count + #{count} where id = #{id}")
public int updateFailCountById(long id,int count);
- 假如项目启动时候通过反射获取mapper方法数组顺序是 public int updateFailCountById(long id)在第一个,此时SqlSource解析出来的sql是update A set fail_count = fail_count + 1 where id = ?。
- 此时调用updateFailCountById(long id)方法,正常,不会有问题;
- 调用 public int updateFailCountById(long id,int count)方法,根据上面解析出来的sql,按照调用犯法的入参和Mybatis的参数组装规则,parameterObject 此时是map,参数组装时候根据sql中的id取出值,进行sql执行,此时不管count传入的值是多少,都会只更新+1操作。count值大于1时候会造成发送失败消息数量比实际数量少的情况,这种情况生产上不容易观察出来,所以生产上只看到发送失败消息数量比实际数量多的情况;
- 假如项目启动时候通过反射获取mapper方法数组顺序是public int updateFailCountById(long id,int count)在第一个,此时SqlSource解析出来的sql是update A set fail_count = fail_count + ? where id = ?。
- 此时调用updateFailCountById(long id,int count方法,不会有问题;
- 调用updateFailCountById(long id)方法,同样根据上面解析出来的sql,按照调用犯法的入参和Mybatis的参数组装规则,parameterObject 此时是long类型的id值,参数组装时候根据sql中的需要的参数是两个,所以遍历两次,两次都是取parameterObject 的值,比如id传入188,此时sql组装完是update A set fail_count = fail_count + 188 where id = 188。会出现发送失败消息数量比实际数量多的情况,多多少倍都能解释通了,这个正是生产上发下问题的原因。
到此,问题找到了,那么解决起来就简单了,把重载的方法改下就行。真是改一行代码,背后的原因涉及的真是一个宇宙呀!!!!!