Linux 7.0 ทำให้ PostgreSQL พังได้อย่างไร
(read.thecoder.cafe)- ใน Linux 7.0 มีการถอดโหมด preemption PREEMPT_NONE ซึ่งเคยเป็นค่าเริ่มต้นของเซิร์ฟเวอร์แบบเดิมออก ส่งผลให้เกิด regression ด้านประสิทธิภาพอย่างรุนแรงบนฮาร์ดแวร์ชุดเดิม โดย throughput ของ PostgreSQL ลดลงเหลือครึ่งหนึ่ง
- วิศวกรของ AWS รัน pgbench บนเครื่อง Graviton4 แบบ 96-vCPU และพบว่าเมื่อเทียบกับ Linux 6.x แล้ว บน Linux 7.0 จำนวนทรานแซกชันต่อวินาทีลดลงจาก 98,565 เหลือ 50,751 และ CPU 55% ถูกใช้ไปกับฟังก์ชัน spinlock เดียว
- spinlock ที่ใช้ปกป้องการเข้าถึง shared buffer pool ของ PostgreSQL เมื่อรวมกับ minor page fault ของหน้าเมมโมรีขนาด 4KB จะทำให้ถ้า scheduler preempt ระหว่างที่ยังถือ lock อยู่ backend ที่รอทั้งหมดจะหมุนกิน CPU ไปเปล่า ๆ
- เมื่อเปิดใช้ Huge Pages (2MB หรือ 1GB) จำนวน page fault ที่เป็นไปได้จะลดจาก 31 ล้านครั้งเหลือเพียงหลักหมื่นถึงหลักร้อย จึงแก้ปัญหา regression นี้ได้
- ฝั่งเคอร์เนลเสนอให้ใช้ Restartable Sequences (rseq) แต่ชุมชน PostgreSQL มองว่าการที่การอัปเกรดเคอร์เนลทำให้ประสิทธิภาพถดถอยแบบนี้ขัดกับหลักการที่ว่า "ไม่ทำให้ userspace พัง"
อาการของปัญหา
- วิศวกร AWS ชื่อ Salvatore Dipietro รัน pgbench บนโปรเซสเซอร์ 96-vCPU Graviton4 โดยทดสอบโหลดแบบขนานสูงด้วย scale factor 8,470 (ตารางประมาณ 847 ล้านแถว), ไคลเอนต์ 1,024 ตัว, และ 96 threads
- throughput ลดลงเกือบครึ่ง จาก 98,565 TPS บน Linux 6.x เหลือ 50,751 TPS บน Linux 7.0
- ผลการทำ profiling ด้วย
perfพบว่า 55.60% ของเวลา CPU ถูกใช้ภายในฟังก์ชันs_lock- เส้นทางการเรียก:
StartReadBuffer→GetVictimBuffer→StrategyGetBuffer→s_lock
- เส้นทางการเรียก:
Preemption คืออะไร
- การที่ scheduler ของ OS หยุด thread ที่กำลังทำงานอยู่แล้วส่ง CPU ให้ thread อื่น คือ preemption
- ก่อน Linux 7.0 มีอยู่ 3 ตัวเลือก
- PREEMPT_NONE: แทบจะไม่หยุด thread จนกว่า thread จะยอมคืน CPU เอง (syscall, I/O block, sleep) เป็นค่าเริ่มต้นของเซิร์ฟเวอร์แบบดั้งเดิม ทำให้มี context switch น้อยและ throughput สูง
- PREEMPT_FULL: สามารถหยุด thread ที่กำลังรันอยู่ได้เกือบทุกจุดที่ปลอดภัย ช่วยลดเวลา response แต่เพิ่ม overhead จาก context switch เป็นค่าเริ่มต้นของเดสก์ท็อปแบบดั้งเดิม
- PREEMPT_LAZY: ทางสายกลางที่เพิ่มเข้ามาใน Linux 6.12 โดยรอจุดเปลี่ยนตามธรรมชาติแต่ยังยอมให้ preempt ได้เมื่อจำเป็น ออกแบบมาให้พฤติกรรมด้าน throughput ใกล้เคียง PREEMPT_NONE
- ใน Linux 7.0 มีการ ถอด PREEMPT_NONE ออกจากสถาปัตยกรรม CPU รุ่นใหม่ จึงเหลือเพียง PREEMPT_FULL และ PREEMPT_LAZY
- แม้ PREEMPT_LAZY จะทำหน้าที่แทนได้สำหรับซอฟต์แวร์เซิร์ฟเวอร์ส่วนใหญ่ แต่กับ PostgreSQL กลับเกิด ความแตกต่างที่ร้ายแรง
การจัดการหน่วยความจำของ PostgreSQL
- PostgreSQL ใช้ data page ขนาดคงที่ (ค่าเริ่มต้น 8KB) เป็นหน่วยเก็บข้อมูลพื้นฐาน โดยทั้งแถวของตาราง, โหนดของ B-tree index, metadata และอื่น ๆ ล้วนเก็บอยู่ในหน้าเหล่านี้
- เพื่อลดการอ่านดิสก์ จึงมีการแคช data page ที่เพิ่งอ่านล่าสุดไว้ในพื้นที่หน่วยความจำร่วมขนาดใหญ่ชื่อ shared buffer pool
- เมื่อมีไคลเอนต์เชื่อมต่อ จะมีการสร้าง backend process เฉพาะขึ้นมา และหาก page ที่ต้องใช้ยังไม่อยู่ใน buffer pool ก็ต้องอ่านจากดิสก์แล้วหาบัฟเฟอร์ว่างหรือบัฟเฟอร์ที่ไล่ออกได้
- ฟังก์ชันที่รับผิดชอบการเลือกบัฟเฟอร์นี้คือ
StrategyGetBuffer
- ฟังก์ชันที่รับผิดชอบการเลือกบัฟเฟอร์นี้คือ
spinlock ของ PostgreSQL
- spinlock คือกลไก lock ที่ไม่หลับรอ แต่จะวนลูปตรวจสอบต่อเนื่องขณะรอ lock
- ใน critical section ที่สั้นมาก การหมุนรอมักมีประสิทธิภาพกว่าการทำให้ thread หลับแล้วปลุกกลับมา
- สมมติฐานสำคัญคือ thread ที่ถือ lock จะปล่อย lock ได้อย่างรวดเร็วมาก
StrategyGetBufferใช้ global spinlock เพียงตัวเดียว เพื่อปกป้องการเลือกบัฟเฟอร์- ในสภาพแวดล้อม 96-vCPU และไคลเอนต์ 1,024 ตัว backend ทั้งหมดต้องแย่ง lock เดียวกัน
หน่วยความจำเสมือนและ TLB
- ทุก process ใช้ virtual memory address และฮาร์ดแวร์จะแปลงเป็น physical address ผ่าน page table ที่เป็นโครงสร้างต้นไม้หลายชั้น
- การไล่ page table ทุกครั้งนั้นช้า CPU จึงมี TLB (Translation Lookaside Buffer) สำหรับแคชผลการแปลงล่าสุด
- ถ้า TLB hit จะเข้าถึงได้เร็ว แต่ถ้า TLB miss จะต้องทำ page-table walk จึงเสียเวลาเพิ่ม
- Linux ใช้หลักการ lazy allocation โดยเมื่อจอง virtual memory แล้วจะยังไม่ผูกกับ physical page จริงจนกว่าจะมีการเข้าถึงครั้งแรก
- เมื่อเข้าถึงครั้งแรกจะเกิด minor page fault: เคอร์เนลจะจัดสรร physical page และบันทึก mapping ซึ่งช้ากว่าการอ่าน/เขียนปกติระดับหลายไมโครวินาที
ปัญหาของหน้า 4KB
- ใน benchmark ตั้งค่า
shared_buffersไว้ที่ 120GB ซึ่งเมื่อใช้หน้าเมมโมรีขนาด 4KB จะเท่ากับมี memory page ราว 31 ล้านหน้า หรือก็คือมี first-access page fault ที่อาจเกิดขึ้นได้ 31 ล้านครั้ง - ใน benchmark ระยะยาวที่ใช้ shared buffer pool ขนาด 120GB พื้นที่หน่วยความจำใหม่ยังคงเข้าสู่ working set อยู่เรื่อย ๆ ทำให้ page fault ไม่ได้เกิดเฉพาะตอนเริ่มต้น แต่เกิดต่อเนื่องตลอด
- หาก
StrategyGetBufferเข้าใช้ shared memory ระหว่างที่ถือ spinlock อยู่ และพื้นที่นั้นยังไม่ถูกแมป จะเกิด minor page fault - PREEMPT_NONE (ก่อน Linux 7.0): แม้ backend A จะเข้าไปใน page-fault handler ก็ยังมีโอกาสน้อยที่จะถูก schedule out ก่อน fault จะถูกจัดการเสร็จ เพราะหลีกเลี่ยงจุด reschedule แบบสมัครใจ ทำให้เวลารอนานขึ้นแต่ความเสียหายยังจำกัด
- PREEMPT_LAZY (หลัง Linux 7.0): scheduler สามารถ preempt backend A ภายใน page-fault handler แล้วสลับไป schedule process อื่น ได้ แม้การจัดการ fault จะเสร็จแล้วก็ยังต้องรอเพิ่มอีก
tจนกว่า scheduler จะคืนสิทธิ์การทำงาน- เวลารอเพิ่มนี้ไม่ใช่แค่
tธรรมดา แต่ขยายเป็น จำนวน backend ทั้งหมดที่กำลังหมุนรอ × t ในรูปของการสูญเปล่า CPU - บนเครื่อง 96-vCPU ที่มี backend หลายร้อยตัว ผลคูณนี้ร้ายแรงมาก จนสุดท้าย CPU 56% ถูกใช้ไปกับ
s_lock
- เวลารอเพิ่มนี้ไม่ใช่แค่
การแก้ปัญหาด้วย Huge Pages
- เมื่อ
shared_buffersเท่ากับ 120GB หากเปลี่ยน ขนาดของ memory page จำนวน page fault ที่เป็นไปได้จะลดลงอย่างมาก- หน้า 4KB: ~31,000,000 page fault ที่อาจเกิดขึ้น
- 2MB Huge Pages: ~61,440
- 1GB Huge Pages: ~120
- การเพิ่มขนาด page ไม่ได้ช่วยแค่ลด page fault แต่ยัง ลดแรงกดดันต่อ TLB ด้วย เพราะใช้ TLB entry น้อยลงมากเพื่อครอบคลุมหน่วยความจำเท่าเดิม จึงลดทั้ง TLB miss และ page-table walk
- ทำให้
StrategyGetBufferไม่เกิด fault ระหว่างถือ lock ผู้ถือ lock จึงทำงานเสร็จได้เร็ว และ backend อื่นรอเพียงระดับไมโครวินาทีแทนมิลลิวินาที จึง แก้ regression ได้ - ใน PostgreSQL การตั้งค่า huge pages ควบคุมด้วยพารามิเตอร์
huge_pages- รองรับ 3 ค่า คือ
off,on,try(ค่าเริ่มต้น) tryจะใช้ huge pages ถ้าทำได้ และถ้าทำไม่ได้จะ fallback ไปใช้ 4KB แบบเงียบ ๆ จึงมีความเสี่ยงที่จะไม่รู้ว่าตั้งค่าผิด- หากตั้งเป็น
onเมื่อไม่สามารถใช้ huge pages ได้ PostgreSQL จะเริ่มต้นไม่สำเร็จ ทำให้รู้ปัญหาได้ทันที
- รองรับ 3 ค่า คือ
- ข้อแลกเปลี่ยน: huge pages ใช้วิธีจัดสรรและจองล่วงหน้า ดังนั้นแม้ PostgreSQL จะไม่ได้ใช้ทั้งหมด หน่วยความจำส่วนนั้นก็จะไม่สามารถนำไปใช้กับส่วนอื่นของระบบได้ และถ้าใช้เพียงบางส่วนของ page ที่เหลือก็จะสูญเปล่า แต่ใน production ที่ใช้
shared_buffersขนาดใหญ่ โดยมากถือว่าคุ้มที่จะยอมรับข้อแลกเปลี่ยนนี้
สิ่งที่จะเกิดขึ้นต่อไป
- Peter Zijlstra วิศวกรเคอร์เนลของ Intel ผู้มีส่วนออกแบบการเปลี่ยนแปลงด้าน preemption เสนอให้ PostgreSQL ใช้ Restartable Sequences (rseq)
rseqเป็นฟีเจอร์ของเคอร์เนล Linux ที่ทำให้โค้ด userspace ตรวจจับได้ว่ามีการ preempt หรือ migration ระหว่าง critical section หรือไม่ และสามารถเริ่มช่วงนั้นใหม่ได้- หากนำ
rseqมาใช้กับเส้นทาง spinlock ของ PostgreSQL ก็อาจหลีกเลี่ยงสถานการณ์ที่ผู้ถือ lock ถูก preempt แล้วทำให้ backend ที่รอทั้งหมดล่าช้าได้
- ปฏิกิริยาจากชุมชน PostgreSQL เป็นไปในทางลบ
- ยากที่จะยอมรับว่าต้องนำฟีเจอร์เคอร์เนลเพิ่มเติมมาใช้เพื่อเรียกคืนประสิทธิภาพที่เคยได้มา "ฟรี" ก่อน Linux 7.0
- และมองว่านี่ขัดกับหลักการเก่าแก่ของเคอร์เนลที่ว่า "ไม่ทำให้ userspace พัง" (ซอฟต์แวร์ที่ทำงานปกติก่อนอัปเกรดเคอร์เนล ก็ควรยังทำงานปกติหลังอัปเกรด)
ยังไม่มีความคิดเห็น