โหมดมืด
บทที่ 17 — JDBC + Connection Pool (ใต้ JPA/Spring Data)
← บทที่ 16: JVM Internals | สารบัญ | บทที่ 18: Reflection + Annotation Processing →
📓 โซนขั้นสูง—ข้ามได้ (บท 11-19) บทนี้เจาะลึกเรื่องการต่อฐานข้อมูลระดับใต้ JPA/Spring มือใหม่ข้ามไปก่อนได้ ค่อยกลับมาเปิดตอนเจอปัญหา connection กับฐานข้อมูลจริงในงาน (pool เต็ม, connection รั่ว, query ช้า) ตอนเริ่มเขียน app ปกติ Spring Data JPA จัดการให้หมดแล้ว ไม่ต้องรู้บทนี้ก่อน
ศัพท์ย่อที่จะเจอ:
ศัพท์ คำแปล/อธิบาย JDBC Java Database Connectivity — API มาตรฐานของ Java สำหรับต่อฐานข้อมูล connection คอนเนกชัน — ช่องเชื่อมต่อหนึ่งสายไปยังฐานข้อมูล connection pool แอ่งคอนเนกชัน — ที่เก็บ connection ที่เปิดค้างไว้หมุนเวียนใช้ซ้ำ แทนการเปิด-ปิดใหม่ทุกครั้ง HikariCP ฮิคาริ — library connection pool ที่เร็วและนิยมที่สุด pool sizing การปรับจำนวน connection ในแอ่งให้พอดี leak detection การจับ connection ที่ยืมไปแล้วลืมคืน batch การส่งหลายคำสั่ง SQL พร้อมกันรวดเดียว ORM Object-Relational Mapping — การแปลง object ↔ ตารางในฐานข้อมูล N+1 ปัญหาคิวรีซ้ำ ๆ มากเกินจำเป็น JPA Java Persistence API — มาตรฐาน ORM ของ Java (กำหนดโดย Jakarta EE) Hibernate implementation ของ JPA ที่นิยมที่สุด (Spring Boot ใช้เป็น default) Spring Data JPA layer ของ Spring ที่ห่อ JPA/Hibernate — ให้เขียน repository.findById()ได้โดยไม่ต้องเขียน SQL เอง (รายละเอียดอยู่ในชุดบท Spring Boot)
หลายคนเรียน Spring Data JPA แล้วใช้ repository.findById() ได้ทันที — ไม่เคยรู้ว่า "ใต้นั้น" มีอะไร
แต่พอเจอ:
connection leak — pool exhaustedtoo many connections to PostgreSQL- Query ช้าทั้งที่ index มีแล้ว
Lock wait timeout- ปัญหา N+1 ที่มองหาในระดับ ORM เท่าไหร่ก็หาสาเหตุไม่เจอ
→ ไม่ debug ออก เพราะไม่รู้ว่า JDBC กับ connection pool ทำงานยังไง
บทนี้พาคุณ ลงไปใต้ JPA — เขียน raw JDBC, เข้าใจ connection lifecycle, เข้าใจ pool (HikariCP) ระดับ tuning
ใช้เวลา 3-4 ชั่วโมง
Part 1: JDBC คืออะไร และทำไมต้องรู้
1.1 JDBC = "USB ของ Java กับ Database"
💡 USB = มาตรฐานรูปช่องเสียบ — ใครทำอุปกรณ์ตามสเปก ก็เสียบเข้ากันได้กับเครื่องไหนก็ได้ JDBC เป็นแบบนั้นกับฐานข้อมูล — Java เขียน code ด้วย API มาตรฐานชุดเดียว แล้วเปลี่ยน driver ก็ต่อ DB ได้ทุกยี่ห้อ
JDBC (Java Database Connectivity) = standard API ของ Java สำหรับคุยกับ relational database
JDBC = interface มาตรฐาน. แต่ละ DB มี driver ของตัวเองที่ implement (เขียนโค้ดตามที่ interface กำหนด — เหมือนผลิตอุปกรณ์ USB ตามสเปก) interface นี้
| DB | Driver |
|---|---|
| PostgreSQL | org.postgresql:postgresql |
| MySQL | com.mysql:mysql-connector-j |
| SQLite | org.xerial:sqlite-jdbc |
| Oracle | com.oracle.database.jdbc:ojdbc11 |
| SQL Server | com.microsoft.sqlserver:mssql-jdbc |
| H2 (in-memory) | com.h2database:h2 |
1.2 ทำไม Spring Data / Hibernate ยังต้องรู้ JDBC
| ตอน | คุณใช้ | แต่ใต้นั้นคือ |
|---|---|---|
userRepo.findById(1L) | Spring Data | Hibernate → JDBC |
entityManager.createQuery(...) | JPA | Hibernate → JDBC |
jdbcTemplate.query(...) | Spring JDBC | JDBC ตรง ๆ |
connection.prepareStatement(...) | Plain JDBC | JDBC driver → DB protocol |
ทุกทางวิ่งลง JDBC ในที่สุด. ถ้า:
- Tune connection pool — ต้องเข้าใจ JDBC
- Debug N+1 / lazy loading — ต้องอ่าน SQL log ของ JDBC
- เข้าใจ transaction isolation — ต้องเข้าใจ JDBC level
Part 2: Hello JDBC — ขั้นต่ำที่สุด
2.1 Setup
JDBC คือ API มาตรฐานที่ Java ใช้คุยกับฐานข้อมูล — ต้องเพิ่ม "driver" ของ DB ที่ใช้ (เช่น PostgreSQL) เข้า pom.xml ก่อน แล้วเชื่อมต่อด้วย connection string (JDBC URL):
pom.xml:
xml
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version> <!-- ตรวจ version ล่าสุดได้ที่ mvnrepository.com/artifact/org.postgresql/postgresql -->
</dependency>💡 ถ้าใช้ Spring Boot ไม่ต้องระบุ
<version>— Spring Boot BOM (bill of materials) จัดการ version ที่ compatible ให้อัตโนมัติ:xml<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency>
หรือใช้ H2 in-memory เพื่อทดสอบ (JDBC URL: jdbc:h2:mem:test):
xml
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.224</version>
</dependency>2.2 Code แรก — connect, query, print
🛠️ Setup ก่อนรันตัวอย่าง — เลือกวิธีที่เหมาะกับคุณ
ตัวเลือก A (แนะนำสำหรับมือใหม่): H2 in-memory — ไม่ต้องติดตั้งอะไรเพิ่ม
เพิ่ม dependency ใน
pom.xml(ถ้ายังไม่มี):xml<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>2.2.224</version> </dependency>แล้วเปลี่ยน URL และ SQL ในตัวอย่างด้านล่าง:
java// ใช้ H2 แทน PostgreSQL String url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"; // ไม่ต้อง user/password try (Connection conn = DriverManager.getConnection(url)) { // สร้างตารางก่อน (H2 ไม่มีตารางสำเร็จรูป) conn.createStatement().execute( "CREATE TABLE IF NOT EXISTS users (id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), age INT)"); conn.createStatement().execute( "INSERT INTO users(name, age) VALUES ('Alice', 25), ('Bob', 17), ('Charlie', 30)"); // ... แล้วรัน query เหมือนกัน }ตัวเลือก B: PostgreSQL ผ่าน Docker (ถ้ามี Docker ติดตั้งแล้ว — Docker = โปรแกรมรัน container บน PC ของคุณ ดาวน์โหลดได้ที่ docker.com):
bashdocker run -d --name pg-dev -e POSTGRES_PASSWORD=secret -p 5432:5432 postgres:16จากนั้นสร้างตาราง:
sqlCREATE DATABASE mydb; \c mydb CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100), age INT); INSERT INTO users(name, age) VALUES ('Alice', 25), ('Bob', 17), ('Charlie', 30);
⛔ ห้าม hardcode password ใน source code เด็ดขาด — ถ้า commit ขึ้น Git credential จะรั่วทันที ตัวอย่างด้านล่างใช้
"secret"เพื่อความเรียบง่าย — ในงานจริงให้อ่านจาก environment variable หรือ secret manager:javaString password = System.getenv("DB_PASSWORD"); // ✅ production-safe
java
import java.sql.*;
public class JdbcHello {
public static void main(String[] args) throws Exception {
// ⚠️ ตัวอย่างนี้ต่อ PostgreSQL — ต้องทำ "Setup ก่อนรัน" ในกล่องด้านบนก่อน
// (สร้างตาราง users + seed ข้อมูล) และตั้ง env variable DB_PASSWORD ให้ตรงกับ DB
// ถ้าจะใช้ H2 แทน (ตัวเลือก A ในกล่องด้านบน — ไม่ต้องติดตั้งอะไร):
// String url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1";
// แล้วเรียก DriverManager.getConnection(url) แบบไม่ต้องส่ง user/password
// (อย่าลืม CREATE TABLE + INSERT ตาม snippet H2 ในกล่องด้านบนก่อน query)
// 1. JDBC URL: jdbc:<dbtype>://<host>:<port>/<dbname>
String url = "jdbc:postgresql://localhost:5432/mydb";
String user = "postgres";
// อ่านจาก env — ห้าม hardcode! ตั้งค่าก่อนรัน เช่น (Linux/macOS): export DB_PASSWORD=secret
// (Windows PowerShell): $env:DB_PASSWORD="secret"
String password = System.getenv("DB_PASSWORD");
// 2. เปิด connection (try-with-resources จะ close ให้)
try (Connection conn = DriverManager.getConnection(url, user, password)) {
// 3. เตรียม statement
String sql = "SELECT id, name FROM users WHERE age > ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
// 4. set parameter (index เริ่ม 1 ไม่ใช่ 0!)
ps.setInt(1, 18);
// 5. execute + อ่านผล
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { // เลื่อน cursor
long id = rs.getLong("id");
String name = rs.getString("name");
System.out.printf("%d - %s%n", id, name);
}
}
}
}
}
}อธิบายทุก class:
| Class/Interface | คือ |
|---|---|
| DriverManager | factory (ตัวสร้าง — รับ config แล้วสร้าง object ให้ เหมือนโรงงาน) สำหรับเปิด Connection (ใช้ได้กับ script เล็ก ๆ / one-off; production ใช้ DataSource ที่มี pool) |
| Connection | "session" กับ DB. มี state — autocommit, transaction, lock |
| PreparedStatement | SQL ที่ compile แล้ว + รับ parameter (กัน SQL injection) |
| Statement | SQL ตรง ๆ ไม่มี parameter (ห้ามใช้กับ user input!) |
| ResultSet | cursor วิ่งบนผลลัพธ์ — next() เลื่อน row |
2.3 ⚠️ ทำไมต้อง PreparedStatement (ไม่ใช่ Statement)
❌ อันตราย — SQL injection:
java
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
Statement stmt = conn.createStatement();
stmt.executeQuery(sql);ถ้า name = "Alice'; DROP TABLE users; --" → ขาด
✅ ปลอดภัย — parameter binding:
java
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE name = ?");
ps.setString(1, name);
ps.executeQuery();JDBC driver จะ escape ให้อัตโนมัติ + DB optimize plan cache ด้วย
กฎทอง: ใช้ PreparedStatement เสมอ ไม่มีข้อยกเว้น
Part 3: Connection Lifecycle — ทำไมต้องสน
3.1 1 Connection เกิดอะไรขึ้นบ้าง
ตอนคุณเรียก DriverManager.getConnection(...) — มันไม่ฟรี:
text
Step 1: DNS resolve database hostname (~5-50ms)
Step 2: TCP handshake (SYN, SYN-ACK, ACK) (~1-5ms)
Step 3: TLS handshake (ถ้าใช้ SSL) (~10-100ms)
Step 4: Auth (username/password verify) (~5-50ms)
Step 5: Negotiate protocol, codec, settings (~5-20ms)
─────────
~30-200ms ต่อ connection!→ ห้ามเปิด connection ใหม่ทุก request ในระบบที่มี traffic — ใช้ pool
3.2 Database ด้านนั้นมีต้นทุนด้วย
แต่ละ connection ที่ฝั่ง PostgreSQL:
1 OS process — PostgreSQL ใช้ process-per-connection (สร้าง OS process ใหม่ 1 ตัวต่อ 1 connection) ต่างจาก MySQL ที่ใช้ thread ภายใน process เดียว
Process (PostgreSQL) Thread (MySQL) แยกหน่วยความจำ ✅ มีของตัวเอง ❌ ใช้ร่วมกัน น้ำหนัก หนักกว่า เบากว่า กัน crash ลาม ✅ ดีกว่า — overhead ต่อ idle connection อยู่ที่ ~5-10MB (RSS หรือ Resident Set Size = หน่วยความจำจริงที่ process ใช้อยู่ ไม่รวมส่วนที่ swap ออกไป — ของ process ฝั่ง DB)
หมายเหตุ
work_mem:work_memไม่ได้จัดสรรตอนเปิด connection — จัดสรรตอน query ทำงานจริง (ต่อ sort/hash node ต่อ query) connection ที่ idle อยู่จึงไม่ใช้work_memแต่ connection ที่ทำ query หนักพร้อมกันหลายตัวสามารถใช้ memory สูงมากได้จากwork_memหลายชุดรวมกันมี max connection limit (default PostgreSQL = 100)
ถ้า app เปิด 100 connection ค้าง → DB เต็ม → user คนอื่นต่อไม่ได้
💡 คำแนะนำ: Postgres มักตายเพราะ connection ล้น ไม่ใช่เพราะ query เยอะ — เก็บ connection คืนเร็ว ๆ สำคัญที่สุด
Part 4: Connection Pool — แก้ปัญหายังไง
4.1 หลักการ
Pool เก็บ connection ที่ เปิดอยู่แล้ว — request ที่เข้ามา borrow จาก pool, ใช้เสร็จ return
ลด overhead ของ connect ทุกครั้ง
4.2 Pool ที่ใช้กันใน production
| Pool | จุดเด่น |
|---|---|
| HikariCP | เร็วที่สุด, Spring Boot default ตั้งแต่ 2.0 |
| Apache DBCP2 | classic, ใช้ใน old enterprise |
| C3P0 | feature เยอะ, slow |
| Tomcat JDBC Pool | bundled กับ Tomcat |
| Agroal | ของ Quarkus, optimize for native |
ใน 2026 — ใช้ HikariCP เสมอ (ยกเว้นมีเหตุผลพิเศษ)
4.3 HikariCP — เร็วเพราะอะไร
HikariCP ทำหลายอย่างเพื่อความเร็ว:
FastListแทนArrayList— ไม่เช็ค range, ไม่ remove จากกลางConcurrentBag— collection สำหรับ lend/return ที่ optimize ThreadLocal (ที่เก็บข้อมูลแยกต่อ thread)- Bytecode-level optimization — Javassist เขียน proxy (ตัวแทน object ที่ intercept การเรียก method)
- ไม่มี wrapper เกินจำเป็น — minimal code path
📝 รายละเอียด implementation ด้านบนเพื่อความรู้เพิ่มเติม ไม่จำเป็นต้องจำทั้งหมด — สำคัญคือรู้ว่า HikariCP เร็วมากและใช้เป็น default ใน Spring Boot
→ borrow + return ใช้เวลาระดับ microseconds (ไมโครวินาที)
4.4 ใช้ HikariCP โดยตรง (ไม่ผ่าน Spring)
HikariCP เป็น connection pool ที่เร็วและนิยมที่สุด (Spring Boot ใช้เป็น default) — ถ้าไม่ได้ใช้ Spring เราตั้งค่าและใช้เองได้ตรง ๆ ผ่าน HikariDataSource:
pom.xml:
xml
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>Code:
java
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://localhost/mydb");
cfg.setUsername("postgres");
cfg.setPassword(System.getenv("DB_PASSWORD")); // ห้าม hardcode — ใช้ env variable
cfg.setMaximumPoolSize(20); // จำนวน connection สูงสุดใน pool
cfg.setMinimumIdle(5); // ต่ำสุดที่ idle
cfg.setConnectionTimeout(30_000); // รอ borrow ได้นานสุด (ms)
cfg.setIdleTimeout(600_000); // idle เกินนี้ → close (10 min)
cfg.setMaxLifetime(1_800_000); // connection age สูงสุด (30 min)
cfg.setLeakDetectionThreshold(60_000); // 60s ไม่คืน → log warning
cfg.setPoolName("HikariMyApp");
HikariDataSource ds = new HikariDataSource(cfg);
// ใช้
try (Connection conn = ds.getConnection()) { // borrow
// ... query
} // return อัตโนมัติ4.5 ใน Spring Boot
application.yml:
yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: postgres
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
pool-name: HikariMyAppSpring Boot detect HikariCP ใน classpath → ใช้เป็น DataSource อัตโนมัติ
Part 5: Pool Sizing — กี่ตัวคือเหมาะ
5.1 สูตรพื้นฐาน
หลายคนคิดว่า "เพิ่ม pool ใหญ่ ๆ = เร็วขึ้น" — ผิด
สูตรจาก HikariCP wiki: About Pool Sizing (อ้างอิงการทดลองของชุมชน PostgreSQL) — ใช้คำนวณ optimal concurrent DB connection:
text
connections = ((core_count * 2) + effective_spindle_count)core_count= จำนวน CPU core ของ DB servereffective_spindle_count= ตัวเลขบอก I/O parallelism (ความสามารถอ่าน-เขียน disk พร้อมกันได้กี่ทาง)- คำว่า spindle = แกนหมุนของ HDD (Hard Disk Drive = ฮาร์ดดิสก์แบบจานหมุน) ยิ่งมีหลายแกน ก็ยิ่งอ่าน-เขียนพร้อมกันได้หลายทาง
- SSD/NVMe ไม่มีแกนหมุน — ในสูตรนี้ให้นับเป็น
1เสมอ (เป็นค่า baseline) - สรุปสำหรับเครื่องทั่วไปที่ใช้ SSD:
effective_spindle_count = 1เสมอ ดังนั้นสูตรกลายเป็น(CPU core × 2) + 1
⚠️ สูตรนี้คือ optimal concurrent DB connection ไม่ใช่ app-level pool size โดยตรง — ถือเป็นจุดเริ่มต้น แล้วปรับตาม workload จริง
📌 หมายเหตุ 1 — สูตรนี้มาจากยุค HDD: เป็นการทดลองเชิงประสบการณ์สมัย HDD (จานหมุน) เครื่องที่ใช้ SSD/NVMe ในปัจจุบัน (cloud DB ส่วนใหญ่เป็น NVMe) อ่าน-เขียนพร้อมกันได้มากกว่า จึงอาจรับ pool ใหญ่กว่าสูตรได้บ้าง
📌 หมายเหตุ 2 — ทำจริงยังไง: ถ้ายังไม่ได้วัดอะไรเลย ให้เริ่มที่ pool size = 10 ก่อน แล้วค่อยดู DB wait-event monitoring จริง — เห็น connection ต้องรอนาน ค่อยเพิ่ม; DB CPU สูงแต่ connection ไม่ต้องรอ ค่อยลด อย่าเดา ให้วัดแล้วปรับ
ตัวอย่าง: 8 core + SSD = 17 connection เท่านั้น
"More connections = faster" คือความเข้าใจผิดที่ใหญ่ที่สุด
5.2 ทำไม pool ใหญ่ทำให้ช้าลง
- DB context switch — มากเกินไป
- Memory pressure — ทุก connection กิน RAM
- Lock contention — connection มากขึ้น = lock conflict มากขึ้น
- Queue at DB — DB ทำ N query พร้อมกันไม่ไหว, คนอื่นรอ
5.3 ตัวอย่าง pool size ใน production
| App | Workload | Pool size |
|---|---|---|
| Spring Boot API | OLTP, mostly < 100ms query | 10-20 |
| Heavy reporting | Long queries | 5-10 + read replica |
| Worker / batch | Bulk insert | 5-10 |
| Background scheduler | Cron | 3-5 |
rule of thumb: เริ่มจาก 10. โหลดเต็มแล้วค่อยเพิ่ม. ดู DB CPU + connection wait time
5.4 หลาย instance ของ app
ระวัง — ถ้าคุณรัน 10 pod ใน K8s, แต่ละ pod มี pool 20 → DB เห็น 200 connection
ตั้ง pool size ต่อ pod + คำนวณ total
text
PostgreSQL max_connections = 100
หาก app มี 10 pod
→ pool size ต่อ pod ≤ 8 (เผื่อ admin, monitoring, replica)หรือใช้ PgBouncer (connection pooler ระหว่าง app กับ DB) เพื่อ multiplex (รวม connection จาก app หลายตัวเข้า connection จริงกับ DB น้อยตัว — ประหยัด connection ฝั่ง DB)
Part 6: Transaction — ของยากที่หลายคนเข้าใจผิด
6.1 Autocommit (default)
จุดที่มือใหม่เข้าใจผิดบ่อย: JDBC ตั้งค่าเริ่มต้นเป็น autocommit = true — แปลว่าทุกคำสั่งถูก commit ทันทีแยกกัน ไม่ได้รวมเป็น transaction เดียว ถ้าคำสั่งที่ 2 พังขณะที่ 1 commit ไปแล้ว ข้อมูลจะไม่สอดคล้องกัน:
ตอนเปิด connection — JDBC default autocommit = true:
java
// ps1, ps2, ps3 แทน PreparedStatement ที่ prepare ไว้ล่วงหน้า เช่น:
// PreparedStatement ps1 = conn.prepareStatement("INSERT INTO orders ...");
Connection conn = ds.getConnection();
ps1.executeUpdate(); // commit ทันที!
ps2.executeUpdate(); // commit ทันที!
ps3.executeUpdate(); // commit ทันที!ถ้า ps2 fail — ps1 ยัง commit ไปแล้ว = data inconsistent
6.2 Manual transaction
เมื่อต้องการรวมหลายคำสั่งเป็น transaction เดียว (สำเร็จทั้งหมดหรือ rollback ทั้งหมด) ให้ปิด autocommit ด้วย setAutoCommit(false), ทำคำสั่ง, แล้ว commit() — ถ้า error ก็ rollback() (ใน Spring ใช้ @Transactional ทำให้):
java
// ✅ ใช้ try-with-resources — close + รับ exception ปลอดภัย
try (Connection conn = ds.getConnection()) {
conn.setAutoCommit(false); // เริ่ม transaction
try (PreparedStatement ps1 = conn.prepareStatement("INSERT INTO orders(product, qty) VALUES (?, ?)");
PreparedStatement ps2 = conn.prepareStatement("UPDATE inventory SET qty = qty - ? WHERE product = ?");
PreparedStatement ps3 = conn.prepareStatement("INSERT INTO audit_log(action) VALUES (?)")) {
ps1.setString(1, "widget"); ps1.setInt(2, 1);
ps1.executeUpdate();
ps2.setInt(1, 1); ps2.setString(2, "widget");
ps2.executeUpdate();
ps3.setString(1, "order created");
ps3.executeUpdate();
conn.commit(); // commit ทั้งหมด
} catch (Exception e) {
conn.rollback(); // rollback ทั้งหมด
throw e;
}
}
// ออกจาก try-with-resources → conn.close() ถูกเรียกอัตโนมัติ
// HikariCP จะ reset autoCommit = true ตอน return connection ให้เอง (ค่า default)
// จึงไม่ต้องเรียก setAutoCommit(true) เองใน finally
// ⚠️ pool ยี่ห้ออื่น (เช่น Apache DBCP2, C3P0) default การ reset state ต่างกัน — ถ้าไม่ได้ใช้ HikariCP
// ให้เช็คเอกสารว่ามัน reset autoCommit ให้หรือไม่ ไม่งั้น connection ที่ยืมมาอาจติด autoCommit=false ค้าง⚠️ อย่าเขียนแบบเก่า ที่
Connection conn = ds.getConnection();แล้วfinally { conn.setAutoCommit(true); conn.close(); }— ถ้าgetConnection()โยน exception,connจะเป็นnull→ NPE ใน finally และsetAutoCommit(true)ใน finally อาจโยนSQLExceptionทับ exception เดิม ทำให้ debug ยาก ใช้ try-with-resources จะปลอดภัยกว่า
ใน Spring ใช้ @Transactional ทำเรื่องนี้ให้
6.3 Isolation Level
JDBC support 4 ระดับ:
คำอธิบายปัญหาในตาราง:
- Dirty read = อ่านข้อมูลที่ transaction อื่นยังไม่ commit (อาจถูก rollback ทีหลัง)
- Non-repeatable read = อ่าน row เดิม 2 ครั้งในขณะ transaction เดียวแล้วได้ค่าต่างกัน
- Phantom read = query เดิมรันซ้ำแล้วได้จำนวนแถวต่างกัน (แถวใหม่ถูก insert โดย transaction อื่น)
✅ = ปัญหานี้เกิดได้ | ❌ = ป้องกันได้ในระดับนี้
| Level | Dirty read | Non-repeatable | Phantom | ค่า constant (Java: Connection.TRANSACTION_*) |
|---|---|---|---|---|
| READ_UNCOMMITTED | ✅ เกิด | ✅ | ✅ | 1 (TRANSACTION_READ_UNCOMMITTED) |
| READ_COMMITTED | ❌ | ✅ | ✅ | 2 (TRANSACTION_READ_COMMITTED) |
| REPEATABLE_READ | ❌ | ❌ | ✅ | 4 (TRANSACTION_REPEATABLE_READ) |
| SERIALIZABLE | ❌ | ❌ | ❌ | 8 (TRANSACTION_SERIALIZABLE) |
java
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);PostgreSQL default = READ_COMMITTED (พอใช้ส่วนใหญ่) MySQL InnoDB default = REPEATABLE_READ
💡 หมายเหตุ: ตาราง SQL standard บอกว่า REPEATABLE_READ ยังเกิด phantom read ได้ — แต่ MySQL InnoDB ใช้ next-key locking (เทคนิคที่ InnoDB ล็อกทั้งแถวข้อมูล และ ช่องว่างระหว่างแถว กันไม่ให้มีแถวใหม่แทรกเข้ามาในช่วงที่กำลังอ่าน) ทำให้กัน phantom ได้จริงในระดับนี้ (มากกว่าที่ standard กำหนด)
- ⚠️ แต่กันได้เฉพาะ การอ่านแบบล็อก (เช่น
SELECT ... FOR UPDATE) และคำสั่งUPDATE/DELETEส่วนSELECTธรรมดา (non-locking) ยังใช้ MVCC snapshot อยู่ — ในลำดับ "อ่านก่อนแล้วค่อยเขียน" ก็ยังเจออาการคล้าย phantom ได้ ดังนั้น logic ที่มีการเขียนพร้อมกันหลายคนควรทดสอบจริงเสมอ- ส่วน PostgreSQL ยึดตาม SQL standard ตรงไปตรงมากว่า ถ้าย้าย code ข้ามระหว่าง DB ต้องระวังความต่างนี้
6.4 Savepoint — checkpoint กลาง transaction
Savepoint คือ "จุดบันทึก" กลาง transaction — เราย้อนกลับมาจุดนี้ได้โดยไม่ rollback ทั้งหมด เหมาะกับ partial rollback (เช่นบางขั้นพังแต่อยากเก็บขั้นก่อนหน้าไว้):
java
conn.setAutoCommit(false);
ps1.executeUpdate();
Savepoint afterStep1 = conn.setSavepoint("after-step-1"); // จุดบันทึกหลังขั้นที่ 1
try {
ps2.executeUpdate();
} catch (Exception e) {
conn.rollback(afterStep1); // ย้อนแค่หลัง savepoint — ps1 ยังอยู่
}
conn.commit();ใช้ตอน sub-transaction หรือ partial rollback
Part 7: Connection Leak — ปัญหายอดฮิต
7.1 อาการ
text
HikariPool-1 - Connection is not available, request timed out after 30000ms= ทุก connection ใน pool ถูก borrow ไปแล้วไม่คืน → request ใหม่ต้องรอ → timeout
7.2 สาเหตุ
connection leak เกิดเมื่อยืม connection จาก pool แล้วลืมคืน (ไม่ close()) — มักเพราะไม่ได้ใช้ try-with-resources หรือมี exception เกิดก่อนถึงบรรทัด close ทางแก้คือใช้ try-with-resources เสมอ:
java
// ds คือ DataSource จาก HikariCP (ดูการ setup ใน Part 4.4)
// someCondition คือ boolean ที่ประกาศไว้แล้ว (ตัวอย่างนี้เป็น pseudo-code แสดง pattern)
// ❌ leak
public void doSomething() throws SQLException {
Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement("...");
ps.executeUpdate();
// ลืม close — conn ค้างใน pool ตลอด
}หรือเทียบ ✅ vs ❌ ด้วย 2 ตัวอย่างแยกกันชัด ๆ:
java
// ✅ ปลอดภัย — try-with-resources จะ close ให้แม้มี exception
public void doSomethingSafe() throws SQLException {
try (Connection conn = ds.getConnection()) {
if (someCondition) throw new RuntimeException("oops");
// ... ใช้ conn
}
// ออกจาก try → conn.close() ถูกเรียกอัตโนมัติทุกกรณี — ไม่ leak
}java
// ❌ leak — ถ้า exception เกิดก่อนถึง conn.close() บรรทัดล่าง connection ค้างใน pool ตลอด
public void doSomethingLeaky() throws SQLException {
Connection conn = ds.getConnection();
if (someCondition) throw new RuntimeException("oops");
conn.close(); // ← บรรทัดนี้ไม่ถูกเรียกเมื่อ exception ทะลุไป
}7.3 วิธีหา leak
Hikari leak detection
yaml
spring.datasource.hikari.leak-detection-threshold: 60000 # 60sถ้า connection ถูก borrow เกิน 60s ไม่คืน → log warning + stack trace
Connection pool metrics (Micrometer)
text
hikaricp.connections.active # ใช้อยู่
hikaricp.connections.idle # ว่าง
hikaricp.connections.pending # รอ borrow
hikaricp.connections.timeout # timeout จริง ๆ
hikaricp.connections.usage # histogram ของเวลา holdตั้ง alert ที่ pending > 0 for 1 min หรือ active = max ค้าง
7.4 Best practice
- ใช้ try-with-resources เสมอ ทุกระดับ (Connection, Statement, ResultSet)
- อย่า hold connection ใน loop ที่มี business logic ยาว
- อย่าเรียก REST API ระหว่าง connection borrowed (จะ block + leak ถ้าช้า)
- ใช้ Spring
@Transactionalให้ Spring จัดการให้ — น้อยที่จะ leak
Part 7.5: Generated Keys + CallableStatement + DatabaseMetaData
7.5 RETURN_GENERATED_KEYS — รับ id หลัง insert
หลัง insert แถวใหม่ เรามักอยากได้ค่า id ที่ DB สร้างให้ (auto-increment) — บอก JDBC ด้วย Statement.RETURN_GENERATED_KEYS แล้วอ่านผ่าน getGeneratedKeys() วิธีนี้ portable (ใช้ได้กับ DB หลายยี่ห้อ ไม่ต้องเปลี่ยน code เมื่อเปลี่ยน DB) กว่า SELECT LAST_INSERT_ID() ที่ใช้ได้แค่ MySQL:
java
String sql = "INSERT INTO users(name, email) VALUES (?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, "Anna");
ps.setString(2, "a@x.com");
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
long id = keys.getLong(1);
System.out.println("inserted id = " + id);
}
}
}
// ระบุ column name (PostgreSQL / Oracle)
try (PreparedStatement ps = conn.prepareStatement(sql, new String[]{"id"})) {
// ...
}ใช้แทน SELECT currval(...) หรือ SELECT LAST_INSERT_ID() ที่ DB-specific
7.6 CallableStatement — เรียก stored procedure
sql
CREATE OR REPLACE FUNCTION calc_discount(amount NUMERIC, tier INT)
RETURNS NUMERIC AS $$
BEGIN
RETURN amount * (CASE tier WHEN 1 THEN 0.05 WHEN 2 THEN 0.10 ELSE 0.15 END);
END;
$$ LANGUAGE plpgsql;java
try (CallableStatement cs = conn.prepareCall("{ ? = call calc_discount(?, ?) }")) {
cs.registerOutParameter(1, Types.NUMERIC); // OUT param
cs.setBigDecimal(2, new BigDecimal("1000")); // IN param
cs.setInt(3, 2);
cs.execute();
BigDecimal discount = cs.getBigDecimal(1); // อ่าน OUT
}
// stored procedure ที่ return ResultSet
try (CallableStatement cs = conn.prepareCall("{ call list_users_by_dept(?) }")) {
cs.setInt(1, 10);
try (ResultSet rs = cs.executeQuery()) {
while (rs.next()) System.out.println(rs.getString("name"));
}
}💡 stored procedure มักเป็น "anti-pattern" (รูปแบบที่ดูเหมือนแก้ปัญหาได้ แต่จริง ๆ ก่อปัญหาในระยะยาว) ใน app สมัยใหม่ เพราะ logic แฝงอยู่ใน DB → version control ยาก, test ยาก — เรียนไว้เพื่อ อ่าน legacy code ได้ ไม่ใช่เพื่อเขียนใหม่ แต่ legacy system ยังเจอเยอะ
7.7 DatabaseMetaData — ถามรายละเอียดของ DB
DatabaseMetaData ให้เราถามข้อมูลเกี่ยวกับ DB เองตอน runtime — เวอร์ชัน, รายชื่อตาราง, คอลัมน์, primary/foreign key, index เครื่องมืออย่าง Flyway (เครื่องมือจัดการ version ของ database schema), jOOQ (library สร้าง SQL query แบบ type-safe) ใช้สิ่งนี้ทำ schema introspection (การสำรวจโครงสร้างตารางใน database ณ ตอน runtime):
java
DatabaseMetaData md = conn.getMetaData();
System.out.println("DB: " + md.getDatabaseProductName() + " " + md.getDatabaseProductVersion());
System.out.println("Driver: " + md.getDriverName() + " " + md.getDriverVersion());
try (ResultSet rs = md.getTables(null, "public", "%", new String[]{"TABLE"})) {
while (rs.next()) System.out.println(rs.getString("TABLE_NAME"));
}
try (ResultSet rs = md.getColumns(null, "public", "users", "%")) {
while (rs.next()) {
System.out.println(rs.getString("COLUMN_NAME") + " " + rs.getString("TYPE_NAME"));
}
}
md.getPrimaryKeys(null, "public", "users");
md.getImportedKeys(null, "public", "orders"); // FK ที่ชี้เข้ามา
md.getIndexInfo(null, "public", "users", false, false);ใช้ทำ: schema introspection (Flyway/Liquibase ใช้ภายใน — Liquibase คือเครื่องมือจัดการ schema version คล้าย Flyway), code gen (สร้าง Java code จาก schema อัตโนมัติ — jOOQ ทำสิ่งนี้), DBA tool (เครื่องมือสำหรับ Database Administrator)
Part 8: Batch Operations — เร็วกว่ามาก
8.1 ปัญหา: insert ทีละ row
การ insert ทีละแถวใน loop แต่ละครั้งคือการสื่อสารไป-กลับ DB หนึ่งรอบ (round trip). ถ้ามีพันแถว ก็เท่ากับพันรอบ round trip — ช้ามากเพราะ network latency (เวลาที่ข้อมูลวิ่งไป-กลับผ่านเครือข่าย) สะสมทีละรอบ ๆ:
java
// ❌ ไม่ดี — สร้าง PreparedStatement ใหม่ทุก iteration (เสีย plan cache) และยัง leak ถ้าไม่ close
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO users(name) VALUES (?)")) {
for (User u : users) {
ps.setString(1, u.getName());
ps.executeUpdate(); // 1 round trip ต่อ row!
}
}แต่ละ executeUpdate = 1 round trip = network latency × 1000 rows = ช้ามาก
8.2 Batch
ทางแก้คือ "batch" — สะสมหลายคำสั่งด้วย addBatch() แล้วส่งทีเดียวด้วย executeBatch() ลด round trip เหลือครั้งเดียว เร็วขึ้นได้ 50-100 เท่า:
java
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO users(name) VALUES (?)")) {
for (User u : users) {
ps.setString(1, u.getName());
ps.addBatch(); // ใส่ใน batch
}
int[] results = ps.executeBatch(); // ส่งทีเดียว
}→ 1 round trip = เร็วขึ้น 50-100x
8.3 PostgreSQL: reWriteBatchedInserts
เฉพาะ PostgreSQL มี option พิเศษที่ทำให้ batch insert เร็วขึ้นอีก — เปิด reWriteBatchedInserts=true ใน JDBC URL แล้ว driver จะรวมหลาย INSERT เป็น statement เดียว (multi-row VALUES):
text
jdbc:postgresql://localhost/mydb?reWriteBatchedInserts=trueถ้าใช้ Spring Boot เพิ่มใน application.yml:
yaml
spring:
datasource:
url: jdbc:postgresql://localhost/mydb?reWriteBatchedInserts=trueJDBC driver จะรวม INSERT ... VALUES (?), (?), (?), ... เป็น 1 statement
8.4 ใน Hibernate / JPA
ถ้าใช้ Hibernate/JPA (ไม่ได้เขียน JDBC ตรง) ก็เปิด batching ได้ผ่าน config — ตั้ง batch_size และ order_inserts/updates เพื่อให้ Hibernate รวมคำสั่งเป็น batch ให้อัตโนมัติ:
yaml
spring.jpa.properties.hibernate.jdbc.batch_size: 50
spring.jpa.properties.hibernate.order_inserts: true
spring.jpa.properties.hibernate.order_updates: true
# ถ้า entity ใช้ @Version (optimistic locking) ต้องเปิดอันนี้ด้วย
# ไม่งั้น Hibernate จะไม่ batch entity ที่มี version field
spring.jpa.properties.hibernate.jdbc.batch_versioned_data: truePart 9: ResultSet ลึก
9.1 Cursor types
java
PreparedStatement ps = conn.prepareStatement(
sql,
ResultSet.TYPE_FORWARD_ONLY, // default — เร็วสุด
ResultSet.CONCUR_READ_ONLY
);| Type | ความหมาย |
|---|---|
| TYPE_FORWARD_ONLY | next() อย่างเดียว — default |
| TYPE_SCROLL_INSENSITIVE | เลื่อนได้ทั้ง 2 ทาง, snapshot |
| TYPE_SCROLL_SENSITIVE | เลื่อนได้ + เห็นการเปลี่ยน |
ส่วนใหญ่ใช้ FORWARD_ONLY ก็พอ
9.2 Fetch size
ถ้า query คืน 1M row — JDBC driver จะ "fetch ทั้งหมด" มาเก็บใน memory → OOM (OutOfMemoryError — app crash เพราะ memory เต็ม)
ตั้ง fetch size:
java
ps.setFetchSize(1000);
ResultSet rs = ps.executeQuery();
while (rs.next()) { /* ... */ } // driver ดึงทีละ 1000⚠️ PostgreSQL: fetch size > 0 ต้องอยู่ใน transaction (
autoCommit = false) — ไม่งั้น JDBC จะ fetch ทั้งหมดทันที
9.3 Stream กับ Java Stream
ตั้งแต่ Spring Framework 5.3+ (Spring Boot 2.4+) มี JdbcTemplate.queryForStream:
java
// RowMapper = function แปลงแถวใน ResultSet เป็น Java object
RowMapper<User> userRowMapper = (rs, rowNum) ->
new User(rs.getLong("id"), rs.getString("name"), rs.getInt("age"));
try (Stream<User> stream = jdbcTemplate.queryForStream(
"SELECT * FROM users", userRowMapper)) {
stream.forEach(processOne);
}ดีกว่า query(...) ตอนข้อมูลใหญ่ — ไม่โหลดทั้งหมดเข้า memory
Part 10: Spring JdbcTemplate / JdbcClient
10.1 JdbcTemplate (classic)
JDBC ดิบมี boilerplate เยอะ (เปิด/ปิด connection, จัดการ exception) Spring มี JdbcTemplate ที่ห่อให้ — เราเขียนแค่ SQL + RowMapper (วิธีแปลงแถวเป็น object) ส่วนที่เหลือ Spring จัดการให้:
java
@Repository
public class UserRepository {
private final JdbcTemplate jdbc;
public UserRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public List<User> findByAgeGreaterThan(int age) {
return jdbc.query(
"SELECT id, name, age FROM users WHERE age > ?",
(rs, rowNum) -> new User(
rs.getLong("id"),
rs.getString("name"),
rs.getInt("age")
),
age
);
}
public int updateName(long id, String name) {
return jdbc.update("UPDATE users SET name = ? WHERE id = ?", name, id);
}
}10.2 JdbcClient (Spring Boot 3.2+, fluent API)
java
@Repository
public class UserRepository {
private final JdbcClient jdbcClient;
public UserRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public List<User> findByAgeGreaterThan(int age) {
return jdbcClient
.sql("SELECT id, name, age FROM users WHERE age > :age")
.param("age", age)
.query(User.class) // auto-mapping
.list();
}
}อ่านง่ายขึ้นเยอะ — แนะนำใช้ใน project ใหม่
10.3 NamedParameterJdbcTemplate
แทนที่จะใช้ ? ตามตำแหน่ง (นับลำดับยากเมื่อมีหลายตัว) NamedParameterJdbcTemplate ให้ตั้งชื่อ parameter (:age, :country) แล้วส่งเป็น Map — อ่านง่ายและสลับลำดับได้:
⚠️
NamedParameterJdbcTemplateเป็น คนละ class กับJdbcTemplateใน 10.1 — ต้อง inject แยกต่างหาก:
java
@Repository
public class UserRepository {
// NamedParameterJdbcTemplate เป็นคนละ object กับ JdbcTemplate
private final NamedParameterJdbcTemplate namedJdbc;
public UserRepository(NamedParameterJdbcTemplate namedJdbc) {
this.namedJdbc = namedJdbc;
}
public List<User> findByAgeAndCountry(int age, String country) {
Map<String, Object> params = Map.of("age", age, "country", country);
return namedJdbc.query(
"SELECT * FROM users WHERE age > :age AND country = :country",
params,
rowMapper
);
}
}Part 11: Read Replica + Routing DataSource
11.1 Pattern: Primary + Replica
เมื่อระบบโหลดอ่านสูง วิธีกระจายภาระคือใช้ DB หลายตัว — Primary รับการเขียน (write) ส่วน Replica (สำเนาที่ sync ตามมา) รับการอ่าน (read) แอปต้อง route คำสั่งให้ถูกตัว:
text
Write Read
│ │
▼ ▼
Primary DB ──replication──→ ReplicaApp route:
INSERT/UPDATE/DELETE→ PrimarySELECT→ Replica
11.2 Implementation: AbstractRoutingDataSource
Spring มี AbstractRoutingDataSource ที่เลือก DataSource ตอน runtime — เราเขียน logic เลือก primary/replica ตามว่า transaction เป็น read-only ไหม แล้ว @Transactional(readOnly = true) จะถูก route ไป replica อัตโนมัติ:
java
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
// Spring เรียก determineCurrentLookupKey() ทุกครั้งก่อนที่จะ borrow connection
// เพื่อรู้ว่าจะเอา connection จาก DataSource ไหน (PRIMARY หรือ REPLICA)
protected Object determineCurrentLookupKey() {
// ⚠️ isCurrentTransactionReadOnly() ทำงานเฉพาะเมื่อมี @Transactional active เท่านั้น
// ถ้าเรียกนอก Spring transaction → คืน false → route ไป PRIMARY เสมอ
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "REPLICA" // → replica (อ่านอย่างเดียว)
: "PRIMARY"; // → primary (อ่าน-เขียน)
}
}
@Bean
public DataSource dataSource(@Qualifier("primary") DataSource primary,
@Qualifier("replica") DataSource replica) {
RoutingDataSource routing = new RoutingDataSource();
routing.setTargetDataSources(Map.of("PRIMARY", primary, "REPLICA", replica));
routing.setDefaultTargetDataSource(primary);
return routing;
}
// ใน service:
@Transactional(readOnly = true)
public List<User> listUsers() { ... } // → replica
@Transactional
public void createUser(User u) { ... } // → primary11.3 ⚠️ ระวัง replication lag
Replica จะ "ตามหลัง" primary นิดหน่อย (ms-second). ถ้า:
text
1. POST /users → write primary
2. GET /users/1 → อ่าน replica → ไม่เจอ! (lag)แก้:
- Read-after-write จาก primary (อ่านข้อมูลจาก primary ทันทีหลัง write แทนที่จะอ่านจาก replica เพื่อหลีกเลี่ยง lag)
- Sticky session ช่วงสั้น (ผูก user กับ primary ชั่วคราว เช่น 1-5 วินาทีหลัง write)
Part 12: ใช้ PostgreSQL feature ผ่าน JDBC
12.1 JSONB
PostgreSQL มี type JSONB ที่เก็บข้อมูลกึ่งโครงสร้าง — ผ่าน JDBC เราส่งค่าด้วย PGobject (ตั้ง type เป็น "jsonb") และอ่านกลับเป็น JSON string:
java
PGobject jsonObj = new PGobject();
jsonObj.setType("jsonb");
jsonObj.setValue("{\"key\": \"value\"}");
ps.setObject(1, jsonObj);
// อ่าน
String json = rs.getString("data"); // คืน JSON string12.2 Array
PostgreSQL รองรับ array ในคอลัมน์ — JDBC แปลงไปมากับ Java array ได้ผ่าน conn.createArrayOf() ตอนส่ง และ rs.getArray() ตอนอ่าน:
java
Array arr = conn.createArrayOf("text", new String[]{"a", "b", "c"});
ps.setArray(1, arr);
// อ่าน
String[] result = (String[]) rs.getArray("tags").getArray();12.3 LISTEN/NOTIFY
java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import org.postgresql.PGConnection;
import org.postgresql.PGNotification;
// ⚠️ LISTEN/NOTIFY ต้องใช้ dedicated connection (ไม่ยืมจาก pool แบบ borrow/return)
// เพราะต้องค้าง connection ตลอดเวลา — ใช้ DriverManager.getConnection() แยกต่างหาก
try (Connection conn = DriverManager.getConnection(url, user, password)) {
PGConnection pg = conn.unwrap(PGConnection.class);
try (Statement stmt = conn.createStatement()) { // ✅ try-with-resources ปิด statement
stmt.execute("LISTEN my_channel");
}
// ⚠️ ตัวอย่างสาธิต: while(true) ในโค้ดจริงต้องมีทางออก (เช็ค Thread.currentThread().isInterrupted()
// หรือ volatile flag) ไม่งั้น loop วนไม่หยุด → dedicated connection ค้างไว้จนกว่าจะ kill process
while (!Thread.currentThread().isInterrupted()) {
// getNotifications(1000) = blocking poll สูงสุด 1 วินาที (event-driven ไม่ busy-loop)
PGNotification[] notifications = pg.getNotifications(1000);
if (notifications != null) { // ✅ เช็ค null ก่อน — คืน null เมื่อยังไม่มี notification
for (PGNotification n : notifications) {
System.out.println("Got: " + n.getParameter());
}
}
// รอ 1 วินาทีแล้ว poll ใหม่ (polling loop — ตรวจซ้ำทุก 1 วินาที)
}
}
// ออกจาก try-with-resources → dedicated connection ถูก close คืน (ไม่ leak)ทำ real-time event โดยไม่ต้องมี broker — เหมาะกับงานเล็ก
Part 13: Production Checklist
text
☐ ใช้ PreparedStatement เสมอ (อย่า concat string)
☐ ใช้ try-with-resources ทุก Connection / Statement / ResultSet
☐ ใช้ HikariCP เป็น pool
☐ ตั้ง pool size ตาม CPU × 2 + spindle (ไม่ใช่ทำใหญ่)
☐ ตั้ง leak detection threshold (60s)
☐ ตั้ง max lifetime (30 min — ป้องกัน stale connection)
☐ Export metric ของ pool ไป Prometheus
☐ Alert เมื่อ pool wait time > 100ms
☐ ใช้ batch สำหรับ bulk insert/update
☐ ตั้ง fetch size สำหรับ large result set
☐ ตั้ง statement timeout (กัน query ค้าง)
☐ ใช้ @Transactional (readOnly = true) สำหรับ query (เปิด route ไป replica)
☐ ตั้ง connection timeout (กัน DB ตายแล้ว app ค้าง)
☐ Log slow query (เกิน 1s)
☐ Test ด้วย Testcontainers (library ที่เปิด Docker container ของ PostgreSQL จริง ๆ ขณะรัน test — ได้พฤติกรรมตรงกับ production ต่างจาก H2 ที่เป็น in-memory DB พฤติกรรมต่างกัน)Part 14: Lab
Lab 1: เปรียบเทียบเปิดทีละ connection vs pool
ฝึกวัดเวลาจริงเพื่อเห็นว่า connection pool เร็วกว่าการเปิด connection ใหม่ทุกครั้งมากแค่ไหน — เปิดใหม่ 100 รอบ (ช้า) เทียบกับยืมจาก HikariCP 100 รอบ (เร็ว) เพราะการสร้าง connection มีต้นทุนสูง:
java
import java.sql.*;
import com.zaxxer.hikari.*;
public class BenchPool {
// ✏️ แก้ค่าตามของตัวเอง
static final String URL = "jdbc:postgresql://localhost:5432/mydb";
static final String USER = "postgres";
static final String PW = System.getenv("DB_PASSWORD"); // ห้าม hardcode
public static void main(String[] args) throws Exception {
// No pool
long t0 = System.nanoTime();
for (int i = 0; i < 100; i++) {
try (Connection c = DriverManager.getConnection(URL, USER, PW);
Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT 1")) {
rs.next(); // ✅ Statement และ ResultSet ถูก close โดย try-with-resources
}
}
System.out.printf("No pool: %d ms%n", (System.nanoTime() - t0) / 1_000_000);
// With Hikari
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(URL);
cfg.setUsername(USER);
cfg.setPassword(PW);
cfg.setMaximumPoolSize(10);
try (HikariDataSource ds = new HikariDataSource(cfg)) {
long t1 = System.nanoTime();
for (int i = 0; i < 100; i++) {
try (Connection c = ds.getConnection();
Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT 1")) {
rs.next();
}
}
System.out.printf("Hikari: %d ms%n", (System.nanoTime() - t1) / 1_000_000);
}
}
}ผลลัพธ์ทั่วไป: pool เร็วกว่า 10-50x
Lab 2: ทำ leak แล้วดู
⚠️ Lab นี้ต้องการ Spring Boot project — ถ้ายังไม่มี Spring Boot project ให้กลับไปทำบท Spring Boot ก่อน แล้วค่อยกลับมารัน Lab นี้ (annotation
@RestControllerและ@GetMappingเป็นของ Spring Boot ไม่ใช่ Java ล้วน ๆ)
ฝึกสร้าง connection leak ของจริง — ยืม connection แล้วไม่ close() ยิง endpoint ซ้ำ ๆ จน pool หมด แล้วดู error "Connection is not available" และ leak detection ของ HikariCP รายงาน เพื่อเข้าใจอาการและวิธีจับ:
java
@RestController
public class LeakController {
// ✅ constructor injection (แนะนำกว่า @Autowired field injection — ทดสอบง่ายกว่า)
private final DataSource ds;
public LeakController(DataSource ds) { this.ds = ds; }
@GetMapping("/leak")
public String leak() throws SQLException {
Connection conn = ds.getConnection(); // ไม่ปิด! → leak
return "ok";
}
}yaml
spring.datasource.hikari:
maximum-pool-size: 5
leak-detection-threshold: 5000curl /leak 6 ครั้ง → ครั้งที่ 6 จะค้างรอ ~30 วินาที (default connection-timeout) แล้วค่อย error "Connection is not available". ดู log → เห็น stack trace ของ leak
💡 ถ้าอยากเห็นผลเร็วขึ้น เพิ่ม
connection-timeout: 3000ใน config ของ lab
Lab 3: ดู batch effect
ต่อยอดจาก BenchPool ใน Lab 1 — ใช้ connection เดิมเปรียบเทียบ no-batch vs batch:
java
// ต้องมีตาราง: CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100));
try (Connection conn = DriverManager.getConnection(BenchPool.URL, BenchPool.USER, BenchPool.PW)) {
conn.setAutoCommit(false);
// no batch — insert ทีละ row (ช้า)
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO users(name) VALUES (?)")) {
long t0 = System.nanoTime();
for (int i = 0; i < 1000; i++) {
ps.setString(1, "user_" + i);
ps.executeUpdate(); // 1 round trip ต่อ row
}
conn.commit();
System.out.printf("No batch: %d ms%n", (System.nanoTime() - t0) / 1_000_000);
}
// batch — สะสมแล้วส่งทีเดียว (เร็ว)
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO users(name) VALUES (?)")) {
long t1 = System.nanoTime();
for (int i = 0; i < 1000; i++) {
ps.setString(1, "batch_" + i);
ps.addBatch();
}
ps.executeBatch(); // ส่งทีเดียว
conn.commit();
System.out.printf("Batch: %d ms%n", (System.nanoTime() - t1) / 1_000_000);
}
}→ batch เร็วกว่า 10-100x
Part 15: Checkpoint
- ทำไม
PreparedStatementปลอดภัยกว่าStatement? - การเปิด connection ใหม่ทุก request แย่ยังไง? Pool แก้ปัญหานี้ยังไง?
- PostgreSQL formula สำหรับ pool size คือ?
setAutoCommit(false)ทำอะไร? ต่างกับ default ยังไง?- ความต่างระหว่าง READ_COMMITTED กับ REPEATABLE_READ?
- Leak detection ของ HikariCP ตั้งยังไง? log อะไรออกมา?
- ทำไม batch insert เร็วกว่าทีละ row?
setFetchSizeใช้ตอนไหน? มี caveat อะไรใน PostgreSQL?@Transactional(readOnly = true)ทำอะไร? routing ไหน?- ถ้า DB max_connections = 100 และมี 10 pod — ตั้ง pool size pod ละเท่าไหร่?
Part 16: สรุปบทนี้
- JDBC = API มาตรฐาน. Driver = implement per DB
- Open connection แพง — ใช้ HikariCP เสมอ
- Pool size: small is better — สูตร
(core × 2) + spindle - Connection leak = ปัญหายอดฮิต — ใช้ try-with-resources + leak detection
- PreparedStatement เสมอ — กัน SQL injection + plan cache
- Transaction ต้อง
setAutoCommit(false)+ commit/rollback คู่กันเสมอ - Batch เร็วกว่า single insert 50-100x
- Routing DataSource ส่ง read ไป replica
- Production: leak detection + metric + slow query log + Testcontainers
บทต่อไป — เราจะดู Reflection + Annotation Processing — ของลึกที่ทำให้ Spring/Hibernate magic เกิด
← บทที่ 16 | สารบัญ | บทที่ 18: Reflection + Annotation Processing →