jdbc中预编译语句PreparedStatement的深层分析

翻阅大大小小的博客,无论中外只要你搜索PreparedStatement这个关键字,得到的大多数结果是在重复执行一条语句多次的情况下PreparedStatement会获得比Statement更好的性能,辟如下文引用的这段文字。

选择PreparedStatement还是Statement取决于你要怎么使用它们. 对于只执行一次的SQL语句选择Statement是最好的. 相反, 如果SQL语句被多次执行选用PreparedStatement是最好的.
PreparedStatement的第一次执行消耗是很高的. 它的性能体现在后面的重复执行.

没错,jdbc中PreparedStatement的初衷除了防止sql注入以外,还有一个功能就是让用户在执行重复的sql能够提高运行效率,对于效率的提升主要体现在了省去了sql语句编译的时间。但是在实际的使用中,还是有不少门道的。

首先来看两段代码,测试的表结构如下:

idfirstsecond
bigint(11) AUTO_INCREMENTvarchar(10)varchar(10)
public void preparedGet(int first) {
        Connection conn = getConnection();
        String sql = "select * from test_table where first = ?";

        PreparedStatement pst = null;
        ResultSet rs = null;
        try {
            pst = conn.prepareStatement(sql);
            pst.setString(1, "" + first);

            int loop = 0;
            while(loop < 5) {
                rs = pst.executeQuery();
                rs.close();
                loop++;
            }

            pst.close();
        } catch (SQLException e) {
            e.printStackTrace();
        } 
    }

