Command Palette

Search for a command to run...

[Spring Boot Basics] Kết nối database trong Spring Boot: DataSource, HikariCP, H2, PostgreSQL, MySQL và JdbcClient

Chương 3 dựng danh mục sản phẩm thành một HTTP API, và mọi sản phẩm nằm trong một ConcurrentHashMap trống trơn sau mỗi lần restart. Chương 4 đưa danh mục đó lên một database thật. Bài này đặt nền móng: connection đến từ đâu, Spring Boot chọn database nào khi bạn không cấu hình gì, H2 cho môi trường dev và PostgreSQL hoặc MySQL cho các môi trường còn lại, HikariCP làm pool ở giữa, và JdbcClient để chạy SQL.

Bài kết thúc đúng chỗ bài 21 đã chỉ ra: một JdbcProductRepository đứng sau interface ProductRepository sẵn có, ProductService giữ nguyên, và curl nhận dữ liệu từ PostgreSQL. Trên đường đi, bài đo lại những chỗ các tutorial hay chép từ phiên bản cũ — H2 console, spring.sql.init.mode, KeyHolder và pool cạn connection — trên đúng các phiên bản series này dùng.

Các request mượn connection mà pool giữ sẵn tới database

Các ví dụ dùng Spring Boot 4.1.1 và Java 21, với H2 nhúng cho môi trường dev, cùng PostgreSQL 18 và MySQL 8.4 chạy trong Docker. Đường dẫn jar dài trong log được rút gọn thành .../.

DataSource là gì, và vì sao cần connection pool?

Một JDBC Connection là một phiên làm việc đang mở với database: một socket mạng, một lần đăng nhập, và trên PostgreSQL còn là một process riêng phía server. DriverManager.getConnection(url, user, password) tạo một connection mới ở mỗi lần gọi. javax.sql.DataSource là interface đứng trước chuyện đó: method đáng quan tâm duy nhất là getConnection(), và nó không hứa gì về việc connection lấy từ đâu. Code xin DataSource một connection rồi gọi close() khi dùng xong; việc đó mở rồi đóng một connection vật lý hay mượn một connection rồi trả lại là do implementation quyết định.

Implementation mặc định của Spring Boot là HikariDataSource của HikariCP, một connection pool. Nó giữ các connection luôn mở, cho mượn một cái khi gọi getConnection(), và nhận lại khi gọi close() mà không đóng socket. Khác biệt này đo được dễ dàng. Một runner tạm mở connection rồi chạy SELECT 1 một trăm lần theo mỗi cách, với container PostgreSQL được dựng ở phần sau của bài:

Java
private void openNew(int n) throws SQLException {
    for (int i = 0; i < n; i++) {
        try (Connection connection = DriverManager.getConnection(url, username, password);
             Statement statement = connection.createStatement()) {
            statement.executeQuery("SELECT 1").close();
        }
    }
}
 
private void borrow(int n) throws SQLException {
    for (int i = 0; i < n; i++) {
        try (Connection connection = dataSource.getConnection();
             Statement statement = connection.createStatement()) {
            statement.executeQuery("SELECT 1").close();
        }
    }
}

Sau hai mươi lần chạy làm nóng cho mỗi cách, năm vòng 100 lần in ra:

Text
round 1: DriverManager 2.542 ms per connection, pool 0.176 ms per connection
round 2: DriverManager 3.156 ms per connection, pool 0.159 ms per connection
round 3: DriverManager 2.558 ms per connection, pool 0.129 ms per connection
round 4: DriverManager 2.446 ms per connection, pool 0.135 ms per connection
round 5: DriverManager 2.266 ms per connection, pool 0.120 ms per connection

Tốt nhất trong năm vòng: 2.27 ms để mở một connection và 0.12 ms để mượn một connection, ít hơn khoảng 19 lần. Các con số chỉ mang tính tham khảo — một máy, database trong container local, không có độ trễ mạng trong cả hai số — nhưng nguồn gốc của chi phí thì không đổi. File pg_hba.conf trong image postgres:18 kết thúc bằng host all all all scram-sha-256, nên mỗi connection mới từ bên ngoài container đều chạy một lượt trao đổi password SCRAM, và PostgreSQL khởi tạo một backend process để phục vụ nó. Pool trả chi phí đó một lần cho mỗi connection thay vì một lần cho mỗi query.

Pool còn một việc thứ hai: giới hạn số connection application mở cùng lúc, để bảo vệ database. Phần HikariCP gần cuối bài đo xem điều gì xảy ra khi chạm tới giới hạn đó.

Thêm JDBC và H2 vào project Spring Boot

JDBC starter mang theo auto-configuration cho DataSource, HikariCP và module JDBC của Spring. Cho môi trường dev, thêm H2, một database viết bằng Java chạy ngay trong JVM của application. Bài dùng lại web starter và validation starter từ Chương 3, nên các id trên Initializr là web, validation, jdbch2:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,jdbc,h2" -o demo.zip

File build sinh ra, đã bỏ các starter -test mà Initializr thêm cho mỗi starter:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	runtimeOnly 'com.h2database:h2'
}

Danh sách đó có ba chi tiết đáng chú ý. Database được khai báo runtimeOnly: code của application làm việc với các interface JDBC và không bao giờ import class nào của H2. spring-boot-h2console không thuộc về H2: trong Spring Boot 4, web console của H2 nằm trong một module riêng, và Initializr thêm nó khi chọn H2 cùng với web. Và file jar đóng gói cho thấy JDBC starter resolve ra những gì: HikariCP-7.0.2.jar, spring-jdbc-7.0.9.jar, spring-tx-7.0.9.jarspring-boot-jdbc-4.1.1.jar, bên cạnh h2-2.4.240.jar.

Project hoàn chỉnh, tổ chức package theo feature như bài 21 đã chọn:

Tree
src/main
├── java/com/example/demo
│   ├── DemoApplication.java
│   ├── common
│   │   └── GlobalExceptionHandler.java
│   └── product
│       ├── CreateProductRequest.java
│       ├── DuplicateSkuException.java
│       ├── InsufficientStockException.java
│       ├── JdbcProductRepository.java
│       ├── Product.java
│       ├── ProductController.java
│       ├── ProductMapper.java
│       ├── ProductNotFoundException.java
│       ├── ProductRepository.java
│       ├── ProductResponse.java
│       └── ProductService.java
└── resources
    ├── application.properties
    ├── application-dev.properties
    ├── application-prod.properties
    ├── data.sql
    └── schema.sql

Các thí nghiệm còn dùng vài class tạm trong package com.example.demo.lab; xoá package đó khi làm xong.

Spring Boot quyết định bạn dùng database nào

Không có URL: H2 nhúng với tên sinh ngẫu nhiên

Có starter và H2 trên classpath, không có cấu hình database nào, application vẫn khởi động:

