title: JDBC
date: 2021-06-07 22:42:01
tags: JDBC
categories: Java
description:
top_img:
comments:
cover:
JDBC
基本介紹
使用Java程序訪問數據庫時,Java代碼並不是直接通過TCP連接去訪問數據庫,而是通過JDBC接口來訪問,而JDBC接口則通過JDBC驅動來實現真正對數據庫的訪問。
例如,我們在Java代碼中如果要訪問MySQL,那么必須編寫代碼操作JDBC接口。注意到JDBC接口是Java標准庫自帶的,所以可以直接編譯。而具體的JDBC驅動是由數據庫廠商提供的,例如,MySQL的JDBC驅動由Oracle提供。因此,訪問某個具體的數據庫,我們只需要引入該廠商提供的JDBC驅動,就可以通過JDBC接口來訪問,這樣保證了Java程序編寫的是一套數據庫訪問代碼,卻可以訪問各種不同的數據庫,因為他們都提供了標准的JDBC驅動:
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│ ┌───────────────┐ │
│ Java App │
│ └───────────────┘ │
│
│ ▼ │
┌───────────────┐
│ │JDBC Interface │<─┼─── JDK
└───────────────┘
│ │ │
▼
│ ┌───────────────┐ │
│ JDBC Driver │<───── Vendor
│ └───────────────┘ │
│
└ ─ ─ ─ ─ ─│─ ─ ─ ─ ─ ┘
▼
┌───────────────┐
│ Database │
└───────────────┘
因為JDBC接口並不知道我們要使用哪個數據庫,所以,用哪個數據庫,我們就去使用哪個數據庫的“實現類”,我們把某個數據庫實現了JDBC接口的jar包稱為JDBC驅動。
因為我們選擇了MySQL 5.x作為數據庫,所以我們首先得找一個MySQL的JDBC驅動。所謂JDBC驅動,其實就是一個第三方jar包,我們直接添加一個Maven依賴就可以了:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
<scope>runtime</scope>
</dependency>
JDBC連接
使用JDBC時,我們先了解什么是Connection。Connection代表一個JDBC連接,它相當於Java程序到數據庫的連接(通常是TCP連接)。打開一個Connection時,需要准備URL、用戶名和口令,才能成功連接到數據庫。
URL是由數據庫廠商指定的格式,例如,MySQL的URL是:
jdbc:mysql://<hostname>:<port>/<dbName>?key1=value1&key2=value2
假設數據庫運行在本機localhost
,端口使用標准的3306
,數據庫名稱是learnjdbc
,那么URL如下:
jdbc:mysql://localhost:3306/learnjdbc?useSSL=false&characterEncoding=utf8
后面的兩個參數表示不使用SSL加密,使用UTF-8作為字符編碼(注意MySQL的UTF-8是utf8
)。
要獲取數據庫連接,使用如下代碼:
// JDBC連接的URL, 不同數據庫有不同的格式:
String JDBC_URL = "jdbc:mysql://localhost:3306/test";
String JDBC_USER = "root";
String JDBC_PASSWORD = "password";
// 獲取連接:
Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD);
// TODO: 訪問數據庫...
// 關閉連接:
conn.close();
核心代碼是DriverManager
提供的靜態方法getConnection()
。DriverManager
會自動掃描classpath,找到所有的JDBC驅動,然后根據我們傳入的URL自動挑選一個合適的驅動。
因為JDBC連接是一種昂貴的資源,所以使用后要及時釋放。使用try (resource)
來自動釋放JDBC連接是一個好方法:
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
...
}
JDBC查詢
獲取到JDBC連接后,下一步我們就可以查詢數據庫了。查詢數據庫分以下幾步:
第一步,通過Connection
提供的 createStatement()
方法創建一個Statement
對象,用於執行一個查詢;
第二步,執行Statement
對象提供的executeQuery("SELECT * FROM students")
並傳入SQL語句,執行查詢並獲得返回的結果集,使用ResultSet
來引用這個結果集;
第三步,反復調用ResultSet
的next()
方法並讀取每一行結果。
完整查詢代碼如下:
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
try (Statement stmt = conn.createStatement()) {
try (ResultSet rs = stmt.executeQuery("SELECT id, grade, name, gender FROM students WHERE gender=1")) {
while (rs.next()) {
long id = rs.getLong(1); // 注意:索引從1開始
long grade = rs.getLong(2);
String name = rs.getString(3);
int gender = rs.getInt(4);
}
}
}
}
注意要點:
Statment
和ResultSet
都是需要關閉的資源,因此嵌套使用try (resource)
確保及時關閉;
rs.next()
用於判斷是否有下一行記錄,如果有,將自動把當前行移動到下一行(一開始獲得ResultSet
時當前行不是第一行);
ResultSet
獲取列時,索引從1
開始而不是0
;
必須根據SELECT
的列的對應位置來調用getLong(1)
,getString(2)
這些方法,否則對應位置的數據類型不對,將報錯。
SQL注入
使用Statement
拼字符串非常容易引發SQL注入的問題,這是因為SQL參數往往是從方法參數傳入的。
要避免SQL注入攻擊,一個辦法是針對所有字符串參數進行轉義,但是轉義很麻煩,而且需要在任何使用SQL的地方增加轉義代碼。
還有一個辦法就是使用PreparedStatement
。使用PreparedStatement
可以完全避免SQL注入的問題,因為PreparedStatement
始終使用?
作為占位符,並且把數據連同SQL本身傳給數據庫,這樣可以保證每次傳給數據庫的SQL語句是相同的,只是占位符的數據不同,還能高效利用數據庫本身對查詢的緩存。上述登錄SQL如果用PreparedStatement
可以改寫如下:
我們把上面使用Statement
的代碼改為使用PreparedStatement
:
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
try (PreparedStatement ps = conn.prepareStatement("SELECT id, grade, name, gender FROM students WHERE gender=? AND grade=?")) {
ps.setObject(1, "M"); // 注意:索引從1開始
ps.setObject(2, 3);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
long grade = rs.getLong("grade");
String name = rs.getString("name");
String gender = rs.getString("gender");
}
}
}
}
使用PreparedStatement
和Statement
稍有不同,必須首先調用setObject()
設置每個占位符?
的值,最后獲取的仍然是ResultSet
對象。
插入
插入操作是INSERT
,即插入一條新記錄。通過JDBC進行插入,本質上也是用PreparedStatement
執行一條SQL語句,不過最后執行的不是executeQuery()
,而是executeUpdate()
。示例代碼如下:
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) { try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO students (id, grade, name, gender) VALUES (?,?,?,?)")) { ps.setObject(1, 999); // 注意:索引從1開始 ps.setObject(2, 1); // grade ps.setObject(3, "Bob"); // name ps.setObject(4, "M"); // gender int n = ps.executeUpdate(); // 1 }}
設置參數與查詢是一樣的,有幾個?
占位符就必須設置對應的參數。雖然Statement
也可以執行插入操作,但我們仍然要嚴格遵循絕不能手動拼SQL字符串的原則,以避免安全漏洞。
當成功執行executeUpdate()
后,返回值是int
,表示插入的記錄數量。此處總是1
,因為只插入了一條記錄。
插入並獲取主鍵
如果數據庫的表設置了自增主鍵,那么在執行INSERT
語句時,並不需要指定主鍵,數據庫會自動分配主鍵。對於使用自增主鍵的程序,有個額外的步驟,就是如何獲取插入后的自增主鍵的值。
要獲取自增主鍵,不能先插入,再查詢。因為兩條SQL執行期間可能有別的程序也插入了同一個表。獲取自增主鍵的正確寫法是在創建PreparedStatement
的時候,指定一個RETURN_GENERATED_KEYS
標志位,表示JDBC驅動必須返回插入的自增主鍵。示例代碼如下:
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) { try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO students (grade, name, gender) VALUES (?,?,?)", Statement.RETURN_GENERATED_KEYS)) { ps.setObject(1, 1); // grade ps.setObject(2, "Bob"); // name ps.setObject(3, "M"); // gender int n = ps.executeUpdate(); // 1 try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { long id = rs.getLong(1); // 注意:索引從1開始 } } }}
觀察上述代碼,有兩點注意事項:
一是調用prepareStatement()
時,第二個參數必須傳入常量Statement.RETURN_GENERATED_KEYS
,否則JDBC驅動不會返回自增主鍵;
二是執行executeUpdate()
方法后,必須調用getGeneratedKeys()
獲取一個ResultSet
對象,這個對象包含了數據庫自動生成的主鍵的值,讀取該對象的每一行來獲取自增主鍵的值。如果一次插入多條記錄,那么這個ResultSet
對象就會有多行返回值。如果插入時有多列自增,那么ResultSet
對象的每一行都會對應多個自增值(自增列不一定必須是主鍵)。
更新
更新操作是UPDATE
語句,它可以一次更新若干列的記錄。更新操作和插入操作在JDBC代碼的層面上實際上沒有區別,除了SQL語句不同:
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) { try (PreparedStatement ps = conn.prepareStatement("UPDATE students SET name=? WHERE id=?")) { ps.setObject(1, "Bob"); // 注意:索引從1開始 ps.setObject(2, 999); int n = ps.executeUpdate(); // 返回更新的行數 }}
executeUpdate()
返回數據庫實際更新的行數。返回結果可能是正數,也可能是0(表示沒有任何記錄更新)。
刪除
刪除操作是DELETE
語句,它可以一次刪除若干列。和更新一樣,除了SQL語句不同外,JDBC代碼都是相同的:
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) { try (PreparedStatement ps = conn.prepareStatement("DELETE FROM students WHERE id=?")) { ps.setObject(1, 999); // 注意:索引從1開始 int n = ps.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 | - | - | - |
對應用程序來說,數據庫事務非常重要,很多運行着關鍵任務的應用程序,都必須依賴數據庫事務保證程序的結果正常。
舉個例子:假設小明准備給小紅支付100,兩人在數據庫中的記錄主鍵分別是123
和456
,那么用兩條SQL語句操作如下:
UPDATE accounts SET balance = balance - 100 WHERE id=123 AND balance >= 100;UPDATE accounts SET balance = balance + 100 WHERE id=456;
這兩條語句必須以事務方式執行才能保證業務的正確性,因為一旦第一條SQL執行成功而第二條SQL失敗的話,系統的錢就會憑空減少100,而有了事務,要么這筆轉賬成功,要么轉賬失敗,雙方賬戶的錢都不變。
這里我們不討論詳細的SQL事務,如果對SQL事務不熟悉,請參考SQL事務。
要在JDBC中執行事務,本質上就是如何把多條SQL包裹在一個數據庫事務中執行。我們來看JDBC的事務代碼:
Connection conn = openConnection();try { // 關閉自動提交: conn.setAutoCommit(false); // 執行多條SQL語句: insert(); update(); delete(); // 提交事務: conn.commit();} catch (SQLException e) { // 回滾事務: conn.rollback();} finally { conn.setAutoCommit(true); conn.close();}
其中,開啟事務的關鍵代碼是conn.setAutoCommit(false)
,表示關閉自動提交。提交事務的代碼在執行完指定的若干條SQL語句后,調用conn.commit()
。要注意事務不是總能成功,如果事務提交失敗,會拋出SQL異常(也可能在執行SQL語句的時候就拋出了),此時我們必須捕獲並調用conn.rollback()
回滾事務。最后,在finally
中通過conn.setAutoCommit(true)
把Connection
對象的狀態恢復到初始值。
實際上,默認情況下,我們獲取到Connection
連接后,總是處於“自動提交”模式,也就是每執行一條SQL都是作為事務自動執行的,這也是為什么前面幾節我們的更新操作總能成功的原因:因為默認有這種“隱式事務”。只要關閉了Connection
的autoCommit
,那么就可以在一個事務中執行多條語句,事務以commit()
方法結束。
如果要設定事務的隔離級別,可以使用如下代碼:
// 設定隔離級別為READ COMMITTED:conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
如果沒有調用上述方法,那么會使用數據庫的默認隔離級別。MySQL的默認隔離級別是REPEATABLE READ
。
JDBC Batch
使用JDBC操作數據庫的時候,經常會執行一些批量操作。
例如,一次性給會員增加可用優惠券若干,我們可以執行以下SQL代碼:
INSERT INTO coupons (user_id, type, expires) VALUES (123, 'DISCOUNT', '2030-12-31');INSERT INTO coupons (user_id, type, expires) VALUES (234, 'DISCOUNT', '2030-12-31');INSERT INTO coupons (user_id, type, expires) VALUES (345, 'DISCOUNT', '2030-12-31');INSERT INTO coupons (user_id, type, expires) VALUES (456, 'DISCOUNT', '2030-12-31');...
實際上執行JDBC時,因為只有占位符參數不同,所以SQL實際上是一樣的:
for (var params : paramsList) { PreparedStatement ps = conn.preparedStatement("INSERT INTO coupons (user_id, type, expires) VALUES (?,?,?)"); ps.setLong(params.get(0)); ps.setString(params.get(1)); ps.setString(params.get(2)); ps.executeUpdate();}
類似的還有,給每個員工薪水增加10%~30%:
UPDATE employees SET salary = salary * ? WHERE id = ?
通過一個循環來執行每個PreparedStatement
雖然可行,但是性能很低。SQL數據庫對SQL語句相同,但只有參數不同的若干語句可以作為batch執行,即批量執行,這種操作有特別優化,速度遠遠快於循環執行每個SQL。
在JDBC代碼中,我們可以利用SQL數據庫的這一特性,把同一個SQL但參數不同的若干次操作合並為一個batch執行。我們以批量插入為例,示例代碼如下:
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO students (name, gender, grade, score) VALUES (?, ?, ?, ?)")) { // 對同一個PreparedStatement反復設置參數並調用addBatch(): for (Student s : students) { ps.setString(1, s.name); ps.setBoolean(2, s.gender); ps.setInt(3, s.grade); ps.setInt(4, s.score); ps.addBatch(); // 添加到batch } // 執行batch: int[] ns = ps.executeBatch(); for (int n : ns) { System.out.println(n + " inserted."); // batch中每個SQL執行的結果數量 }}
執行batch和執行一個SQL不同點在於,需要對同一個PreparedStatement
反復設置參數並調用addBatch()
,這樣就相當於給一個SQL加上了多組參數,相當於變成了“多行”SQL。
第二個不同點是調用的不是executeUpdate()
,而是executeBatch()
,因為我們設置了多組參數,相應地,返回結果也是多個int
值,因此返回類型是int[]
,循環int[]
數組即可獲取每組參數執行后影響的結果數量。
JDBC連接池
為了避免頻繁地創建和銷毀JDBC連接,我們可以通過連接池(Connection Pool)復用已經創建好的連接。
JDBC連接池有一個標准的接口javax.sql.DataSource
,注意這個類位於Java標准庫中,但僅僅是接口。要使用JDBC連接池,我們必須選擇一個JDBC連接池的實現。常用的JDBC連接池有:
- HikariCP
- C3P0
- BoneCP
- Druid
目前使用最廣泛的是HikariCP。我們以HikariCP為例,要使用JDBC連接池,先添加HikariCP的依賴如下:
<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>2.7.1</version></dependency>
緊接着,我們需要創建一個DataSource
實例,這個實例就是連接池:
HikariConfig config = new HikariConfig();config.setJdbcUrl("jdbc:mysql://localhost:3306/test");config.setUsername("root");config.setPassword("password");config.addDataSourceProperty("connectionTimeout", "1000"); // 連接超時:1秒config.addDataSourceProperty("idleTimeout", "60000"); // 空閑超時:60秒config.addDataSourceProperty("maximumPoolSize", "10"); // 最大連接數:10DataSource ds = new HikariDataSource(config);
注意創建DataSource
也是一個非常昂貴的操作,所以通常DataSource
實例總是作為一個全局變量存儲,並貫穿整個應用程序的生命周期。
有了連接池以后,我們如何使用它呢?和前面的代碼類似,只是獲取Connection
時,把DriverManage.getConnection()
改為ds.getConnection()
:
try (Connection conn = ds.getConnection()) { // 在此獲取連接 ...} // 在此“關閉”連接
通過連接池獲取連接時,並不需要指定JDBC的相關URL、用戶名、口令等信息,因為這些信息已經存儲在連接池內部了(創建HikariDataSource
時傳入的HikariConfig
持有這些信息)。一開始,連接池內部並沒有連接,所以,第一次調用ds.getConnection()
,會迫使連接池內部先創建一個Connection
,再返回給客戶端使用。當我們調用conn.close()
方法時(在try(resource){...}
結束處),不是真正“關閉”連接,而是釋放到連接池中,以便下次獲取連接時能直接返回。
因此,連接池內部維護了若干個Connection
實例,如果調用ds.getConnection()
,就選擇一個空閑連接,並標記它為“正在使用”然后返回,如果對Connection
調用close()
,那么就把連接再次標記為“空閑”從而等待下次調用。這樣一來,我們就通過連接池維護了少量連接,但可以頻繁地執行大量的SQL語句。
通常連接池提供了大量的參數可以配置,例如,維護的最小、最大活動連接數,指定一個連接在空閑一段時間后自動關閉等,需要根據應用程序的負載合理地配置這些參數。此外,大多數連接池都提供了詳細的實時狀態以便進行監控。
小結
數據庫連接池是一種復用Connection
的組件,它可以避免反復創建新連接,提高JDBC代碼的運行效率;
可以配置連接池的詳細參數並監控連接池。