เจาะลึกกลไกภายในของ PostgreSQL Storage Engine ว่าทำไมข้อมูล Data Pages ขนาด 8KB ที่พักอยู่ใน Shared Buffer Pool บน RAM ถึงประมวลผลเร็วกว่าการอ่านจาก Disk นับแสนเท่า และการเขียน PL/pgSQL ด้วยแนวคิด "Buffer Pool Proximity" ช่วยตัดภาระ Disk I/O Bottleneck ได้อย่างไร
เมื่อเราอยากประมวลผลในฐานข้อมูล คำถามถัดไปคือฐานข้อมูลเก็บและดึงข้อมูลอย่างไร — วันนี้เราดูสองชั้นที่ทำงานคู่กัน: ความจำบน RAM กับไฟล์บนดิสก์
พื้นที่ RAM ส่วนกลางของฐานข้อมูล ใน PostgreSQL คือ shared_buffers ถูกตัดเป็นบล็อก 8KB ทำหน้าที่แคชพักข้อมูล เพื่อให้อ่านและแก้บน RAM ได้ทันทีโดยไม่ต้องแตะฮาร์ดดิสก์
กลไกที่จัดการพื้นที่จัดเก็บบนดิสก์ จับบล็อก 8KB ไว้ในไฟล์ แล้วคอยดึงขึ้นมาที่ RAM หรือเขียนบล็อกที่ถูกแก้กลับลงดิสก์
โดยรวมวันนี้คือเรื่องที่ RAM กับดิสก์ส่งไม้ต่อกันอย่างไร เวลาฐานข้อมูลจะค้นหาหรือแก้ไขข้อมูลขนาด 8KB — ถ้าบล็อกนั้นอยู่บน RAM อยู่แล้ว งานจะจบเร็วมาก ถ้าไม่มี ต้องเดินไปเปิดไฟล์บนดิสก์ก่อน
จาก Day 1 ที่บอกว่าต้องย้ายงานเข้าฐานข้อมูล วันนี้บอกต่อว่าให้เขียน Query ให้เจอข้อมูลบน RAM (Buffer Hit) แทนที่จะบังคับให้ฐานข้อมูลเดินไปอ่านดิสก์ (Disk I/O) ที่ช้ากว่าเป็นพันเท่า
การสร้าง In-Database Automation Engine บน PostgreSQL ให้มีประสิทธิภาพระดับ Nanoseconds เราไม่สามารถมองฐานข้อมูลเป็นเพียงตารางลอยๆ ได้ แต่ต้องเข้าใจกลไกภายในของ Storage Engine โดยเฉพาะ Shared Buffer Pool (shared_buffers) ซึ่งเป็นพื้นที่ RAM หลักที่ PostgreSQL ใช้พักบล็อกข้อมูล (Data Pages ขนาด 8KB)
ระบบคำนวณดอกเบี้ยและยอดคงเหลือสิ้นวัน (End-of-Day Balance Processing) ของสถาบันการเงินแห่งหนึ่ง มีข้อมูลบัญชีลูกค้า 10,000,000 แถว ทีมพัฒนาเขียนสคริปต์บน App Server ดึงข้อมูลทีละ Batch ข้าม Network มาคำนวณใน App Memory ผลปรากฏว่า:
ทำไมการรันบน RAM (shared_buffers) ถึงเร็วกว่าการปล่อยให้หลุดไปอ่าน Disk? ดูความแตกต่างทางฟิสิกส์ของหน่วยเวลา:
"ทุกท่านครับ คอขวดการประมวลผลสิ้นวันไม่ได้เกิดจาก CPU คำนวณช้า แต่เกิดจาก Disk I/O Bottleneck จากการกวาดอ่าน Data Pages ขนาด 8KB จาก Disk ขึ้นๆ ลงๆ จนค่า Cache Hit Ratio ตกเหลือเพียง 60%
ด้วยการใช้ PL/pgSQL In-Database Processing ร่วมกับกลไก Buffer Pool Proximity เราจะประมวลผลข้อมูลที่โหลดอยู่ใน shared_buffers บน RAM โดยตรง ยึดค่า Cache Hit Ratio ให้สูงกว่า 99% ลด Disk Read จากกิกะไบต์เหลือศูนย์ และตัดเวลาประมวลผลสิ้นวันจาก 8 ชั่วโมงเหลือเพียงไม่ถึง 3 นาทีครับ"
shared_buffers เสมอ
ลองปรับเปอร์เซ็นต์ Cache Hit Ratio และจำนวนคำขอข้อมูล เพื่อดูว่าเมื่อข้อมูลหลุดไปอ่านจาก Disk (NVMe/SSD) เวลารวมจะพุ่งสูงขึ้นกี่พันเท่าตัว
ในการเขียน PL/pgSQL ร่วมกับ Storage Engine การฮาร์ดโค้ดชนิดตัวแปร (เช่น v_name VARCHAR(50);) มีความเสี่ยงสูงหากอนาคตตารางเปลี่ยน Schema การใช้ Dynamic Data Anchoring (%TYPE และ %ROWTYPE) ช่วยผูกชนิดข้อมูลกับตารางโดยตรงในระดับ RAM ป้องกัน Data Alignment Mismatch ได้อย่างสมบูรณ์
จำง่ายๆ: %TYPE ผูกกับ "คอลัมน์เดียว" ส่วน %ROWTYPE ผูกกับ "ทั้งแถว (ทุกคอลัมน์)"
DO $$
DECLARE
-- [Statement 1: %TYPE] ผูกชนิดข้อมูลของตัวแปรตามคอลัมน์เฉพาะในตาราง
-- ไวยากรณ์: ชื่อตาราง.ชื่อคอลัมน์%TYPE;
v_emp_id employees.employee_id%TYPE := 101;
v_salary employees.salary%TYPE;
-- [Statement 2: %ROWTYPE] สร้างตัวแปรเรกคอร์ดรับโครงสร้างทั้งแถวของตาราง
-- ไวยากรณ์: ชื่อตาราง%ROWTYPE;
v_emp_rec employees%ROWTYPE;
BEGIN
-- [Statement 3: SELECT ... INTO Record] ดึงข้อมูลทั้งแถวจาก Buffer มาพักใน RAM Record
SELECT * INTO v_emp_rec
FROM employees
WHERE employee_id = v_emp_id;
-- [Statement 4: Accessing Record Fields] อ้างอิงฟิลด์ผ่านเครื่องหมายจุด (.field_name)
RAISE NOTICE 'พนักงาน: % % | เงินเดือนปัจจุบัน: %',
v_emp_rec.first_name, v_emp_rec.last_name, v_emp_rec.salary;
END $$;
salary จาก NUMERIC(8,2) เป็น NUMERIC(14,2) โค้ด PL/pgSQL นี้จะปรับตามอัตโนมัติ ไม่ต้องแก้โค้ดและไม่เกิด Runtime Overflow!
DO $$
DECLARE
-- [1] ประกาศตัวแปรโดยอ้างอิงชนิดข้อมูลจากตาราง accounts โดยตรง
v_acc_id accounts.account_id%TYPE := 5001; -- ถ้านานไป account_id เปลี่ยนเป็น BIGINT โค้ดนี้จะไม่พัง
v_balance accounts.balance%TYPE; -- อ้างอิงชนิดข้อมูล balance จากตาราง accounts
BEGIN
-- [2] ดึงค่า balance มาใส่ตัวแปร v_balance
SELECT balance INTO v_balance
FROM accounts
WHERE account_id = v_acc_id;
-- [3] แสดงผลลัพธ์ออก Log
RAISE NOTICE 'รหัสบัญชี: % | ยอดเงินคงเหลือ: % บาท', v_acc_id, v_balance;
END $$;
-- 📤 ผลลัพธ์ใน Log:
-- NOTICE: รหัสบัญชี: 5001 | ยอดเงินคงเหลือ: 45000.00 บาท
v_balance จะมีชนิดข้อมูลเหมือนกับคอลัมน์ balance ในตารางเป๊ะๆ โดยไม่ต้องเปิดดูโครงสร้างตารางล่วงหน้า
DO $$
DECLARE
v_target_id accounts.account_id%TYPE := 5001;
v_acc_row accounts%ROWTYPE; -- [1] สร้างตัวแปรเรกคอร์ดรองรับทุกคอลัมน์ของตาราง accounts
BEGIN
-- [2] ดึงข้อมูลทั้งแถว (Data Page ใน shared_buffers) มายัดใส่ v_acc_row
SELECT * INTO v_acc_row
FROM accounts
WHERE account_id = v_target_id;
-- [3] แสดงผลข้อมูลจากฟิลด์ต่างๆ ของ %ROWTYPE โดยใช้เครื่องหมายจุด (.)
IF v_acc_row.status = 'ACTIVE' THEN
RAISE NOTICE 'บัญชี % ของคุณ % % มีสถานะใช้งานปกติ ยอดเงิน: % บาท',
v_acc_row.account_id, v_acc_row.first_name, v_acc_row.last_name, v_acc_row.balance;
ELSE
RAISE NOTICE 'บัญชี % มีสถานะ: %', v_acc_row.account_id, v_acc_row.status;
END IF;
END $$;
first_name, last_name, balance อีกต่อไป ใช้ v_acc_row.ชื่อคอลัมน์ ได้ทันที
เปรียบเทียบโค้ดเดิมที่กวาดอ่านข้อมูลข้าม Network จนล้าง Cache ใน RAM ทิ้ง กับโค้ดใหม่ที่เขียนด้วย PL/pgSQL ประมวลผลติดชิด shared_buffers:
# โค้ดฝั่ง Application Server
# วนลูปดึงข้อมูลทีละ 1,000 แถวมาคำนวณดอกเบี้ย
# เกิด Buffer Read จาก Disk ซ้ำซ้อน และ Cache Thrashing!
accounts = db.execute(
"SELECT account_id, balance FROM accounts WHERE status = 'ACTIVE'"
)
for acc in accounts:
new_balance = acc.balance + (acc.balance * 0.05) # คำนวณดอกเบี้ย 5%
db.execute(
"UPDATE accounts SET balance = %s WHERE account_id = %s",
(new_balance, acc.account_id)
)
DO $$
DECLARE
-- [บรรทัดที่ 1] ประกาศตัวแปรอัตราดอกเบี้ย โดยใช้อ้างอิง %TYPE จากคอลัมน์ balance
v_interest_rate accounts.balance%TYPE := 0.05;
-- [บรรทัดที่ 2] ประกาศตัวแปรเก็บจำนวนแถวที่ประมวลผลสำเร็จ
v_processed_cnt INT;
BEGIN
-- [บรรทัดที่ 3] เริ่มต้น CTE เพื่อประมวลผลคำนวณและอัปเดตข้อมูลบน shared_buffers ใน RAM โดยตรง
WITH updated_accounts AS (
-- [บรรทัดที่ 4-7] คำสั่ง UPDATE ประมวลผลแบบ Set-based ติดชิด Buffer Pool
-- ทำการอ่านและแก้ไข Data Pages ขนาด 8KB ที่อยู่ใน shared_buffers รวดเดียว ไม่ต้องดึงออกนอก DB Engine
UPDATE accounts
SET balance = balance + (balance * v_interest_rate),
last_interest_applied = CURRENT_TIMESTAMP
WHERE status = 'ACTIVE'
-- [บรรทัดที่ 8] พ่น account_id ของแถวที่อัปเดตสำเร็จบน RAM ส่งต่อไปเก็บใน CTE updated_accounts
RETURNING account_id
)
-- [บรรทัดที่ 9] สั่งนับจำนวนรายการที่ถูกประมวลผลทั้งหมด ยัดใส่ v_processed_cnt
SELECT COUNT(*) INTO v_processed_cnt FROM updated_accounts;
-- [บรรทัดที่ 10] แสดงผลการประมวลผลบน RAM
RAISE NOTICE 'ประมวลผลคิดดอกเบี้ยสำเร็จ % รายการ (Buffer Cache Hit Rate > 99%%)', v_processed_cnt;
END $$;
แบบทดสอบวัดระดับความเข้าใจสถาปัตยกรรม Storage Engine, Shared Buffers, Data Pages 8KB, Dynamic Anchoring (%TYPE / %ROWTYPE) และข้อสอบทบทวน Day 1 คลิกเลือกคำตอบ ก, ข, ค, หรือ ง เพื่อดูผลลัพธ์และคำอธิบายอย่างละเอียดทันที
บทเรียน Day 2: Storage Engine & Buffer Pool Mechanics ชี้ให้เห็นถึงความสำคัญของการประมวลผลติดชิดหน่วยความจำ Shared Buffer Pool (shared_buffers) โดยใช้หลักการ Buffer Pool Proximity ร่วมกับการประกาศตัวแปรที่ปลอดภัยแบบ %TYPE และ %ROWTYPE ซึ่งช่วยลด Disk I/O Bottleneck และรักษาค่า Cache Hit Ratio ให้สูงกว่า 99%
Transaction Lock Contention & Concurrency Control: ในวันถัดไป เราจะไปเจาะลึกเรื่องการบริหารจัดการ Row-level Locks (FOR UPDATE, SKIP LOCKED) และการตั้งค่า lock_timeout เพื่อป้องกันปัญหาตารางติด Lock ค้างระหว่างการประมวลผลระดับ Concurrency สูงครับ!