当前位置: 首页 > 科技观察

一个SQL错误的问题让我找到了公司框架中三个Bug

时间:2023-03-12 18:14:04 科技观察

一个SQL错误问题导致我发现公司框架的三个bug一直躺在草稿箱里。昨天突然想起来,于是重新编辑了文章发出来!问题1当时我在开发一个异常记录接口。其实业务流程很简单,就是前端将错误日志提交给后台,后台收到信息进行处理。一些,然后插入到数据库中。因为这个接口的并发度比较高,为了不影响其他业务,同时提高响应速度。所以使用@Async注解+Spring线程池的方案来实现。线程池的配置如下:使用task:annotation-driven/开启异步的时候一定要记得配置executor属性,不然异步使用的线程池其实是org.springframework.core.task.SimpleAsyncTaskExecutor,但是这个SimpleAsyncTaskExecutor并不是真正的线程池。该类不重用线程,每次调用都会创建一个新线程。溃败。关键部分伪代码如下:@Asyncpublicvoidtest(){ExceptionLogEntityexceptionLogEntity=newExceptionLogEntity();exceptionLogEntity.setXX("");exceptionLogEntity.setXXX("");exceptionLogEntity.setIp("");exceptionLogEntity.setUrl("");exceptionLogEntity.setBusinessScene("");exceptionLogEntity.setExceptionType("");exceptionLogEntity.setExceptionDetailType("");exceptionLogEntity.setExceptionMessage("");exceptionLogEntity.setNoticeStatus("");exceptionLogEntity.setCreateTime(newDate());exceptionLogEntsetUpdateTime(newDate());ExceptionLogEntityinsert=exceptionLogDao.insert(exceptionLogEntity);log.info("Entityprimarykeyid=[{}]",insert.getId());}写完代码,简单测试了一下,没有问题后,告诉前端可以连接了。但是奇怪的事情发生了。前端老铁告诉我,接口有时会返回错误。我一听,就觉得不对劲。我心想这么简单的界面怎么会有bug。于是在日志平台上查看了日志,果然是有问题。发现了一个SQL错误,具体错误如下(感知信息已处理):Causedby:java.lang.reflect.InvocationTargetExceptionINSERTINTO`xx`(`xx`,`xx`,`ip`,`url`,`business_scene`,`exception_type`,`exception_detail_type`,`exception_message`,`notice_status`,`create_time`,`update_time`,`xx`,`member_phone`,`ip`,`url`,`business_scene`,`exception_type`,`exception_detail_type`,`exception_message`,`notice_status`,`create_time`,`update_time`)VALUES('','','','','','','','','','2020-01-0819atsun.reflect.GeneratedMethodAccessor115.invoke(未知来源)-----------------------------------------atsun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)atjava.lang.reflect.Method.invoke(Method.java:498)atcoderead.mybatis.log.JdbcCommonCollects$PreparedStatementHandler.invoke(JdbcCommonCollects.java:118)...21moreCausedby:com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException:Column'update_time'specifiedtwiceatsun.reflect.NativeConstructorAccessorImpl.newInstance0(NativeMethod)atsun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)atsun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)atjava.lang.reflect.Constructor.newInstance(Constructor.java:423)atcom.mysql。jdbc.Util.handleNewInstance(Util.java:425)复现问题这个问题简单来说就是SQL中的字段重复了。column'update_time'指定了两次,其实不仅仅是update_time字段,还有很多字段。重复。因为这个接口除了并发度更高之外,没有其他接口的特别之处!突然,我的脑海里闪过一道灵光。会不会是这个接口并发度很高,导致框架在INSERT类型的SQL中产生了错误???如果是这样的话,我是不是可以直接制造这样的场景呢?然后我写了下面的代码:publicvoidtest(){for(inti=0;i<1000;i++){ExceptionLogEntityexceptionLogEntity=newExceptionLogEntity();exceptionLogEntity.setXX("");exceptionLogEntity.setXXX("");exceptionLogEntity.setIp("");exceptionLogEntity.setUrl("");exceptionLogEntity.setBusinessScene("");exceptionLogEntity.setExceptionType("");exceptionLogEntity。setExceptionDetailType("");exceptionLogEntity.setExceptionMessage("");exceptionLogEntity.setNoticeStatus("");exceptionLogEntity.setCreateTime(newDate());exceptionLogEntity.setUpdateTime(newDate());Threadthread=newThread(()->{exceptionLogDao.insert(exceptionLogEntity);});thread.start();}}代码写完后直接重启项目,最后触发了上面的代码。果然,同样的错误又出现了。这里首先介绍一下使用的ORM框架:该ORM框架是集团O2O研发中心开发的基础组件,提供与Mybatis相同的功能:只需要定义接口,无需编写实现类。另外针对单表操作也封装了一套常用的增删改查查询处理,不需要重新定义连接端口。对开发者非常友好,大大减少了冗长的jdbc操作代码量,提高了开发效率。问了旁边的同事在使用公司ORM框架自带的INSERT方法的时候有没有遇到SQL列重复的问题。太神奇了,看来这真的不是个别现象,真的是框架的bug。于是我花了一点时间找出问题出在哪里,果然找到了。具体代码如下:publicListgetDalColumnsWithoutId(){if(this.columnsWithoutId!=null){returnthis.columnsWithoutId;}else{this.columnsWithoutId=newArrayList(this.dalColumns.size()-1);Iteratorvar1=this.dalColumns.iterator();while(var1.hasNext()){DalColumncolumn=(DalColumn)var1.next();if(!column.isIdColumn()){this.columnsWithoutId.add(column);}}returnthis.columnsWithoutId;}}问题出在columnsWithoutId,它的类型是List,也就是类的A属性默认为null,保存一张表除了主键ID以外的所有字段。上面的代码在单线程的情况下没有问题,但是在多线程的情况下就是大问题了。columnsWithoutId是多线程情况下的线程共享变量。假设在某个时刻,恰好有多个线程执行if代码块,发现columnsWithoutId的值为null,则else代码块会同时执行,然后执行多次循环后,字段incolumnsWithoutId自然会重复。解决问题这个问题已经找到了,然后就有了解决方案。官方修复我当时想,既然这个框架是公司研发中心提供的基础组件,那么意味着公司内部很多项目组都会用到它,问题可能早就被修复了。更不用说我实际上是在公司的wiki上找到它的。一位负责该组件的大佬在wiki上分享了《定位并解决单表插入偶发列重复问题》一文。(我就不贴文章地址了,公司内部地址你也访问不了。)文章详细描述了问题发生的场景(和我上面描述的类似),问题定位过程,以及解决方案。文章摘录中的解决方案:很明显线程安全的问题确定了,解决方案也确定了。线程安全的解决方案一共有三种:方案一:互斥同步——Synchronized,ReentrantLock方案二:非阻塞同步——CAS方案三:无同步方案:——ThreadLocal结合业务场景,综合分析,最终确定使用解决方案1,使用synchronized关键字和双重检查锁来解决这个问题。老大修复了bug,发布了新版本到maven仓库。如果项目出现以上问题,直接升级对应的组件版本即可。现在官方给出了解决方案,我直接升级项目中的组件版本号就可以了。结果我在项目里一改版本,我的项目就再也没有启动过。研究发现组件版本差异太大,很多依赖冲突。我项目中使用的是1.1.6-RELEASE,官方在2.1.0-RELEASE版本修复了这个问题。该组件版本差异太大。贸然升级肯定有很大的风险,所以放弃了!寻找另一种方法来解决问题。既然你自带的插入方式有问题,我就自定义一个插入方式。惹不起我,我瞒着不行吗!最后问同事最后是怎么解决的,大家都说是自定义SQL,不再用框架自带的方法了。每个人都很聪明。!(其实这个还是有后续的,在后面的开发过程中,需要用到批量插入功能,偷懒用了框架自带的批量方式,结果又被骗了。从那以后,我再也没有用过内置的方法。。。)问题2在排查上面的问题时,在框架中看到如下代码:privateNumberexecute4PrimaryKey(StringsqlId,MapparamMap,KeyHolderkeyHolder){longstartTimestamp=System.currentTimeMillis();Stringsql=null;Objectvar8;try{MappedStatementmappedStatement=this.configuration.getMappedStatement(sqlId,true);mappedStmtThreadLocal.set(mappedStatement);sql=mappedStatement.getBoundSql(paramMap);intresult=false;intresult;if(keyHolder!=null){this.execution.update(sql,newMapSqlParameterSource(DalUtils.mapIfNull(paramMap)),keyHolder);result=keyHolder.getKey()==null?0:keyHolder.getKey().intValue();}else{result=this.execution.update(sql,DalUtils.mapIfNull(paramMap));}Integervar9=result;returnvar9;}catch(Exceptionvar13){this.throwException(var13);var8=null;}finally{mappedStmtThreadLocal.remove();logger.debug("{}方法:{},sql:{},参数:{}",newObject[]{this.logPrefix,"execute",sql,paramMap});this.logProfileLongTimeRunningSql(startTimestamp,sql,paramMap);}return(Number)var8;}仔细看,上面的代码有个问题,不容易发现。问题是我们系统中表的主键类型定义为bigint,这个类型对应Java中的Long类型。你懂吗?对了,就是数据溢出的问题。为了重现问题,我将表中的主键增量设置为JavaInt类型的最大值+1,即2147483648。然后再向数据库中添加一条记录。此时表中记录的主键为2147483648,但Java实体中的主键ID已经是负数-2147483648。使用Long类型时,注意JavaScript接收后端Long类型的数据精度丢失。真的很难找到另一种方法来解决这个问题。只是后面会出现类似的问题,必须升级ORM框架的版本。问题3其实我看内部文章的时候就应该发现这个问题的,只是当时没有看到而已!这时,我留下了没有技术的眼泪。对于问题1,当时的老大最后用synchronized加锁解决了多线程下共享变量的读写问题。具体代码如下(敏感信息编码):修改前代码:publicListgetDalColumnsWithoutId(){if(this.columnsWithoutId!=null){returnthis.columnsWithoutId;}else{this.columnsWithoutId=newArrayList(this.dalColumns.size()-1);Iteratorvar1=this.dalColumns.iterator();while(var1.hasNext()){DalColumncolumn=(DalColumn)var1.next();if(!column.isIdColumn()){this.columnsWithoutId.add(column);}}returnthis.columnsWithoutId;}}修改代码(参考文章截图中的代码):问题重现如果要重现这个问题,不容易重现,因为情况比较极端,但在复杂的生产环境中还是很有可能出现的。例如:假设有两个线程A和B,线程A执行到for循环,线程B执行到最开始的if判断。由于此时cloumnsWithId已经被线程A赋值,肯定不能为null,然后线程B直接返回,导致B线程的调用者获取到一个为空或者保存了一些字段的cloumnsWithId。这种情况的最终结果是INSERTSQL中的字段丢失了。解决方案对于多线程下的共享变量,要坚持读写可以并行,读写必须排队的原则。当然,如果可以不锁,也是不错的选择。发现这个问题后,我找到了上一篇文章中提到的老大,将我的想法反馈给他,提出了我的解决方案。直接在getDalColumnsWithoutId方法上添加同步。修改先if判断逻辑,然后使用synchronized锁代码块。publicListgetDalColumnsWithoutId(){intsize=CollectionUtils.size(this.dalColumns);if(CollectionUtils.size(this.columnsWithoutId)==size&&size>0){returnthis.columnsWithoutId;}同步(锁){if(columnsWithoutId==null){columnsWithoutId=newArrayList<>(size*2);}for(DalColumncolumn:this.dalColumns){if(!column.isIdColumn()){this.columnsWithoutId.add(column);}}}returnthis.columnsWithoutId;}当时和老大的交流截图,老大的肯定:综上所述,遇到问题不要着急,要学会大胆假设,然后认真求证。勇于质疑权威,不要认为既然已经是框架就没有bug。比如Spring的版本号从原来的1.0到现在已经超过了5.0。除了给Spring增加新的功能外,其他的升级都是为了修复Spring中的bug。做出自己的假设后,您需要重现场景。可以连续重现一个正常的错误。如果问题重现,就是找到问题的症结所在,剩下的就是如何解决了!