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

数据库 SQL Server
本文是对之前开发中遇到的问题的一个总结,文章其实早就写好,但是觉得自己写得不够深入,就让文章一直躺在草稿箱里。昨天突然想起来了,就将文章重新修改了一下,还是发出来吧!

本文是对之前开发中遇到的问题的一个总结,文章其实早就写好,但是觉得自己写得不够深入,就让文章一直躺在草稿箱里。昨天突然想起来了,就将文章重新修改了一下,还是发出来吧!

[[389221]]

问题1

当时我在开发一个异常日志记录接口,其实业务流程很简单,就是前端提交错误日志到后台,后台接受到信息处理一番,然后插入数据库。因为这个接口的并发量比较高,为了不影响其他业务,并且同时提高响应的速度。于是采用 @Async 注解+ Spring线程池的方案来实现。线程池的配置如下:

  1. <task:annotation-driven executor="jobExecutor"/>   
  2. <task:executor id="jobExecutor" pool-size="20" queue-capacity="500" />   

使用 task:annotation-driven/ 开启异步时,一定要记得配置executor属性,不然异步使用的线程池其实是

org.springframework.core.task.SimpleAsyncTaskExecutor ,但这个 SimpleAsyncTaskExecutor 不是真的线程池,这个类不重用线程,每次调用都会创建一个新的线程。

关键部分伪代码如下:

  1. @Async  
  2.  public void test(){  
  3.   
  4.      ExceptionLogEntity exceptionLogEntity = new ExceptionLogEntity();  
  5.      exceptionLogEntity.setXX("");  
  6.      exceptionLogEntity.setXXX("");  
  7.      exceptionLogEntity.setIp("");  
  8.      exceptionLogEntity.setUrl("");  
  9.      exceptionLogEntity.setBusinessScene("");  
  10.      exceptionLogEntity.setExceptionType("");  
  11.      exceptionLogEntity.setExceptionDetailType("");  
  12.      exceptionLogEntity.setExceptionMessage("");  
  13.      exceptionLogEntity.setNoticeStatus("");  
  14.      exceptionLogEntity.setCreateTime(new Date());  
  15.      exceptionLogEntity.setUpdateTime(new Date());  
  16.   
  17.   
  18.      ExceptionLogEntity insert = exceptionLogDao.insert(exceptionLogEntity);  
  19.      log.info("实体的主键id=[{}]"insert.getId());  
  20.   
  21.  }  

