Skip to content

บทที่ 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 จัดการให้หมดแล้ว ไม่ต้องรู้บทนี้ก่อน

ศัพท์ย่อที่จะเจอ:

ศัพท์คำแปล/อธิบาย
JDBCJava Database Connectivity — API มาตรฐานของ Java สำหรับต่อฐานข้อมูล
connectionคอนเนกชัน — ช่องเชื่อมต่อหนึ่งสายไปยังฐานข้อมูล
connection poolแอ่งคอนเนกชัน — ที่เก็บ connection ที่เปิดค้างไว้หมุนเวียนใช้ซ้ำ แทนการเปิด-ปิดใหม่ทุกครั้ง
HikariCPฮิคาริ — library connection pool ที่เร็วและนิยมที่สุด
pool sizingการปรับจำนวน connection ในแอ่งให้พอดี
leak detectionการจับ connection ที่ยืมไปแล้วลืมคืน
batchการส่งหลายคำสั่ง SQL พร้อมกันรวดเดียว
ORMObject-Relational Mapping — การแปลง object ↔ ตารางในฐานข้อมูล
N+1ปัญหาคิวรีซ้ำ ๆ มากเกินจำเป็น
JPAJava Persistence API — มาตรฐาน ORM ของ Java (กำหนดโดย Jakarta EE)
Hibernateimplementation ของ JPA ที่นิยมที่สุด (Spring Boot ใช้เป็น default)
Spring Data JPAlayer ของ Spring ที่ห่อ JPA/Hibernate — ให้เขียน repository.findById() ได้โดยไม่ต้องเขียน SQL เอง (รายละเอียดอยู่ในชุดบท Spring Boot)

หลายคนเรียน Spring Data JPA แล้วใช้ repository.findById() ได้ทันที — ไม่เคยรู้ว่า "ใต้นั้น" มีอะไร

แต่พอเจอ:

  • connection leak — pool exhausted
  • too 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 นี้

DBDriver
PostgreSQLorg.postgresql:postgresql
MySQLcom.mysql:mysql-connector-j
SQLiteorg.xerial:sqlite-jdbc
Oraclecom.oracle.database.jdbc:ojdbc11
SQL Servercom.microsoft.sqlserver:mssql-jdbc
H2 (in-memory)com.h2database:h2

1.2 ทำไม Spring Data / Hibernate ยังต้องรู้ JDBC

ตอนคุณใช้แต่ใต้นั้นคือ
userRepo.findById(1L)Spring DataHibernate → JDBC
entityManager.createQuery(...)JPAHibernate → JDBC
jdbcTemplate.query(...)Spring JDBCJDBC ตรง ๆ
connection.prepareStatement(...)Plain JDBCJDBC 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):

bash
docker run -d --name pg-dev -e POSTGRES_PASSWORD=secret -p 5432:5432 postgres:16

จากนั้นสร้างตาราง:

sql
CREATE 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:

java
String 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คือ
DriverManagerfactory (ตัวสร้าง — รับ config แล้วสร้าง object ให้ เหมือนโรงงาน) สำหรับเปิด Connection (ใช้ได้กับ script เล็ก ๆ / one-off; production ใช้ DataSource ที่มี pool)
Connection"session" กับ DB. มี state — autocommit, transaction, lock
PreparedStatementSQL ที่ compile แล้ว + รับ parameter (กัน SQL injection)
StatementSQL ตรง ๆ ไม่มี parameter (ห้ามใช้กับ user input!)
ResultSetcursor วิ่งบนผลลัพธ์ — 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 DBCP2classic, ใช้ใน old enterprise
C3P0feature เยอะ, slow
Tomcat JDBC Poolbundled กับ Tomcat
Agroalของ Quarkus, optimize for native

ใน 2026 — ใช้ HikariCP เสมอ (ยกเว้นมีเหตุผลพิเศษ)

4.3 HikariCP — เร็วเพราะอะไร

HikariCP ทำหลายอย่างเพื่อความเร็ว:

  1. FastList แทน ArrayList — ไม่เช็ค range, ไม่ remove จากกลาง
  2. ConcurrentBag — collection สำหรับ lend/return ที่ optimize ThreadLocal (ที่เก็บข้อมูลแยกต่อ thread)
  3. Bytecode-level optimization — Javassist เขียน proxy (ตัวแทน object ที่ intercept การเรียก method)
  4. ไม่มี 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: HikariMyApp

Spring 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 server
  • effective_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

AppWorkloadPool size
Spring Boot APIOLTP, mostly < 100ms query10-20
Heavy reportingLong queries5-10 + read replica
Worker / batchBulk insert5-10
Background schedulerCron3-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 อื่น)

✅ = ปัญหานี้เกิดได้ | ❌ = ป้องกันได้ในระดับนี้

LevelDirty readNon-repeatablePhantomค่า constant (Java: Connection.TRANSACTION_*)
READ_UNCOMMITTED✅ เกิด1 (TRANSACTION_READ_UNCOMMITTED)
READ_COMMITTED2 (TRANSACTION_READ_COMMITTED)
REPEATABLE_READ4 (TRANSACTION_REPEATABLE_READ)
SERIALIZABLE8 (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

  1. ใช้ try-with-resources เสมอ ทุกระดับ (Connection, Statement, ResultSet)
  2. อย่า hold connection ใน loop ที่มี business logic ยาว
  3. อย่าเรียก REST API ระหว่าง connection borrowed (จะ block + leak ถ้าช้า)
  4. ใช้ 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=true

JDBC 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: true

Part 9: ResultSet ลึก

9.1 Cursor types

java
PreparedStatement ps = conn.prepareStatement(
    sql,
    ResultSet.TYPE_FORWARD_ONLY,        // default — เร็วสุด
    ResultSet.CONCUR_READ_ONLY
);
Typeความหมาย
TYPE_FORWARD_ONLYnext() อย่างเดียว — 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──→  Replica

App route:

  • INSERT/UPDATE/DELETE → Primary
  • SELECT → 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) { ... }     // → primary

11.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 string

12.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: 5000

curl /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

  1. ทำไม PreparedStatement ปลอดภัยกว่า Statement?
  2. การเปิด connection ใหม่ทุก request แย่ยังไง? Pool แก้ปัญหานี้ยังไง?
  3. PostgreSQL formula สำหรับ pool size คือ?
  4. setAutoCommit(false) ทำอะไร? ต่างกับ default ยังไง?
  5. ความต่างระหว่าง READ_COMMITTED กับ REPEATABLE_READ?
  6. Leak detection ของ HikariCP ตั้งยังไง? log อะไรออกมา?
  7. ทำไม batch insert เร็วกว่าทีละ row?
  8. setFetchSize ใช้ตอนไหน? มี caveat อะไรใน PostgreSQL?
  9. @Transactional(readOnly = true) ทำอะไร? routing ไหน?
  10. ถ้า 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 →