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_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 ​

  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 →