PreparedStatement是如何大幅度提高性能的

本文讲述了如何正确的使用prepared statements。为什么它可以让你的应用程序运行的更快,和同样的让数据库操作变的更快。 

为什么Prepared Statements非常重要?如何正确的使用它?

数据库有着非常艰苦的工作。它们接受来自众多并发的客户端所发出的SQL查询,并尽可能快的执行查询并返回结果。处理statements是一个开销昂贵的操作,不过现在有了Prepared Statements这样的方法,可以将这种开销降到最低。可是这种优化需要开发者来完成。所以本文会为大家展示如何正确的使用Prepared Statements才能使数据库操作达到最优化。

数据库是如何执行一个statement的?

显然,我不会在这里写出很多的细节,我们只关注最关键的部分。当一个数据库收到一个statement后,数据库引擎会先解析statement,然后检查其是否有语法错误。一旦statement被正确的解析,数据库会选出执行statement的最优途径。遗憾的是这个计算开销非常昂贵。数据库会首先检查是否有相关的索引可以对此提供帮助,不管是否会将一个表中的全部行都读出来。数据库对数据进行统计,然后选出最优途径。当决创建查询方案后,数据库引擎会将它执行。

存取方案(Access Plan)的生成会占用相当多的CPU。理想的情况是,当我们多次发送一个statement到数据库,数据库应该对statement的存取方案进行重用。如果方案曾经被生成过的话,这将减少CPU的使用率。

Statement Caches

数据库已经具有了类似的功能。它们通常会用如下方法对statement进行缓存。使用statement本身作为key并将存取方案存入与statement对应的缓存中。这样数据库引擎就可以对曾经执行过的statements中的存取方案进行重用。举个例子,如果我们发送一条包含SELECT a, b FROM t WHERE c = 2的statement到数据库,然后首先会将存取方案进行缓存。当我们再次发送相同的statement时,数据库会对先前使用过的存取方案进行重用,这样就降低了CPU的开销。

注意,这里使用了整个statement为key。也就是说,如果我们发送一个包含SELECT a, b FROM t WHERE c = 3的statement的话,缓存中不会没有与之对应的存取方案。这是因为“c=3”与曾经被缓存过的“c=2”不同。所以,举个例子:

for (int i = 0; i < 1000; i++)  {

PreparedStatement ps = conn.prepareStatement("select a,b from t where c = " + i);

ResultSet rs = Ps.executeQuery();

rs.close();

ps.close();

}

在这里缓存不会被使用,因为每一次迭代都会发送一条包含不同SQL语句的statement给数据库。并且每一次迭代都会生成一个新的存取方案。现在让我们来看看下一段代码:

PreparedStatement ps = conn.prepareStatement("select a,b from t where c = ?");

for (int i = 0; i < 1000; i++)  {

ps.setInt(1, i);

ResultSet rs = ps.executeQuery();

rs.close();

ps.close();

}

这样就具有了更好的效率,这个statement发送给数据库的是一条带有参数“?”的SQL语句。这样每次迭代会发送相同的statement到数据库,只是参数“c=?”不同。这种方法允许数据库重用statement的存取方案,这样就具有了更好的效率。这可以让你的应用程序速度更快,并且使用更少的CPU,这样数据库服务器就可以为更多的人提供服务。

PreparedStatement与J2EE服务器

当我们使用J2EE服务器时事情会变的比较复杂。通常,一个perpared statement会同一个单独的数据库连接相关联。当数据库连接被关闭时prepared statement也会被丢弃。通常,一个胖客户端会获取一个数据库连接并将其一直保持到退出。它会用“饿汉”(eagerly)或“懒汉”(lazily)方式创建所有的parepared statements。“饿汉”方式会在应用启动时创建一切。“懒汉”方式意味着只有在使用的时候才去创建。“饿汉”方式会使应用程序在启动的时候梢有延迟,但一旦启动后就会运行的相当理想。“懒汉”方式使应用程序启动速度非常快(但不会做任何准备工作),当需要使用prepared statement的时候再去创建。这样,在创建全部statement的过程中,性能是非常不稳定的,但一旦创建了所有statement后,它会像“饿汉”式应用程序一样具有很好的运行效果。请根据你的需要来选择最好的方式,是快速启动?还是前后一致的性能。

J2EE应用的问题是它不会像这样工作,连接只会在请求期间被保持。那意味着必须每一次请求的时候都创建prepared statement。这远没有胖客户端那种一直保持prepared statement的执行性能好。J2EE厂商已经注意到了这个问题,并且提供了连接池(ConnectionPool)以避免这种问题。

