7.16 JDBC连接池

常见的c3p0之类的连接池,直接向应用分配自己管理的连接。绝大多数JDBC方法,都会抛出SQLException,要求每一处SQL操作都能正确处理连接意外并不现实。典型的,数据库重启之后,初次SQL操作往往会失败,接下来连接池才会恢复所有连接。Mysql的Connector/J,抛出CommunicationsException的问题并不少见。所以Limax提供一个简单的连接池实现,通过区分SQLException决定是否重启操作,避免把可以通过重启操作解决的问题推给应用。


  • 7.16.1 基本原理

    1. 连接通过consumer的方式提供给应用, SQL操作集合在一个特定范围内完成,这样所有的SQLException才有可能被连接池者监管,consumer才有机会被重启。这里提供两个范围支持,函数范围和线程范围(跨线程转移连接不支持,这也不必要,即便使用常规连接池,线程也是从连接池请求新连接,而不至于私下交换)。

    2. 通过重启consumer也顺便解决死锁,超时之类的异常。


  • 7.16.2 编程接口

    • 接口limax.sql.SQLConnectionConsumer

      应用实现这个接口打包SQL操作集合


    • 接口limax.sql.SQLExecutor

      定义方法void execute(SQLConnectionConsumer consumer) throws Exception

      定义常量COMMIT和ROLLBACK,专用于线程范围连接池。

      execute(SQLExecutor.COMMIT) 提交事务。

      execute(SQLExecutor.ROLLBACK)回滚事务。


    • 类limax.sql.SQLPooledExecutor

      构造函数:


      SQLPooledExecutor(String url, int size, boolean threadAffinity, Consumer<Exception> logger)
      

      url为数据库连接url,size决定了连接池尺寸,threadAffinity决定了该连接池是函数范围还是线程范围,logger用于记录导致consumer重启的那些异常,便于应用排查重启原因,改进设计。


      SQLPooledExecutor(String url, int size, Consumer<java.lang.Exception> logger) 
      SQLPooledExecutor(String url, int size, boolean threadAffinity) 
      SQLPooledExecutor(String url, int size)
      

      简化版本的构造函数,其中不含threadAffinity参数的构造函数,默认threadAffinity为false。


      构造函数返回时,size个连接已经建立,这样可以确保数据库认可连接url,并且允许建立size个连接,如果构造函数迟迟不返回,应该检查数据库服务器是否启动,这种情况下logger也会不断输出导致重连的异常。


      成员函数:


      void execute(SQLConnectionConsumer consumer) throws Exception
      

      执行consumer中应用提供的SQL操作集合,consumer不必catch异常,特别是SQLException,应该在execute外catch异常,保证连接池能够检查所有异常决定是否重启,导致重启的异常通过logger记录,不会导致的重启的异常通过execute往外抛。。



      void shutdown()
      

      关闭连接池,拒绝新的执行请求。


    • 类limax.sql.RestartTransactionException

      线程范围连接池使用这个异常来指明事务需要被重启。


  • 7.16.3 函数范围连接池

    一次execute就能完成整个事务,使用函数范围连接池。这种类型的连接池,绝大多数情况够用了。

    • 特性

      1. 连接初始化为auto-commit方式。

      2. 一次execute之后无论成败,连接重新设置为auto-commit方式。

      3. 执行多语句事务,首先应该将auto-commit设置为false。


    • 示例


      public final class MysqlTest {
          public static void main(String[] args) throws Exception {
              SQLPooledExecutor executor = new SQLPooledExecutor(
                  "jdbc:mysql://192.168.1.3:3306/test?user=root&password=admin&characterEncoding=utf8", 
                  3, e -> e.printStackTrace());
              Thread.sleep(10000);
              executor.execute(conn -> {
                  conn.setAutoCommit(false);
                  try (PreparedStatement ps = conn.prepareStatement("INSERT INTO test(name) VALUES(?)")) {
                      ps.setString(1, "A");
                      ps.executeUpdate();
                      ps.setString(1, "B");
                      ps.executeUpdate();
                  }   
              });
              executor.shutdown();
          }
      }
      

      在sleep的10秒期间,重启数据库,观察输出,执行结束后两条记录均被插入数据库。


  • 7.16.4 线程范围连接池

    线程范围连接池跨越多个execute操作,除非复杂应用,这种类型的连接池很少用到,最典型的应用是limax.zdb.LoggerMysql.java中的writer连接池,一次checkpoint操作发起的全部修改动作打包为一个事务。


    • 特性

      1. 连接初始化为非auto-commit方式,consumer禁止设置auto-commit。

      2. 线程首次调用execute,连接分配给线程,意味着事务的开始。

      3. 任何一次execute抛出异常或者以SQLExecutor.COMMIT,SQLExecutor.ROLLBACK作为consumer调用execute,连接将归还连接池,事务结束。

      4. 任何一次execute抛出异常,事务回滚并结束。其中,RestartTransactionException指示应用可以重启事务。


    • 示例


      public final class MysqlTest {
          private static SQLPooledExecutor executor = new SQLPooledExecutor(          
              "jdbc:mysql://192.168.1.3:3306/test?user=root&password=admin&characterEncoding=utf8", 3, true);
          private static void insert(String s) throws Exception {
              System.out.println("insert " + s);
              executor.execute(conn -> {
                  try (PreparedStatement ps = conn.prepareStatement("INSERT INTO test(name) VALUES(?)")) {
                      ps.setString(1, s);
                      ps.executeUpdate();
                  }
              });
          }
          public static void main(String[] args) throws Exception {
              while (true) {
                  try {
                      insert("A");
                      Thread.sleep(5000);
                      insert("B");
                      Thread.sleep(5000);
                      insert("C");
                      executor.execute(SQLExecutor.COMMIT);
                      Thread.sleep(5000);
                  } catch (RestartTransactionException e) {
                      System.out.println("restart");
                  }
              }
          }
      }
      

      程序启动之后,任何时间点重启数据库,可以看到类似如下的结果。

      insert A

      insert B

      insert C

      restart

      insert A

      restart

      insert A

      restart

      insert A

      insert B

      发生了3次restart,原因在于,连接池size = 3,轮转分配,首次restart之后的两次insert A,触发了后续连接的恢复。

      可以看出,无论以任何方式重启数据库,ABC插入数据库的事务完整性总能得到保证。

      特别注意, executor.execute(SQLExecutor.COMMIT);跟其它SQL操作在同一个try块内, 千万不能习惯性放到finally块,这个操作同样有可能抛出异常。


  • 7.16.5 高级话题

    1. SQLConnectionConsumer约束了应用实现,同时也就约束了更好的事务完整性。

    2. 既然操作可能重启,那么必须设计可重启的代码,比如某些参数多次执行不可改变。

    3. 一些大型结果集的SELECT操作,也许设计允许失败,这种情况下,应该判断操作是否重启,作出相应决策。

    4. 如果数据库服务器长时间维护,应用也许会表现为不响应,碰到这种情况应该检查logger输出,检查连接状态。

    5. RestartTransactionException从RuntimeException派生,而不是SQLException,这是应用复杂性决定的,也许调用栈内大多数方法都跟SQL毫无关系,可以参考limax.zdb.Checkpoint.java中的使用。


上一页 下一页