เจาะลึกกลไก Explicit Row-level Locking ของ PostgreSQL เมื่อระบบต้องรองรับ Concurrency สูงระดับแสนผู้ใช้ รู้วิธีใช้ FOR UPDATE, NOWAIT, SKIP LOCKED และการตั้งค่า lock_timeout เพื่อขจัดปัญหา Race Condition, Overbooking, คิวล็อกค้างยาว และ Deadlock อย่างหมดจด
พอคนใช้พร้อมกันเยอะ คอขวดถัดไปไม่ได้อยู่ที่เครือข่ายหรือดิสก์อีกแล้ว แต่เป็นการแย่งกันแตะแถวเดียวกัน จนคิวล็อกสะสม
สภาวะที่หลาย transaction พยายามเข้าถึงหรือแก้ข้อมูลชิ้นเดียวกันในเวลาเดียวกัน จนเกิดคิวรอ (block) สะสม ระบบจึงช้าหรือค้าง
แม่กุญแจที่ฐานข้อมูลล็อกไว้เฉพาะแถวที่กำลังถูกอัปเดต เพื่อไม่ให้คำสั่งอื่นมาแก้ทับจนข้อมูลมั่ว
โดยรวมวันนี้คือการดูว่าการล็อกแถวส่งผลให้เกิดคิวค้างอย่างไร และมีไวยากรณ์อะไรบ้าง เช่น FOR UPDATE SKIP LOCKED ที่ช่วยให้ทำงานขนานกันได้โดยไม่ต้องจอดรอแถวที่ยังล็อกอยู่
เมื่อผู้ใช้พร้อมกันจำนวนมาก ระบบค้างเพราะติดล็อกได้ง่าย วันนี้จึงสอนให้ควบคุมแม่กุญแจและปลดล็อกเฉพาะจุด เพื่อให้รองรับ high concurrency ได้จริง
เมื่อระบบขยายสเกลรองรับผู้ใช้งานพร้อมกันหลายหมื่น Concurrency คอขวดจะไม่ได้อยู่ที่ Network หรือ Disk I/O อีกต่อไป แต่อยู่ที่ Transaction Lock Contention การที่สองคำสั่งหรือสองผู้ใช้พยายามจะแก้ไขข้อมูลในแถวเดียวกัน (Same Row) ในมิลลิวินาทีเดียวกัน หากไม่มีกลยุทธ์การล็อกที่ถูกต้อง แถวข้อมูลจะถูกล็อกค้าง เกิดคิวรอคอยยาวเป็นโดมิโน และเกิด Deadlock จนเซิร์ฟเวอร์ล่ม
ในวันเปิดจองบัตรคอนเสิร์ตรอบพิเศษ แฟนเพลง 100,000 คน พยายามกดจองที่นั่งยอดฮิตพร้อมกันในเสี้ยววินาที ทีมพัฒนาเขียนคำสั่งบน App Server อ่านที่นั่งว่างด้วย SELECT ธรรมดา แล้วยิง UPDATE กลับไปโดยไม่มี Explicit Locking Control ผลลัพธ์คือ:
"ทุกท่านครับ คอขวดระดับ Concurrency สูง ไม่ได้เกิดจากฐานข้อมูลไม่มี Index แต่เกิดจาก Transaction Lock Contention ที่ต่างคนต่างแย่งกันถือ Lock ค้างจนเกิด Lock Queue Cascade และ Deadlock
ด้วยการนำเสนอ In-Database Non-Blocking Concurrency Engine ร่วมกับเทคนิค FOR UPDATE SKIP LOCKED และการตั้งค่า lock_timeout เราจะทำให้ Worker Threads นับร้อยรันประมวลผลคิวงานพร้อมกันได้โดยไม่ชนกันเลยแม้แต่มิลลิวินาทีเดียว ขจัดปัญหา Overbooking และป้องกันระบบล่มจาก Deadlock ได้ 100% ครับ"
55P03 (lock_not_available) คืนกลับทันทีใน 0 ms เพื่อไม่ให้เกิดการนั่งรอคิวบล็อก Connection
SET LOCAL lock_timeout = '2s';) หากรอเกินเวลาจะยกเลิกคำสั่งทันที ป้องกันไม่ให้คำสั่งค้างยาวจนล้น Connection Pool
deadlock_timeout
SELECT ทั่วไปจะอ่าน Snapshot เดิมได้เสมอโดยไม่ถูก FOR UPDATE บล็อก
ลองเลือกกลยุทธ์การล็อก (Lock Strategy) และปรับจำนวน Worker Threads พร้อมกัน เพื่อสังเกตพฤติกรรมการต่อคิว อัตราความสำเร็จ และการเกิด Lock Contention
การควบคุม Lock ใน PostgreSQL ต้องเริ่มต้นจากคำสั่ง SELECT ... FOR UPDATE ร่วมกับคำสั่งจัดการ Transaction ใน PL/pgSQL มาดูโครงสร้างไวยากรณ์หลัก 3 รูปแบบที่สถาปนิกและนักพัฒนาต้องเชี่ยวชาญ:
-- [รูปแบบที่ 1: FOR UPDATE ธรรมดา - นั่งรอคิวจนกว่าคนก่อนหน้าจะปลดล็อก]
SELECT balance INTO v_balance
FROM accounts
WHERE account_id = 101
FOR UPDATE;
-- [รูปแบบที่ 2: FOR UPDATE NOWAIT - ถ้าติดล็อก ให้พ่น Error 55P03 ทันทีใน 0ms]
SELECT balance INTO v_balance
FROM accounts
WHERE account_id = 101
FOR UPDATE NOWAIT;
-- [รูปแบบที่ 3: FOR UPDATE SKIP LOCKED - ถ้าติดล็อก ให้ข้ามไปอ่านแถวถัดไปทันที]
SELECT task_id, payload INTO v_task_id, v_payload
FROM task_queue
WHERE status = 'PENDING'
ORDER BY priority DESC
LIMIT 1
FOR UPDATE SKIP LOCKED;
FOR UPDATE จะมีผลตั้งแต่บรรทัดที่สั่ง จนถึงบรรทัดที่ Transaction จบลง (COMMIT หรือ ROLLBACK) เท่านั้น เมื่อสิ้นสุดบล็อก Locks จะถูกปลดปล่อยอัตโนมัติ
DO $$
DECLARE
-- [1] ประกาศตัวแปรโดยใช้ %TYPE เชื่อมโยงกับตาราง accounts
v_from_acc accounts.account_id%TYPE := 101;
v_to_acc accounts.account_id%TYPE := 102;
v_amount accounts.balance%TYPE := 500.00;
v_from_bal accounts.balance%TYPE;
BEGIN
-- [2] ล็อกแถวบัญชีต้นทางล่วงหน้าด้วย FOR UPDATE เพื่ออ่านยอดเงินล่าสุด
-- ป้องกันไม่ให้ Transaction อื่นเข้ามาตัดเงินหรือแก้ไขยอดแทรกกลางคัน
SELECT balance INTO v_from_bal
FROM accounts
WHERE account_id = v_from_acc
FOR UPDATE; -- ล็อก Row นี้ทันทีจนกว่าจะจบ Block
-- [3] ตรวจสอบยอดเงินคงเหลือ
IF v_from_bal >= v_amount THEN
-- หักเงินบัญชีต้นทาง
UPDATE accounts SET balance = balance - v_amount WHERE account_id = v_from_acc;
-- เพิ่มเงินบัญชีปลายทาง
UPDATE accounts SET balance = balance + v_amount WHERE account_id = v_to_acc;
RAISE NOTICE 'โอนเงินสำเร็จจำนวน % บาท (ยอดคงเหลือใหม่: %)', v_amount, (v_from_bal - v_amount);
ELSE
RAISE NOTICE 'ยอดเงินไม่เพียงพอ (ยอดคงเหลือปัจจุบัน: %)', v_from_bal;
END IF;
END $$;
-- 📤 ผลลัพธ์ใน Log:
-- NOTICE: โอนเงินสำเร็จจำนวน 500.00 บาท (ยอดคงเหลือใหม่: 4500.00)
DO $$
DECLARE
v_seat_id seats.seat_id%TYPE := 55;
v_status seats.status%TYPE;
BEGIN
-- [1] ตั้งค่าระยะเวลารอคิวล็อกสูงสุดไม่เกิน 2 วินาที ป้องกันการค้างยาว
SET LOCAL lock_timeout = '2s';
-- [2] พยายามจองและล็อกที่นั่ง หากมีคนอื่นกำลังล็อกอยู่ ให้พ่น Error ทันทีด้วย NOWAIT
SELECT status INTO v_status
FROM seats
WHERE seat_id = v_seat_id AND status = 'AVAILABLE'
FOR UPDATE NOWAIT;
-- [3] หากดึงผ่าน แสดงว่าจองสำเร็จ ทำการอัปเดตสถานะ
UPDATE seats SET status = 'RESERVED', reserved_at = CURRENT_TIMESTAMP WHERE seat_id = v_seat_id;
RAISE NOTICE 'จองที่นั่งหมายเลข % สำเร็จ!', v_seat_id;
EXCEPTION
-- [4] ดักจับ Exception เมื่อติดล็อกและเกิด NOWAIT/lock_timeout (รหัส Error 55P03)
WHEN lock_not_available OR 55P03 THEN
RAISE NOTICE 'ที่นั่งหมายเลข % กำลังมีผู้อื่นทำการจอง กรุณาลองใหม่อีกครั้ง (Fail-Fast สำเร็จ)', v_seat_id;
WHEN NO_DATA_FOUND THEN
RAISE NOTICE 'ที่นั่งหมายเลข % ไม่ว่างหรือไม่มีในระบบ', v_seat_id;
END $$;
NOWAIT ร่วมกับบล็อก EXCEPTION ช่วยให้ผู้ใช้ได้รับข้อความแจ้งเตือนทันทีโดยไม่ต้องรอหมุนค้าง 2-8 วินาที
เปรียบเทียบโค้ดเดิมที่แย่งงานคิวเดียวกันจนเกิด Race Condition กับโค้ดใหม่ที่ใช้ FOR UPDATE SKIP LOCKED:
# โค้ด Worker บน Application Server
# Worker หลายตัวรันพร้อมกัน ยิงดึงงานคิวตัวเดียวกัน
# ⚠️ Worker 1 และ Worker 2 ได้ task_id เดียวกันไปทำซ้ำ!
task = db.execute(
"SELECT id, payload FROM tasks "
"WHERE status = 'PENDING' LIMIT 1"
)
if task:
# หรือถ้าใส่ FOR UPDATE ธรรมดา Worker ตัวอื่นจะยืนติด Lock รอค้าง
db.execute(
"UPDATE tasks SET status = 'PROCESSING' WHERE id = %s",
(task.id,)
)
process_task(task)
DO $$
DECLARE
-- [บรรทัดที่ 1] ประกาศตัวแปรรับค่า ID และ Payload งานที่จะหยิบไปทำ
v_task_id tasks.id%TYPE;
v_payload tasks.payload%TYPE;
BEGIN
-- [บรรทัดที่ 2] ดึงงานที่ค้าง โดยใช้ FOR UPDATE SKIP LOCKED
-- หาก Worker ตัวอื่นกำลังประมวลผลงาน ID ใดอยู่ คำสั่งนี้จะข้ามแถวนั้นไปหยิบงานถัดไปทันที
SELECT id, payload
INTO v_task_id, v_payload
FROM tasks
WHERE status = 'PENDING'
ORDER BY priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED; -- 🎯 ข้ามแถวติดล็อกทันที ทำงานพร้อมกันร้อย Thread โดยไม่ชนกัน
-- [บรรทัดที่ 3] ตรวจสอบว่ามีงานให้ทำหรือไม่
IF v_task_id IS NOT NULL THEN
-- ทำการอัปเดตสถานะงานเป็น PROCESSING บน RAM
UPDATE tasks
SET status = 'PROCESSING',
processed_by = 'Worker-01',
started_at = CURRENT_TIMESTAMP
WHERE id = v_task_id;
RAISE NOTICE 'Worker หยิบงาน ID: % (Payload: %) ไปประมวลผลสำเร็จ', v_task_id, v_payload;
ELSE
RAISE NOTICE 'ไม่มีงานค้างในคิว (หรือ งานทั้งหมดกำลังถูก Worker ตัวอื่นทำอยู่)';
END IF;
END $$;
แบบทดสอบวัดระดับความเข้าใจการจัดการ Concurrency, Explicit Row-level Locks (FOR UPDATE / NOWAIT / SKIP LOCKED), lock_timeout, การป้องกัน Deadlock และข้อสอบทบทวน Day 1 & Day 2 คลิกเลือกคำตอบ ก, ข, ค, หรือ ง เพื่อดูผลลัพธ์และคำอธิบายอย่างละเอียดทันที
บทเรียน Day 3: Transaction Lock Contention & Concurrency Control แสดงให้เห็นถึงกลไกการควบคุมและป้องกันการชนกันของข้อมูลในระดับ Concurrency สูง โดยใช้การล็อกระดับแถวแบบเจาะจง (FOR UPDATE), กลไก Fail-Fast (NOWAIT / lock_timeout), และกลไกประมวลผลคิวงานแบบ Non-Blocking (SKIP LOCKED) ซึ่งช่วยขจัดปัญหา Race Condition, Overbooking และ Deadlock ได้อย่างเด็ดขาด
Architecture Capstone & Phase 1 Synthesis: เราจะทำการรวบยอดสรุปสถาปัตยกรรม Phase 1 (Days 1–3: Chatty Network RTT, Buffer Pool Memory, และ Transaction Lock Scope) เพื่อสร้าง End-to-End Architectural Benchmark & Presentation Defense Framework เตรียมพร้อมสำหรับการเข้าสู่ Phase 2 (Deep Dive into the 4 Pillars) ครับ!