Java JDBC使用详解


Java 各类 ORM 框架之中,JDBC 可谓万物之母,或许你无需自己手动编写 JDBC 相关代码,但学习它的工作方式一定能让对其它基于它之上的框架的有进一步的了解。

下面手把手带你摸透 JDBC 的前世今生

一、服务建立

1. 连接创建

通过 JDBC 创建数据库连接比较简单,使用 DriverManager.getConnection() 方法获取即可。

其中通过 Class.forName() 用于指定驱动主类,在当下流行的 Maven 工程中这一步通常可省略。

public class ConnectionUtil {

    public static Connection getConnection() {
        try {
            String url = "jdbc:oracle:thin:@//192.168.0.20:1521/helowin";
            String username = "budai";
            String password = "123456";
            // 指定驱动主类
            Class.forName("oracle.jdbc.driver.OracleDriver")
            // 获取连接
            return DriverManager.getConnection(url, username, password);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
}

这里介绍几类常用数据库的 JDBC 连接串以供参考:

数据库 ClassName URL
MySQL com.mysql.jdbc.Driver jdbc:mysql://{host}:{port}/{database}
Oracle oracle.jdbc.OracleDriver jdbc:oracle:thin:@//{host}:{port}/{database}
Hive org.apache.hive.jdbc.HiveDriver jdbc:hive2://{host}:{port}/{database}
Postgres org.postgresql.Driver jdbc:postgresql://{Endpoint}:{port}/{project_name}

2. 动态驱动

上述示例为通过 Maven 工程依赖从而实现自动化的驱动加载,而 DriverManagerClassLoader 类加载器实现动态的驱动加载,若需加载外部文件或网络资源驱动文件,可通 URLClassLoader 加载器实现。

在自定义动态驱动加载时需注意一点,即同一个工程并不允许同时加载同一驱动的不同版本,即加载相应驱动的不同版本时需要先卸载掉之前加载的驱动,否则由于双亲委派的机制即便再次加载仍读取旧版本的驱动依赖。

public class ConnectionUtil {

     public Connection getConnection(DbEntity dbEntity) {
        String url = "jdbc:oracle:thin:@//192.168.0.20:1521/helowin";
        String userName = "budai";
        String password = "123456";
        String driverClass = "oracle.jdbc.driver.OracleDriver"
        try {
            // Load driver class and register
            ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
            Class<?> clz = classLoader.loadClass(driverClass);
            DriverManager.registerDriver((java.sql.Driver) clz.newInstance());
            // Get connection
            return DriverManager.getConnection(url, userName, password);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

在实现类的卸载时, JDK 中并没有提供显式的类卸载方式,一个类被卸载只能通过 GC 回收的方式,而一个类想要被垃圾回收需要满足下述三个条件:

  • 对象不存在任何实例对象(new 实例对象为空);
  • 对象不存在任何引用关系(GC root 引用为空);
  • 对象的加载器已经被销毁(ClassLoader 为空);

因此在实现动态驱动加载的时一定要注意创建的对象资源是否正常的关闭,并通过 classLoader = nullURLClassLoader 中的 close() 方法销毁类加载器从而保证能够正常的卸载。

二、语句执行

1. 会话连接

通过 Statement 对象用于 SQL 语句的执行,其执行方式包含以下三种:

方法 描述
execute() 可用于执行任意 SQL ,如常见的增删改查,返回集为 ResultSet。
executeQuery() 仅用于执行查询操作,返回集为 ResultSet 。
executeUpdate() 仅用于执行修改操作,返回所影响的行数。

除了基本的 Statement 之外, java.sql 包下扩展了一系列针对不同常见的语句执行类。

类名 描述
Statement 最基础的语句执行。
PreparedStatement 提供预编的方式语句执行,安全性相对更高。
CallableStatement 作用效果类似 PreparedStatement,但其可以调用存储过程。

2. 结果查询

ResultSet 用于读取 Statement 执行结果集,通过 next() 以游标的方式遍历结果集。

通过 getXXX() 方法读取当前游标行的记录列,如 getString("name") 即获取记录中列 name 的值并以 String 方式返回。当然也可以通过下标方式访问,如 getString(1) 即获取第一列的值并以 String 方式返回。

下面是一个虚拟表查询示例,其中将 Connection 等资源声明在 try() 中当代码执行完 try{} 语句时会自动进行资源关闭,省去手动添加 finally 关闭资源。

public void queryDemo(){
    String sql = "select 'alex' as name from dual";
    try (
        // 获取连接
        Connection con = ConnectionUtil.getConnection();
        // 建立会话
        Statement stmt = con.createStatement();
        // 执行查询
        ResultSet rs = stmt.executeQuery(sql);
    ) {
        while(rs.next()) {
            System.out.println(rs.getString("name"));
        }
    } catch(Exception e) {
        e.printStackTrace();
    }
}

3. 语句预编

在上面的查询案例中我们并没有添加条件,但在更多的业务场景下是需要根据条件进行筛选。但如果采用字符串拼接的方式,极为可能遭到 SQL 注入攻击。

如下两种情况下,第一种情况下没有任何问题,根据获取的 ID 和密码查询用户信息。但在第二种情况下,获取的 ID 中恶意拼接了恒等导致密码验证无效,无论输入任何密码结果都为真。

// 情况 1
String id1 = "123";
String pwd1 = "123456";
String sql1 = "select * from tb_user where id = " + id1 + " and password = " + pwd1;

// 情况 2
String id2 = "123 and 1=1";
String pwd2 = "123456";
String sql2 = "select * from tb_user where id = " + id2 + " and password = " + pwd2;

为了防止上述情况, Java 通过 PreparedStatement 提供了预编机制,引入占位符 ? 防止恶意 SQL 注入攻击。

即将语句中待输入的字符串通过 ? 进行占位,后续再进行赋值,而后面无论用户输入什么值,都会将输入当成属性值而不会编译为 SQL 语句。

String sql3 = "select * from tb_user where id = ? and pwd = ?;

下面通过具体实现案例加深了解。

public void demo1(){
    // 占位符预编译,提升安全性
    String sql = "select * from tb_user where id = ? and pwd = ?";
    try (
            Connection con = DriverManager.getConnection();
            PreparedStatement ps = con.prepareStatement(sql); 
    ) {
        // 占位符赋值
        ps.setString(1, "123");
        ps.setString(2, "123456");
        try(ResultSet rs = ps.execute()) {
            while(rs.next()) {
                // 结果处理
            }
        }
    } catch(Exception ex) {
        ex.printStackTrace();
    }  
}

三、进阶操作

在之前介绍通过 JDBC 方式实现基本的数据查询,下面即更深入的介绍如何实现高效查询。

1. 手动事务

默认情况下 Statement 对象执行语句后自动立即提交 commit,但在某些业务场景下为了降低与数据库交互次数通常需要关闭自动提交,从而实现批次手动提交提高效率。

public void demo(){
    Connection conn = null;
    try {
        conn = ConnectionUtil.getConnection();
        // 关闭自动提交
        conn.setAutoCommit(false);
        // 创建会话
        Statement stmt = conn.createStatement();
        for(int i = 1; i < 5; i++) {
            String sql = "insert into tb_user values(" + i + ", 'alex', 'male')";
            stmt.execute(sql);
        }
        // 手动提交
        conn.commit()
    } catch(Exception e) {
        if (Objects.nonNull) {
            // 发生异常时回滚事务
            conn.rollback();
        }
    }
}

2. 查询参数

StatementResultSet 中都提供 fetchSize 参数用于指定单次查询的行数。

当每次通过 executeQuery() 执行查询都将返回一个结果集,若查询结果过大时,一次性加载内存中显然是十分不合理的,通过 fetchSize 即设置每次读取的记录数,然后分批进行读取从而降低内存负载。

简而言之,若 fetchSize 越小,加载进内存的数据则越少,但与数据库服务之间的通讯频次则越高,反之则内存压力大而服务器压力小,需要根据具体的业务场景而定。

MySQL 中默认是一次性将数据读入内存,而在 Oracle 中默认的 fetchSize 则为 10

(1) 区别

既然 StatementResultSet 都提供了 fetchSize 参数,那二者又有何区别?

因为 ResultSet 是由 Statement 执行而来,所以 ResultSet 将会继承 Statement 中配置的 fetchSize 。这样的设计的一大目的是为了通过 Statement 给所有结果集指定 fetchSize ,但是当存在不同的结果集可根据场景实现覆盖。

try (
        Connection con = DriverManager.getConnection();
        Statement stmt = con.createStatement(); 
) {
    stmt.setFetchSize(50);
    try(ResultSet rs1 = stmt.executeQuery(sql1)) {
        // 覆盖 fetchSize 为 40
        rs1.setFetchSize(40)
        while(rs1.next()) {
            // 结果处理
        }
    }
    try(ResultSet rs2 = stmt.executeQuery(sql2)) {
        // 覆盖 fetchSize 为 45
        rs2.setFetchSize(45)
        while(rs2.next()) {
            // 结果处理
        }
    }
} catch(Exception ex) {
    ex.printStackTrace();
}  

3. 结果缓存

通过 ResultSet 可以读取执行结果,但前提是会话必须一直处于连通状态,即当出现连接波动可能导致程序异常丢失结果。

对此 java.sql 包中提供 CachedRowSet 以缓存的方式的读取数据,即将数据先刷入本地缓存中再实现读取,此时即便连接关闭也可实现数据读取,配合 fetchSize 参数可实现全量数据的本地缓存读取,当然此种方式对内存压力相对较高。

CachedRowSet 的具体使用示例如下:

try (
        Connection con = dataSource.getConnection();
        Statement stmt = con.createStatement();
) {
        RowSetFactory factory = RowSetProvider.newFactory();
        try (
                ResultSet rs = stmt.executeQuery(sql);
                CachedRowSet rowSet = factory.createCachedRowSet();
        ) {
            // 结果集刷入缓存
            rowSet.populate(rs);
            // 即便提前关闭也不影响后续读取
            rowSet.close();
            while (rowSet.next()) {
                // 后续从 rowSet 读取即可,操作同 rs 读取
            }
        }
    } catch (Exception ex) {
        ex.printStackTrace();
}

四、连接池

在项目工程中,通常会频繁的与数据库建立连接,而如果每次都重复的进行创建关闭显然十分消耗服务资源。因此为了提供更高的性能 Java 引入了连接池功能,与线程池类似,都是通过池化技术实现更有效的性能管理。

下面就具体介绍如何自定义创建连接池,这里以 ApacheBasicDataSource 为例,当然还有其它如阿里巴巴的 DruidDataSource ,不同连接池实现流程类似,这里不一一介绍。

1. 依赖导入

使用 BasicDataSource 连接池需要导入相关的 Maven 依赖,依赖配置如下:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-dbcp2</artifactId>
    <version>2.9.0</version>
</dependency>

2. 连接池

新建 BasicPoolUtil 类用于配置连接池信息,连接池各项具体参数配置如下:

参数 描述
setUrl() 数据库 JDBC url 连接串。
setUsername() 数据库用户名。
setPassword() 数据库用户密码。
setClassName() 数据库 Class Name。
setClassLoad() 指定数据库 Class Name 的加载器。
InitialSize() 初始化连接池大小。
setMaxIdle() 最大空闲连接数,超过最大值时获取连接将根据 MaxWait 进行等待,若仍无空闲连接则抛出异常,设置为 -1 表示无限制。
setMaxTotal() 最大数据库连接数,即连接池保留的最少连接,设置为 -1 表示无限制。
setMaxWaitMillis() 最大建立连接等待时间,设置为 -1 表示无限制。
setMinEvictableIdleTimeMillis() 超过 MaxIdle 的连接最长存活时间,默认 30m。
setTimeBetweenEvictionRunsMillis() 空闲连接清理线程运行间隔周期,默认 60s。
public class BasicPoolUtil {

    public static Datasource buildDatasource() throws Exception {
        BasicDataSource dataSource = new BasicDataSource();
        // 连接 URL
        dataSource.setUrl("jdbc:mysql://192.168.0.20:3306/test_db");
        // 驱动类
        dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
        // 用户名
        dataSource.setUsername("root");
        // 用户密码
        dataSource.setPassword("123456");
        dataSource.setInitialSize(4);
        dataSource.setMaxTotal(8);
        dataSource.setMaxIdle(4);
        dataSource.setMaxWaitMillis(10000);
        dataSource.setMinEvictableIdleTimeMillis(TimeUnit.SECONDS.toMillis(30L));
        dataSource.setTimeBetweenEvictionRunsMillis(TimeUnit.SECONDS.toMillis(60L));
        return dataSource;
    }
}

3. 类加载器

在上面的参数列表中提到了通过设置 setClassLoad() 参数可以指定数据库的主类加载方式,通过该方式即可实现将数据库的版本隔离管理,无需在工程 Maven 逐一引入各个数据的不同版本,而是通过外部驱动包实现动态管理。

如下示例即利用 URLClassLoader 实现驱动的主类加载,实现数据库的隔离管理。

public static ClassLoader getClassLoader(String driverPath) {
    // 获取当前线程上下文加载器
    ClassLoader parent = Thread.currentThread().getContextClassLoader();
    URL[] urls;
    try {
        File driver = new File(driverPath);
        // 驱动包不存在抛出异常
        if (!driver.exists()) {
            throw new FileNotFoundException();
        }
        // File 转 URL 资源格式
        list.add(driver.toURI().toURL());
        urls = list.toArray(new URL[0]);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    return new URLClassLoader(urls, parent);
}

4. 基本使用

通过连接池的方式唯一的区别是获取连接时是通过 datasource.getConnection() 方式获取,当完成对应操作关闭时是将连接返回到连接池中进行统一管理,而不是真正意义上的连接关闭。

当连接池中活动的连接数低于 MaxIdle 时,即使我们关闭连接也只是将连接放回连接池中,连接真正意义上还是处于激活状态,待下次获取连接时则无需重新创建连接,通过池化技术从而省去大量的连接创建资源消耗。

连接池基本使用示例如下:

@Test
public void demo() {
    // 获取连接池
    Datasource datasource = BasicPoolUtil.buildDatasource(connInfo);
    try (
            Connection conn = datasource.getConnection();
            Statement stmt = conn.createStatement();
    ) {         
        String sql = "drop table tb_user";       
        stmt.execute(sql);
    } catch (Exception ex) {
        ex.printStackTrace();
    }
}

五、元数据信息

1. 表信息

通过 DatabaseMetaData 获取关系型数据库表结构信息,如 库名表名备注 等信息。

public void TableDemo() {
    String schema = "budai";
    // 读取表与视图
    String[] queryType = new String[]{"TABLE", "VIEW"};

    List<Map<String, String>> tableInfoMap = new ArrayList<>();
    try (Connection connection = ConnectionUtil.getConnection()) {
        DatabaseMetaData metaData = connection.getMetaData();
        ResultSet rs = metaData.getTables(null, schema, null, queryType);

        while (rs.next()) {
            Map<String, String> table = new HashMap<>();
            table.put("TableSchema", rs.getString("TABLE_SCHEM"));
            table.put("TableName", rs.getString("TABLE_NAME"));
            table.put("TableType", rs.getString("TABLE_TYPE"));
            table.put("TableCatalog", rs.getString("TABLE_CAT"));
            table.put("Remarks", rs.getString("REMARKS"));
            tableInfoMap.add(table);
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }
    System.out.println(tableDTO);
}

2. 表结构

通过 DatabaseMetaData 获取关系型数据库表结构信息,如 字段名字段类型字段长度 等信息。

public void columnsInfo() throws SQLException {
    String schema = "BUDAI";
    String tableName = "TB_22091901";

    List<Map<String, String>> tableInfoMap = new ArrayList<>();
    try (Connection conn = ConnectionUtil.getConnection()) {
        try {
            // 获取主键列表
            List<String> primaryKeyList = new ArrayList<>();
            DatabaseMetaData metaData = conn.getMetaData();
            ResultSet primaryKeyRs = metaData.getPrimaryKeys(null, schema, tableName);
            while (primaryKeyRs.next()) {
                String name = primaryKeyRs.getString("COLUMN_NAME");
                if (!primaryKeyList.contains(name)) {
                    primaryKeyList.add(name);
                }
            }

            // 获取字段描述信息
            ResultSet columnRs = metaData.getColumns(null, schema, tableName, null);
            while (columnRs.next()) {
                Map<String, String> column = new LinkedHashMap<>();
                String columnName = columnRs.getString("COLUMN_NAME");
                columnVo.put("ColumnName", columnName);
                Integer isPrimaryKey = primaryKeyList.contains(columnName) ? 1 : 0;
                column.put("IsPrimaryKey", isPrimaryKey == 1 ? "yes" : "no");
                column.put("DataType", columnRs.getString("DATA_TYPE"));
                column.put("TypeName", columnRs.getString("TYPE_NAME"));
                column.put("ColumnSize", columnRs.getString("COLUMN_SIZE"));
                column.put("DecimalDigits", columnRs.getString("DECIMAL_DIGITS"));                    
                column.put("Nullable", columnRs.getString("NULLABLE"));
                column.put("IsNullable", columnRs.getString("IS_NULLABLE"));
                column.put("Remarks", columnRs.getString("REMARKS"));
                column.put("ColumnDef", columnRs.getString("COLUMN_DEF"));
                column.put("NumPrecRadix", columnRs.getString("NUM_PREC_RADIX"));
                column.put("CharOctetLength", columnRs.getString("CHAR_OCTET_LENGTH"));
                column.put("OrdinalPosition", String.valueOf(columnRs.getInt("ORDINAL_POSITION")));
                tableInfoMap.add(column);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    System.out.println(tableMap);
}

文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录