代码写完我简单的测试了一下,没啥问题后就告知前端可以对接了。可是奇怪的事情发生了,前端老铁告诉我接口有时会返回错误。我一听就感觉不对劲,心想这么简单的接口,我怎么可能有bug。于是我到日志平台上查询了一下日志,结果还真的是有问题。发现了一个SQL错误,具体错误如下(敏感信息已经处理):

  1. Caused by: java.lang.reflect.InvocationTargetException  
  2.   
  3. INSERT INTO `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-08 19  
  4.   
  5. at sun.reflect.GeneratedMethodAccessor115.invoke(Unknown Source)  
  6.   
  7. --------------------------------------------  
  8.   
  9. at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)  
  10.   
  11. at java.lang.reflect.Method.invoke(Method.java:498)  
  12.   
  13. at coderead.mybatis.log.JdbcCommonCollects$PreparedStatementHandler.invoke(JdbcCommonCollects.java:118)  
  14.   
  15. ... 21 more  
  16.   
  17. Caused by: com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: Column 'update_time' specified twice  
  18.   
  19. at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)  
  20.   
  21. at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)  
  22.   
  23. at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)  
  24.   
  25. at java.lang.reflect.Constructor.newInstance(Constructor.java:423)  
  26.   
  27. at com.mysql.jdbc.Util.handleNewInstance(Util.java:425)   
[[389222]]

复现问题

这个问题简单的来说就是SQL中的字段发生了重复, Column 'update_time' specified twice。其实不止update_time字段发生了重复,是很多字段都发生了重复。因为这个接口除了并发高一点之外,和其他的接口没有什么特别之处!突然我脑子里灵光一闪,难道是这个接口并发较高,导致框架在生成INSERT类型的SQL发生了错误???要是这样的话,我直接创造一个这样的场景不就可以实现了嘛!然后我写下了下面的代码:

  1. public void test(){  
  2.   
  3.         for (int i = 0; i < 1000; i++) {  
  4.             ExceptionLogEntity exceptionLogEntity = new ExceptionLogEntity();  
  5.             exceptionLogEntity.setXX("");  
  6.             exceptionLogEntity.setXXX("");  
  7.             exceptionLogEntity.setIp("");  
  8.             exceptionLogEntity.setUrl("");  
  9.             exceptionLogEntity.setBusinessScene("");  
  10.             exceptionLogEntity.setExceptionType("");  
  11.             exceptionLogEntity.setExceptionDetailType("");  
  12.             exceptionLogEntity.setExceptionMessage("");  
  13.             exceptionLogEntity.setNoticeStatus("");  
  14.             exceptionLogEntity.setCreateTime(new Date());  
  15.             exceptionLogEntity.setUpdateTime(new Date());  
  16.   
  17.             Thread thread = new Thread(() -> {  
  18.                 exceptionLogDao.insert(exceptionLogEntity);  
  19.             });  
  20.   
  21.             thread.start();  
  22.         }  
  23.     }  

代码写完直接重启项目,最后一触发上面的代码。果然,同样的错误又出现了。这里我先介绍下使用的ORM框架:

此ORM框架是集团O2O研发中心研发的基础组件,提供同Mybatis一样的功能:只需定义接口,无需写实现类。此外,对单表操作还封装了一套常用的增删改查处理,连接口也无需再定义,对开发人员非常友好,大大降低了冗长的jdbc操作代码量,提升了开发效率。

我问了问旁边的同事,在使用公司的ORM框架自带的INSERT方法时,有没有遇到SQL列重复的问题吗?嘿你还别说,大家异口同声说遇到过。那厉害了,看来这真的不是个别现象,真的是框架的bug。于是乎我花了一点时间来找找看到底是在哪个地方出现了问题,皇天不负有心人我还真的找到了,具体的代码如下:

  1. public List<DalColumn> getDalColumnsWithoutId() {  
  2.     if (this.columnsWithoutId != null) {  
  3.         return this.columnsWithoutId;  
  4.     } else {  
  5.         this.columnsWithoutId = new ArrayList(this.dalColumns.size() - 1);  
  6.         Iterator var1 = this.dalColumns.iterator();  
  7.   
  8.         while(var1.hasNext()) {  
  9.             DalColumn column = (DalColumn)var1.next();  
  10.             if (!column.isIdColumn()) {  
  11.                 this.columnsWithoutId.add(column);  
  12.             }  
  13.         }  
  14.   
  15.         return this.columnsWithoutId;  
  16.     }  
  17. }  

问题就出在 columnsWithoutId 上,它的类型是 List ,是所在类的一个属性默认为null,保存的是一个表除主键ID之外的所有字段。上面的代码在单线程情况下没有问题,但是在多线程的情况下就问题大了。 columnsWithoutId 在多线程情况下就是一个 线程共享变量 ,假设在某个时机有多个线程恰好执行到 if 代码块,发现 columnsWithoutId 的值为null,则就会同时执行 else代码块,那这时就会执行多次循环,自然 columnsWithoutId 中的字段就会出现了重复。

解决问题

这个问题已经找到,那接下来就差一个解决方案了。

官方修复

我当时想既然这个框架是公司研发中心提供的基础组件,那就是说明在公司内部会有很多项目组使用,问题说不定早就被修复了。你还别说在公司的wiki上我还真的找到了,有位负责这个组件的大佬在wiki上分享了”单表插入偶发列重复问题定位与解决”的文章。(文章地址我就不发了,公司内部地址发出来你们也访问不了)文章里详细的描述了问题发生的场景(和我上面描述的差不多)、问题定位过程还有解决方案。摘选文章上的解决方案:

明确了是线程安全的问题,解决方案就好确定了,解决线程安全有三种方案:

方案一:互斥同步——synchronized,ReentrantLock

方案二:非阻塞同步——CAS

方案三:无同步方案:——ThreadLocal

结合业务场景,综合分析,最终确认使用方案一,采用synchronized关键字,使用双重检查锁的方式解决此问题。

大佬已经将bug修复并发布了新版本到Maven仓库,项目中有发生上述问题的,直接升级对应的组件版本即可。既然官方已经给出了解决方案,我直接升级项目中的组件版本号就行了。结果我一更换项目中的版本,我的项目就再也没有启动起来。研究发现,组件版本相差太多,很多依赖发生了冲突。我项目中使用的是 1.1.6-RELEASE ,问题官方在版本 2.1.0-RELEASE 中修复。这组件版本差距太大贸然升级肯定是有很大的风险了,于是我放弃了!

另辟蹊径解决

既然你自带的插入方法有问题,我直接自定义一个插入方法不就得了。惹不起我还躲不起么!我最后又问了同事们最后都是怎么解决的,大家也都说是自定义SQL,而且都不再用框架自带的方法了,大家都很聪明呀!(其实这个还有后续,后面有一期开发过程中,需要用到批量插入的功能,我又偷懒用了框架自带的batch方法,又把我给坑了。从那之后,我再也没有用自带的方法了……)

问题2

我在排查上面的问题的时候,在框架里面看到了下面的代码:

  1. private Number execute4PrimaryKey(String sqlId, Map<String, Object> paramMap, KeyHolder keyHolder) {  
  2.     long startTimestamp = System.currentTimeMillis();  
  3.     String sql = null;  
  4.   
  5.     Object var8;  
  6.     try {  
  7.         MappedStatement mappedStatement = this.configuration.getMappedStatement(sqlId, true);  
  8.         mappedStmtThreadLocal.set(mappedStatement);  
  9.         sql = mappedStatement.getBoundSql(paramMap);  
  10.         int result = false;  
  11.         int result;  
  12.         if (keyHolder != null) {  
  13.             this.execution.update(sql, new MapSqlParameterSource(DalUtils.mapIfNull(paramMap)), keyHolder);  
  14.             result = keyHolder.getKey() == null ? 0 : keyHolder.getKey().intValue();  
  15.         } else {  
  16.             result = this.execution.update(sql, DalUtils.mapIfNull(paramMap));  
  17.         }  
  18.   
  19.         Integer var9 = result;  
  20.         return var9;  
  21.     } catch (Exception var13) {  
  22.         this.throwException(var13);  
  23.         var8 = null;  
  24.     } finally {  
  25.         mappedStmtThreadLocal.remove();  
  26.         logger.debug("{} method:{}, sql:{}, param:{}", new Object[]{this.logPrefix, "execute", sql, paramMap});  
  27.         this.logProfileLongTimeRunningSql(startTimestamp, sql, paramMap);  
  28.     }  
  29.   
  30.     return (Number)var8;  
  31. }  

大家仔细看看,这上面的代码有个不容易发现的问题。问题是这样的,我们系统中表的主键类型定义的都是 bigint ,这个类型对应的是Java中的 Long 类型,说到这大家明白了么?对了,就是数据溢出的问题。

复现问题

我将表中的主键自增量设置为Java Int类型的最大值+1,也就是2147483648。然后数据库再新增一条记录,此时表中记录的主键是2147483648,但Java实体中的主键ID已经是个负数-2147483648了。

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

使用Long类型时,要注意JavaScript接收后端Long类型数据精度丢失问题

解决问题

这个问题还真不好另辟蹊径了,只能后面出现类似的问题,升级ORM框架的版本了。

问题3

其实这个问题我应该在看那篇内部文章的时候就应该发现的,但我当时就是没有看出来!此时的我留下了没有技术的泪水。对于问题1,当时那位大佬最终采用 synchronized 进行加锁解决多线程情况下共享变量读写问题,具体代码如下(敏感信息打码):

修改前的代码:

  1. public List<DalColumn> getDalColumnsWithoutId() {  
  2.         if (this.columnsWithoutId != null) {  
  3.             return this.columnsWithoutId;  
  4.         } else {  
  5.             this.columnsWithoutId = new ArrayList(this.dalColumns.size() - 1);  
  6.             Iterator var1 = this.dalColumns.iterator();  
  7.   
  8.             while(var1.hasNext()) {  
  9.                 DalColumn column = (DalColumn)var1.next();  
  10.                 if (!column.isIdColumn()) {  
  11.                     this.columnsWithoutId.add(column);  
  12.                 }  
  13.             }  
  14.   
  15.             return this.columnsWithoutId;  
  16.         }  
  17.     }  

修改后的代码(引用文章中的代码截图):

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

问题复现

这个问题要复现的话,不太好复现,因为情况是比较极端的,但是在复杂的生产环境还是很可能出现的。

举例说明:

假设有A、B两个线程,A线程执行到for循环处,B线程恰好执行到最开始的if判断处,由于此时的cloumnsWithId已经被A线程赋值,肯定不为null,然后线程B就直接返回了,导致B线程调用者获得了一个空的或者保存部分字段的cloumnsWithId。这种情况最终导致的结果是INSERT SQL中的字段缺失。

解决办法

对于多线程下的共享变量,我们一定要坚持 读读可并行,读写要排队 的原则。当然能不加锁的话,也是不错的选择。

我发现这个问题之后,就找到了前文中说的大佬,跟他反馈了我的想法,并提出我的解决方案。

  1. 直接在 getDalColumnsWithoutId 方法上添加 synchronized 。
  2. 修改第一个if判断逻辑,然后使用 synchronized 锁代码块。
  1. public List<DalColumn> getDalColumnsWithoutId() {  
  2.     int size = CollectionUtils.size(this.dalColumns);  
  3.     if (CollectionUtils.size(this.columnsWithoutId) == size && size > 0 ) {  
  4.         return this.columnsWithoutId;  
  5.     }  
  6.     synchronized(lock){  
  7.         if (columnsWithoutId == null) {  
  8.             columnsWithoutId = new ArrayList<>(size*2);  
  9.         }  
  10.         for (DalColumn column :  this.dalColumns){  
  11.             if (!column.isIdColumn()) {  
  12.                 this.columnsWithoutId.add(column);  
  13.             }  
  14.         }  
  15.     }  
  16.     return this.columnsWithoutId;  
  17. }  

当时与大佬的沟通截图,来自大佬的肯定:

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

总结

在遇到问题的时候不要着急,一定要学会大胆假设,然后小心求证。要勇敢地质疑权威,不要认为既然已经是框架了就没有bug。例如Spring从当初的1.0到现在版本号都超过5.0了,除了给Spring增加新的功能,其他的升级就是在修复Spring中的bug。做出自己的假设之后,就要进行场景复现,一个正常的bug是可以不断地被复现的。复现了问题那就是找到问题的症结所在,剩下的就是如何去解决啦!

 

责任编辑:华轩 来源: 今日头条
相关推荐

2022-09-20 10:41:32

接口优化网络

2016-12-14 10:00:44

数据结构编译器

2010-04-19 09:31:44

KDE

2021-06-03 10:34:43

GitHub停车系统项目

2020-09-29 07:44:20

跨域前后端分离插件

2022-11-30 09:18:51

JavaMyBatisMQ

2011-05-23 13:05:25

重载重写隐藏

2021-04-22 07:47:47

JavaJDKMYSQL

2013-06-18 11:37:42

XFSRHEL 6.4

2022-05-16 08:42:26

Pandasbug

2022-05-16 08:43:33

CIOIT咨询

2022-05-31 08:35:05

RocketMQACK客户端

2022-01-07 10:24:58

裁员经营管理互联网

2015-07-03 11:16:14

编程一个手镯

2022-04-06 08:47:03

Dubbo服务协议

2010-03-24 10:32:05

Python多线程

2021-02-02 05:41:16

底层设计顶层

2020-08-04 08:44:08

HashCode

2021-09-13 08:41:52

职场互联网自闭

2010-03-30 19:04:25

Oracle 10g
点赞
收藏

51CTO技术栈公众号