public void simpleGet(int first) {
        Connection conn = getConnection();
        String sql = "select * from test_table where first = '" + first +  "'";

        Statement stmt = null;
        ResultSet rs = null;
        try {
            stmt = conn.createStatement();

            int loop = 0;
            while(loop < 5) {
                rs = stmt.executeQuery(sql);
                rs.close();
                loop++;
            }

            stmt.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

第一段代码使用了PreparedStatement,第二段代码使用的是Statement 每次查询都执行5次,程序结束后查看mysql的日志
Statement的运行日志:
Statement的运行日志
PreparedStatement的运行日志:
PreparedStatement的运行日志

可以看到两种方法运行的log是完全一样的,也就是说PreparedStatement并没有开启预编译功能。
下面我们一起深挖一下源代码,看看其中的门道,代码版本为5.1
ConnectionImpl.java中的prepareStatement()函数

    boolean canServerPrepare = true;

    String nativeSql = getProcessEscapeCodesForPrepStmts() ? nativeSQL(sql) : sql;

    if (this.useServerPreparedStmts && getEmulateUnsupportedPstmts()) {
        canServerPrepare = canHandleAsServerPreparedStatement(nativeSql);
    }

    if (this.useServerPreparedStmts && getEmulateUnsupportedPstmts()) {
        canServerPrepare = canHandleAsServerPreparedStatement(nativeSql);
    }

    if (this.useServerPreparedStmts && canServerPrepare) {
        if (this.getCachePreparedStatements()) {
            ......
        } else {
            try {
                //这里使用的是ServerPreparedStatement
                pStmt = ServerPreparedStatement.getInstance(getMultiHostSafeProxy(), nativeSql, this.database, resultSetType, resultSetConcurrency);

                pStmt.setResultSetType(resultSetType);
                pStmt.setResultSetConcurrency(resultSetConcurrency);
            } catch (SQLException sqlEx) {
                // Punt, if necessary
                if (getEmulateUnsupportedPstmts()) {
                    pStmt = (PreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
                } else {
                    throw sqlEx;
                }
            }
        }
    } else {
        //这里使用的是clientPrepareStatement
        pStmt = (PreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
    }

可以看到,由于我们的代码中没有设置useServerPreparedStmts,实际上走到的是clientPrepareStatement这个分支,下面我们来对比一下clientPrepareStatement和ServerPreparedStatement的区别
实际上clientPrepareStatement调用的是com.mysql.jdbc.PreparedStatement.getInstance方法,其实本质上就是构造了一个PreparedStatement对象,在该类的构造方法中也并没有任何关于预编译的内容,其execute()方法也就是创建了一个MySQLConnection对象,然后调用executeInternal()方法将对应的sql请求发送到服务端。由此可以看到若想使用jdbc的预编译功能,得开启useServerPreparedStmts参数,下面我们先加上这个参数再次运行上面的测试代码看看效果。在jdbc连接的url中加上useServerPrepStmts=true参数
useServerPrepStmts=true后的PreparedStatement
这时候我们确实看到了sql server进行了预编译,可以发现在ServerPreparedStatement的构造函数中调用了serverPrepare()方法

    Buffer prepareResultPacket = mysql.sendCommand(MysqlDefs.COM_PREPARE, sql, null, false, characterEncoding, 0);
    //记录下预编译语句的id
    this.serverStatementId = prepareResultPacket.readLong();
    this.fieldCount = prepareResultPacket.readInt();
    this.parameterCount = prepareResultPacket.readInt();
    this.parameterBindings = new BindValue[this.parameterCount];

该方法中向sql服务器发送了一条PREPARE指令,并记录下了编译好的sql语句所对应的serverStatementId,在之后每次调用set*的方法的时候,会把对应的每个参数的值记录在parameterBindings里。
同时ServerPreparedStatement重写了executeInternal()方法,该方法调用了serverExecute()

    Buffer packet = mysql.getSharedSendPacket();

    packet.clear();
    packet.writeByte((byte) MysqlDefs.COM_EXECUTE);
    //将该语句对应的id写入数据包
    packet.writeLong(this.serverStatementId);
    //将对应的参数写入数据包
    for (int i = 0; i < this.parameterCount; i++) {
        if (!this.parameterBindings[i].isLongData) {
            if (!this.parameterBindings[i].isNull) {
                storeBinding(packet, this.parameterBindings[i], mysql);
            } else {
                nullBitsBuffer[i / 8] |= (1 << (i & 7));
            }
        }
    }
    //发送数据包
    Buffer resultPacket = mysql.sendCommand(MysqlDefs.COM_EXECUTE, null, packet, false, null, 0);

从这段代码我们可以看到,ServerPreparedStatement在记录下serverStatementId后,对于相同SQL模板的操作,每次只是发送serverStatementId和对应的参数,省去了编译sql的过程。
分析到这里,我们已经基本上清楚了PreparedStatement预编译的原理。但是细心的读者会发现serverStatementId是绑定在ServerPreparedStatement对象上的啊,一般情况下,当我们执行完一条sql指令后,会调用close方法回收资源并清除该对象,那岂不是说每次我们执行sql指令时都会创建新的PreparedStatement对象同时编译语句吗?我们来看下面的代码:

    public void preparedGet(int first) {
        Connection conn = getConnection();
        String sql = "select * from test_table where first = ?";

        PreparedStatement pst = null;
        ResultSet rs = null;
        try {
            pst = conn.prepareStatement(sql);
            pst.setString(1, "" + first);

            rs = pst.executeQuery();
            rs.close();
            pst.close();
        } catch (SQLException e) {
            e.printStackTrace();
        } 
    }

我们将代码中的循环去掉,改为重复运行该方法若干次,再看看日志的输出。
重复执行PreparedStatement
确实如我们之前所猜测,由于serverStatementId是和ServerPreparedStatement对象绑定的,在我们每次new了一个新的PreparedStatement实例后,jdbc都执行了语句编译的操作,这不是我们所期望的。有什么办法可以让jdbc只对相同的语句编译一次呢?
回到之前的分析的ConnectionImpl.java中的prepareStatement()函数,有这么一条语句:

if (this.getCachePreparedStatements())

当时跳过了这条语句走了else分支创建了一个新的ServerPreparedStatement对象,下面我们进入到这个if分支中一探究竟:

pStmt = (com.mysql.jdbc.ServerPreparedStatement) this.serverSideStatementCache.remove(sql);

serverSideStatementCache的类型是LRUCache,一个LRU的缓存。当找到了相同的sql语句时,直接返回了缓存中的PreparedStatement对象,之后将该对象从缓存中删除。
当执行完sql语句后,调用close方法时:

    public void close() throws SQLException {
        MySQLConnection locallyScopedConn = this.connection;

        if (locallyScopedConn == null) {
            return; // already closed
        }

        synchronized (locallyScopedConn.getConnectionMutex()) {
            if (this.isCached && isPoolable() && !this.isClosed) {
                clearParameters();
                this.isClosed = true;
                //将该对象重新存入缓存中
                this.connection.recachePreparedStatement(this);
                return;
            }

            realClose(true, true);
        }
    }

为了使getCachePreparedStatements()条件生效,我们添加jdbc的url中添加cachePrepStmts=true参数,再次执行上述测试代码:
添加cachePrepStmts
到这里,我们才真正地实现了一次编译次次运行。

  • 12
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值