PrepareStatement 是如何高效替换问号的?

 关注公众号【1024个为什么】,及时接收最新推送文章!

本篇文章基于 MySQL Connector/J 5.1.40, clientPrepareStatement 模式。

写前 2 篇文章看源码时,发现 PrepareStatement  替换问号的方案比想象的要复杂、高效,总结一下加深印象,以后有类似的场景也可以借用此方案。

从一个带问号的 SQL 语句到一个正常的带真实参数的 SQL 语句,可以分为下图中的三步。

e243341735aa089cfe7287fcb2ec4435.png

本次就以下面的 SQL 语句为例,看一下替换问号的过程。

SELECT * FROM t1 WHERE id=? AND status=? ORDER BY id DESC LIMIT 10;

| 第一步,把动态 SQL(带问号的) 拆成静态 SQL 段

这一步的触发时机是获取 PreparedStatement 对象时

PreparedStatement ps = conn.prepareStatement(sql);

拆后的信息会被封装在 PerseInfo 中,PerseInfo 是 PreparedStatement 的内部类

db087117107798f7e5e2cfc29ee8e7ee.png

而真正拆的过程,是在 PerseInfo 的构造方法中,拆分的结果会存到 2 个数组中,endpointList、staticSql,就是图中框 ① 里的。

通过源码看一下拆分过程

for (i = this.statementStartPos; i < this.statementLength; ++i) {
         char c = sql.charAt(i);
         ...
         if ((c == '?') && !inQuotes && !inQuotedId) {
         // 添加静态 SQL 段的起止下标
              endpointList.add(new int[] { lastParmEnd, i });
              lastParmEnd = i + 1;
          }
          ...
          // 添加最后一个问号后的静态 SQL 段的起止下标
          endpointList.add(new int[] { lastParmEnd, this.statementLength });
          this.staticSql = new byte[endpointList.size()][];


          for (i = 0; i < this.staticSql.length; i++) {
              int[] ep = endpointList.get(i);
              int end = ep[1];
              int begin = ep[0];
              int len = end - begin;
              ...
              this.staticSql[i] = StringUtils.getBytes(sql, encoding, conn.getServerCharset(), begin, len, conn.parserKnowsUnicode(), conn,
                                    conn.getExceptionInterceptor());

可以看到, SQL 语句是按字符的粒度遍历,找到 '?' ,就把 lastParmEnd   (初始值=0)和当前下标添加到 endpointList 中,lastParmEnd + 1 ,继续遍历,直到结束。遍历结束后,把最后一个问号后面的内容也当做一个静态 SQL 段加到 endpointList 中。

简单来说,带有 n 个问号的 SQL 语句, 会被拆分成 n + 1 个静态 SQL 段(不含问号的内容)。

然后遍历 endpointList,根据里面记录的下标,从原始 SQL 中截取出对应的静态 SQL 段。

这 2 个数组的数据拿到后,就能知道有多少个参数了,接下来就会初始化存放参数的数组,为下一步设置参数铺好路。

com.mysql.jdbc.PreparedStatement#initializeFromParseInfo

this.parameterValues = new byte[this.parameterCount][]
this.parameterTypes = new int[this.parameterCount];

下面主要会用到这 2 个。

| 第二步,设置参数

触发时机是

ps.setInt(1,666);
  ps.setInt(2,5);

这里会把所有参数内容先转换成字符串再转换成 byte 后,放到第一步初始化好的 parameterValues 中

protected final void setInternal(int paramIndex, byte[] val) throws SQLException {
    synchronized (checkClosed().getConnectionMutex()) {


        int parameterIndexOffset = getParameterIndexOffset();


        checkBounds(paramIndex, parameterIndexOffset);


        this.isStream[paramIndex - 1 + parameterIndexOffset] = false;
        this.isNull[paramIndex - 1 + parameterIndexOffset] = false;
        this.parameterStreams[paramIndex - 1 + parameterIndexOffset] = null;
        this.parameterValues[paramIndex - 1 + parameterIndexOffset] = val;
    }
}

同时也会设置好每个参数对应的真实的类型。

this.parameterTypes[parameterIndex - 1 + getParameterIndexOffset()] = Types.INTEGER;

这里注意一下,源码中的下标都做了减 1 的处理,这也是为什么我们设置参数时,传入的下标从 1 开始,而内部都是数组,下标从 0 开始。

| 第三步,执行时,才组装真正的 SQL

触发时机是

ResultSet resultSet = ps.executeQuery();

内部会经过这个方法 com.mysql.jdbc.PreparedStatement#fillSendPacket(),它里面会把参数和第一步里的静态 SQL 段组装到一起。

组装过程如下

for (int i = 0; i < batchedParameterStrings.length; i++) {
    checkAllParametersSet(batchedParameterStrings[i], batchedParameterStreams[i], i);


    sendPacket.writeBytesNoNull(this.staticSqlStrings[i]);


    if (batchedIsStream[i]) {
        streamToBytes(sendPacket, batchedParameterStreams[i], true, batchedStreamLengths[i], useStreamLengths);
    } else {
        sendPacket.writeBytesNoNull(batchedParameterStrings[i]);
    }
}


sendPacket.writeBytesNoNull(this.staticSqlStrings[batchedParameterStrings.length]);

会遍历参数信息,拼装好参数后,遍历结束后再拼上最后一个静态 SQL 段。

这里说是拼装,其实操作的都是 byte 数组,直接往 byte 数组中轮流交替放置静态 SQL 段、参数内容。

到这里就得到了带有真实参数内容的一个完整的 SQL,可以直接发到服务端执行了。

| 总结一下

整个过程,就遍历了一次原始 SQL,时间复杂度是 O(SQL.length)。而且这个遍历拆分过程,还可以缓存,相同的 SQL 就不用重复遍历拆分了。

后面 2 次遍历,时间复杂度都是 O(参数个数.length),用最少的遍历解决问题。

原创不易,如有收获,一键三连,感谢支持!

相关阅读:

《写个任务,翻车了 -- 记一次内存溢出排查》

《深究 PrepareStatement》

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值