Mybatis 中同一个mapper文件方法重载问题引发的生产事故

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. 在上面的代码进行了两个注释,注释1的地方,一看就是利用反射获取方法中定义的方法数组,此处是关键,获取到的方法在数组中的顺序不一样
  2. type.getMethods();在第一次获取时候,会调用一个native方法getDeclaredMethods0获取,由jvm决定顺序(ps:这个具体怎么决定的,暂时没研究,自己感觉会与class对象加载时候分配的内存排序有关),后面获取会从缓存中获取。
  3. 注释2的地方就是根据反射获取的方法数组遍历,去解析MappedStatemen,此时已经解析出SqlSource(ps:是在mapper方法上加的@Update注解)。
  4. 下面的代码是将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);
    }

三. 方法调用时参数组装问题

  1. 经过上面的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);
                    }
                }
            }
        }
    }
  1. 上面代码中能看出,大概过程是从解析好的boundSql中获取对应的sql需要的参数信息,然后进行遍历,从parameterObject(调用方法时候传入的实际参数值)中获取对应值进行设置参数值。
  2. 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);
  1. 假如项目启动时候通过反射获取mapper方法数组顺序是 public int updateFailCountById(long id)在第一个,此时SqlSource解析出来的sql是update A set fail_count = fail_count + 1 where id = ?。
  2. 此时调用updateFailCountById(long id)方法,正常,不会有问题;
  3. 调用 public int updateFailCountById(long id,int count)方法,根据上面解析出来的sql,按照调用犯法的入参和Mybatis的参数组装规则,parameterObject 此时是map,参数组装时候根据sql中的id取出值,进行sql执行,此时不管count传入的值是多少,都会只更新+1操作。count值大于1时候会造成发送失败消息数量比实际数量少的情况,这种情况生产上不容易观察出来,所以生产上只看到发送失败消息数量比实际数量多的情况;
  4. 假如项目启动时候通过反射获取mapper方法数组顺序是public int updateFailCountById(long id,int count)在第一个,此时SqlSource解析出来的sql是update A set fail_count = fail_count + ? where id = ?。
  5. 此时调用updateFailCountById(long id,int count方法,不会有问题;
  6. 调用updateFailCountById(long id)方法,同样根据上面解析出来的sql,按照调用犯法的入参和Mybatis的参数组装规则,parameterObject 此时是long类型的id值,参数组装时候根据sql中的需要的参数是两个,所以遍历两次,两次都是取parameterObject 的值,比如id传入188,此时sql组装完是update A set fail_count = fail_count + 188 where id = 188。会出现发送失败消息数量比实际数量多的情况,多多少倍都能解释通了,这个正是生产上发下问题的原因。

到此,问题找到了,那么解决起来就简单了,把重载的方法改下就行。真是改一行代码,背后的原因涉及的真是一个宇宙呀!!!!!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值