本专题从由浅入深讲述 Java 程序是如何执行 SQL 的。专题包含以下内容:
ORM和数据库现状JDBC的使用和源码解读MyBatis的使用和源码解读Hibernate的使用和源码解读Spring Boot Jpa的使用和源码解读
由于篇幅限制,专题会分为几篇来介绍,本文为第一篇,主要介绍前两部分。
1. ORM 和数据库现状
Java 程序执行一条 SQL 的流程可以用下图概括:
Java 应用与数据库之间通过 JDBC 提供的统一接口 JDBC API 进行通信,不同的数据库厂商都提供了对应的驱动实现了 JDBC API;但是业务层直接调用 JDBC API 又比较繁琐还容易出错,因此人们对 JDBC API 的操作进行再次封装,形成了当今各种 ORM(Object Relational Mapping,数据持久层的对象关系映射)框架,主流的有 Mybatis、Hibernate 和 spring-data-jpa 等。
- 数据库的类型我们根据业务需求选择;
JDBCAPI 是Java连接数据库操作的原生接口规范,具体实现由具体的数据库厂商提供,即数据库驱动;Java程序直接调用JDBCAPI 还是不够方便,ORM框架是对JDBCAPI 的进一步封装,方便快速开发,主流的有MyBatis、Hibernate和Spring Data Jpa;- JPA 协议是 Sun 公司提出的 ORM 规范,
Hibernate和Spring Data Jpa实现了该规范。
数据库按照存储方式可分为:
- 关系型数据库(RDBMS):MySQL、Oracle、PostgreSQL
- 非关系型数据库(NoSQL):MongoDB、Redis
- 图数据库:Neo4j
- 时序数据库:InfluxDB、Prometheus
- 内存数据库:Redis、Memcached、H2
Java 应用根据自身业务需求选择不同类型的数据库,最为常用的是关系型数据库 MySQL。MySQL 发展至今已经有多个版本,主流版本包括 5.6、5.7 和 8.0。8.0 版本相比 5.x 新增不少亮点,建议尽快升级:
-
速度提升 2 倍以上,在读写工作负载、IO 密集型工作负载以及高竞争工作负载时具有更好的性能;
-
对
NoSQL存储功能做了更大改进,消除了对独立的 NoSQL 文档数据库的需求,而 MySQL 文档存储也为 schema-less 模式的 JSON 文档提供了多文档事务支持和完整的 ACID 合规性; -
新增隐藏索引、窗口函数等功能。
2. 项目搭建
JDBC(Java DataBase Connectivity) 是 Java 连接数据库操作的原生接口。 JDBC 是所有框架操作数据库的必须要用的,由数据库厂商提供,但是为了方便 Java 程序员调用各个数据库,各个数据库厂商都要实现 JDBC 接口,这就是数据库驱动。 有了数据库驱动,Java 程序员就可以在工程里执行 SQL 了。
Connector/J 是 MySQL 对 JDBC API 接口实现的 Java 版本,即 MySQL 驱动的 Java 版本。现在有 5.1 和 8.0 两个可用版本,对应的 MySQL 版本以及 JDK 版本如下:
并不是
MySQL 5.6只能使用Connector/J 5.1,MySQL 8.0只能使用Connector/J 8.0,它们是互相兼容的。
我们下面基于 MySQL 5.7 和 Connector/J 5.1 来搭建一个 Java 工程执行 SQL。
2.1. IDEA 新建 Maven 工程
该目录结构原型来源于 github mall 开源项目,非常标准的 pojo 模型。
选取 mall 工程的 商品表 为原型,开发增加/删除/更新/查询商品(列表)的业务,工程具体各层间内容:
|_controller:放置控制器代码
|_dao:放置执行SQL代码
|_domain:放置 pojo 数据模型代码
|__entity:放置数据库实体对象定义
|__dto:存放数据传输对象定义
|__vo:存放显示层对象定义
|_service:放置具体的业务逻辑代码(接口和实现分离)
|__impl:存放业务逻辑实际实现
|_utils:放置工具类和辅助代码
2.2. pom.xml 添加 Connector/J 依赖
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
</dependency>
</dependencies>
2.3. DBUtil.java 连接数据库
public class DBUtil {
private static final String URL="jdbc:mysql://localhost:3306/teachingdb?useSSL=false";
private static final String NAME="root";
private static final String PASSWORD="123456";
private static Connection conn=null;
// 静态代码块:加载驱动、连接数据库
static {
try {
// 1.加载驱动程序
Class.forName("com.mysql.jdbc.Driver");
// 2.获得数据库的连接
conn = DriverManager.getConnection(URL, NAME, PASSWORD);
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
// 对外提供一个方法来获取数据库连接
public static Connection getConnection(){
return conn;
}
}
需要注意以下几点:
- 如果 URL 为 localhost:3306,可以直接简写为
jdbc:mysql:///teachingdb?useSSL=false - 不加
useSSL=false参数会提示如下告警日志:
Tue Apr 30 10:59:43 CST 2024 WARN: Establishing SSL connection without server's identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn't set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to 'false'. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification.
- 如果驱动升级为
Connector/J 8.0,需要修改驱动类名为com.mysql.cj.jdbc.Driver。
2.4. 构建 SQL 并执行
public List<PmsProductDO> selectAll() throws SQLException {
// 获取数据库连接
Connection con= DBUtil.getConnection();
// 构建并执行 sql
Statement stmt=con.createStatement();
ResultSet rs=stmt.executeQuery("select name, price from pms_product");
List<PmsProductDO> pmsProductDOList=new ArrayList<PmsProductDO>();
PmsProductDO pmsProductDOTmp=null;
// 如果对象中有数据,就会循环打印出来
while(rs.next()){
pmsProductDOTmp=new PmsProductDO();
pmsProductDOTmp.setName(rs.getString("name"));
pmsProductDOTmp.setPrice(rs.getBigDecimal("price"));
pmsProductDOList.add(pmsProductDOTmp);
}
return pmsProductDOList;
}
2.5. 总结
总结下来 Java 程序执行 SQL 的主要流程为:
本文以 Connector/J 8.0 为例,解析源码。
3. 源码解析
下面我们按照搭建项目的执行流程分析 JDBC 源码实现。
3.1. 加载驱动
加载驱动只有一行代码:
Class.forName("com.mysql.cj.jdbc.Driver");
我们看一下 com.mysql.cj.jdbc.Driver 干了什么:
加载 Driver 主要干了 2 件事:
- 实现了 JDK 定义的
java.sql.Driver接口,即 JDBC API; - 创建
Driver实例对象注入到java.sql.DriverManager。
折叠的注释中强调:业务可以注入多个
Driver(都会注入到registeredDrivers列表中);也可以自定义Driver,但是要做到尽可能简单和独立,尽量不依赖过多代码。
顺便浏览一下 JDK 的 JDBC API 都有哪些方法:
3.2. 获取数据库连接
通过 DriverManager.getConnection(String url, String user, String password) 获取数据库连接:
conn = DriverManager.getConnection(URL, NAME, PASSWORD);
入参的账号、密码也可以不传,放到 URL 中;然后调用私有方法 getConnection 。
@CalleSensitiveJvm 专用注解,说明该方法内部存在有安全风险的方法,通过该注解标注调用方是Jvm内部。
getConnection 内部会遍历 registeredDrivers 列表中的每一个 Driver ,如果调用方有权限则会通过 connect() 方法获取连接:
获取数据库连接一共 3 步:URL 校验 -> 获取数据库 URL 连接 -> 获取数据库连接。
- URL 校验
根据正则匹配规则获取驱动类型:
private static final Pattern SCHEME_PTRN = Pattern.compile("(?<scheme>[\\w\\+:%]+).*");
(?<scheme> xxx ) 表示命名组捕获,在外面可以通过
matcher.group("schema")捕获 xxx 规则的子字符串。
matcher.group("schema") 捕获的内容为 jdbc:mysql:,通过 Type.isSupported() 判别是支持该驱动类型的:
- 获取数据库连接URL实例
获取数据库连接实例用到了读写锁 ReentrantReadWriteLock 和 本地缓存 LinkedHashMap:
- 生成缓存 key;
- 加读锁;
- 第一次查询本地缓存;
- 如果没有查询到,释放读锁,加写锁,第二次查询缓存(有可能在加锁过程中缓存被其他线程写入);
- 没查询到则创建数据库连接实例
ConnectorUrl并写入缓存; - 加读锁(锁降级),释放读锁。
读写锁
ReentrantReadWriteLock:
- 线程进入读锁的前提条件:没有其他线程的写锁,没有写请求或者有写请求,但调用线程和持有锁的线程是同一个;
- 线程进入写锁的前提条件:没有其他线程的读锁;没有其他线程的写锁。
锁降级: 当前线程持有写锁,通过直接获取读锁,会先释放写锁继续持有读锁,即为锁降级。写锁降级为读锁然后释放读锁,而不是直接释放写锁,原因有 2 点:
- 提升性能:写操作后及时释放写锁有利于提升性能,方便其它线程获取读锁,
- 如果当前线程还要继续读取共享资源,释放写锁后还需要重新获取读锁,性能和安全方面都不及锁降级。
获取数据库URL连接实例的核心是 Type.getConnectionUrlInstance 方法:
由于事先构造好的 ConnectionUrlParser 获取 Type 的具体类型为 com.mysql.cj.conf.url.SingleConnectionUrl,所以最终返回的是 SingleConnectionUrl 实例对象:
最终获取的数据库URL连接实例 SingleConnectionUrl 内容如下:
- 获取数据库连接实例
当获取到 SingleConnectionUrl 实例后,就可以获取数据库连接了,再回到 NonRegisteringDriver.connect 方法:
通过 ConnectionImpl.getInstance 构造数据库连接实例:
构造数据库连接的关键步骤:
public ConnectionImpl(HostInfo hostInfo) throws SQLException {
try {
// 数据库名称
this.database = hostInfo.getDatabase();
this.nullStatementResultSetFactory = new ResultSetFactory(this, null);
// 连接数据库的会话
this.session = new NativeSession(hostInfo, this.propertySet);
// 连接数据库需要的所有元数据
this.dbmd = getMetaData(false, false);
// ...
} catch (CJException e1) {
throw SQLExceptionsMapping.translateException(e1, getExceptionInterceptor());
}
try {
createNewIO(false);
unSafeQueryInterceptors();
} catch (SQLException ex) {
cleanup(ex);
throw ex;
} catch (Exception ex) {
cleanup(ex);
throw SQLError.createSQLException(...);
}
}
最重要的是 createNewIO 方法,这是 JDBC 驱动与 MySQL 服务器建立连接的核心:
首先与数据库建立底层的 Socket 长连接,然后创建 NativeProtocol 实例进行封装。底层数据传输依靠 Socket 连接,而上层数据格式化、解析、事物管理等都依靠 NativeProtocol 实例:
Socket 连接创建完成后,会初始化两个字节流:
- mysqlInput:读取数据库服务器的响应数据;
- mysqlOutput:将
SQL转换为字节流发送给数据库服务器。
NativeProtocol 实例就是将处理好的 SQL 最终传递到 mysqlOutput ,从 mysqlInput 读取数据库响应:
建立 Socket 连接的过程就是我们熟悉的 socket 编程接口 new Socket() 、bind()、connect():
通过 configureSocket 方法可知该连接为长连接:
客户端与服务端的通信是 BIO 模式。
拓展:Java IO 流的 3 种工作模式:
AIO、BIO和NIO。
建立连接过程中涉及到 3 个超时时间: (1)loginTimeout: 是驱动程序等待与服务器建立连接的超时时间; (2)socketTimeout: 驱动程序等待 socketTimeout 后未收到服务端响应会关闭连接,适用于与服务器的所有套接字通信; (3)connectTimeout: 数据库驱动与数据库服务器建立TCP连接的超时时间。
建立好 Socket 连接后,开始构建 NativeProtocol 实例:
构建 NativeProtocol 实例的核心是初始化过程:
初始化最重要的是初始化 packetSender 和 packetReader ,它们通过 SimplePacketSender 和 SimplePacketReader 封装了 Socket 连接的 mysqlInput 和 mysqlOutput,所有中间层的解析都是通过 SimplePacketSender 和 SimplePacketReader 对象完成的(后面在分析 SQL 执行时还会看到!!):
到此,我们通过数据库驱动,与数据库的连接就建立完成了。
通过分析源码可以看到驱动与数据库服务器建立一个连接还是比较耗时的,所以在实际应用中都是使用连接池来管理数据库连接。
总结一下,主要流程如下图所示:
数据库厂商提供的驱动程序实现 jdk 的 DriverManager 接口,用于注册数据库,主要用于初始化数据库连接用到的所有基本信息,例如用户名称、密码、服务器地址、端口和协议等等;数据库驱动程序实现 NonRegisteringDriver 和 Connection 接口,主要完成与数据库的连接,该连接为 socket 长连接,所有的连接超时时间都是在这一过程设置的。
3.3. 创建 Statement
所有涉及 SQL 执行的代码都在 DAO 层:
首先创建一个 Statement 实例,然后调用 executeQuery 方法执行 SQL:
创建 Statement 时只传入了两个参数 resultSetType 和 resultSetConcurrency。
3.3.1. resultSetType:结果集游标类型
有 3 个可选值:
TYPE_FORWARD_ONLY:默认选项,游标只能前进不能后退;TYPE_SCROLL_INSENSITIVE:游标可以前进后退,但数据库的数据发生变更时结果集无法感知;TYPE_SCROLL_SENSITIVE:游标可以前进后退,数据库的数据发生变更时结果集可实时感知。
举个例子,例如遍历输出完一次 SQL 执行结果集后,还想从头再输出一次:
但是会在 49 行游标移动到开头时报错:
因为 TYPE_FORWARD_ONLY 类型的 Statement 不允许游标后退!改成 TYPE_SCROLL_INSENSITIVE 后就可以了。
3.3.2. resultSetConcurrency:是否允许通过结果集更新数据库
resultSetConcurrency 参数有 2 个选项:
- CONCUR_READ_ONLY: 默认选项,不允许通过结果集更新数据库;
- CONCUR_UPDATABLE: 允许通过结果集更新数据库。
举例说明,假设需要修改数据库记录的 price 为 200:
需要将 resultSetConcurrency 赋值为 CONCUR_UPDATABLE ,然后才可以更新数据库记录。
3.3.3. PreparedStatement
PreparedStatement 继承自 Statement,不同的是它可以使用占位符,它是由占位符标识需要输入数据的位置,然后再逐一填入数据。当然,PreparedStatement 也可以执行没有占位符的 sql 语句。
假如我没使用 Statement 执行一个 INSERT 语句,代码如下:
public int insert() throws SQLException {
// 获取数据库连接
Connection con = DBUtil.getConnection();
// 构建并执行 sql
Statement stmt = con.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_UPDATABLE);
String sql = "INSERT into pms_product (name, price, product_sn) VALUES (" + "'红牛'" + ", 4.5" + ", 's10000678'" + ")";
return stmt.executeUpdate(sql);
}
上面的实现主要有 2 个缺点:
- 拼接的 sql 随着字段的增多逐渐复杂,容易出错;
- 假如我们的字段值是通过接口入参传递的,容易发生 ==sql 注入== 的安全漏洞。
此时使用 PreparedStatement 可以解决这一问题:
public int insert(String name, BigDecimal price, String sn) throws SQLException {
// 获取数据库连接
Connection con = DBUtil.getConnection();
// 构建并执行 sql
String sql = "INSERT into pms_product (name, price, product_sn) VALUES (?, ?, ?)";
PreparedStatement pstmt = con.prepareStatement(sql);
pstmt.setString(1, name);
pstmt.setBigDecimal(2, price);
pstmt.setString(3, sn);
return pstmt.executeUpdate();
}
PreparedStatement 就是事先准备一个 sql 语句,参数值使用 ?作为占位符,然后按照占位符的顺序设置参数值,再通过 executeUpdate 执行 sql。
PreparedStatement同样可以执行 SQL 的所有操作,SELECT 操作调用executeQuery方法,INSERT、UPDATE 和 DELETE 操作调用executeUpdate方法。
3.4. 发送 SQL 字节流
上文详细介绍了如何准备 SQL 语句,以及如何通过 Statement 执行 SQL。本小节详细分析一下 Statement 的 executeQuery 方法内部实现过程。整个方法比较长,下面只展示核心部分:
public java.sql.ResultSet executeQuery(String sql) throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
JdbcConnection locallyScopedConn = this.connection;
// ...
statementBegins();
this.results = ((NativeSession) locallyScopedConn.getSession()).execSQL(this, sql, this.maxRows, null,
createStreamingResultSet(), getResultSetFactory(), cachedMetaData, false);
return this.results;
}
}
调用 NativeSession 的 execSQL 方法:
public <T extends Resultset> T execSQL(Query callingQuery, String query, int maxRows, NativePacketPayload packet, boolean streamResults,
ProtocolEntityFactory<T, NativePacketPayload> resultSetFactory, ColumnDefinition cachedMetadata, boolean isBatch) {
// ...
try {
return packet == null
? ((NativeProtocol) this.protocol).sendQueryString(callingQuery, query, this.characterEncoding.getValue(), maxRows, streamResults,
cachedMetadata, resultSetFactory)
: ((NativeProtocol) this.protocol).sendQueryPacket(callingQuery, packet, maxRows, streamResults, cachedMetadata, resultSetFactory);
} catch (CJException sqlE) { ... }
}
然后通过内部的 sendQueryString 方法:
public final <T extends Resultset> T sendQueryString(Query callingQuery, String query, String characterEncoding, int maxRows, boolean streamResults,
ColumnDefinition cachedMetadata, ProtocolEntityFactory<T, NativePacketPayload> resultSetFactory) throws IOException {
// ...
return sendQueryPacket(callingQuery, sendPacket, maxRows, streamResults, cachedMetadata, resultSetFactory);
}
sendQueryPacket 方法内部完成了 SQL 请求的发送,以及结果读取。本节我们主要关注 SQL 请求的发送:
我们上面说过,SimplePacketSender 是 NativeProtocol 实例对数据库的 mysqlOutput 的封装,将字节流写到 outputStream 后,经过处理最终会发送到 mysqlOutput。
3.5. 接收 SQL 执行结果
上一节提到最终调用 sendQueryPacket 内部完成了 SQL 请求的发送,以及结果读取,本节我们来研究结果读取的过程:
sendCommand 完成发送 SQL 请求,返回的结果 resultPacket 有多种情况,可能是影响行数、查询结果的列数、错误码等。此处我们是一个查询列表的方法,所以返回了列数 3 。最终从 mysqlInput IO 流读取数据库响应:
4. 总结
综上所述,一条 SQL 从业务层达到数据库,然后获取响应的完整链路如下图所示:
业务层与 NativeProtocol 层的交互通过 NativePacketPayload 类型的 message 传递;NativeProtocol 层的 IO 类型 packetReader 和 packetSender 为 SimplePacketReader 和 SimplePacketSender 类型,是对 Socket 层 mysqlInput 和 mysqlOutput 的封装;数据库接收请求和返回响应都是通过这两个 IO 完成的。我们对 SQL 执行的全过程分析到此结束!