当J2EE服务器提供了一个连接给你的应用程序时,其实它并没有给你真正的数据库连接,你只是获得了一个包装器(Wrapper)。你可以去看看你所获得的连接的类名以证实这一点。它并不是一个JDBC连接,而是一个由应用服务器创建的类。所有的JDBC操作都会被应用服务器的连接池管理器所代理。所有的JDBC ResultSets,statements,CallableStatements,preparedStatements等都会被包装并以一个“代理对象”(Proxy Object)的形式返回给应用程序。当你关闭了连接,这些对象会被标记为失效,并被垃圾回收器所回收。

通常,如果你对一个数据库连接执行close,那这个连接会被JDBC驱动程序关闭。但我们需要在J2EE服务器执行close的时候数据库连接会被返回连接池。我们可以创建一个像真正的连接一样的JDBC Connection代理类来解决这个问题。它有一个对真正连接的引用。当我们执行一个连接上的方法时,代理会将操作转给真正的连接。但是,当我们对一个连接执行close时,这个连接并不会关闭,而是会送回连接池,并可以被其他请求所使用。一个已被准备过的prepared statement也会因此而得到重用。

J2EE PreparedStatement Cache

J2EE服务器的连接池管理器已经实现了缓存的使用。J2EE服务器保持着连接池中每一个连接准备过的prepared statement列表。当我们在一个连接上调用preparedStatement时,应用服务器会检查这个statement是否曾经准备过。如果是,这个PreparedStatement会被返回给应用程序。如果否,调用会被转给JDBC驱动程序,然后将新生成的statement对象存入连接缓存。

每个连接都有一个缓存的原因是因为:JDBC驱动程序就是这样工作的。任何prepared statement都是由指定的连接所返回的。

如果我们想利用这个缓存的优势,那就如前面所说的,使用参数化的查询语句可以在缓存中找到曾经使用过的statement。大部分应用服务器允许你调整prepared statements缓存的大小。

摘要

我们绝对应该使用包含参数化的查询语句的prepared statement。这样数据库就会重用准备过的存取方案。缓存适用于整个数据库,所以,如果你安排所有的应用程序使用相同的参数化SQL语句,然后你的其他应用程序就可以重用被准备过的prepared statement。这是应用服务器的一个优势,因为所有的数据库操作都集中在数据库操作层(Database Access Layer,包括O/R映射,实体Bean,JDBC等)。

第二,正确的使用prepared statement也是利用prepared statement的缓存优势的关键。由于应用程序可以重用准备过的prepared statement,也就减少了调用JDBC驱动程序的次数,从而提高了应用程序的性能。这样就拥有了可以与胖客户端比肩的效率,却又不需要总维持一个连接。

使用参数化的prepared statement,你的应用程序会具有更好的性能。



------------------------------------------------------------------------------------------------------------------------

  1. PreparedStatement preparedStatement = connection.prepareStatement(sql); 
    • Creates a PreparedStatement object for sending parameterized SQL statements to the database(!JAVA DOC).
    • A SQL statement with or without IN parameters can be pre-compiled and stored in a PreparedStatement object. This object can then be used to efficiently execute this statement multiple times.
    • If the driver supports precompilation, the method prepareStatement will send the statement to the database for precompilation
  2. preparedStatement.setString(int parameterIndex, String param); 
    • Sets the designated parameter to the given Java String value
    • Throw SQLException: if parameterIndex does not correspond to a parameter marker in the SQL statement; if a database access error occurs or this method is called on a closed PreparedStatement
  3. ResultSet resultSet = preparedStatement.executeQuery(); 
    • Executes the SQL statement in this PreparedStatement object

上述例子是JAVA doc里边对PreparedStatement和其提供的操作数据库方法的介绍,其中:

  • PreparedStatement prepareStatement = connection.prepareStatement(sql);会将SQl语句保存到preparedStatement对象中,如果db支持预编译,则会将SQL语句发送给db进行预编译,若不支持,则此处不与数据库交互。并且PreparedStatement支持一次编译多次执行

    • 此处的db是否支持预编译有两层意思:1. db是否支持预编译 2. 连接数据库的url是否指定了需要预编译
  • preparedStatement.setString(int parameterIndex, String param);会将传进来的参数设置到对应占位符的位置

    • 此处JDBC中会对参数占位符的个数和传递的参数做参数匹配,若超出(即只需要1个参数,却设置了两个参数,在设置第二个参数的时候就会抛异常)则会抛出SQLException
  • ResultSet resultSet = preparedStatement.executeQuery();首先会进行参数判断,若参数对应不上(即需要1个参数,但没有传递参数,则会抛SQLException异常)。然后会将SQL语句交给db去编译(若没有开启预编译),执行

