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 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:
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:
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 connectionTố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, jdbc và h2:
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.zipFile build sinh ra, đã bỏ các starter -test mà Initializr thêm cho mỗi starter:
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'
}<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>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.jar và spring-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:
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.sqlCá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:
./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=81252026-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:
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());java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab=ds --spring.main.web-application-type=nonejdbcUrl = jdbc:h2:mem:ff0b7eac-4eb7-4de3-861e-ca54cee094bf;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driverClassName = org.h2.Driver
username = saMộ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.
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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --debug --spring.main.web-application-type=noneTrong phần Positive matches:
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:
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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab=ds --spring.main.web-application-type=none --spring.datasource.url=jdbc:h2:mem:demojdbcUrl = jdbc:h2:mem:demo
driverClassName = org.h2.Driver
username = saBoot 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" và "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.

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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.main.web-application-type=none --spring.datasource.embedded-database-connection=none***************************
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:
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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --lab=starts --spring.datasource.url=jdbc:h2:mem:demo2026-09-13T16:16:45.517+07:00 INFO 10738 --- [demo] [ main] com.example.demo.lab.StartCounter : This database has seen 1 application start(s)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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --lab=starts --spring.datasource.url=jdbc:h2:file:./data/demo2026-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)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:
jdbc:h2:mem:demo;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGHDATABASE_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 SQL | H2 mode mặc định | H2 với URL ở trên | PostgreSQL 18.6 |
|---|---|---|---|
INSERT … ON CONFLICT DO NOTHING | lỗi cú pháp | 0 row, không lỗi | 0 row, không lỗi |
INSERT … ON CONFLICT (sku) DO NOTHING | lỗi cú pháp | lỗi cú pháp | 0 row, không lỗi |
INSERT … ON CONFLICT (sku) DO UPDATE SET stock = EXCLUDED.stock | lỗi cú pháp | lỗi cú pháp | 1 row |
INSERT … RETURNING id | lỗi cú pháp | lỗi cú pháp | trả về id mới |
SELECT gen_random_uuid() IS NOT NULL | không tìm thấy function | true | true |
SELECT COUNT(*) FROM "item" với table tạo tên item | không tìm thấy table | chạy được | chạy được |
SELECT '5'::int + 1 | 6 | 6 | 6 |
SELECT pg_sleep(1) | không tìm thấy function | không tìm thấy function | chạy được |
SELECT generate_series(1, 3) | không tìm thấy function | không tìm thấy function | 3 row |
SELECT DATE '2026-01-31' + INTERVAL '1 month' | lỗi | lỗi | 2026-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:
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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.h2.console.enabled=trueLog khởi động có thêm dòng H2 console available at '/h2-console' như đã thấy ở trên, và console trả lời:
curl -i http://localhost:8125/h2-consoleHTTP/1.1 302
Location: http://localhost:8125/h2-console/
Content-Length: 0
Date: Sun, 13 Sep 2026 09:12:05 GMTcurl -i http://localhost:8125/h2-console/HTTP/1.1 200
Cache-Control: no-cache
Content-Type: text/html
Content-Length: 938
Date: Sun, 13 Sep 2026 09:12:05 GMTBỏ 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ề:
HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunkedcò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 profiledev.
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 và -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:
docker run -d --name sb-a25-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=demo -p 55425:5432 postgres:18docker exec sb-a25-pg psql -U demo -d demo -c "select version();" 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:
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.4docker exec sb-a25-mysql mysql -udemo -psecret -e "SELECT VERSION();" 2>/dev/nullVERSION()
8.4.11PostgreSQL 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:
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'
}<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>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:
spring.application.name=demo
spring.datasource.url=jdbc:postgresql://localhost:55425/demo
spring.datasource.username=demo
spring.datasource.password=secretspring.application.name=demo
spring.datasource.url=jdbc:mysql://localhost:33325/demo
spring.datasource.username=demo
spring.datasource.password=secretKhởi động với PostgreSQL, log cho thấy connection đầu tiên của pool:
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:
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:
spring.application.name=demospring.datasource.url=jdbc:h2:mem:demo;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
spring.h2.console.enabled=truespring.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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=devDB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prod2026-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.
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.
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)
);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, price là NUMERIC(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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=dev --logging.level.org.springframework.jdbc.datasource.init=DEBUG2026-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 NOTHINGCùng cấu hình log đó với profile prod, trên database PostgreSQL còn trống:
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=DEBUGLần này không có dòng ScriptUtils nào, và PostgreSQL không có table:
docker exec sb-a25-pg psql -U demo -d demo -c '\dt'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:
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 đó:
docker exec sb-a25-pg psql -U demo -d demo -c '\dt' List of tables
Schema | Name | Type | Owner
--------+----------+-------+-------
public | products | table | demo
(1 row)docker exec sb-a25-pg psql -U demo -d demo -c 'SELECT id, sku, stock FROM products ORDER BY id;' 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:
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:
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:
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 NOTHINGTheo 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:
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:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev --lab=jdbc --spring.main.web-application-type=nonedocker exec sb-a25-pg psql -U demo -d demo -c "DROP TABLE IF EXISTS products;"DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --lab=jdbc --spring.main.web-application-type=noneOutput 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:
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());
}
}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.JdbcTransactionManagerJdbcClientAutoConfiguration dựng jdbcClient trên NamedParameterJdbcTemplate duy nhất; mục của nó trong report --debug là:
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
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);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(*).

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:
package com.example.demo.lab;
public record StockLevel(String sku, int unitsInStock) {
}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);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ệ:
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()
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);id 1: Optional[Product[id=1, name=Mechanical keyboard, sku=KB-01, price=89.90, stock=25, category=keyboards]]
id 999: Optional.emptysingle() 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:
org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0Dù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ự:
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);[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:
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, UPDATE và DELETE rồi trả về số row đã thay đổi:
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);first: 1, second: 0Con 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 autoCommit là true, 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:
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:
rows inserted: 1
getKeys(): {id=4}
getKey(): 4 (java.lang.Long)Trên PostgreSQL:
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:
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:
getKeys(): {id=5}
getKeyAs(Long.class): 5getKeyAs(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:
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);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:
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:
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 23505Trên PostgreSQL:
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 0Hai 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() là 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:
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:
JdbcTemplate | NamedParameterJdbcTemplate | JdbcClient | |
|---|---|---|---|
| Placeholder | positional ? | named :name | cả hai |
| Parameter | varargs sau mapper | một Map hoặc SqlParameterSource | các lời gọi param(…) hoặc paramSource(object) |
| Query | jdbcTemplate.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 mapping | RowMapper bạn truyền vào | RowMapper bạn truyền vào | do query(Class) chọn, hoặc RowMapper bạn truyền vào |
| Bean của Boot | jdbcTemplate | namedParameterJdbcTemplate | jdbcClient |
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:
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:
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:
***************************
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
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:
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:
package com.example.demo.product;
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Product " + id + " not found");
}
}package com.example.demo.product;
public class DuplicateSkuException extends RuntimeException {
public DuplicateSkuException(String sku) {
super("SKU " + sku + " already exists");
}
}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:
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) {
}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) {
}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:
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:
DB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prodcurl -i http://localhost:8125/api/productsHTTP/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"}]curl -i -H 'Content-Type: application/json' -d '{"name":"Webcam","sku":"CAM-01","price":59.00,"stock":8,"category":"video"}' http://localhost:8125/api/productsHTTP/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"}curl -i http://localhost:8125/api/products/4HTTP/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"}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/productsHTTP/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"}curl -i http://localhost:8125/api/products/99HTTP/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"}curl -i -H 'Content-Type: application/json' -d '{"name":"","sku":"X-1","price":1.00,"stock":1,"category":"misc"}' http://localhost:8125/api/productsHTTP/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:
docker exec sb-a25-pg psql -U demo -d demo -c "SELECT id, name, sku, price, stock, category FROM products ORDER BY id;" 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ỏ id và instance khỏi body để các câu trả lời giống nhau gom thành một nhóm:
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 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"} -> 201Mộ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
docker stop sb-a25-pgDB_PASSWORD=secret java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prodApplication 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:
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:
curl -s -i -w '\ntime %{time_total}s\n' http://localhost:8125/api/productsHTTP/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.118023s2026-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 refusedRequest 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
DB_PASSWORD=wrong java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8125 --spring.profiles.active=prodCaused 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=always | Với spring.sql.init.mode=never |
|---|---|---|
| Database bị dừng | khởi động thất bại: Connection to localhost:55425 refused | khởi động được; request đầu tiên 500, root cause java.net.ConnectException: Connection refused |
| Password sai hoặc thiếu | khở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:
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=DEBUGMộ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:
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...............5000Năm setting bạn sẽ gặp đầu tiên:
| Setting của HikariCP | Property của Spring Boot | Mặc định trong lần chạy này | Điều khiển gì |
|---|---|---|---|
maximumPoolSize | spring.datasource.hikari.maximum-pool-size | 10 | số connection tối đa pool mở, tính cả connection đang bận lẫn đang rảnh |
minimumIdle | spring.datasource.hikari.minimum-idle | 10 | số connection rảnh pool cố giữ sẵn; ở đây bằng giá trị tối đa |
connectionTimeout | spring.datasource.hikari.connection-timeout | 30000 ms | getConnection() chờ một connection rảnh bao lâu trước khi ném exception |
idleTimeout | spring.datasource.hikari.idle-timeout | 600000 ms (10 phút) | một connection vượt quá minimumIdle được rảnh bao lâu trước khi bị đóng |
maxLifetime | spring.datasource.hikari.max-lifetime | 1800000 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:
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***************************
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 configurationProperty 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:
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:
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=2000Ba request, gửi cách nhau 200 ms:
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
waitrequest 3: HTTP 500 after 2.028100s
request 1: HTTP 200 after 5.065418s
request 2: HTTP 200 after 5.018442sslow-1.out và slow-2.out chứa held a connection for 5029 ms và held 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:
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)
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
| H2 | PostgreSQL | MySQL | |
|---|---|---|---|
| Vai trò trong series | database cho dev, profile dev | database thật, profile prod | lựa chọn thay thế, chỉ có trong bài này |
| Driver artifact | com.h2database:h2 (2.4.240) | org.postgresql:postgresql (42.7.13) | com.mysql:mysql-connector-j (9.7.0) |
| Driver class Boot suy ra | org.h2.Driver | org.postgresql.Driver | com.mysql.cj.jdbc.Driver |
| Định dạng URL | jdbc:h2:mem:demo hoặc jdbc:h2:file:./data/demo | jdbc:postgresql://localhost:55425/demo | jdbc:mysql://localhost:33325/demo |
| Docker image | không có, chạy trong JVM của application | postgres: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 dev và prod 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.sql và data.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() và 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 và @GeneratedValue, và có sẵn các thao tác CRUD từ một interface JpaRepository.