一个诡异的生产问题排查

Updated on with 0 views and 0 comments

背景

连连认证表原本姓名字段长度为 80,由于业务需求对接对公商委。而对公商委会使用企业名字作为户名存入认证表,由于监管要求,户名需要加密存储。当姓名明文超过 15个 字符串之后,加密之后的字符串将超过 80。所以将姓名字段扩长到 256。

数据加密存储只会,查询数据的时候,会做相应的解密。

当天晚上 DB 扩长字段完成之后,加长之后的数据插入数据库成功。查询数据成功,但是调用解密服务解密失败。

后经 DB 确认,数据库姓名数据正常,且能正常解密。起初怀疑调用的解密服务异常,后在技术中心帮助下,打印出调用解密服务入参,发现传入姓名内容被截取,长度为扩长前的长度。

最后重启系统,查询出来的字符串正常返回,且解密成功。

系统

系统版本:
jdbc:ojdbc14#10.2.0.5.0
Oracle Database 11g
druid:1.0.9
spring: 3.2.8
ibatis:2.3.4.726

druid 配置:

initialSize=1
maxActive=1
poolPreparedStatements=true
maxPoolPreparedStatementPerConnectionSize= 100

iabatis 执行原理

使用 ibatis 执行查询语句时,如 queryForObject,会在 SqlMapExecutorDelegate 执行 sql 语句。SqlMapExecutorDelegate 在执行语句前会做一些前提准备,比如校验查询参数,最后 SQL 语句真正执行动作委托给 SqlExecutor

image.png

这里使用委托者模式,接受请求的对象将请求委托给另一个对象来处理。这种模式的优点在于解耦了业务代码与实际执行代码的联系,在于对外隐藏真正执行对象,易于扩展。

SqlExecutor#executeQuery 执行过程主要分为以下三步。

image.png

第一步,获取 PreparedStatement,使用 conn.prepareStatement(sql) 获取。

image.png

第二步调用 PreparedStatement.setxxx 方法设置参数。ibatis 中会自动根据传入的参数类型寻找对应的TypeHandler。在 TypeHandler 会将参数转化对应的类型,然后调用相应的 PreparedStatement.setxxx

StringTypeHandler#setParameter

image.png

这一步 ibatis 解决繁琐参数设置过程。

第三步,调用 PreparedStatement.execute() 执行 SQL 语句。

第四步,使用 ResultSet 获取返回值。

这一步比较繁琐,首先需要调用 ResultSet.getxx 获取返回的每列的数据,这里会查找相应类型的 TypeHandler ,使用 TypeHandler#getResult 获取相应类型数据。

image.png

最后将数据设置到返回对象中。

可以见到 ibatis 提前注册各种类型的 TypeHandler,解决繁琐取值,设置动作。

总结 ibatis 框架 sql 执行的核心原理等同于以下代码。


PreparedStatement preparedStatemen = connection.prepareStatement("SQL");
preparedStatemen.execute();
ResultSet resultSet = preparedStatemen.getResultSet();
while (resultSet .next()) {
	// 使用 resultSet  获取列数据
}

Mybatis sql 执行核心原理同 ibatis。

PreparedStatement

上面 ibatis 执行原理中我们看到 PreparedStatement。在 JDBC 中我们还可以使用 Statement 执行 SQL 语句。

PreparedStatementStatement区别,

  1. Statement 只能执行静态 SQL 语句,且 Statement 每执行一次都要对传入的SQL语句编译一次,效率较差。
  2. PreparedStatement 创建时,会提前预编译相关语句。
  3. PreparedStatement SQL 语句可以使用占位符 ,然后调用相应 setxx 方法设置参数。这种方法将实际数据与 SQL 语句分开,不但提高了灵活性,可读性与维护性也较好,还提高相关安全性,可以预防 SQL 注入。

由于 PreparedStatement 创建时会提前预编译 SQL 语句,如果每次运行重新创建代价较高,所以部分线程池将会将其缓存起来,比如 Druid,C3P0等,提高性能。

