java JDBC詳解


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來引用這個結果集;

第三步,反復調用ResultSetnext()方法並讀取每一行結果。

完整查詢代碼如下:

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);
            }
        }
    }
}

注意要點:

StatmentResultSet都是需要關閉的資源,因此嵌套使用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");
            }
        }
    }
}

使用PreparedStatementStatement稍有不同,必須首先調用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,兩人在數據庫中的記錄主鍵分別是123456,那么用兩條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都是作為事務自動執行的,這也是為什么前面幾節我們的更新操作總能成功的原因:因為默認有這種“隱式事務”。只要關閉了ConnectionautoCommit,那么就可以在一個事務中執行多條語句,事務以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代碼的運行效率;

可以配置連接池的詳細參數並監控連接池。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM