กลับไปยังบทความ

Backend

PostgreSQL Architecture: เจาะลึก Process, Memory และ WAL ทำงานร่วมกันอย่างไร

เข้าใจ PostgreSQL architecture ตั้งแต่ postmaster, backend process ต่อ connection, pg_hba.conf, shared buffers, WAL, work_mem ไปจนถึงหน้าที่ของ background process แต่ละตัว

24 กันยายน 256911 นาทีในการอ่าน

postgresql architecture processes and memory

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

ดูทั้งระบบในตารางเดียว:

ชั้นองค์ประกอบขอบเขต
Clientpsql, pgAdmin, driver ในแอปของเราอยู่นอก server
หัวหน้าpostmasterหนึ่งตัวต่อ cluster
Backend processหนึ่งตัวต่อหนึ่ง connectionต่อ session
Local memorywork_mem, temp_buffers, maintenance_work_memเป็นของ backend ตัวเดียว
Shared memoryshared buffers, WAL buffers, lock table และอื่น ๆทุก process ใช้ร่วมกัน
Background processcheckpointer, 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 ได้ด้วย:

bash
ps -ef --forest | grep [p]ostgres

ผลลัพธ์ (ตัดบางคอลัมน์ออก) จาก server Ubuntu:

code
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) idle

Process ลูกทุกตัวมี 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:

code
# 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       reject

Method ที่ใช้บ่อย:

  • peer: สำหรับ local socket ใช้ชื่อ user ของระบบปฏิบัติการเป็นตัวยืนยัน
  • scram-sha-256: ยืนยันด้วยรหัสผ่านโดยไม่ส่งรหัสผ่านตรง ๆ ควรใช้กับ connection ผ่าน network
  • md5: วิธีรหัสผ่านแบบเก่า ถูก deprecate ใน PostgreSQL เวอร์ชันใหม่แล้ว ควรย้ายไป SCRAM
  • trust: ไม่ตรวจอะไรเลย ใช้ได้เฉพาะเครื่อง dev ที่ทิ้งได้

แก้ไฟล์แล้วสั่ง SELECT pg_reload_conf(); เพื่อ reload config ได้เลย ไม่ต้อง restart

Backend process: ที่ที่ query ทำงานจริง

หลังผ่าน authentication แล้ว backend จะดูแล client คนนั้นคนเดียวไปตลอดอายุของ connection ในแต่ละ statement มันจะ:

  1. parse ข้อความ SQL
  2. rewrite (จัดการ view และ rule)
  3. plan เลือกวิธี scan ลำดับการ join และวิธี join
  4. execute ตาม plan โดยอ่านและแก้ page ใน shared buffers
  5. ส่งผลลัพธ์กลับไปให้ 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:

sql
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 ที่รันรายงานหนัก ๆ:

sql
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 ลง WALcheckpoint_timeout (5min), max_wal_size (1GB)
Background writerทยอยเขียน dirty page ลง disk เพื่อให้ backend มี buffer ว่างใช้bgwriter_delay, bgwriter_lru_maxpages
WAL writerflush WAL buffers ลง WAL files เป็นระยะwal_writer_delay (200ms)
Autovacuum launcher และ workersลบ row version ที่ตายแล้ว อัปเดต statistics และป้องกัน transaction ID wraparoundautovacuum_max_workers (3), threshold รายตาราง
Archivercopy WAL segment ที่เขียนเสร็จแล้วไปเก็บไว้สำหรับ backup และ PITRarchive_mode, archive_command หรือ archive_library
Loggerรวบรวมข้อความของ server ลง log fileslogging_collector, log_directory
Logical replication launcherสร้าง worker สำหรับ subscription ของ logical replicationmax_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:

sql
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 ลักษณะนี้อยู่เป็นประจำ

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