DruidDataSource一次踩坑记录

1、问题发生背景
(1)H5活动落地页,在编辑的时候,其中有一个导航模块,数据库表设计的时候,是存在一个zone_config字段里面,长度是4000个字符,由于页面没有任何限制,可以随便设置,当设置的导航超过4000个字符点击保存时候,数据库会报错,导致编辑表的数据丢失。
报错内容:

图1

下面是保存的代码实现:
图2
druid版本:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.0.28</version>
</dependency>

2、问题定位过程
(1)由于一开始问题不是100%复现,发生该问题的时候,第一时间的反应是事务有问题,事务没有回滚,于是查看保存的代码。由于报错的日志是SqlException,SqlException是继承Exception的,它应该是check Exception,所以应该在事务里加一个 rollbackFor = Exception. class, 但是加了之后依旧没用。然后改成 rollbackFor = Throwable. class 依旧没有作用。
(2)所以排除上述的第一种原因,继续在测试环境复现该问题,后来100%复现了该问题,其实问题不是出现在第一次报错的时候,而是报错了之后,再次点击保存的时候,又再次报错,这个时候才会清空了编辑表的数据,下面是第二次报错的内容:
### Error updating database.  Cause: java.sql.SQLException: internal error
### Cause: java.sql.SQLException: internal error
; uncategorized SQLException for SQL []; SQL state [null]; error code [17089]; internal error; nested exception is java.sql.SQLException: internal error
图3
(3)第一次忽略了这个报错,真正的原因是这个报错导致的,第一次保存报错的时候数据库的数据其实还是在的,是第二次报错才导致数据库的数据没有回滚。然后在测试环境debug查看,为什么事务没有回滚,因为事务回滚最终会执行doRollback方法,于是把断点打到 DataSourceTransactionManager 的doRollBack()上,下面2张图是2次debug的结果:
第一次报错:
图4
第二次报错:
图5
到这里就有一点眉目了,看了下con.rollback()发现,如果transactionInfo没了,就直接return了,所以导致事务没有回滚。
图6
从上面2张图发现第一次报错transactioninfo还在,里面的内容确实是之前执行过的sql信息,但是第二次为什么就空了呢?因此可以确定问题应该出现在执行doRollback之前。
(3)看第一次报错,其实是执行到第5句sql的时候才抛的错,第二次报错debug发现其实执行到第四句(即第一次执行insert就报错了)就报错了!所以并不是sql的问题,所以看执行debug执行第四句的时候为什么报错了,transactionInfo是在哪里被清空的 。debug发现,是在处理exception的时候被清空掉的
图7
public void disable(Throwable error) {
if (this.holder != null) {
this.holder.clearStatementCache();
}

this.traceEnable = false;
this.holder = null;
this.transactionInfo = null;
this.disable = true;
this.disableError = error;
}
(4)那第一次报错为什么没有清除 transactionInfo 呢,因为是上面的 exceptionSorter .isExceptionFatal(sqlEx)判断导致的,第一次的报错code是01461并不是FatalException,17089是属于fatalException。如果是 fatalException不会进行回收,直接抛弃连接,并把缓存移除
图8
图9
(5)在看下上面disabel方法还有一个clearCache方法,这里会看到还有一个缓存的功能, LRUCache。
           1)这里缓存的是 PreparedStatementHolder, PreparedStatementHolder里面就是 PreparedStatement,它的key是根据sql来构建的,第一次方法执行报错了他没有被清空,第二次由于执行的sql是一样的,所以他拿到的 PreparedStatementHolder 对象是从缓存中拿出来的。如图11
           2)如果是FatalException的话会把LRUCache中的缓存移除。如图10
           3)如果没有抛异常,每次在closePreparedStatement之前,会PreparedStatementHolder放入缓存。如图14
图10
图11
看了下mybatis xml 的配置,原来确实开启了druid的cache功能,对于同样的statement可以recycle。
图12
查看https://github.com/alibaba/druid/wiki/DruidDataSource配置属性列表  该片文章,对属性做了一些注释
图13
图14
(6)下面2张图,分别是第一次报错和第二次报错 的截图,可以发现,同样的sql其实用到的 prepareStatement确实是同一个。但是还有1个疑问:
        1)就算用了同个 prepareStatement但应该只是参数还一样而已,报错应该是相同的,为什么第二次的报错是内部错误?

图15
图16
(7)然后带着上述的疑问继续debug,发现其实第二次报内部错误的时候, bindIndicators报 npe了,如图17
图17
catch (NullPointerException var22) {
DatabaseError.throwSqlException(89);
}
throw了一个89,这个就是后面的17089了。所以2次报的错是不一样的,但是为什么bindIndicators这个值为空了呢?

(8) 继续 debug, OraclePreparedStatement中有个hardClose()方法,里面把 bindIndicators清空,于是debug跟踪,第一次报错的时候,在 closePoolableStatement的时候,调用了 hardClose()方法中把值给清空了,如图:
图18
图19
(9)到此,找到了原因,就是第一次报错把bindIndicators清空了,并没有把cache移除,导致第二次还是从缓存中拿 。从网上也搜到了相关文章http://tech.lede.com/2017/02/09/rd/server/DruidOra17401Analysis/,说druid在1.0.27修复了上述的bug,但是没弄明白为什么1.0.28还存在。查看了下druid版本,升级到了1.0.29就OK了。不会再报内部错误,每次点击保存的报错都是01461,事务每次都能正常回滚。
(10) http://tech.lede.com/2017/02/09/rd/server/DruidOra17401Analysis/ 提到了可以在JVM启动参数中增加一个 fatalErrorCodes,试验了下在JVM中增加了如下启动参数, -Ddruid.oracle.fatalErrorCodes=01461,然后debug发现,确实可以把 1461转换成 fatalException,如图:
图20
但是同样的他会把transactionInfo清空,因此他只能解决1461的时候移除cache,不能解决事务回滚的问题。

相关参考文章:
1、 https://github.com/alibaba/druid/releases
2、 http://tech.lede.com/2017/02/09/rd/server/DruidOra17401Analysis/
3、https://github.com/alibaba/druid/wiki/DruidDataSource%E9%85%8D%E7%BD%AE%E5%B1%9E%E6%80%A7%E5%88%97%E8%A1%A8

网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者陈仁广权发布。