Bash
./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125
Text
2026-09-13T16:11:00.288+07:00  INFO 9389 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat initialized with port 8125 (http)
2026-09-13T16:11:00.296+07:00  INFO 9389 --- [demo] [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2026-09-13T16:11:00.296+07:00  INFO 9389 --- [demo] [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/11.0.24]
2026-09-13T16:11:00.310+07:00  INFO 9389 --- [demo] [           main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 377 ms
2026-09-13T16:11:00.502+07:00  INFO 9389 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8125 (http) with context path '/'
2026-09-13T16:11:00.508+07:00  INFO 9389 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 0.749 seconds (process running for 0.943)

Không dòng nào nhắc tới database. Dù vậy, Boot không hề bỏ qua nó. Các thí nghiệm trong bài là những class CommandLineRunner trong package lab, chỉ chạy khi một argument --lab=… gọi đúng tên, còn --spring.main.web-application-type=none khiến process thoát khi runner chạy xong. Một runner tra bean DataSource:

Java
HikariDataSource ds = (HikariDataSource) context.getBean(DataSource.class);
System.out.println("jdbcUrl         = " + ds.getJdbcUrl());
System.out.println("driverClassName = " + ds.getDriverClassName());
System.out.println("username        = " + ds.getUsername());
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab=ds --spring.main.web-application-type=none
Text
jdbcUrl         = jdbc:h2:mem:ff0b7eac-4eb7-4de3-861e-ca54cee094bf;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driverClassName = org.h2.Driver
username        = sa

Một HikariDataSource đã được cấu hình sẵn, trỏ tới một database H2 in-memory có tên là một UUID ngẫu nhiên, vì spring.datasource.generate-unique-name mặc định là true. DB_CLOSE_DELAY=-1 giữ database H2 in-memory tiếp tục tồn tại khi connection cuối cùng đóng lại, còn sa là user mặc định của H2. Log không có gì vì HikariCP mở pool một cách lazy, ở lần getConnection() đầu tiên, mà lúc đó chưa có gì xin connection.

Thứ đầu tiên xin connection sẽ đưa URL vào log. Bật H2 console, nói ở phần dưới, là một trường hợp như vậy: auto-configuration của nó mượn một connection trong lúc khởi động để báo database nằm ở đâu.

Text
2026-09-13T16:12:05.135+07:00  INFO 9574 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2026-09-13T16:12:05.206+07:00  INFO 9574 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:593e4a79-efef-4920-b929-b135e2402251 user=SA
2026-09-13T16:12:05.207+07:00  INFO 9574 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2026-09-13T16:12:05.214+07:00  INFO 9574 --- [demo] [           main] o.s.b.h.a.H2ConsoleAutoConfiguration     : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:593e4a79-efef-4920-b929-b135e2402251'

UUID khác với lần khởi động trước: mỗi lần khởi động là một database mới, trống.

Auto-configuration nào đưa ra quyết định

Condition report --debug từ bài 10 cho thấy lựa chọn này. Chạy không có web server để process thoát ngay sau khi khởi động:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --debug --spring.main.web-application-type=none

Trong phần Positive matches:

Text
   DataSourceConfiguration.Hikari matched:
      - @ConditionalOnClass found required class 'com.zaxxer.hikari.HikariDataSource' (OnClassCondition)
      - @ConditionalOnProperty (spring.datasource.type=com.zaxxer.hikari.HikariDataSource) matched (OnPropertyCondition)
      - @ConditionalOnMissingBean (types: javax.sql.DataSource; SearchStrategy: all) did not find any beans (OnBeanCondition)

Trong phần Negative matches:

Text
   DataSourceAutoConfiguration.EmbeddedDatabaseConfiguration:
      Did not match:
         - EmbeddedDataSource found supported pooled data source (DataSourceAutoConfiguration.EmbeddedDatabaseCondition)

DataSourceAutoConfiguration có hai cách cho bạn một embedded database: một DataSource nhúng không có pool, hoặc một pool trỏ tới URL của embedded database. HikariCP có trên classpath nhờ JDBC starter, nên nhánh có pool thắng, DataSourceConfiguration.Hikari tạo HikariDataSource, còn H2 chỉ cung cấp URL. Dòng @ConditionalOnMissingBean là cơ chế lùi lại quen thuộc: khai báo bean DataSource của riêng bạn thì Boot không tạo nữa. Cũng chính HikariDataSource này phục vụ mọi trường hợp khác trong bài; chỉ cấu hình của nó thay đổi.

Đặt spring.datasource.url thì driver class được suy ra

Đặt URL sẽ thay thế URL sinh ngẫu nhiên, và không cần đặt driver class kèm theo:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab=ds --spring.main.web-application-type=none --spring.datasource.url=jdbc:h2:mem:demo
Text
jdbcUrl         = jdbc:h2:mem:demo
driverClassName = org.h2.Driver
username        = sa

Boot khớp tiền tố jdbc:h2: với org.h2.Driver. Các lần chạy PostgreSQL và MySQL ở phần sau in ra "org.postgresql.Driver""com.mysql.cj.jdbc.Driver" trong bản dump cấu hình của HikariCP, cũng không hề đặt spring.datasource.driver-class-name. Hãy bỏ trống property đó, trừ khi bạn dùng một driver mà Boot không nhận ra từ URL.

Spring Boot quyết định: có URL thì dùng database đó và suy ra driver, không có URL mà có H2 trên classpath thì dùng H2 in-memory với tên sinh ngẫu nhiên, không có URL và không có embedded database thì lỗi khi khởi động; hai nhánh đầu trở thành HikariDataSource cùng các bean JdbcTemplate, NamedParameterJdbcTemplate và JdbcClient

Không có URL và không có embedded database: lỗi khi khởi động

Nhánh thứ ba là lỗi phần lớn mọi người gặp đầu tiên: không có URL và cũng không có embedded database để dựa vào. Ở đây nó được tạo ra bằng spring.datasource.embedded-database-connection=none, property ngăn Boot dùng H2 đang có trên classpath:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.main.web-application-type=none --spring.datasource.embedded-database-connection=none
Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.
 
Reason: Failed to determine a suitable driver class
 
 
Action:
 
Consider the following:
	If you want an embedded database (H2, HSQL or Derby), please put it on the classpath.
	If you have database settings to be loaded from a particular profile you may need to activate it (no profiles are currently active).

Gợi ý thứ hai là nguyên nhân thường gặp trong project thật: URL nằm trong một file profile, và profile đó chưa được kích hoạt.

H2 cho môi trường dev

H2 không cần cài đặt và khởi động trống trơn trong vài mili giây, nên là database dễ chịu cho việc phát triển ở máy local. Hai lựa chọn quyết định có thể tin nó tới đâu: dữ liệu được giữ ở đâu, và nó bắt chước database production sát tới mức nào.

In-memory hay file: dữ liệu có còn sau khi restart?

jdbc:h2:mem: giữ mọi thứ trong bộ nhớ của JVM; jdbc:h2:file: ghi xuống đĩa. Để thấy khác biệt, một runner ghi lại mỗi lần khởi động vào một table rồi đếm số row:

src/main/java/com/example/demo/lab/StartCounter.java
package com.example.demo.lab;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
 
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
 
@Component
@ConditionalOnProperty(name = "lab", havingValue = "starts")
public class StartCounter implements CommandLineRunner {
 
    private static final Logger log = LoggerFactory.getLogger(StartCounter.class);
 
    private final JdbcClient jdbcClient;
 
    public StartCounter(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }
 
    @Override
    public void run(String... args) {
        jdbcClient.sql("CREATE TABLE IF NOT EXISTS app_start (started_at TIMESTAMP)").update();
        jdbcClient.sql("INSERT INTO app_start (started_at) VALUES (CURRENT_TIMESTAMP)").update();
        Long starts = jdbcClient.sql("SELECT COUNT(*) FROM app_start").query(Long.class).single();
        log.info("This database has seen {} application start(s)", starts);
    }
}

JdbcClient có phần riêng bên dưới; ở đây nó chỉ chạy ba câu SQL. Application được khởi động hai lần với URL in-memory:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --lab=starts --spring.datasource.url=jdbc:h2:mem:demo
Text
2026-09-13T16:16:45.517+07:00  INFO 10738 --- [demo] [           main] com.example.demo.lab.StartCounter        : This database has seen 1 application start(s)
Text
2026-09-13T16:16:47.350+07:00  INFO 10781 --- [demo] [           main] com.example.demo.lab.StartCounter        : This database has seen 1 application start(s)

Rồi hai lần với URL dạng file:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --lab=starts --spring.datasource.url=jdbc:h2:file:./data/demo
Text
2026-09-13T16:16:49.435+07:00  INFO 10808 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection conn0: url=jdbc:h2:file:./data/demo user=
2026-09-13T16:16:49.461+07:00  INFO 10808 --- [demo] [           main] com.example.demo.lab.StartCounter        : This database has seen 1 application start(s)
Text
2026-09-13T16:16:51.072+07:00  INFO 10842 --- [demo] [           main] com.example.demo.lab.StartCounter        : This database has seen 2 application start(s)

Chỉ file mode đếm được tới 2. Database là file data/demo.mv.db, được tạo tương đối với thư mục khởi động application. Còn hai khác biệt nữa. URL dạng file ghi log user= trong khi URL in-memory ghi user=SA: Boot chỉ điền user sa cho URL in-memory. Và, như phần schema.sql giải thích, Boot không coi URL dạng file là embedded: khởi động với jdbc:h2:file:./data2/demo và cấu hình mặc định, application không chạy cả schema.sql lẫn data.sql.

Khi phát triển hằng ngày, database in-memory là lựa chọn mặc định đơn giản hơn, vì lần khởi động nào cũng bắt đầu từ cùng một schema và cùng dữ liệu mẫu. File mode hợp với dữ liệu bạn tự nhập tay và muốn giữ lại; nếu dùng, hãy thêm data/ vào .gitignore.

Chế độ tương thích PostgreSQL của H2 và những gì nó không che được

H2 có thể bắt chước database khác bằng MODE= trong URL. Production của series là PostgreSQL, nên URL cho dev dùng PostgreSQL mode cùng hai setting mà tài liệu H2 khuyên dùng kèm:

Text
jdbc:h2:mem:demo;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH

DATABASE_TO_LOWER=TRUE lưu identifier không đặt trong dấu nháy ở dạng chữ thường, giống PostgreSQL, còn DEFAULT_NULL_ORDERING=HIGH xếp NULL như giá trị lớn nhất, đúng vị trí PostgreSQL 18 đặt chúng trong ORDER BY tăng dần. Compatibility mode thay đổi cú pháp H2 chấp nhận; nó không biến H2 thành PostgreSQL. Cùng các câu SQL, chạy qua JdbcTemplate trên H2 ở mode mặc định, H2 với URL ở trên, và PostgreSQL 18.6:

Câu SQLH2 mode mặc địnhH2 với URL ở trênPostgreSQL 18.6
INSERT … ON CONFLICT DO NOTHINGlỗi cú pháp0 row, không lỗi0 row, không lỗi
INSERT … ON CONFLICT (sku) DO NOTHINGlỗi cú pháplỗi cú pháp0 row, không lỗi
INSERT … ON CONFLICT (sku) DO UPDATE SET stock = EXCLUDED.stocklỗi cú pháplỗi cú pháp1 row
INSERT … RETURNING idlỗi cú pháplỗi cú pháptrả về id mới
SELECT gen_random_uuid() IS NOT NULLkhông tìm thấy functiontruetrue
SELECT COUNT(*) FROM "item" với table tạo tên itemkhông tìm thấy tablechạy đượcchạy được
SELECT '5'::int + 1666
SELECT pg_sleep(1)không tìm thấy functionkhông tìm thấy functionchạy được
SELECT generate_series(1, 3)không tìm thấy functionkhông tìm thấy function3 row
SELECT DATE '2026-01-31' + INTERVAL '1 month'lỗilỗi2026-02-28 00:00:00.0

ON CONFLICT DO NOTHING không kèm column chạy được ở cả hai, và data.sql bên dưới dựa vào điều đó. Ghi rõ column xung đột, cách code PostgreSQL thường viết, là lỗi cú pháp trong H2; upsert và RETURNING cũng vậy:

Text
Syntax error in SQL statement "INSERT INTO item (sku, stock) VALUES ('A-1', 5) [*]ON CONFLICT (sku) DO NOTHING"; SQL statement:

pg_sleep cũng không tồn tại, nên bài test pool cạn connection gần cuối bài chạy trên PostgreSQL. SQL chạy được trên H2 vẫn phải chạy thử trên PostgreSQL trước khi phát hành; chạy test với PostgreSQL thật bằng Testcontainers thuộc về khoá Advanced.

H2 console trong Spring Boot 4

H2 có sẵn một web console để xem table và chạy SQL. Boot phục vụ nó khi spring.h2.console.enabled=true; property này mặc định là false, còn spring.h2.console.path mặc định là /h2-console. Trong Boot 4, các property đó đến từ module spring-boot-h2console, và chính module đó làm chúng có tác dụng. Khi module có trên classpath, như Initializr thêm vào:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.h2.console.enabled=true

Log khởi động có thêm dòng H2 console available at '/h2-console' như đã thấy ở trên, và console trả lời:

Bash
curl -i http://localhost:8125/h2-console
Text
HTTP/1.1 302
Location: http://localhost:8125/h2-console/
Content-Length: 0
Date: Sun, 13 Sep 2026 09:12:05 GMT
Bash
curl -i http://localhost:8125/h2-console/
Text
HTTP/1.1 200
Cache-Control: no-cache
Content-Type: text/html
Content-Length: 938
Date: Sun, 13 Sep 2026 09:12:05 GMT

Bỏ spring-boot-h2console khỏi build mà vẫn giữ cờ đó, không có gì phàn nàn — không cảnh báo, không dòng log — và cũng không có console nào. /h2-console trả về:

Text
HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked

còn /h2-console/ trả về body lỗi JSON của Boot với "status":404. Các tutorial viết cho Boot 3, khi auto-configuration của H2 console còn nằm trong spring-boot-autoconfigure, chỉ bảo bạn đặt property; trên Boot 4, thiếu module thì hỏng mà không báo gì.

⚠️ Không bao giờ bật H2 console trên production. Đó là một trang web mở connection tới database và chạy bất cứ SQL nào được gõ vào. Truy cập từ xa mặc định bị tắt (spring.h2.console.settings.web-allow-others=false), nhưng chỗ an toàn cho console là máy của chính developer, nên series này chỉ bật nó trong profile dev.

Chạy PostgreSQL và MySQL bằng Docker

PostgreSQL là database thật của series; MySQL là lựa chọn thay thế phổ biến và chỉ xuất hiện một lần, trong bài này. Cả hai chạy dưới dạng container Docker. Host port ở đây là 55425 và 33325 để không đụng với database đã cài sẵn trên máy; nếu các port mặc định còn trống, -p 5432:5432-p 3306:3306 là lựa chọn thông thường.

Khởi động container

PostgreSQL 18, kèm một database, một user và một password được tạo ở lần khởi động đầu tiên:

Bash
docker run -d --name sb-a25-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=demo -p 55425:5432 postgres:18
Bash
docker exec sb-a25-pg psql -U demo -d demo -c "select version();"
Text
                                                         version
--------------------------------------------------------------------------------------------------------------------------
 PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
(1 row)

MySQL 8.4, theo cùng cách:

Bash
docker run -d --name sb-a25-mysql -e MYSQL_ROOT_PASSWORD=rootsecret -e MYSQL_DATABASE=demo -e MYSQL_USER=demo -e MYSQL_PASSWORD=secret -p 33325:3306 mysql:8.4
Bash
docker exec sb-a25-mysql mysql -udemo -psecret -e "SELECT VERSION();" 2>/dev/null
Text
VERSION()
8.4.11

PostgreSQL nhận connection chưa tới một giây sau docker run; MySQL mất khoảng năm giây để khởi tạo. Không lệnh nào mount named volume, nên container mới luôn bắt đầu với database trống.

Thêm JDBC driver

Mỗi database cần driver của nó trên classpath. Dependency management của Boot cung cấp version: 42.7.13 cho PostgreSQL và 9.7.0 cho Connector/J:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'com.mysql:mysql-connector-j'
	runtimeOnly 'org.postgresql:postgresql'
}

Cấu hình connection cho PostgreSQL và MySQL

Ba property là đủ để application mở connection; định dạng URL là jdbc:postgresql://host:port/database cho PostgreSQL và jdbc:mysql://host:port/database cho MySQL:

src/main/resources/application.properties
spring.application.name=demo
spring.datasource.url=jdbc:postgresql://localhost:55425/demo
spring.datasource.username=demo
spring.datasource.password=secret

Khởi động với PostgreSQL, log cho thấy connection đầu tiên của pool:

Text
2026-09-13T16:18:06.166+07:00  INFO 11235 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2026-09-13T16:18:06.248+07:00  INFO 11235 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@1756f7cc
2026-09-13T16:18:06.249+07:00  INFO 11235 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.

Và với MySQL:

Text
2026-09-13T16:27:56.305+07:00  INFO 22425 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2026-09-13T16:27:56.458+07:00  INFO 22425 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@4dcbae55
2026-09-13T16:27:56.459+07:00  INFO 22425 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.

Khác với H2, connection của cả hai driver này không in ra URL; dòng HikariPool-1 - Start completed. mới là dòng cho biết database đã chấp nhận đăng nhập. Các pool này mở ngay trong lúc khởi động, trên thread main, vì project đã có sẵn file schema.sql của một phần phía sau: khi spring.sql.init.mode giữ giá trị mặc định, Boot mượn một connection để xác định database có phải embedded hay không. Với spring.sql.init.mode=never, dòng HikariPool-1 - Starting... chỉ xuất hiện ở request đầu tiên, trên thread nio-8125-exec-1.

Profile dev và prod: H2 ở máy local, PostgreSQL trên production

Giữ một database duy nhất trong application.properties nghĩa là phải sửa file mỗi khi đổi. Profile của bài 13 giải quyết chuyện đó: H2 trong profile dev, PostgreSQL trong profile prod, và password lấy từ environment variable thay vì từ một file nằm trong Git. File gốc chỉ giữ những gì mọi môi trường dùng chung:

src/main/resources/application.properties
spring.application.name=demo
src/main/resources/application-dev.properties
spring.datasource.url=jdbc:h2:mem:demo;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
spring.h2.console.enabled=true
src/main/resources/application-prod.properties
spring.datasource.url=jdbc:postgresql://localhost:55425/demo
spring.datasource.username=demo
spring.datasource.password=${DB_PASSWORD}

Mỗi môi trường chọn profile của mình khi khởi động cùng một file jar:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=dev
Bash
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prod
Text
2026-09-13T16:25:15.023+07:00  INFO 20819 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "prod"

${DB_PASSWORD} cần để ý ở một điểm. Bài 11 đã cho thấy một placeholder không resolve được sẽ ném Could not resolve placeholder. Khi bind vào spring.datasource.password, environment variable bị thiếu không gây ra lỗi như vậy: khởi động mà không có DB_PASSWORD, runner ds in chính placeholder ra làm password.

Text
jdbcUrl         = jdbc:postgresql://localhost:55425/demo
username        = demo
password        = ${DB_PASSWORD}

Vì thế sai sót này lộ ra dưới dạng PostgreSQL từ chối đăng nhập, FATAL: password authentication failed for user "demo", được trích đầy đủ ở phần lỗi connection.

Tạo table bằng schema.sql và data.sql

Table products phải tồn tại trước khi có gì query được nó. Cho tới khi bài 31 thay bằng migration Flyway, SQL initialization của Spring Boot làm việc này: lúc khởi động, nó chạy schema.sql rồi data.sql ở gốc classpath.

src/main/resources/schema.sql
CREATE TABLE IF NOT EXISTS products (
    id       BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name     VARCHAR(100)   NOT NULL,
    sku      VARCHAR(40)    NOT NULL,
    price    NUMERIC(10, 2) NOT NULL,
    stock    INT            NOT NULL,
    category VARCHAR(50)    NOT NULL,
    CONSTRAINT uk_product_sku UNIQUE (sku)
);
src/main/resources/data.sql
INSERT INTO products (name, sku, price, stock, category) VALUES
    ('Mechanical keyboard', 'KB-01', 89.90, 25, 'keyboards'),
    ('Wireless mouse', 'MS-01', 24.50, 3, 'mice'),
    ('USB-C hub', 'HUB-07', 39.00, 10, 'accessories')
ON CONFLICT DO NOTHING;

Các column bám theo domain record: id do database sinh ra, sku được giữ duy nhất bằng một constraint có tên, priceNUMERIC(10, 2) cho BigDecimal, còn category tạm thời là một chuỗi đơn giản; bài 28 biến nó thành một entity riêng. Cả hai file chạy nguyên văn trên H2 ở PostgreSQL mode và trên PostgreSQL 18, hai database series này dùng.

spring.sql.init.mode: embedded chạy trên H2, không chạy trên PostgreSQL

spring.sql.init.mode nhận embedded (mặc định), always hoặc never. Bật log DEBUG cho org.springframework.jdbc.datasource.init, profile dev cho thấy cả hai script đều chạy:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=dev --logging.level.org.springframework.jdbc.datasource.init=DEBUG
Text
2026-09-13T16:20:00.040+07:00 DEBUG 11584 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executing SQL script from URL [jar:nested:.../demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/schema.sql]
2026-09-13T16:20:00.045+07:00 DEBUG 11584 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executed SQL script from URL [jar:nested:.../demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/schema.sql] in 5 ms.
2026-09-13T16:20:00.046+07:00 DEBUG 11584 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executing SQL script from URL [jar:nested:.../demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/data.sql]
2026-09-13T16:20:00.047+07:00 DEBUG 11584 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : 3 returned as update count for SQL: INSERT INTO products (name, sku, price, stock, category) VALUES ('Mechanical keyboard', 'KB-01', 89.90, 25, 'keyboards'), ('Wireless mouse', 'MS-01', 24.50, 3, 'mice'), ('USB-C hub', 'HUB-07', 39.00, 10, 'accessories') ON CONFLICT DO NOTHING

Cùng cấu hình log đó với profile prod, trên database PostgreSQL còn trống:

Bash
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prod --logging.level.org.springframework.jdbc.datasource.init=DEBUG

Lần này không có dòng ScriptUtils nào, và PostgreSQL không có table:

Bash
docker exec sb-a25-pg psql -U demo -d demo -c '\dt'
Text
Did not find any tables.

Pool vẫn mở trong lúc khởi động, như các dòng log PostgreSQL ở trên, vì Boot mở connection để kiểm tra database có phải embedded không. Database H2 in-memory thì phải; PostgreSQL thì không, nên embedded bỏ qua cả hai script. URL H2 dạng file ở phần trước cũng bị bỏ qua như thế. always chạy chúng trên mọi database:

src/main/resources/application-prod.properties
spring.datasource.url=jdbc:postgresql://localhost:55425/demo
spring.datasource.username=demo
spring.datasource.password=${DB_PASSWORD}
spring.sql.init.mode=always 

Sau một lần khởi động với dòng đó:

Bash
docker exec sb-a25-pg psql -U demo -d demo -c '\dt'
Text
          List of tables
 Schema |   Name   | Type  | Owner
--------+----------+-------+-------
 public | products | table | demo
(1 row)
Bash
docker exec sb-a25-pg psql -U demo -d demo -c 'SELECT id, sku, stock FROM products ORDER BY id;'
Text
 id |  sku   | stock
----+--------+-------
  1 | KB-01  |    25
  2 | MS-01  |     3
  3 | HUB-07 |    10
(3 rows)

Script chạy ở mỗi lần khởi động phải chạy lại được

always nghĩa là mỗi lần khởi động, nên ở lần thứ hai cả hai file chạy trên một database đã có table và các row. IF NOT EXISTS biến lệnh CREATE TABLE lần hai thành một cảnh báo được log ghi lại rồi bỏ qua:

Text
2026-09-13T16:19:58.399+07:00 DEBUG 11556 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : SQLWarning ignored: SQL state '42P07', error code '0', message [relation "products" already exists, skipping]

Câu insert mới là vấn đề. Trước khi thêm ON CONFLICT DO NOTHING vào data.sql, lần khởi động thứ hai làm application dừng hẳn:

Text
Caused by: org.springframework.jdbc.datasource.init.ScriptStatementFailedException: Failed to execute SQL script statement #1 of URL [jar:nested:.../demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/data.sql]: INSERT INTO products (name, sku, price, stock, category) VALUES ('Mechanical keyboard', 'KB-01', 89.90, 25, 'keyboards'), ('Wireless mouse', 'MS-01', 24.50, 3, 'mice'), ('USB-C hub', 'HUB-07', 39.00, 10, 'accessories')
Caused by: org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "uk_product_sku"
  Detail: Key (sku)=(KB-01) already exists.

Có mệnh đề đó, lần khởi động thứ hai ghi log câu insert như một lệnh không làm gì rồi chạy tiếp:

Text
2026-09-13T16:19:58.404+07:00 DEBUG 11556 --- [demo] [           main] o.s.jdbc.datasource.init.ScriptUtils     : 0 returned as update count for SQL: INSERT INTO products (name, sku, price, stock, category) VALUES ('Mechanical keyboard', 'KB-01', 89.90, 25, 'keyboards'), ('Wireless mouse', 'MS-01', 24.50, 3, 'mice'), ('USB-C hub', 'HUB-07', 39.00, 10, 'accessories') ON CONFLICT DO NOTHING

Theo dõi script nào đã chạy rồi chính là việc của một công cụ migration, và bài 31 thay schema.sql cùng data.sql bằng Flyway.

JdbcClient cơ bản

JdbcClient, có từ Spring Framework 6.1, là API series này dùng cho SQL. Các ví dụ map row vào domain record của danh mục từ bài 21, record này có thêm component category trong chương này:

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record Product(Long id, String name, String sku, BigDecimal price, int stock) { 
public record Product(Long id, String name, String sku, BigDecimal price, int stock, String category) { 
 
    public Product withId(Long newId) {
        return new Product(newId, name, sku, price, stock); 
        return new Product(newId, name, sku, price, stock, category); 
    }
 
    public Product withStock(int newStock) {
        return new Product(id, name, sku, price, newStock); 
        return new Product(id, name, sku, price, newStock, category); 
    }
}

Một runner JdbcClientLab trong package lab chạy mọi ví dụ của phần này, một lần với profile dev trên H2 và một lần với prod trên PostgreSQL; trước lần thứ hai, table được drop để cả hai database bắt đầu từ cùng ba row:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev --lab=jdbc --spring.main.web-application-type=none
Bash
docker exec sb-a25-pg psql -U demo -d demo -c "DROP TABLE IF EXISTS products;"
Bash
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --lab=jdbc --spring.main.web-application-type=none

Output giống nhau trên cả hai database, trừ những chỗ được tách riêng.

JDBC starter tạo những bean nào

Runner ds cũng liệt kê các bean JDBC theo type:

Java
for (Class<?> type : List.of(DataSource.class, JdbcTemplate.class, NamedParameterJdbcTemplate.class,
        JdbcClient.class, PlatformTransactionManager.class)) {
    for (String name : context.getBeanNamesForType(type)) {
        System.out.printf("%-28s %-28s %s%n", type.getSimpleName(), name, context.getBean(name).getClass().getName());
    }
}
Text
DataSource                   dataSource                   com.zaxxer.hikari.HikariDataSource
JdbcTemplate                 jdbcTemplate                 org.springframework.jdbc.core.JdbcTemplate
NamedParameterJdbcTemplate   namedParameterJdbcTemplate   org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
JdbcClient                   jdbcClient                   org.springframework.jdbc.core.simple.DefaultJdbcClient
PlatformTransactionManager   transactionManager           org.springframework.jdbc.support.JdbcTransactionManager

JdbcClientAutoConfiguration dựng jdbcClient trên NamedParameterJdbcTemplate duy nhất; mục của nó trong report --debug là:

Text
   JdbcClientAutoConfiguration matched:
      - @ConditionalOnSingleCandidate (types: org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; SearchStrategy: all) found a single bean 'namedParameterJdbcTemplate'; @ConditionalOnMissingBean (types: org.springframework.jdbc.core.simple.JdbcClient; SearchStrategy: all) did not find any beans (OnBeanCondition)

transactionManager là thứ @Transactional sẽ dùng ở bài 30. Inject JdbcClient qua constructor như mọi bean khác.

Query các row vào record

Java
List<Product> cheap = jdbcClient.sql("""
        SELECT id, name, sku, price, stock, category
        FROM products
        WHERE price < :maxPrice
        ORDER BY price
        """)
        .param("maxPrice", new BigDecimal("50.00"))
        .query(Product.class)
        .list();
cheap.forEach(System.out::println);
Text
Product[id=2, name=Wireless mouse, sku=MS-01, price=24.50, stock=3, category=mice]
Product[id=3, name=USB-C hub, sku=HUB-07, price=39.00, stock=10, category=accessories]

sql() nhận câu SQL, param() bind :maxPrice, query(Product.class) chọn cách mỗi row trở thành object, và list() chạy query. Với một type như Product, DefaultJdbcClient dùng SimplePropertyRowMapper, đưa mỗi column vào record component cùng tên; với type đơn giản như String hay Long, nó dùng SingleColumnRowMapper và đọc column duy nhất, đó là cách query(Long.class).single() đọc một COUNT(*).

Đường đi của một query JdbcClient: SQL với named parameter, PreparedStatement có dấu hỏi trên một connection mượn từ pool, các row trong ResultSet, SimplePropertyRowMapper, các record Product, và nhánh SQLException được dịch thành các subtype của DataAccessException

Tên không cần khớp từng ký tự. Column dạng snake_case vẫn tới được component dạng camelCase:

src/main/java/com/example/demo/lab/StockLevel.java
package com.example.demo.lab;
 
public record StockLevel(String sku, int unitsInStock) {
}
Java
StockLevel level = jdbcClient.sql("SELECT sku, stock AS units_in_stock FROM products WHERE sku = :sku")
        .param("sku", "MS-01")
        .query(StockLevel.class)
        .single();
System.out.println(level);
Text
StockLevel[sku=MS-01, unitsInStock=3]

Chiều ngược lại thì không bỏ qua chỗ thiếu. Mỗi record component cần một column: chỉ select id, name, sku vào Product thất bại trên cả hai database, với message đổ lỗi cho SQL dù SQL hoàn toàn hợp lệ:

Text
org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback; bad SQL grammar [SELECT id, name, sku FROM products WHERE id = ?]

Cause mới nói đúng vấn đề: Column "price" not found [42122-240] từ H2, và The column name price was not found in this ResultSet. từ PostgreSQL.

optional() và single()

Java
Optional<Product> found = jdbcClient.sql("SELECT id, name, sku, price, stock, category FROM products WHERE id = :id")
        .param("id", 1L)
        .query(Product.class)
        .optional();
Optional<Product> missing = jdbcClient.sql("SELECT id, name, sku, price, stock, category FROM products WHERE id = :id")
        .param("id", 999L)
        .query(Product.class)
        .optional();
System.out.println("id 1:   " + found);
System.out.println("id 999: " + missing);
Text
id 1:   Optional[Product[id=1, name=Mechanical keyboard, sku=KB-01, price=89.90, stock=25, category=keyboards]]
id 999: Optional.empty

single() dành cho row bắt buộc phải có. Cùng query cho id 999 nhưng dùng single() thì ném:

Text
org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0

Dùng optional() khi không có row là câu trả lời bình thường, như trong findById; single() khi không có row là bug, như với một COUNT(*); và list() cho số row bất kỳ.

Named parameter, positional parameter và paramSource

Placeholder :name là named parameter. Placeholder ? là positional parameter và nhận các lời gọi param(value) theo thứ tự:

Java
List<String> names = jdbcClient.sql("SELECT name FROM products WHERE price BETWEEN ? AND ? ORDER BY price")
        .param(new BigDecimal("20.00"))
        .param(new BigDecimal("50.00"))
        .query(String.class)
        .list();
System.out.println(names);
Text
[Wireless mouse, USB-C hub]

Named parameter không bị ảnh hưởng khi đổi thứ tự và có thể xuất hiện hai lần trong một câu SQL, nên phần còn lại của bài dùng chúng. Cách nào thì driver cũng nhận placeholder ? và các giá trị riêng — SQL được trích trong exception duplicate key bên dưới là VALUES (?, ?, ?, ?, ?) — nên giá trị không bao giờ bị dán thẳng vào chuỗi SQL.

paramSource(object) điền mọi named parameter từ các property của object, với record thì là các accessor method. Các câu insert bên dưới dùng câu SQL này:

Java
private static final String INSERT = """
        INSERT INTO products (name, sku, price, stock, category)
        VALUES (:name, :sku, :price, :stock, :category)
        """;

update() và số row bị ảnh hưởng

update() chạy các câu INSERT, UPDATEDELETE rồi trả về số row đã thay đổi:

Java
String reserve = "UPDATE products SET stock = stock - :quantity WHERE sku = :sku AND stock >= :quantity";
int first = jdbcClient.sql(reserve).param("quantity", 2).param("sku", "MS-01").update();
int second = jdbcClient.sql(reserve).param("quantity", 2).param("sku", "MS-01").update();
System.out.println("first: " + first + ", second: " + second);
Text
first: 1, second: 0

Con chuột có 3 cái trong kho. Lần update đầu đưa về 1; lần thứ hai không khớp row nào vì stock >= 2 không còn đúng. 0 là cách SQL báo một điều kiện không thoả, nên hãy kiểm tra con số thay vì mặc định rằng row đã đổi. :quantity xuất hiện hai lần và chỉ bind một lần.

Mỗi câu SQL này tự commit. Connection mà runner mượn báo autoCommittrue, và một UPDATE theo sau bởi một INSERT lỗi vẫn giữ nguyên kết quả update; trong một lần chạy riêng trên H2, bên trong transaction mở bằng TransactionTemplate, cùng phép kiểm tra in ra false và một exception được ném ra đã rollback lệnh update. Bài 30 nói về @Transactional.

Lấy generated key bằng KeyHolder trên H2 và PostgreSQL

Database sinh ra id, và câu insert phải trả nó về. Một KeyHolder nhận các generated key:

Java
Product webcam = new Product(null, "Webcam", "CAM-01", new BigDecimal("59.00"), 8, "video");
KeyHolder keyHolder = new GeneratedKeyHolder();
int inserted = jdbcClient.sql(INSERT)
        .paramSource(webcam)
        .update(keyHolder);
System.out.println("rows inserted: " + inserted);
System.out.println("getKeys(): " + keyHolder.getKeys());
try {
    Number key = keyHolder.getKey();
    System.out.println("getKey(): " + key + " (" + key.getClass().getName() + ")");
}
catch (DataAccessException e) {
    System.out.println(e.getClass().getName() + ": " + e.getMessage());
}

Trên H2:

Text
rows inserted: 1
getKeys(): {id=4}
getKey(): 4 (java.lang.Long)

Trên PostgreSQL:

Text
rows inserted: 1
getKeys(): {id=4, name=Webcam, sku=CAM-01, price=59.00, stock=8, category=video}
org.springframework.dao.InvalidDataAccessApiUsageException: The getKey method should only be used when a single key is returned. The current key entry contains multiple keys: [{id=4, name=Webcam, sku=CAM-01, price=59.00, stock=8, category=video}]

update(keyHolder) xin driver các generated key mà không nói chúng nằm ở column nào. H2 trả về column identity. Driver PostgreSQL trả về mọi column của row mới, và getKey() từ chối một map có nhiều hơn một phần tử, nên code chạy được trên H2 lại ném exception trên production. Hãy chỉ rõ column chứa key:

Java
Product headset = new Product(null, "Headset", "HS-01", new BigDecimal("45.00"), 12, "audio");
KeyHolder idHolder = new GeneratedKeyHolder();
jdbcClient.sql(INSERT)
        .paramSource(headset)
        .update(idHolder, "id");
System.out.println("getKeys(): " + idHolder.getKeys());
Long id = idHolder.getKeyAs(Long.class);
System.out.println("getKeyAs(Long.class): " + id);

Trên cả hai database:

Text
getKeys(): {id=5}
getKeyAs(Long.class): 5

getKeyAs(Long.class) còn giúp bạn khỏi phải cast từ Number.

RowMapper tự viết

Khi column và component không khớp tên, hoặc giá trị cần chuyển đổi dọc đường, hãy tự viết phần mapping. RowMapper là một function từ row hiện tại của ResultSet sang một object:

Java
RowMapper<Product> productRowMapper = (rs, rowNum) -> new Product(
        rs.getLong("id"),
        rs.getString("name"),
        rs.getString("sku"),
        rs.getBigDecimal("price"),
        rs.getInt("stock"),
        rs.getString("category"));
jdbcClient.sql("SELECT * FROM products WHERE category = :category")
        .param("category", "mice")
        .query(productRowMapper)
        .list()
        .forEach(System.out::println);
Text
Product[id=2, name=Wireless mouse, sku=MS-01, price=24.50, stock=1, category=mice]

Stock là 1 vì ví dụ update() đã chạy trước đó trong cùng runner.

Exception translation: từ SQLException sang DataAccessException

JDBC báo mọi lỗi bằng một checked SQLException mang error code của từng vendor và một SQLState. Spring dịch nó thành unchecked exception trong hệ DataAccessException, để code bắt lỗi theo ý nghĩa thay vì theo mã của vendor. Lab insert KB-01 lần thứ hai rồi in ra thứ nhận được:

Java
try {
    jdbcClient.sql(INSERT)
            .paramSource(new Product(null, "Compact keyboard", "KB-01", new BigDecimal("59.00"), 5, "keyboards"))
            .update();
}
catch (DataAccessException e) {
    System.out.println(e.getClass().getName() + ": " + e.getMessage());
    for (Class<?> c = e.getClass(); c != RuntimeException.class; c = c.getSuperclass()) {
        System.out.println("  is a " + c.getName());
    }
    Throwable cause = e.getMostSpecificCause();
    System.out.println("  cause: " + cause.getClass().getName());
    if (cause instanceof SQLException sql) {
        System.out.println("  SQLState " + sql.getSQLState() + ", error code " + sql.getErrorCode());
    }
}

Trên H2:

Text
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO products (name, sku, price, stock, category)
VALUES (?, ?, ?, ?, ?)
]; Unique index or primary key violation: "public.uk_product_sku INDEX public.uk_product_sku_INDEX_E ON public.products(sku NULLS LAST) VALUES ( /* 1 */ 'KB-01' )"; SQL statement:
INSERT INTO products (name, sku, price, stock, category)
VALUES (?, ?, ?, ?, ?) [23505-240]
  is a org.springframework.dao.DuplicateKeyException
  is a org.springframework.dao.DataIntegrityViolationException
  is a org.springframework.dao.NonTransientDataAccessException
  is a org.springframework.dao.DataAccessException
  is a org.springframework.core.NestedRuntimeException
  cause: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException
  SQLState 23505, error code 23505

Trên PostgreSQL:

Text
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO products (name, sku, price, stock, category)
VALUES (?, ?, ?, ?, ?)
]; ERROR: duplicate key value violates unique constraint "uk_product_sku"
  Detail: Key (sku)=(KB-01) already exists.
  is a org.springframework.dao.DuplicateKeyException
  is a org.springframework.dao.DataIntegrityViolationException
  is a org.springframework.dao.NonTransientDataAccessException
  is a org.springframework.dao.DataAccessException
  is a org.springframework.core.NestedRuntimeException
  cause: org.postgresql.util.PSQLException
  SQLState 23505, error code 0

Hai driver cho ra exception class, message và error code khác nhau — H2 báo error code 23505, PostgreSQL báo 0 — nhưng cùng một type của Spring: DuplicateKeyException, subclass của DataIntegrityViolationException. Translator đứng sau jdbcTemplate.getExceptionTranslator()SQLExceptionSubclassTranslator, và cả hai database đều gửi SQLState 23505 cho vi phạm unique. Các lỗi khác trong bài cũng được dịch như vậy: relation "products" does not exist tới dưới dạng BadSqlGrammarException, còn connection bị từ chối thành CannotGetJdbcConnectionException.

Map DuplicateKeyException thành 409 Conflict

ProductService.create đã kiểm tra existsBySku và ném DuplicateSkuException, được advice map thành 409. Unique constraint lấp khoảng hở mà bài 21 còn để ngỏ: hai request có thể cùng qua bước kiểm tra trước khi request nào kịp insert, rồi database từ chối request thứ hai. Lời từ chối đó tới dưới dạng DuplicateKeyException, và nếu không có handler thì client nhận 500. Advice từ bài 21 thêm một method:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
package com.example.demo.common;
 
import java.util.stream.Collectors;
 
import com.example.demo.product.DuplicateSkuException;
import com.example.demo.product.InsufficientStockException;
import com.example.demo.product.ProductNotFoundException;
 
import org.springframework.dao.DuplicateKeyException; 
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
 
@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(ProductNotFoundException.class)
    public ProblemDetail notFound(RuntimeException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }
 
    @ExceptionHandler({DuplicateSkuException.class, InsufficientStockException.class})
    public ProblemDetail conflict(RuntimeException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
    }
 
    @ExceptionHandler(DuplicateKeyException.class) 
    public ProblemDetail duplicateKey(DuplicateKeyException e) { 
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "A row with the same unique value already exists"); 
    } 
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail invalid(MethodArgumentNotValidException e) {
        String detail = e.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + " " + error.getDefaultMessage())
                .sorted()
                .collect(Collectors.joining(", "));
        return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, detail);
    }
}

Detail được viết chung chung có chủ ý: message của exception chứa câu SQL và tên constraint, những thứ client không cần biết. Cuối phần JdbcProductRepository cho thấy handler này hoạt động.

JdbcTemplate vs NamedParameterJdbcTemplate vs JdbcClient

Cả ba bean chạy SQL qua cùng một DataSource; chúng khác nhau ở cách truyền parameter và mapper. Lab chạy cùng một query qua từng API trên cùng dữ liệu:

JdbcTemplateNamedParameterJdbcTemplateJdbcClient
Placeholderpositional ?named :namecả hai
Parametervarargs sau mappermột Map hoặc SqlParameterSourcecác lời gọi param(…) hoặc paramSource(object)
QueryjdbcTemplate.query("… WHERE price < ?", new DataClassRowMapper<>(Product.class), new BigDecimal("50.00"))namedParameterJdbcTemplate.query("… WHERE price < :maxPrice", Map.of("maxPrice", new BigDecimal("50.00")), new DataClassRowMapper<>(Product.class))jdbcClient.sql("… WHERE price < :maxPrice").param("maxPrice", new BigDecimal("50.00")).query(Product.class).list()
Row mappingRowMapper bạn truyền vàoRowMapper bạn truyền vàodo query(Class) chọn, hoặc RowMapper bạn truyền vào
Bean của BootjdbcTemplatenamedParameterJdbcTemplatejdbcClient

Cả ba trả về cùng một list: JdbcTemplate 3, NamedParameterJdbcTemplate 3, JdbcClient 3, equal: true. JdbcClient là một fluent facade chứ không phải engine mới: DefaultJdbcClient giữ một NamedParameterJdbcOperations cùng JdbcOperations phía sau nó và chuyển lời gọi sang hai thứ đó. Code JdbcTemplate có sẵn vẫn chạy nguyên vẹn bên cạnh. Spring Data JDBC, thứ dựng repository trên tầng này, không nằm trong series.

Viết JdbcProductRepository

Bài 21 đặt phần lưu trữ sau một interface để database có thể thay map in-memory mà không đụng tới service. Interface đó, giữ nguyên:

src/main/java/com/example/demo/product/ProductRepository.java
package com.example.demo.product;
 
import java.util.List;
import java.util.Optional;
 
public interface ProductRepository {
 
    List<Product> findAll();
 
    Optional<Product> findById(Long id);
 
    boolean existsBySku(String sku);
 
    Product save(Product product);
}

Implementation dùng JDBC không cần gì ngoài các lời gọi ở trên:

src/main/java/com/example/demo/product/JdbcProductRepository.java
package com.example.demo.product;
 
import java.util.List;
import java.util.Optional;
 
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
 
