程序运行的时候,往往需要存储数据。现代应用程序最基本,也是使用最广泛的数据存储就是关系数据库。Java为关系数据库定义了一套标准的访问接口,JDBC(Java DataBase Connctivity),本章我们介绍如何在应用程序中使用JDBC。
JDBC简介
程序运行时,数据都是在内存中的。当程序终止时,通常都需要将数据保存到磁盘上,无论是保存到本地磁盘,还是通过网络保存到服务器上,最终都会将数据写入磁盘文件。如何定义数据的存储格式就是一个大问题。我们可以自定义各种保存格式。但,存储和读取都需要自己实现;不能做快速查询,只有把数据全部读取到内存中才能自己遍历,有时候数据大小远远超过了内存(比如蓝光电影,40GB的数据),根本无法全部读入内存。
为了便于程序保存和读取数据,而且能通过条件快速查询到指定的数据,就出现了数据库这种专门用于集中存储和查询的软件。
NoSQL
你也许还听说过NoSQL数据库,很多NoSQL宣传其速度和规模远远超过关系数据库,所以很多同学觉得有了NoSQL是否就不需要SQL了呢?千万不要被他们忽悠了,连SQL都不明白怎么可能搞明白NoSQL呢?
数据库类别
既然我们要使用关系数据库,就必须选择一个关系数据库。目前广泛使用的关系数据库也就这么几种:
付费的商用数据库:
- Oracle,典型的高富帅;
- SQL Server,微软自家产品,Windows定制专款;
- DB2,IBM的产品,听起来挺高端;
- Sybase,曾经跟微软是好基友,后来关系破裂,现在家境惨淡。
这些数据库都是不开源而且付费的,最大的好处是花了钱出了问题可以找厂家解决,不过在Web的世界里,常常需要部署成千上万的数据库服务器,当然不能把大把大把的银子扔给厂家,所以,无论是Google、Facebook,还是国内的BAT,无一例外都选择了免费的开源数据库:
- MySQL,大家都在用,一般错不了;
- PostgreSQL,学术气息有点重,其实挺不错,但知名度没有MySQL高;
- sqlite,嵌入式数据库,适合桌面和移动应用。
作为一个Java工程师,选择哪个免费数据库呢?当然是MySQL。因为MySQL普及率最高,出了错,可以很容易找到解决方法。而且,围绕MySQL有一大堆监控和运维的工具,安装和使用很方便。
JDBC
JDBC时Java程序访问数据库的标准接口。
使用Java程序访问数据库时,Java代码并不是直接通过TCP连接去访问数据库,而是通过JDBC接口来访问,而JDBC接口则通过JDBC驱动来实现真正对数据库的访问。
例如,我们在Java代码中如果要访问MySQL,那么必须编写代码操作JDBC接口。注意到JDBC接口是Java标准库自带的,所以可以直接编译。而具体的JDBC驱动是由数据库厂商提供的,例如,MySQL的JDBC驱动由Oracle提供。因此,访问某个具体的数据库,我们只需要引入该厂商提供的JDBC驱动,就可以通过JDBC接口来访问,这样保证了Java程序编写的是一套数据库访问代码,却可以访问各种不同的数据库,因为他们都提供了标准的JDBC驱动。
从代码来看,Java标准库自带的JDBC驱动其实就是定义了一组接口,而某个具体的JDBC驱动其实就是实现了这些接口的类。
实际上,一个MySQL的JDBC的驱动就是一个jar包,它本身也是纯Java编写的。我们自己编写的代码只需要引用Java标准库提供的java.sql包下面的相关接口,由此再间接地通过MySQL驱动的jar包通过网络访问MySQL服务器,所有复杂的网络通讯都被封装到JDBC驱动中,因此,Java程序本身只需要引入一个MySQL驱动的jar包就可以正常访问MySQL服务器。
小结
使用JDBC的好处是:
- 各数据库厂商使用相同的接口,Java代码不需要针对不同数据库分别开发;
- Java程序编译期仅依赖
java.sql
包,不依赖具体数据库的jar包; - 可随时替换底层数据库,访问数据库的Java代码基本不变。
JDBC查询
JDBC是一套接口规范,它在哪呢?在Java的标准库java.sql
里。接口不能直接实例化,而是必须实例化其实现类,然后通过接口引用这个实例。那么,JDBC接口的实现类在哪里?我们把某个数据库实现了JDBC接口的jar包称为JDBC驱动。
例如,我们加入MySQL的驱动,添加的Maven依赖是:
1 |
|
注意到这里添加的依赖的scope
是runtime
,因为编译Java程序不需要MySQL的这个jar包,只有在运行期才使用。如果把scope
改成compile
,虽然也能正常编译,但是在IDE里写程序的时候,会多出来一大堆类似com.mysql.jdbc.Connection
这样的类,非常容易与Java标准库的JDBC接口混淆,所以坚决不要设置compile
。
JDBC连接
使用JDBC时,我们先了解什么是Connection。Connection代表一个JDBC连接,它相当于Java程序到数据库的连接(通常是TCP连接)。打开一个Connection时,需要准备URL、用户名和口令。
URL是由数据库厂商指定的格式,例如,MySQL的URL时:
1 |
|
假设数据库运行在本机localhost
,端口使用标准的3306
,数据库名称是learnjdbc
,那么URL如下:
1 |
|
后面两个参数表示不使用SSL加密,使用UTF-8作为字符编码。
要获取数据库连接,使用如下代码:
1 |
|
核心代码时DriverManager
提供的静态方法getConnection()
。DriverManager
会自动扫描classpath,找到所有的JDBC驱动,然后根据我们传入的URL自动挑选一个合适的驱动。
因为JDBC连接是一种昂贵的资源,使用后要及时释放。使用try(resource)
来自动释放JDBC连接是一个好方法:
1 |
|
JDBC查询
获取到JDBC连接后,下一步我们就可以查询数据库了。查询数据库分以下几步:
- 通过
Connection
提供的createStatement()
方法创建一个Statement
对象,用于执行一个查询 - 执行
Statement
对象提供的executeQuery("SELECT * FROM students")
并传入SQL语句,执行查询并获得返回的结果集,使用ResultSet
来引用这个结果集 - 反复调用
ResultSet
的next()
方法并读取每一行结果
完整查询代码如下:
1 |
|
Statement
和ResultSet
都是需要关闭的资源,因此嵌套使用try(resource)
确保及时关闭。
rs.next()
用于判断是否有下一行记录,如果有,会自动把当前行移动到下一行(一开始获得的ResultSet
是当前行不是第一行)。
ResultSet
获取列时,索引从1
开始而不是0
。
必须根据SELECT
的列的对应位置来调用getLong(1)
,getString(2)
这些方法,否则对应位置的数据类型不对,将报错。
SQL注入
使用Statement拼接字符串非常容易引发SQL注入的问题,这是因为SQL参数往往是通过方法参数传入的。
我们来看一个例子:假设用户登录的验证方法如下:
1 |
|
其中,参数name
和pass
通常都是Web页面输入后由程序接收到的。
如果用户的输入是程序期待的值,就可以拼出正确的SQL。例如:name = "bob"
,pass = "1234"
:
1 |
|
但是,如果用户的输入是一个精心构造的字符串,就可以拼出意想不到的SQL,这个SQL也是正确的,但它查询的条件不是程序设计的意图。例如:name = "bob' OR pass="
, pass = " OR pass='"
:
1 |
|
这个SQL语句执行的时候,根本不用判断口令是否正确,这样一来,登录就形同虚设。
要避免SQL注入攻击,一个办法是针对所有字符串参数进行转义,但是转义很麻烦,而且需要在任何使用SQL的地方增加转义代码。
还有一个办法就是使用PreparedStatement
。使用PreparedStatement
可以完全避免SQL注入的问题,因为PreparedStatement
始终使用?
作为占位符,并且把数据连同SQL本身传给数据库,这样可以保证每次传给数据库的SQL语句是相同的,只是占位符的数据不同,还能高效利用数据库本身对查询的缓存。上述登录SQL如果用PreparedStatement
可以改写如下:
1 |
|
所以,PreparedStatement
比Statement
更安全,而且更快。
使用Java操作数据库时,必须使用PreparedStatement,严禁任何通过参数拼接字符串的代码
我们把上面使用Statement
的代码改为使用PreparedStatement
:
1 |
|
使用PreparedStatement
和Statement
稍有不同,必须首先调用setObject()
设置每个占位符?
的值,最后获取的仍然是ResultSet
对象。
另外注意到从结果集读取列时,使用String
类型的列名比索引要易读,而且不易出错。
注意到JDBC查询的返回值总是ResultSet
,即使我们写这样的聚合查询SELECT SUM(score) FROM ...
,也需要按结果集读取:
1 |
|
数据类型
有的童鞋可能注意到了,使用JDBC的时候,我们需要在Java数据类型和SQL数据类型之间进行转换。JDBC在java.sql.Types
定义了一组常量来表示如何映射SQL数据类型,但是平时我们使用的类型通常也就以下几种:
SQL数据类型 | Java数据类型 |
---|---|
BIT, BOOL | boolean |
INTEGER | int |
BIGINT | long |
REAL | float |
FLOAT, DOUBLE | double |
CHAR, VARCHAR | String |
DECIMAL | BigDecimal |
DATE | java.sql.Date, LocalDate |
TIME | java.sql.Time, LocalTime |
注意:只有最新的JDBC驱动才支持LocalDate
和LocalTime
。
JDBC更新
数据库操作总结起来就4个字:增删改查,行话叫CRUD:Create,Retrieve,Update和Delete。查就是查询,上一节介绍过了,就是使用PreparedStatement
进行各种SELECT
,然后处理结果集。现在我们来看看如何使用JDBC进行增删改查。
插入
插入操作是INSERT,即插入一条记录。通过JDBC插入,本质上也是用PreparedStatement
执行一条SQL语句,不过最后不是执行executeQuery()
,而是executeUpdate()
。示例代码如下:
1 |
|
设置参数与查询是一样的,有几个?
占位符就必须设置对应的参数。虽然Statement
也可以执行插入操作,但我们仍然要严格遵循绝不能手动拼SQL字符串的原则,以避免安全漏洞。
当成功执行executeUpdate()
后,返回值是int
,表示插入的记录数量。此处总是1
,因为只插入了一条记录。
插入并获取主键
如果数据库的表设置了自增主键,那么执行INSERT语句时,并不需要指定主键,数据库会自动分配主键。对于使用自增主键的程序,有个额外的步骤,就是如何获取插入后自增主键的值。
要获取自增主键,不能先插入,再查询。因为两条SQL执行期间可能有别的程序也插入了同一个表。获取自增主键的正确写法是,在创建PreparedStatement
的时候,指定一个RETURN_GENERATED_KEYS标志位,表示JDBC驱动必须返回插入的自增主键。示例代码如下:
1 |
|
注意两点:
一是调用prepareStatement()
时,第二个参数必须传入常量Statement.RETURN_GENERATED_KEYS
,否则JDBC驱动不会返回自增主键;
二是执行executeUpdate()
方法后,必须调用getGeneratedKeys()
获取一个ResultSet
对象,这个对象包含了数据库自动生成的主键的值,读取该对象的每一行来获取自增主键的值。如果一次插入多条记录,那么这个ResultSet
对象就会有多行返回值。如果插入时有多列自增,那么ResultSet
对象的每一行都会对应多个自增值(自增列不一定必须是主键)。
更新
更新操作是UPDATE
语句,它可以一次更新若干列的记录。
1 |
|
executeUpdate()
返回数据库实际更新的行数。返回结果可能是正数,也可能是0(表示没有任何记录更新)。
删除
删除操作是DELETE
语句,它可以一次删除若干列。
1 |
|
小结
使用JDBC执行INSERT
,UPDATE
和DELETE
都可视为更新操作。更新操作使用PreparedStatement
的executeUpdate()
进行,返回影响的行数。
JDBC事务
数据库事务(Transaction)是由若干SQL语句构成的一个操作序列。有点类似于Java的synchronized
同步。数据库系统保证一个事务中的所有SQL要么全部执行成功,要么全部不执行,即数据库事务具有ACID特性:
- Atomicity:原子性
- Consistency:一致性
- Isolation:隔离性
- Durability:持久性
数据库事务可以并发执行,而数据库系统从效率考虑,对事务定义了不同的隔离级别。SQL标准定义了4种隔离级别,分别对应可能出现的数据不一致的情况:
Isolation Level | 脏读(Dirty Read) | 不可重复读(Non Repeatable Read) | 幻读(Phantom Read) |
---|---|---|---|
Read Uncommitted | Yes | Yes | Yes |
Read Committed | - | Yes | Yes |
Repeatable Read | - | - | Yes |
Serializable | - | - | - |
对应用程序来说,数据库事务非常重要,很多运行着关键任务的应用程序,都必须依赖数据库事务保证程序的结果正常。
要在JDBC中执行事务,本质上就是如何把多条SQL包裹在一个数据库事务中执行。我们来看JDBC的事务代码:
1 |
|
其中,开启事务的关键代码是conn.setAutoCommit(false)
,表示关闭自动提交。提交事务的代码在执行完若干条SQL语句后,调用conn.commit()
。注意,事务不总能成功,如果事务提交失败,会抛出SQL异常(也可能在执行SQL语句的时候就抛出了),此时我们必须捕获并调用conn.rollback()
回滚事务。最后,在finally
中通过conn.setAutoCommit(true)
把Connection对象的状态恢复到初始值。
实际上,默认情况下,我们获取到Connection连接后,总是处于自动提交模式,也就是每执行一条SQL都是作为事务自动执行的,这也是为什么我们前面的更新操作总能成功的原因:因为默认有这种隐式事务。只要关闭了Connection
的autoCommit
,就可以在一个事务中执行多条语句,事务以commit()
方法结束。
如果要设定事务的隔离级别,可以使用如下代码:
1 |
|
如果没有调用上述方法,那么会使用数据库的默认隔离级别。MySQL的默认隔离级别是REPEATABLE_READ
。
JDBC Batch
使用JDBC操作数据库的时候,经常会执行一些批量操作。
很多情况下,执行JDBC时,只有占位符参数不同,所以SQL实际上是一样的。通过一个循环来执行每个PreparedStatement
虽然可行,但是性能很低。SQL数据库对SQL语句相同,但只有参数不同的若干语句可以作为batch
执行,即批量执行,这种操作有特别的优化,速度远远快于循环执行每个SQL。
在JDBC代码中,我们可以利用SQL这一特性,把同一个SQL但参数不同的若干次操作合并为一个batch执行。我们以批量插入为例:
1 |
|
执行batch和执行一个SQL不同点在于,需要对同一个PreparedStatement
反复设置参数并调用addBatch()
,这样就相当于给一个SQL加上了多组参数,相当于变成了“多行”SQL。
第二个点是,调用的不是executeUpdate()
,而是executeBatch()
,因为我们设置了多组参数,相应地,返回结果也是多个int
值,因此返回类型是int[]
,循环int[]数据即可获得每组参数执行后影响的结果数量。
JDBC连接池
我们在多线程一章讲到过,创建线程是一个很昂贵的操作,如果有大量的小任务要执行,并且频繁地创建和销毁线程,实际上会消耗大量的系统资源,往往创建和销毁线程所耗费的时间比执行任务的时间还长。所以,为了提高效率,可以使用线程池。
类似地,在执行JDBC的增删改查的操作时,如果每一次操作都来打开一次连接,操作,关闭连接,那么创建和销毁JDBC连接的开销就太大了。为了避免频繁地创建和销毁JDBC连接,可以通过使用连接池(Connection Pool)复用已创建好的连接。
JDBC连接池有一个标准的接口javax.sql.DataSource
,注意这个类位于Java标准库中,但仅仅是接口。要使用JDBC连接池,我们必须选择一个JDBC连接池的实现。常用的JDBC连接池有:
- HikariCP
- C3P0
- BoneCP
- Druid
目前使用最广泛的是HikariCP。我们以HikariCP为例,先添加依赖如下:
1 |
|
紧接着,创建一个DataSource实例,这个实例就是连接池:
1 |
|
注意,创建DataSource也是非常昂贵的操作,所以通常DataSource实例总是作为一个全局变量存储,并贯穿整个应用程序的生命周期。
有了连接池后,我们如何使用它呢?和之前的代码类似,只是获取Connection
时,把DriverManager.getConnection()
改为ds.getConnection()
:
1 |
|
通过连接池获取连接时,并不需要指定JDBC的相关URL、用户名、口令等信息,因为这些信息已经存储在连接池内部了。一开始,连接池内部并没有连接,所以,第一次调用ds.getConnection()
,会使连接池内部先创建一个Connection
,再返回给客户端使用。当我们调用conn.close()
时,并不是真正的关闭连接,而是释放到连接池中,以便下次获取连接时能直接返回。
因此,连接池内部维护了若干Connection
实例,如果调用ds.getConnection()
,就选择一个空闲连接,并标记它为“正在使用”然后返回。如果对Connection
调用close()
,那么就把连接再次标记为“空闲”从而等待下次调用。这样一来,我们就通过连接池维护了少量连接,但可以频繁地执行大量的SQL语句。
通常,连接池提供了大量的参数可以配置,例如,维护的最小、最大活动连接数,指定一个连接在空闲一段时间后自动关闭等,需要根据应用程序的负载合理地配置这些参数。此外,大多数连接池都提供了详细的实时状态以便进行监控。