JDBC连接mysql实例

一. 不使用预编译

代码:

public class Main {
    public static void main(String[] args){
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        ResultSet resultSet = null;
        try {
            Class.forName("com.mysql.jdbc.Driver");
            connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/user", "root", "xxx");
            String sql = "SELECT ROUND(member_price,10) as member_price  from member_price WHERE 'month' = '2015-11' and id = ?";
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, "1 or '1'='1'");
            resultSet = preparedStatement.executeQuery();
            preparedStatement.setString(1, "1");
            resultSet = preparedStatement.executeQuery();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                if(resultSet != null){
                    resultSet.close();
                }
                if(preparedStatement != null){
                    preparedStatement.close();
                }
                if(connection != null){
                    connection.close();
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }
}

mysql日志信息:

抓包信息:

1
2

总结:

通过mysql日志和抓包可以发现: 
1. 虽然使用了PreparedStatement,但对性能没有多少提高,每次都是将完整的SQL语句已经替换掉占位符发给mysql,所以每次mysql都会重新编译,执行(mysql日志中的指令为Query
2. 使用PreparedStatement可以防止SQL注入,将查询的内容当做参数而不是SQL指令通过添加单引号实现

二. 使用预编译,不使用缓存

代码:

public class Main {
    public static void main(String[] args){
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        ResultSet resultSet = null;
        try {
            Class.forName("com.mysql.jdbc.Driver");
            connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/user?useServerPrepStmts=true", "root", "xxx");
            String sql = "SELECT ROUND(member_price,10) as member_price  from member_price WHERE `month` = '2015-11' and id = ?";
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, "1 or '1'='1'");
            resultSet = preparedStatement.executeQuery();
            preparedStatement.setString(1, "1");
            resultSet = preparedStatement.executeQuery();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                if(resultSet != null){
                    resultSet.close();
                }
                if(preparedStatement != null){
                    preparedStatement.close();
                }
                if(connection != null){
                    connection.close();
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }

    }
}

mysql日志信息:

PreparedStatement是如何大幅度提高性能的_第1张图片

抓包信息:


PreparedStatement是如何大幅度提高性能的_第2张图片 

总结:

  1. 代码区别: 链接url中添加?useServerPrepStmts=true
  2. 从mysql日志和抓包信息中可以看到,这次执行向mysql发了三次sql的执行信息

    • 第一次发送需要预编译的代码给mysql(mysql指令Prepare)
    • 第二次发送需要执行的SQL语句(Statement ID: 1)和参数(Value: 1 or '1'='1')
    • 第三次同第二次
  3. 此次,由于手动指定了需要预编译SQL语句(url参数 ?useServerPrepStmts=true),则在创建PreparedStatement是就会将SQL语句发送给Mysql进行预编译,此时编译通过的SQL语句就会被Mysql给缓存起来,并生成一个唯一的Statement ID,并返回一个Statement ID(Statement ID:1),下次需要执行这个SQL语句时,程序将需要执行的SQL语句的Statement ID和参数传给MySql,MySQL就会执行对应的SQL语句 

  4. 若预编译的SQL语句有语法错误,则MYSQL预编译期就会返回错误但此时的错误JDBC是感知不到的,即它还会继续执行后续代码,当执行到(preparedStatement.executeQuery();)时,由于没有Statement ID信息,则JDBC会将完整的SQL语句(替换掉占位符的语句)发给mysql,此时就会抛出语法错误异常

三. 使用预编译,使用缓存

代码:

public class Main {
    public static void main(String[] args){
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        ResultSet resultSet = null;
        try {
            Class.forName("com.mysql.jdbc.Driver");
            connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/user?useServerPrepStmts=true&cachePrepStmts=true&prepStmtCacheSize=128&prepStmtCacheSqlLimit=256", "root", "xxx");
            String sql = "SELECT ROUND(member_price,10) as member_price  from member_price WHERE `month` = '2015-11' and id = ? ";
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, "2");
            resultSet = preparedStatement.executeQuery();
            preparedStatement.close(); 
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setString(1, "1 or '1'='1'");
            resultSet = preparedStatement.executeQuery();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                if(resultSet != null){
                    resultSet.close();
                }
                if(preparedStatement != null){
                    preparedStatement.close();
                }
                if(connection != null){
                    connection.close();
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }

    }
}

mysql日志信息:

1

抓包信息:

2

总结:

  1. 通过抓包信息可以发现,preparedStatement.close();preparedStatement = connection.prepareStatement(sql);这两句执行的时候没有向mysql发送请求。
  2. mysql sql语句的缓存是针对连接的,即每个连接的缓存都是独立的
  3. 结合useServerPrepStmts=true&cachePrepStmts=true才能真正的提高性能,因为如果只设置了useServerPrepStmts=true,虽然可以一次编译,多次执行。但这次关闭后,下次又需重新预编译,加上缓存后,下次就不用重新预编译。具体缓存是在java中做的,主要是mysql-connector-java.jar中实现的缓存。
  4. 对于connection.prepareStatement(sql);,只要向mysql中发预编译的请求,mysql就会预编译此sql语句不管之前是否已经编译过,并对此链接生成唯一的Statement ID,只要此链接中的请求拿着Statement ID和参数去执行SQL语句,mysql就不会重新编译SQL语句,而是直接执行SQL语句
  5. 若使用了cachePrepStmts=true参数,当手动调用prepareStatement.close()时PrepareStatement对象只会将关闭状态置为关闭,并不会向mysql发送关闭请求,prepareStatement对象会被缓存起来,等下次使用的时候直接从缓存中取出来使用。原理:

    public void close() throws SQLException {
        MySQLConnection locallyScopedConn = this.connection;
        if (locallyScopedConn == null) return; // already closed
        synchronized (locallyScopedConn.getConnectionMutex()) {
            if (this.isCached && !this.isClosed) { //注意此处
                clearParameters();
                this.isClosed = true; //若开启缓存,则只会将状态位设为已关闭,并且刷新缓存
                this.connection.recachePreparedStatement(this);
                return;
            }
    
            //没有开启缓存,则会向mysql发送closeStmt的请求
            realClose(true, true);
        }
    }
    
    //刷新缓存代码
    public void recachePreparedStatement(ServerPreparedStatement pstmt) throws SQLException {
        synchronized (getConnectionMutex()) {
            if (pstmt.isPoolable()) {
                synchronized (this.serverSideStatementCache) {
                    this.serverSideStatementCache.put(pstmt.originalSql, pstmt); //将sql语句作为key,PreparedStatement对象作为value存放到缓存中
                }
            }
        }
    }
    
  6. preparedStatement = connection.prepareStatement(sql);原理:

    public java.sql.PreparedStatement prepareStatement(String sql,
            int resultSetType, int resultSetConcurrency) throws SQLException {
        synchronized (getConnectionMutex()) {
            checkClosed();
    
            //
            // FIXME: Create warnings if can't create results of the given
            // type or concurrency
            //
            PreparedStatement pStmt = null;
    
            boolean canServerPrepare = true;
    
            String nativeSql = getProcessEscapeCodesForPrepStmts() ? nativeSQL(sql): sql;
    
            if (this.useServerPreparedStmts && getEmulateUnsupportedPstmts()) {
                canServerPrepare = canHandleAsServerPreparedStatement(nativeSql);
            }
    
            if (this.useServerPreparedStmts && canServerPrepare) { //使用预编译
                if (this.getCachePreparedStatements()) {
                    synchronized (this.serverSideStatementCache) {
                        pStmt = (com.mysql.jdbc.ServerPreparedStatement)this.serverSideStatementCache.remove(sql); //从缓存中取,缓存的key值是完整的sql语句
    
                        if (pStmt != null) { //缓存中有,则清空设置的参数,并关闭状态设置为开启
                            ((com.mysql.jdbc.ServerPreparedStatement)pStmt).setClosed(false);
                            pStmt.clearParameters();
                        }
    
                        if (pStmt == null) { //缓存中没有,则向mysql发送请求创建新的
                            try {
                                pStmt = ServerPreparedStatement.getInstance(getLoadBalanceSafeProxy(), nativeSql,
                                        this.database, resultSetType, resultSetConcurrency);
                                if (sql.length() < getPreparedStatementCacheSqlLimit()) {
                                    ((com.mysql.jdbc.ServerPreparedStatement)pStmt).isCached = true;
                                }
    
                                pStmt.setResultSetType(resultSetType);
                                pStmt.setResultSetConcurrency(resultSetConcurrency);
    

    //注意,当创建新的PreparedStatement对象时,不会马上把其存到缓存中,只有当PreparedStatement对象关闭时才会放到缓存中

                            } catch (SQLException sqlEx) {
                                // Punt, if necessary
                                if (getEmulateUnsupportedPstmts()) {
                                    pStmt = (PreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
    
                                    if (sql.length() < getPreparedStatementCacheSqlLimit()) {
                                        this.serverSideStatementCheckCache.put(sql, Boolean.FALSE);
                                    }
                                } else {
                                    throw sqlEx;
                                }
                            }
                        }
                    }
                } else {
                    try {
                        pStmt = ServerPreparedStatement.getInstance(getLoadBalanceSafeProxy(), 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 {
                pStmt = (PreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
            }
    
            return pStmt;
        }
    }
    

本文总结

  1. PreparedStatement的预编译,需要在url中指定开启预编译useServerPrepStmts=true,才会在创建PreparedStatement对象时向mysql发送预编译的请求
  2. 每次向mysql发送预编译请求,不管之前当前链接有没有编译过此SQL语句,mysql都会预编译一次其实这和Statement每次都需要mysql重新编译一次SQL语句的道理是一样的,不管之前有没有执行过此SQL语句,只要请求的命令是Prepare或Query,mysql就会重新编译一次SQL语句,并返回此链接当前唯一的Statement ID,后续执行SQL语句的时候,程序只需拿着Statement ID和参数就可以了。
  3. 若预编译的SQL语句有语法错误,则mysql的响应会携带错误信息,但此错误信息JDBC感知不到(或者说mysql-connetor-java.jar包里的实现将其忽略掉了),此时还会继续往下执行代码,当执行到executeXxx()方法时,由于没有Statement ID,所以就会将拼接完整的SQL语句值已经将占位符(?)替换掉发给mysql请求执行,此时mysql响应有语法错误,然后JDBC就会抛出语法错误异常,所以检查语法那一步实在mysql-server中做的(通过抓包可以看到)
  4. PreparedStatement对性能的提高是利用缓存实现的,此缓存是mysql-connetor-java.jar包里实现的,而非mysql-server中的缓存,缓存需要在url中指定开启cachePrepStmts=true才会有效,缓存的key是完整的sql语句,value是PreparedStatement对象。
  5. mysql-connetor-java.jar包里缓存的实现:只有在PreparedStatement对象关闭时close()才会将PreparedStatement对象放到缓存中,所以只要缓存PreparedStatement对象没有关闭,你不管调用多少次connection.prapareStatement(sql)对相同的sql语句进行预编译,都会将预编译的请求发给mysql,mysql也会对每一个sql语句不管是否相同进行预编译,并生成一个唯一的Statement ID并返回
  6. mysql-connetor-java.jar实现的缓存是针对链接的,每个链接都是独立的,不共享缓存 
    部分代码:

    try {
        Class.forName("com.mysql.jdbc.Driver");
        //第一个连接
        Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/user?useServerPrepStmts=true&cachePrepStmts=true&prepStmtCacheSize=128&prepStmtCacheSqlLimit=256", "root", "xxx");
        String sql = "SELECT ROUND(member_price,10) as member_price  from member_price WHERE `month` = '2015-11' and id = ?";
        PreparedStatement preparedStatement1 = connection.prepareStatement(sql);
        preparedStatement1.setString(1, "2");
        ResultSet resultSet = preparedStatement1.executeQuery();
        preparedStatement1.close();
    
        //第二个连接
        Connection connection2 = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/user?useServerPrepStmts=true&cachePrepStmts=true&prepStmtCacheSize=128&prepStmtCacheSqlLimit=256", "root", "xxx");
        PreparedStatement preparedStatement2 = connection2.prepareStatement(sql);
        preparedStatement2.setString(1, "1");
        ResultSet resultSet = preparedStatement2.executeQuery();
    }catch (Exception e){
        e.printStackTrace();
    }
    

mysql日志: 
PreparedStatement是如何大幅度提高性能的_第3张图片

注意: mysql预编译功能有版本要求,包括server版本和mysql.jar包版本。以前的版本默认useServerPrepStmts=true,5.0.5以后的版本默认useServerPrepStmts=false



你可能感兴趣的:(java)