数据来源于 Druid 竞品数据对比

image.png

在 Druid 中,可以设置以下配置打开 PSCache。

poolPreparedStatements=true
maxPoolPreparedStatementPerConnectionSize=100

我们这次的问题原因就与 Druid 有一定关系。

问题排查

上面 ibatis 的解析的时候知道了,返回内容通过 resultSet.getxxx 获取。调试 resultSet.getString 源码,将会进入 OracleResultSetImpl.getString

image.png

数据内容通过 Accessor 对象获取。继续深入,此时将会调用CharCommonAccessor.getString

image.png

在这个方法中,通过 String(char value[], int offset, int count) 构造方法获取最终字段内容,需要传入字符串的长度。

count 参数决定字符串的长度,该参数长度默认为数据库返回的字符串的长度,如果返回字符的长度大于了该列在数据库最大长度,就会以列的最大长度返回,这就导致上面字符串被截短现象。

也就是说 PreparedStatement 在第一次获取之后列的长度信息之后,就将其缓存起来,然后在字段加长之后,运行查询语句的,PreparedStatement没有重新获取列的长度信息。

由于 Oracle JDBC 驱动未开源且源码国语复杂,相关内容无法深入调试获取。

wireshark 抓包分析数据

使用 wireshark 抓包,根据交互数据包可以分析区别。执行程序如图所示,使用 ibatis 进行查询,相关配置同上。

carbon67.png

使用调试模式,等到连接池初始化之后,开始抓包,再执行查询操作,这个目的在于忽略连接创建交互数据包。

以下为交互的数据包,

image.png

可以看到第二次执行 SQL 数据包明显比第一次少了。从数据包分析,第一次
SQL 执行多出的数据包是为了获取表的相关信息。

前文讲道 Druid 设置 poolPreparedStatements=true 为缓存PreparedStatement 对象,那会不会是缓存该对象,然后导致其不再重新获取表信息?

将 Druid 配置改成如下:

poolPreparedStatements=false
maxPoolPreparedStatementPerConnectionSize=0

再次执行程序,抓取数据。

image.png

可以看到修改配置之后,两次执行 SQL 数据交互行为一致。

真的 Druid 是 PSCache 导致的吗?

这次我们不使用 ibatis, 直接使用 JDBC 操作执行 SQL,数据库连接池依旧使用 Druid ,配置同上,依旧开启 Drud PsCache。

这次数据运行分别使用两个方法,其中 queryWithExecute 使用 preparedStatemen.execute()执行语句,而 queryWithExecuteQuery 使用 preparedStatemen.executeQuery() 执行语句,

queryWithExecute.png

queryWithExecuteQuery.png

由于 ibatis 使用 preparedStatemen.execute() 执行 SQL 语句,所以 queryWithExecuteQuery 最后也能复现问题。

执行 queryWithExecuteQuery 方法,却发现不能复现问题。

起初怀疑 Oracle JDBC 驱动问题,但是查看源码发现其内部调用逻辑大致相同。后来对比 Druid DruidPooledStatement#executeDruidPooledStatement#executeQuery,发现两者有个逻辑有出入。在 execute 方法中人为注释了 oracleSetRowPrefetch() 方法。

方法对比

oracleSetRowPrefetch 方法里面主要为了设置 Oracle Statement 的 rowPrefetch

查看 Oracle 文档,rowPrefetch 可用于设置每次获取数据的行数,减少数据交互。 该方法等同于 JDBC 标准方法 Statemen.setFetchSize()

This reduces round trips to the database by fetching multiple rows of data each time data is fetched--the extra data is stored in client-side buffers for later access by the client. The number of rows to prefetch can be set as desired。

思考是否需要 PsCache

解决办法

  1. 更换数据连接池,使用 Hikaricp。
  2. 重启服务。

帮助链接

druid pool analysis
Request adding PreparedStatement caching to HikariCP


标题:一个诡异的生产问题排查
作者:9526xu
地址:https://studyidea.cn/articles/2019/07/25/1564063324112.html