PostgreSQL architecture ออกแบบเป็นแบบ multi-process มี process หัวหน้าชื่อ postmaster คอยรอรับ connection แล้ว fork process ของระบบปฏิบัติการแยกให้ client แต่ละ session ทุก process ใช้ memory ก้อนใหญ่ร่วมกันสำหรับ cache ของ data page และ WAL record มีกลุ่ม background process คอยเขียนข้อมูลลง disk ทำ checkpoint เก็บกวาด และ archive ส่วน backend แต่ละตัวก็มี memory ส่วนตัวไว้ทำ sort และ hash
พอรู้ว่า process ไหนทำหน้าที่อะไร พฤติกรรมหลายอย่างที่เจอในงานจริงจะเข้าใจได้ทันที เช่น ทำไม connection ถึงแพง ทำไม work_mem ทำให้ RAM หมดได้ ทำไม session เดียว crash แล้วทุกคนหลุด และทำไม commit ถึงปลอดภัยแล้วทั้งที่ยังไม่ได้เขียน data file เลย บทความนี้ไล่แต่ละส่วนตามลำดับที่ query วิ่งผ่าน โดยอ้างอิง PostgreSQL 16
ภาพรวมของ PostgreSQL architecture
ดูทั้งระบบในตารางเดียว:
| ชั้น | องค์ประกอบ | ขอบเขต |
|---|---|---|
| Client | psql, pgAdmin, driver ในแอปของเรา | อยู่นอก server |
| หัวหน้า | postmaster | หนึ่งตัวต่อ cluster |
| Backend process | หนึ่งตัวต่อหนึ่ง connection | ต่อ session |
| Local memory | work_mem, temp_buffers, maintenance_work_mem | เป็นของ backend ตัวเดียว |
| Shared memory | shared buffers, WAL buffers, lock table และอื่น ๆ | ทุก process ใช้ร่วมกัน |
| Background process | checkpointer, background writer, WAL writer, autovacuum, archiver, logger | ทั้ง server |
| ไฟล์ | data files, WAL files, WAL ที่ archive แล้ว, log files | บน disk |
Request ไหลจากบนลงล่าง client ต่อเข้ามา postmaster fork backend ให้ backend ตรวจสอบตัวตน client แล้วรัน SQL กับ shared memory จากนั้น background process ค่อยพาการเปลี่ยนแปลงลง disk ทีหลัง
Postmaster และการ fork หนึ่ง process ต่อหนึ่ง connection
เมื่อสั่ง start PostgreSQL process แรกที่เกิดขึ้นคือ postmaster (ใน ps จะเห็นเป็น binary postgres เฉย ๆ พร้อม -D ที่ชี้ไปยัง data directory) ตัวมันเองแทบไม่ได้ประมวลผล query เลย หน้าที่หลักคือ:
- รอรับ connection บน port ที่ตั้งไว้ (ค่าเริ่มต้น 5432) และ Unix socket
fork()process ลูกใหม่ให้ทุก connection ที่เข้ามา- start และคุม background process ทั้งหมด
- จัดการเมื่อ process ลูกตัวใดตายไป
ข้อสุดท้ายสำคัญมากใน production ถ้า background process ตัวไหนหยุดไป postmaster จะ start ใหม่ให้ แต่ถ้า backend ตัวใดตัวหนึ่ง crash แบบผิดปกติ เช่น segfault จาก extension postmaster จะเชื่อ shared memory ไม่ได้อีกต่อไป มันจะ terminate session อื่นทั้งหมด ล้าง shared memory ใหม่ แล้วทำ crash recovery จาก WAL แปลว่า session ที่มีปัญหาแค่ตัวเดียวทำให้ผู้ใช้ทุกคนหลุดได้เป็นวินาที ข้อยกเว้นคือ logger ซึ่งยังทำงานต่อระหว่างการ reset ทำให้ข้อความที่อธิบายสาเหตุของ crash ไม่หายไป
ดู process tree บน Linux ได้ด้วย:
ps -ef --forest | grep [p]ostgresผลลัพธ์ (ตัดบางคอลัมน์ออก) จาก server Ubuntu:
postgres 812 1 /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main -c config_file=/etc/postgresql/16/main/postgresql.conf
postgres 813 812 \_ postgres: 16/main: checkpointer
postgres 814 812 \_ postgres: 16/main: background writer
postgres 816 812 \_ postgres: 16/main: walwriter
postgres 817 812 \_ postgres: 16/main: autovacuum launcher
postgres 818 812 \_ postgres: 16/main: logical replication launcher
postgres 2231 812 \_ postgres: 16/main: app appdb 10.0.0.12(53418) idleProcess ลูกทุกตัวมี postmaster (PID 812) เป็น parent บรรทัดสุดท้ายคือ client backend ของ user app ที่ต่อ database appdb จาก IP ของ client และกำลังอยู่ในสถานะ idle ถ้าดูจากใน database ก็ใช้ pg_stat_activity ซึ่งมีคอลัมน์ backend_type บอกประเภทของแต่ละ process
ทำไม connection ถึงแพง
การแยก process ต่อ connection ให้ isolation ที่ดี แต่ทุก process กิน memory และมีต้นทุนตอนสร้าง connection ที่เปิดค้างไว้เป็นพัน ๆ ตัวโดยแทบไม่ทำอะไรจะเปลือง RAM และเพิ่มภาระให้ scheduler นี่คือเหตุผลที่ max_connections มีค่าเริ่มต้นแค่ 100 แอปที่รันหลาย instance มักวาง connection pooler อย่าง PgBouncer ไว้หน้า database หรือใช้ pool ในตัวแอป แทนที่จะดัน max_connections ขึ้นไปหลักพัน
Authentication ด้วย pg_hba.conf
หลายคนเข้าใจว่า postmaster เป็นคนตรวจสอบตัวตน client แต่จริง ๆ แล้ว postmaster fork process ลูกก่อน แล้ว backend ตัวใหม่ เป็นคนทำ authentication วิธีนี้ทำให้ตัวหัวหน้าเรียบง่าย และ client ที่ช้าหรือไม่หวังดีก็ไม่สามารถทำให้ตัวรับ connection ค้างได้
กฎอยู่ในไฟล์ pg_hba.conf (host-based authentication) PostgreSQL อ่านจากบนลงล่าง และใช้บรรทัดแรกที่ตรงกับประเภท connection, database, user และ address:
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host appdb app 10.0.0.0/24 scram-sha-256
host all all 0.0.0.0/0 rejectMethod ที่ใช้บ่อย:
peer: สำหรับ local socket ใช้ชื่อ user ของระบบปฏิบัติการเป็นตัวยืนยันscram-sha-256: ยืนยันด้วยรหัสผ่านโดยไม่ส่งรหัสผ่านตรง ๆ ควรใช้กับ connection ผ่าน networkmd5: วิธีรหัสผ่านแบบเก่า ถูก deprecate ใน PostgreSQL เวอร์ชันใหม่แล้ว ควรย้ายไป SCRAMtrust: ไม่ตรวจอะไรเลย ใช้ได้เฉพาะเครื่อง dev ที่ทิ้งได้
แก้ไฟล์แล้วสั่ง SELECT pg_reload_conf(); เพื่อ reload config ได้เลย ไม่ต้อง restart
Backend process: ที่ที่ query ทำงานจริง
หลังผ่าน authentication แล้ว backend จะดูแล client คนนั้นคนเดียวไปตลอดอายุของ connection ในแต่ละ statement มันจะ:
- parse ข้อความ SQL
- rewrite (จัดการ view และ rule)
- plan เลือกวิธี scan ลำดับการ join และวิธี join
- execute ตาม plan โดยอ่านและแก้ page ใน shared buffers
- ส่งผลลัพธ์กลับไปให้ client
สำหรับ SELECT backend จะหา page ที่ต้องการใน shared buffers ก่อน และอ่านจาก disk เฉพาะตอนที่หาไม่เจอ ส่วน UPDATE จะแก้ page ใน shared buffers ติดป้ายว่าเป็น dirty page และเขียน WAL record ที่บอกว่าแก้อะไรไป แต่ ยังไม่ เขียน data file ในจังหวะนั้น
Local memory: work_mem, temp_buffers และ maintenance_work_mem
Backend แต่ละตัวมี memory ส่วนตัวที่ process อื่นมองไม่เห็น
work_mem (ค่าเริ่มต้น 4MB) คืองบ memory สำหรับ operation sort หรือ hash หนึ่งตัว เช่น ORDER BY, DISTINCT, hash join, hash aggregate และ merge join ที่ต้องการ input ที่เรียงแล้ว ถ้าข้อมูลใหญ่เกิน PostgreSQL จะ spill ลงไฟล์ชั่วคราวบน disk ซึ่งเห็นได้ใน plan:
SET work_mem = '4MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, sum(amount)
FROM orders
GROUP BY customer_id
ORDER BY sum(amount) DESC;
-- look for: Sort Method: external merge Disk: 51200kBกับดักคือ work_mem คิด ต่อ operation ต่อ backend ไม่ใช่ต่อ server query ซับซ้อนที่มี sort และ hash สี่ node อาจใช้ถึงสี่เท่าของค่านี้ และ hash operation ยังใช้ได้มากกว่านั้นอีกตาม hash_mem_multiplier (ค่าเริ่มต้น 2.0) ก่อนจะเพิ่มค่าแบบ global ให้คูณกับจำนวน connection ที่ active ด้วย วิธีที่ปลอดภัยกว่าคือเพิ่มเฉพาะ session หรือ role ที่รันรายงานหนัก ๆ:
ALTER ROLE reporting SET work_mem = '256MB';ถ้าอยากอ่าน plan แบบข้างบนให้คล่อง ดูวิธีอ่าน query plan และแก้ SQL ที่ช้า
temp_buffers (ค่าเริ่มต้น 8MB) ใช้ cache page ของ temporary table ที่สร้างด้วย CREATE TEMP TABLE เนื่องจาก temp table เป็นของ session เดียว page ของมันจึงไม่ไปอยู่ใน shared buffers
maintenance_work_mem (ค่าเริ่มต้น 64MB) ใช้กับ VACUUM, CREATE INDEX, REINDEX และ ALTER TABLE ADD FOREIGN KEY การเพิ่มค่านี้เฉพาะ session ที่สร้าง index ใหญ่ เช่นตั้งเป็น 1GB มักทำให้เร็วขึ้นชัดเจน
Shared memory: shared buffers และ WAL buffers
Shared memory ถูกจองครั้งเดียวตอน start และทุก process ใช้ร่วมกัน
Shared buffers
shared_buffers คือ page cache ของ PostgreSQL เก็บ page ขนาด 8KB ของ table และ index ค่าเริ่มต้น 128MB ตั้งไว้เล็กโดยตั้งใจ บน server ที่ใช้รัน database อย่างเดียว จุดเริ่มต้นที่นิยมคือประมาณ 25% ของ RAM เพราะ PostgreSQL ยังพึ่ง file cache ของระบบปฏิบัติการด้วย การยก memory เกือบทั้งหมดให้ shared buffers จึงไม่ได้ช่วย
เมื่อ backend แก้ page ใด page นั้นจะกลายเป็น dirty คือเวอร์ชันใน memory ใหม่กว่าบน disk แล้ว dirty page จะถูกเขียนกลับทีหลังโดย background writer, checkpointer หรือถ้าสองตัวนั้นตามไม่ทัน backend จะต้องเขียนเองตอนที่ต้องการ buffer ว่าง ส่วนการจัดวาง row ภายใน block 8KB อธิบายไว้ในเจาะลึก PostgreSQL storage internals
WAL buffers
ทุกการเปลี่ยนแปลงจะสร้าง write-ahead log (WAL) record ด้วย record จะเข้า wal_buffers ก่อน ค่าเริ่มต้น -1 หมายถึงให้คำนวณอัตโนมัติเป็น 1/32 ของ shared buffers สูงสุด 16MB กฎหลักง่าย ๆ คือ WAL record ของการเปลี่ยนแปลงต้องลง disk ก่อน data page ที่ถูกแก้ และ transaction จะถือว่า commit แล้วเมื่อ WAL ของมันถูก flush เรียบร้อย ถ้าเครื่องล่ม PostgreSQL จะ replay WAL เพื่อทำซ้ำการเปลี่ยนแปลงที่ยังไม่ได้ลง data file
สรุปสั้น ๆ: shared buffers เก็บตัวข้อมูล ส่วน WAL buffers เก็บบันทึกว่าข้อมูลเปลี่ยนไปอย่างไร
ดูค่าปัจจุบันได้ด้วย SHOW shared_buffers; และ SHOW wal_buffers;
Background process แต่ละตัวทำอะไร
| Process | หน้าที่ | Setting หลัก |
|---|---|---|
| Checkpointer | เขียน dirty page ทั้งหมดตอน checkpoint และบันทึก checkpoint ลง WAL | checkpoint_timeout (5min), max_wal_size (1GB) |
| Background writer | ทยอยเขียน dirty page ลง disk เพื่อให้ backend มี buffer ว่างใช้ | bgwriter_delay, bgwriter_lru_maxpages |
| WAL writer | flush WAL buffers ลง WAL files เป็นระยะ | wal_writer_delay (200ms) |
| Autovacuum launcher และ workers | ลบ row version ที่ตายแล้ว อัปเดต statistics และป้องกัน transaction ID wraparound | autovacuum_max_workers (3), threshold รายตาราง |
| Archiver | copy WAL segment ที่เขียนเสร็จแล้วไปเก็บไว้สำหรับ backup และ PITR | archive_mode, archive_command หรือ archive_library |
| Logger | รวบรวมข้อความของ server ลง log files | logging_collector, log_directory |
| Logical replication launcher | สร้าง worker สำหรับ subscription ของ logical replication | max_logical_replication_workers |
รายละเอียดที่ควรรู้:
- Checkpointer checkpoint คือจุดเซฟของระบบ หลังจากนั้น crash recovery ต้อง replay WAL แค่จากจุดนั้น ไม่ต้องย้อนไปตั้งแต่ต้น ถ้า checkpoint ถี่เกินไปจะเปลือง I/O ส่วนหนึ่งเพราะการแก้ page ครั้งแรกหลัง checkpoint จะเขียนภาพทั้ง page ลง WAL ถ้าห่างเกินไป recovery ก็ใช้เวลานาน ถ้าเห็นใน log ว่า checkpoint "occurring too frequently" ให้เพิ่ม
max_wal_size - WAL writer ช่วยแบ่งงาน flush ออกจาก backend แต่เมื่อ
synchronous_commit = onซึ่งเป็นค่าเริ่มต้น backend ที่กำลัง commit ยังต้องรอจนกว่า WAL ของตัวเองจะ flush เสร็จ นี่คือราคาของ durability - Autovacuum มีอยู่เพราะ MVCC:
UPDATEหรือDELETEทิ้ง row version เก่าไว้ และต้องมีคนเก็บกวาด เหตุผลที่ต้องมีหลายเวอร์ชันอธิบายไว้ในACID และ MVCC ฉบับเข้าใจง่าย - Archiver และ logger ทำงานเฉพาะตอนเปิดใช้ คือ
archive_mode = onและlogging_collector = onตามลำดับ บน Debian และ Ubuntu log มักไปที่/var/log/postgresqlโดยไม่ผ่าน collector จึงไม่เห็น logger ในpsข้างบน - ไม่มี stats collector แล้ว diagram เก่า ๆ จะมี process "stats collector" แต่ตั้งแต่ PostgreSQL 15 statistics สะสมถูกเก็บใน shared memory และ process นั้นถูกเอาออกไปแล้ว
PostgreSQL 16 เพิ่ม view pg_stat_io ที่แยกการอ่านเขียนตามประเภท backend ใช้เช็กได้เร็วว่า backend ต้องเขียน dirty page เองหรือเปล่า ถ้าใช่ มักแปลว่าต้องปรับ background writer หรือ checkpointer:
SELECT backend_type, object, context, reads, writes, fsyncs
FROM pg_stat_io
WHERE writes > 0
ORDER BY writes DESC;ไฟล์บน disk
ล่างสุดของ diagram มีไฟล์สี่ประเภท:
- Data files: page ของ table และ index อยู่ใต้
base/แบ่งเป็น segment ละ 1GB - WAL files: segment ละ 16MB ใน
pg_wal/ใช้ทำ crash recovery และ replication ห้ามลบเองด้วยมือเด็ดขาด - Archive files: สำเนาของ WAL segment ที่เขียนเสร็จแล้ว เก็บไว้ที่ปลายทางของ
archive_commandใช้ทำ point-in-time recovery - Log files: error, การเชื่อมต่อ, statement ที่ช้า, ข้อความเรื่อง checkpoint และ autovacuum
อย่าสับสน "log" สองแบบ logger เขียนข้อความวินิจฉัยให้คนอ่าน ส่วน WAL เป็น log การเปลี่ยนแปลงแบบ binary ที่ PostgreSQL ต้องใช้ในการ recovery แนวปฏิบัติเรื่อง backup, replica และ monitoring อยู่ในการรัน database ใน production
คำถามที่พบบ่อย
PostgreSQL เป็น multi-thread หรือ multi-process?
Multi-process ทุก connection ได้ backend process ของตัวเองที่ fork มาจาก postmaster แม้แต่ parallel query ก็ใช้ worker process เพิ่ม ไม่ใช่ thread
ควรตั้ง shared_buffers เท่าไร?
บน server ที่รัน database อย่างเดียว เริ่มที่ประมาณ 25% ของ RAM แล้ววัด cache hit ratio และ I/O ดู ไม่ควรดันสูงกว่านี้มากโดยไม่มีเหตุผล เพราะ file cache ของ OS ก็เก็บข้อมูลของ PostgreSQL อยู่ด้วย
ทำไมเพิ่ม work_mem แล้วเกิด out of memory?
เพราะค่านี้ใช้ต่อ sort หรือ hash node ในทุก backend ถ้ามีหลาย connection รัน query ที่มีหลาย node พร้อมกัน memory ที่ใช้รวมจะสูงกว่าตัวเลขที่ตั้งไว้มาก ควรเพิ่มเฉพาะ role หรือ session ที่รัน query หนัก
WAL writer กับ checkpointer ต่างกันอย่างไร?
WAL writer flush WAL record ซึ่งเป็น log การเปลี่ยนแปลง ส่วน checkpointer flush data page ที่ dirty และกำหนดจุดที่ crash recovery เริ่มต้นได้
นำ PostgreSQL architecture ไปใช้จริง
นิสัยที่ควรมี ซึ่งมาจากการออกแบบนี้โดยตรง:
- ใช้ connection pool แทนการดัน
max_connectionsขึ้นไปหลักพัน - ตั้ง
shared_buffersอย่างมีเหตุผล และเพิ่มwork_memราย role แทนการตั้งแบบ global - เฝ้าดูความถี่ของ checkpoint และ
pg_stat_ioว่า backend ต้องเขียน page เองหรือไม่ - เปิด autovacuum ไว้เสมอ และให้มันตามทันปริมาณการเขียน
- เปิด WAL archiving ก่อนที่จะต้องใช้ point-in-time recovery ไม่ใช่หลังจากนั้น
ถ้าอยากให้มีคนช่วยดู PostgreSQL ของคุณอีกแรง ทีมวิศวกรของ Vectorkub ทำงานกับระบบที่ใช้ database ลักษณะนี้อยู่เป็นประจำ