@Repository
class JdbcProductRepository implements ProductRepository {
 
    private final JdbcClient jdbcClient;
 
    JdbcProductRepository(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }
 
    @Override
    public List<Product> findAll() {
        return jdbcClient.sql("SELECT id, name, sku, price, stock, category FROM products ORDER BY id")
                .query(Product.class)
                .list();
    }
 
    @Override
    public Optional<Product> findById(Long id) {
        return jdbcClient.sql("SELECT id, name, sku, price, stock, category FROM products WHERE id = :id")
                .param("id", id)
                .query(Product.class)
                .optional();
    }
 
    @Override
    public boolean existsBySku(String sku) {
        return jdbcClient.sql("SELECT COUNT(*) FROM products WHERE sku = :sku")
                .param("sku", sku)
                .query(Long.class)
                .single() > 0;
    }
 
    @Override
    public Product save(Product product) {
        if (product.id() == null) {
            KeyHolder keyHolder = new GeneratedKeyHolder();
            jdbcClient.sql("""
                    INSERT INTO products (name, sku, price, stock, category)
                    VALUES (:name, :sku, :price, :stock, :category)
                    """)
                    .paramSource(product)
                    .update(keyHolder, "id");
            return product.withId(keyHolder.getKeyAs(Long.class));
        }
        jdbcClient.sql("""
                UPDATE products
                SET name = :name, sku = :sku, price = :price, stock = :stock, category = :category
                WHERE id = :id
                """)
                .paramSource(product)
                .update();
        return product;
    }
}

Class và constructor để package-private, theo bài 21: không có gì ngoài feature product cần tới chúng. save giữ hợp đồng của bản in-memory — insert khi id là null và trả về product kèm id mới, còn lại thì update — và chỉ rõ column id cho key, nên chạy được trên cả PostgreSQL lẫn H2.

InMemoryProductRepository phải bỏ đi. Khi cả hai class cùng có @Repository, quá trình khởi động dừng ở ProductService:

Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
Parameter 0 of constructor in com.example.demo.product.ProductService required a single bean, but 2 were found:
	- inMemoryProductRepository: defined in URL [jar:nested:.../demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example/demo/product/InMemoryProductRepository.class]
	- jdbcProductRepository: defined in URL [jar:nested:.../demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example/demo/product/JdbcProductRepository.class]

Xoá nó đi, hoặc giữ lại mà không có @Repository để dùng trong unit test thuần.

ProductService không thay đổi

src/main/java/com/example/demo/product/ProductService.java
package com.example.demo.product;
 
import java.util.List;
 
import org.springframework.stereotype.Service;
 
@Service
public class ProductService {
 
    private final ProductRepository repository;
 
    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }
 
    public List<Product> findAll() {
        return repository.findAll();
    }
 
    public Product findById(Long id) {
        return repository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
    }
 
    public Product create(Product product) {
        if (repository.existsBySku(product.sku())) {
            throw new DuplicateSkuException(product.sku());
        }
        return repository.save(product);
    }
 
    public Product reserveStock(Long id, int quantity) {
        Product product = findById(id);
        if (product.stock() < quantity) {
            throw new InsufficientStockException(product.sku(), product.stock(), quantity);
        }
        return repository.save(product.withStock(product.stock() - quantity));
    }
}

Không dòng nào khác so với bài 21. Một runner lab trên profile dev gọi reserveStock hai lần cho con chuột, đi qua nhánh UPDATE của save:

Text
before:  Product[id=2, name=Wireless mouse, sku=MS-01, price=24.50, stock=3, category=mice]
reserve: Product[id=2, name=Wireless mouse, sku=MS-01, price=24.50, stock=1, category=mice]
again:   InsufficientStockException: Only 1 of MS-01 in stock, 2 requested
after:   Product[id=2, name=Wireless mouse, sku=MS-01, price=24.50, stock=1, category=mice]

Feature order của bài 21 không có mặt trong bài này; nó gọi reserveStock theo đúng cách đó.

Tầng web với field category

Các exception là của bài 21:

src/main/java/com/example/demo/product/ProductNotFoundException.java
package com.example.demo.product;
 
public class ProductNotFoundException extends RuntimeException {
 
    public ProductNotFoundException(Long id) {
        super("Product " + id + " not found");
    }
}
src/main/java/com/example/demo/product/DuplicateSkuException.java
package com.example.demo.product;
 
public class DuplicateSkuException extends RuntimeException {
 
    public DuplicateSkuException(String sku) {
        super("SKU " + sku + " already exists");
    }
}
src/main/java/com/example/demo/product/InsufficientStockException.java
package com.example.demo.product;
 
public class InsufficientStockException extends RuntimeException {
 
    public InsufficientStockException(String sku, int available, int requested) {
        super("Only " + available + " of " + sku + " in stock, " + requested + " requested");
    }
}

DTO và mapper mang theo category mới:

src/main/java/com/example/demo/product/CreateProductRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
 
public record CreateProductRequest(
        @NotBlank String name,
        @NotBlank String sku,
        @NotNull @Positive BigDecimal price,
        @NotNull @PositiveOrZero Integer stock) { 
        @NotNull @PositiveOrZero Integer stock, 
        @NotBlank String category) { 
}
src/main/java/com/example/demo/product/ProductResponse.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock) { 
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, String category) { 
}
src/main/java/com/example/demo/product/ProductMapper.java
package com.example.demo.product;
 
import org.springframework.stereotype.Component;
 
@Component
public class ProductMapper {
 
    public Product toProduct(CreateProductRequest request) {
        return new Product(null, request.name(), request.sku(), request.price(), request.stock()); 
        return new Product(null, request.name(), request.sku(), request.price(), request.stock(), request.category()); 
    }
 
    public ProductResponse toResponse(Product product) {
        return new ProductResponse(product.id(), product.name(), product.sku(), product.price(), product.stock()); 
        return new ProductResponse(product.id(), product.name(), product.sku(), product.price(), product.stock(), 
                product.category()); 
    }
}

Controller là của bài 21, không đổi:

src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import java.net.URI;
import java.util.List;
 
import jakarta.validation.Valid;
 
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductService service;
    private final ProductMapper mapper;
 
    public ProductController(ProductService service, ProductMapper mapper) {
        this.service = service;
        this.mapper = mapper;
    }
 
    @GetMapping
    public List<ProductResponse> findAll() {
        return service.findAll().stream()
                .map(mapper::toResponse)
                .toList();
    }
 
    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable Long id) {
        return mapper.toResponse(service.findById(id));
    }
 
    @PostMapping
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
        Product product = service.create(mapper.toProduct(request));
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(product.id())
                .toUri();
        return ResponseEntity.created(location).body(mapper.toResponse(product));
    }
}

Chạy API trên PostgreSQL

Table vừa được tạo và nạp dữ liệu mẫu bởi always:

Bash
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prod
Bash
curl -i http://localhost:8125/api/products
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 283
Date: Sun, 13 Sep 2026 09:25:15 GMT
 
[{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":25,"category":"keyboards"},{"id":2,"name":"Wireless mouse","sku":"MS-01","price":24.50,"stock":3,"category":"mice"},{"id":3,"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":10,"category":"accessories"}]
Bash
curl -i -H 'Content-Type: application/json' -d '{"name":"Webcam","sku":"CAM-01","price":59.00,"stock":8,"category":"video"}' http://localhost:8125/api/products
Text
HTTP/1.1 201
Location: http://localhost:8125/api/products/4
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:15 GMT
 
{"id":4,"name":"Webcam","sku":"CAM-01","price":59.00,"stock":8,"category":"video"}
Bash
curl -i http://localhost:8125/api/products/4
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 82
Date: Sun, 13 Sep 2026 09:25:15 GMT
 
{"id":4,"name":"Webcam","sku":"CAM-01","price":59.00,"stock":8,"category":"video"}
Bash
curl -i -H 'Content-Type: application/json' -d '{"name":"Compact keyboard","sku":"KB-01","price":59.00,"stock":5,"category":"keyboards"}' http://localhost:8125/api/products
Text
HTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:15 GMT
 
{"detail":"SKU KB-01 already exists","instance":"/api/products","status":409,"title":"Conflict"}
Bash
curl -i http://localhost:8125/api/products/99
Text
HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:15 GMT
 
{"detail":"Product 99 not found","instance":"/api/products/99","status":404,"title":"Not Found"}
Bash
curl -i -H 'Content-Type: application/json' -d '{"name":"","sku":"X-1","price":1.00,"stock":1,"category":"misc"}' http://localhost:8125/api/products
Text
HTTP/1.1 422
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:15 GMT
 
{"detail":"name must not be blank","instance":"/api/products","status":422,"title":"Unprocessable Content"}

Status vẫn là của Chương 3: 201 kèm Location cho sản phẩm mới, 409 cho SKU trùng, 404 cho id không tồn tại, 422 cho body vi phạm rule. Chiếc webcam nằm trong PostgreSQL chứ không phải trong một map:

Bash
docker exec sb-a25-pg psql -U demo -d demo -c "SELECT id, name, sku, price, stock, category FROM products ORDER BY id;"
Text
 id |        name         |  sku   | price | stock |  category
----+---------------------+--------+-------+-------+-------------
  1 | Mechanical keyboard | KB-01  | 89.90 |    25 | keyboards
  2 | Wireless mouse      | MS-01  | 24.50 |     3 | mice
  3 | USB-C hub           | HUB-07 | 39.00 |    10 | accessories
  4 | Webcam              | CAM-01 | 59.00 |     8 | video
(4 rows)

Cuối cùng là tình huống race mà constraint sinh ra để xử lý. Mười POST đồng thời với cùng một SKU mới, bỏ idinstance khỏi body để các câu trả lời giống nhau gom thành một nhóm:

Bash
seq 1 10 | xargs -P 10 -I{} curl -s -w ' -> %{http_code}\n' -H 'Content-Type: application/json' \
  -d '{"name":"Dock","sku":"DOCK-01","price":129.00,"stock":4,"category":"accessories"}' http://localhost:8125/api/products \
  | sed 's/"instance":"[^"]*",//; s/"id":[0-9]*,//' | sort | uniq -c
Text
   2 {"detail":"A row with the same unique value already exists","status":409,"title":"Conflict"} -> 409
   7 {"detail":"SKU DOCK-01 already exists","status":409,"title":"Conflict"} -> 409
   1 {"name":"Dock","sku":"DOCK-01","price":129.00,"stock":4,"category":"accessories"} -> 201

Một lệnh insert thắng. Bảy request thấy row mới trong existsBySku và nhận DuplicateSkuException. Hai request qua được bước kiểm tra khi lệnh insert thắng cuộc còn đang chạy, đi tới database, và bị uk_product_sku chặn lại; handler mới biến DuplicateKeyException đó thành 409 thay vì 500. Cách chia tuỳ vào thời điểm — thêm hai lượt với SKU khác chia thành 1/7/2 và 1/9/0 — và chỉ có constraint mới giữ kết quả đúng ở mọi lần.

Khi database dừng hoặc sai password

Một application JDBC có thể khởi động mà không hề nói chuyện với database, nên lỗi nào lộ ra lúc khởi động tuỳ vào thứ gì chạm tới connection trong lúc đó. Trong project này, thứ đó là SQL initialization với spring.sql.init.mode=always. Cả hai lỗi dưới đây được chạy hai lần: với profile prod như đã cấu hình, và thêm --spring.sql.init.mode=never.

Database bị dừng

Bash
docker stop sb-a25-pg
Bash
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prod

Application không khởi động được. Khoảng một giây sau HikariPool-1 - Starting..., chuỗi exception kết thúc bằng:

Text
Caused by: org.springframework.jdbc.datasource.init.UncategorizedScriptException: Failed to execute database script
Caused by: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection
Caused by: org.postgresql.util.PSQLException: Connection to localhost:55425 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.

Với --spring.sql.init.mode=never, cùng file jar ghi log Started DemoApplication in 0.647 seconds, và lỗi database chờ tới request đầu tiên:

Bash
curl -s -i -w '\ntime %{time_total}s\n' http://localhost:8125/api/products
Text
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:27:43 GMT
Connection: close
 
{"timestamp":"2026-09-13T09:27:43.372Z","status":500,"error":"Internal Server Error","path":"/api/products"}
time 1.118023s
Text
2026-09-13T16:27:42.325+07:00  INFO 22311 --- [demo] [nio-8125-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2026-09-13T16:27:43.354+07:00 ERROR 22311 --- [demo] [nio-8125-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection] with root cause
java.net.ConnectException: Connection refused

Request thứ hai lại ghi log HikariPool-1 - Starting..., trên nio-8125-exec-2, và thất bại theo cùng cách sau 1.01 s: pool khởi tạo thất bại sẽ được thử lại ở lần getConnection() kế tiếp. Stack trace đi qua HikariPool.checkFailFast, bước kiểm tra lúc khởi tạo mà initializationFailTimeout điều khiển.

Sai password

Bash
DB_PASSWORD=wrong java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prod
Text
Caused by: org.springframework.jdbc.datasource.init.UncategorizedScriptException: Failed to execute database script
Caused by: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection
Caused by: org.postgresql.util.PSQLException: FATAL: password authentication failed for user "demo"

Cùng hình dạng và cùng vị trí: quá trình khởi động dừng lại. Với never, application khởi động bình thường, và request đầu tiên trả về 500 sau 1.14 s với root cause org.postgresql.util.PSQLException: FATAL: password authentication failed for user "demo". Khởi động mà hoàn toàn không có DB_PASSWORD cũng kết thúc bằng đúng lỗi khởi động này, vì ${DB_PASSWORD} là một password sai.

Vấn đềVới spring.sql.init.mode=alwaysVới spring.sql.init.mode=never
Database bị dừngkhởi động thất bại: Connection to localhost:55425 refusedkhởi động được; request đầu tiên 500, root cause java.net.ConnectException: Connection refused
Password sai hoặc thiếukhởi động thất bại: FATAL: password authentication failed for user "demo"khởi động được; request đầu tiên 500 với cùng PSQLException

Thất bại ngay lúc khởi động là kết quả tốt hơn cho việc deploy: instance hỏng không bao giờ nhận traffic. Nếu không có gì mở connection lúc khởi động, người dùng đầu tiên sẽ là người phát hiện ra lỗi. Migration Flyway ở bài 31 cũng chạy lúc khởi động, nên application vẫn thất bại sớm sau khi bỏ schema.sql.

Cấu hình connection pool HikariCP

Cấu hình pool thực tế và giá trị mặc định

HikariCP in toàn bộ cấu hình khi pool khởi động, ở mức DEBUG:

Bash
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --spring.main.web-application-type=none --logging.level.com.zaxxer.hikari.HikariConfig=DEBUG

Một đoạn trích, đã bỏ phần prefix của logger ở mỗi dòng và lược các setting cho những tính năng series không dùng:

Text
HikariPool-1 - configuration:
autoCommit......................true
connectionTimeout...............30000
driverClassName................."org.postgresql.Driver"
idleTimeout.....................600000
initializationFailTimeout.......1
jdbcUrl.........................jdbc:postgresql://localhost:55425/demo
keepaliveTime...................120000
leakDetectionThreshold..........0
maxLifetime.....................1800000
maximumPoolSize.................10
minimumIdle.....................10
password........................<masked>
poolName........................"HikariPool-1"
username........................"demo"
validationTimeout...............5000

Năm setting bạn sẽ gặp đầu tiên:

Setting của HikariCPProperty của Spring BootMặc định trong lần chạy nàyĐiều khiển gì
maximumPoolSizespring.datasource.hikari.maximum-pool-size10số connection tối đa pool mở, tính cả connection đang bận lẫn đang rảnh
minimumIdlespring.datasource.hikari.minimum-idle10số connection rảnh pool cố giữ sẵn; ở đây bằng giá trị tối đa
connectionTimeoutspring.datasource.hikari.connection-timeout30000 msgetConnection() chờ một connection rảnh bao lâu trước khi ném exception
idleTimeoutspring.datasource.hikari.idle-timeout600000 ms (10 phút)một connection vượt quá minimumIdle được rảnh bao lâu trước khi bị đóng
maxLifetimespring.datasource.hikari.max-lifetime1800000 ms (30 phút)tuổi mà connection bị loại bỏ và thay bằng connection mới

autoCommit true là setting của pool đứng sau hành vi tự commit ở phần JdbcClient, còn initializationFailTimeout 1 là bước kiểm tra fail-fast trong các stack trace ở phần trước.

Property spring.datasource.hikari nhận mili giây

Boot bind spring.datasource.hikari.* thẳng vào các setter của HikariDataSource, và các setter timeout nhận số mili giây dạng long. Cách viết 2s mà bài 23 dùng cho spring.http.clients.connect-timeout làm quá trình khởi động dừng lại ở đây:

Bash
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --spring.main.web-application-type=none --spring.datasource.hikari.connection-timeout=2s
Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
Failed to bind properties under 'spring.datasource.hikari.connection-timeout' to long:
 
    Property: spring.datasource.hikari.connection-timeout
    Value: "2s"
    Origin: "spring.datasource.hikari.connection-timeout" from property source "commandLineArgs"
    Reason: failed to convert java.lang.String to long (caused by java.lang.NumberFormatException: For input string: "2s")
 
Action:
 
Update your application's configuration

Property metadata trong spring-boot-jdbc-4.1.1.jar ghi các timeout này là java.lang.Long. Hãy viết 2000.

Đo pool cạn connection

Để xem pool cạn connection, một endpoint tạm giữ connection trong năm giây bằng pg_sleep của PostgreSQL:

src/main/java/com/example/demo/lab/SlowQueryController.java
package com.example.demo.lab;
 
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class SlowQueryController {
 
    private final JdbcClient jdbcClient;
 
    public SlowQueryController(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }
 
    @GetMapping("/lab/slow")
    public String slow() {
        long start = System.currentTimeMillis();
        jdbcClient.sql("SELECT pg_sleep(5)").query().listOfRows();
        return "held a connection for " + (System.currentTimeMillis() - start) + " ms\n";
    }
}

Application khởi động với pool hai connection và thời gian chờ hai giây:

Bash
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prod --spring.datasource.hikari.maximum-pool-size=2 --spring.datasource.hikari.connection-timeout=2000

Ba request, gửi cách nhau 200 ms:

Bash
for i in 1 2 3; do
  (curl -s -o "slow-$i.out" -w "request $i: HTTP %{http_code} after %{time_total}s\n" http://localhost:8125/lab/slow) &
  sleep 0.2
done
wait
Text
request 3: HTTP 500 after 2.028100s
request 1: HTTP 200 after 5.065418s
request 2: HTTP 200 after 5.018442s

slow-1.outslow-2.out chứa held a connection for 5029 msheld a connection for 5016 ms. Request 3 kết thúc trước vì nó thất bại, và log cho biết lý do:

Text
2026-09-13T16:25:21.805+07:00 ERROR 21373 --- [demo] [nio-8125-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection] with root cause
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 2002ms (total=2, active=2, idle=0, waiting=0)

Pool cạn connection theo thời gian: request 1 và 2 mượn cả hai connection trong năm giây, request 3 chờ connection rảnh, timeout xảy ra ở connectionTimeout với SQLTransientConnectionException, và các connection trở về pool khi query xong

Cả hai connection đều bận (total=2, active=2, idle=0), nên getConnection() của request 3 chờ trọn connectionTimeout, 2002 ms theo cách HikariCP đếm, rồi HikariCP ném SQLTransientConnectionException, được Spring bọc trong CannotGetJdbcConnectionException. Với connectionTimeout mặc định, request 3 sẽ chờ 30 giây thay vì hai giây, và giữ thread Tomcat của nó suốt thời gian đó.

Vì sao tăng kích thước pool không phải cách sửa

Phản xạ đầu tiên là tăng maximum-pool-size. Trong bài test này, việc đó chỉ dời giới hạn đi chỗ khác: mọi connection đều bận với một query năm giây, và khi pool lớn hơn đã đầy thì request tiếp theo lại xếp hàng y như cũ. Mỗi connection trong pool còn là một session trên database — một server process trên PostgreSQL — dùng chung với mọi instance khác của application và mọi client khác, nên pool lớn hơn cho mỗi instance sẽ nhân lên theo số instance. Cách sửa nằm ở chỗ thời gian bị tiêu tốn: query trả về nhanh, không gọi thao tác chậm trong lúc đang giữ connection, và timeout đủ ngắn để thất bại sớm. Tính kích thước pool từ số đo là chủ đề của khoá Advanced; tới lúc đó, hãy giữ giá trị mặc định và coi Connection is not available, request timed out là triệu chứng cần lần ngược về phần việc đã giữ connection.

H2 vs PostgreSQL vs MySQL trong series này

H2PostgreSQLMySQL
Vai trò trong seriesdatabase cho dev, profile devdatabase thật, profile prodlựa chọn thay thế, chỉ có trong bài này
Driver artifactcom.h2database:h2 (2.4.240)org.postgresql:postgresql (42.7.13)com.mysql:mysql-connector-j (9.7.0)
Driver class Boot suy raorg.h2.Driverorg.postgresql.Drivercom.mysql.cj.jdbc.Driver
Định dạng URLjdbc:h2:mem:demo hoặc jdbc:h2:file:./data/demojdbc:postgresql://localhost:55425/demojdbc:mysql://localhost:33325/demo
Docker imagekhông có, chạy trong JVM của applicationpostgres:18 (18.6)mysql:8.4 (8.4.11)

FAQ

Vì sao Spring Boot dùng H2 khi tôi chưa cấu hình database?

Vì H2 có trên classpath và spring.datasource.url chưa được đặt. Khi đó Boot trỏ HikariDataSource tới jdbc:h2:mem: kèm một UUID ngẫu nhiên và ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE, nên mỗi lần khởi động là một database mới, trống. Đặt spring.datasource.url, thường là trong một profile, để dùng database thật.

Sửa lỗi "Failed to configure a DataSource: 'url' attribute is not specified" thế nào?

Boot không tìm thấy URL lẫn embedded database. Đặt spring.datasource.url cho database của bạn, hoặc thêm H2 cho môi trường dev. Nếu URL nằm trong một file profile như application-prod.properties, profile đó phải đang được kích hoạt; message lỗi liệt kê các profile đang active.

Vì sao H2 console trả về 404 trong Spring Boot 4?

Trong Boot 4, phần hỗ trợ console là module riêng spring-boot-h2console. Thiếu nó, spring.h2.console.enabled=true bị bỏ qua mà không có cảnh báo nào và /h2-console trả về 404. Thêm dependency đó — Initializr tự thêm khi bạn chọn H2 cùng web — và chỉ bật console trong profile dev.

Vì sao KeyHolder.getKey() ném exception trên PostgreSQL mà không ném trên H2?

update(keyHolder) không nói column nào chứa key. H2 chỉ trả về column identity; driver PostgreSQL trả về mọi column của row vừa insert, và getKey() ném InvalidDataAccessApiUsageException: The getKey method should only be used when a single key is returned. Chỉ rõ column bằng update(keyHolder, "id") và đọc bằng getKeyAs(Long.class).

Vì sao PostgreSQL báo relation "products" does not exist trong khi H2 chạy được?

schema.sql chỉ chạy trên H2. spring.sql.init.mode mặc định là embedded, chạy script cho database H2 in-memory và bỏ qua PostgreSQL, nên query đầu tiên của repository thất bại với BadSqlGrammarException và root cause org.postgresql.util.PSQLException: ERROR: relation "products" does not exist. Đặt spring.sql.init.mode=always cho PostgreSQL, với script chạy lại được, hoặc chuyển sang migration như bài 31.

Có nên tăng maximum-pool-size khi gặp "Connection is not available, request timed out"?

Không nên làm đầu tiên. Message đó nghĩa là mọi connection đều bận suốt connectionTimeout. Hãy tìm xem thứ gì giữ chúng — trong bài test ở trên, một query năm giây giữ cả hai — vì pool lớn hơn chỉ dời điểm request bắt đầu xếp hàng, và đặt thêm tải lên database.

Kết luận

DataSource cấp phát connection, và DataSource của Spring Boot là một pool HikariCP: mượn một connection mất 0.12 ms, còn mở mới mất 2.27 ms. Không có URL mà có H2 trên classpath, Boot trỏ pool đó tới một database H2 in-memory với tên sinh ngẫu nhiên; đặt spring.datasource.url thì Boot dùng database đó và suy ra driver class; không có cả hai thì khởi động thất bại. H2 in-memory trống trơn mỗi lần khởi động, file mode giữ lại dữ liệu, còn PostgreSQL mode chấp nhận một phần cú pháp PostgreSQL nhưng không nhận ON CONFLICT (sku), RETURNING hay pg_sleep. Trong Boot 4, H2 console cần spring-boot-h2console, và chỉ nên có trên máy developer.

PostgreSQL và MySQL chạy trong Docker, còn profile devprod chuyển qua lại giữa H2 và PostgreSQL với password lấy từ DB_PASSWORD — environment variable chưa đặt sẽ bind thành đúng chuỗi ${DB_PASSWORD}. spring.sql.init.mode=embedded chỉ chạy schema.sqldata.sql trên H2 in-memory; always chạy chúng trên PostgreSQL ở mọi lần khởi động, nên chúng phải chạy lại được. JdbcClient map row vào record theo tên column, optional()single() nói rõ mong đợi bao nhiêu row, update(keyHolder, "id") chạy được trên cả hai database, và SKU trùng tới dưới dạng DuplicateKeyException từ cả hai driver, giờ thành 409. JdbcProductRepository thay kho in-memory sau cùng interface mà ProductService không đổi một dòng. Với always, database dừng hay password sai sẽ chặn quá trình khởi động; với never, request đầu tiên mới phát hiện ra. Giá trị mặc định của HikariCP — mười connection, chờ 30 giây — nên để yên cho tới khi số đo nói khác: hai query chậm là đủ khiến request thứ ba thất bại sau connectionTimeout.

Bài tiếp theo thay SQL viết tay bằng Spring Data JPA và Hibernate: map Product thành entity với @Id@GeneratedValue, và có sẵn các thao tác CRUD từ một interface JpaRepository.

Bài viết liên quan

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Logging trong Spring Boot 4.1.1: SLF4J là facade và Logback là implementation, hai bridge jul-to-slf4j và log4j-to-slf4j, parameterised và fluent logging, ghi log exception, log level, cây logger và log group, --debug so với --trace, pattern dòng log mặc định, logging.file.name kèm rotation, logback-spring.xml với springProfile, MDC và chuyển sang Log4j2.

[Spring Boot Basics] Unit test trong Spring Boot: JUnit 6, AssertJ và Mockito cho tầng Service

Unit test cho tầng service của ứng dụng Spring Boot 4.1.1 với JUnit, AssertJ và Mockito: unit test thay thế những gì, test task của Gradle và report, mỗi test method một instance mới được chứng minh bằng identity, @Nested và tên hiển thị của parameterized test trong JUnit 6, bẫy isEqualTo với BigDecimal và soft assertion cùng thông báo lỗi, @Mock với constructor injection so với @InjectMocks truyền null, stub, verify và ArgumentCaptor, UnnecessaryStubbingException và PotentialStubbingProblem dưới strict stubs, một Clock cố định, và nạp Mockito dưới dạng -javaagent để bỏ cảnh báo self-attaching.

[Spring Boot Basics] Tài liệu API trong Spring Boot với springdoc-openapi và Swagger UI

springdoc-openapi trên Spring Boot 4.1.1: document OpenAPI 3.1 ở /v3/api-docs, Swagger UI và Try it out, những gì springdoc suy ra từ controller, DTO record và Bean Validation constraint, response nào của @RestControllerAdvice được thêm vào, @Tag, @Operation, @ApiResponse, @Parameter và @Schema trên record, bean OpenAPI và customizer toàn cục, GroupedOpenApi, property của springdoc và tắt tài liệu trong profile prod.