- ล็อกแบบ exclusive ระดับโกลบอล ของ Postgres LISTEN/NOTIFY จำกัดปริมาณงานของการใช้งานแบบตรงไปตรงมา แต่ถ้าบัฟเฟอร์การแจ้งเตือนแล้วส่งแบบเป็นชุด ก็สามารถรองรับ การเขียนสตรีมได้สูงสุด 60,000 ครั้งต่อวินาที บนเซิร์ฟเวอร์เดียว
- ทรานแซกชันที่เรียกใช้ NOTIFY จะคง ล็อกระดับโกลบอล ไว้จนกว่าการคอมมิตและ
fsync()จะเสร็จสิ้น เพื่อรับประกันลำดับการคอมมิตของการแจ้งเตือน ทำให้การคอมมิตถูกทำแบบอนุกรม และไม่สามารถใช้ group commit ได้ - อิมพลีเมนเทชันแรกที่เรียก NOTIFY ผ่านทริกเกอร์ทุกครั้งที่มีการเขียนลงตารางสตรีม ให้ค่าหน่วงต่ำ แต่ติดคอขวดที่ 2,900 ครั้งต่อวินาที ทั้งที่ยังใช้ CPU, หน่วยความจำ และ IOPS ได้ไม่เต็มที่
- หากใช้ตารางฐานข้อมูลเป็น แหล่งความจริง แทนการแจ้งเตือน และรวมการแจ้งเตือนไว้ในหน่วยความจำก่อนส่งเป็นระยะภายในทรานแซกชันเดียว จะลดจำนวนครั้งที่ต้องแย่งล็อกได้มาก
- ความเป็นไปได้ที่การแจ้งเตือนในบัฟเฟอร์จะสูญหายเมื่อโปรเซสล่ม สามารถชดเชยได้ด้วยการโพลเป็นช่วงๆ ที่ความถี่ต่ำ และยังทำได้ถึง ค่าหน่วง 15~100ms พร้อมทรูพุตสูงขึ้น 20 เท่าจากเดิม
สตรีมค่าหน่วงต่ำที่สร้างด้วย Postgres
- สตรีมที่อิงกับ Postgres จะเก็บแต่ละชิ้นของสตรีมเป็นแถวใหม่ในตาราง
streamsโดยโทเคนคำตอบจาก LLM ก็อาจเป็นหนึ่งชิ้นได้ - ฝั่งการอ่านไม่รู้ได้ว่าชิ้นถัดไปจะมาถึงเมื่อไร จึงรออย่างมีประสิทธิภาพได้ยากหากใช้แค่การคิวรีธรรมดา
- การโพลเป็นช่วงๆ หากเว้นช่วงนาน ค่าหน่วงจะสูงเกินไปสำหรับงานโต้ตอบอย่างแชตออนไลน์ แต่ถ้าโพลถี่เกินไป ตัวโพลจำนวนมากพร้อมกันก็จะถล่มฐานข้อมูล
- เมื่อใช้ LISTEN/NOTIFY โปรเซสฝั่งอ่านสามารถรอแบบบล็อกอยู่ได้ แล้วตื่นขึ้นทันทีพร้อมการแจ้งเตือนว่ามีการบันทึกชิ้นใหม่ จึงหลีกเลี่ยงการโพลที่ไม่จำเป็นได้
คอขวดของการส่ง NOTIFY ทุกครั้งที่เขียน
- ในอิมพลีเมนเทชันแรก ทุกครั้งที่มีการบันทึกชิ้นใหม่ลงตาราง
streamsทริกเกอร์จะเรียกฟังก์ชันเพื่อ ส่ง NOTIFY หนึ่งครั้ง และโปรเซสฝั่งอ่านจะรอการแจ้งเตือนก่อนอ่านชิ้นใหม่ - แม้จะได้ทั้งความถูกต้องและค่าหน่วงต่ำ แต่ก็ไม่สามารถรักษา การเขียนสตรีมเกิน 2,900 ครั้งต่อวินาที ได้ แม้บนฐานข้อมูล Postgres ขนาดใหญ่
- ระหว่างที่เกิดคอขวด การใช้ CPU, หน่วยความจำ และ IOPS ไม่ได้สูงอย่างมีนัยสำคัญ โดยสาเหตุคือ ล็อกระดับโกลบอลในเส้นทางคอมมิตของ NOTIFY
ล็อกระดับโกลบอลที่รับประกันลำดับการคอมมิต
- ทรานแซกชันที่เรียกใช้ NOTIFY จะเข้ายึด ล็อกแบบ exclusive ระดับโกลบอล เมื่อเริ่มคอมมิต และจะไม่ปล่อยจนกว่าจะคอมมิตสมบูรณ์และข้อมูลถูกเขียนลงดิสก์ด้วย
fsync() - Postgres รับประกันว่าการแจ้งเตือนจะถูกส่งตามลำดับการคอมมิตของทรานแซกชัน และเก็บการแจ้งเตือนขาออกทั้งหมดไว้ในคิวภายในแบบโกลบอลที่ต้องตรงกับลำดับคอมมิตทุกประการ
- การเพิ่มการแจ้งเตือนลงคิวต้องทำแบบทรานแซกชันเป็นส่วนหนึ่งของการคอมมิตด้วย แต่เนื่องจากแต่ละทรานแซกชันใช้เวลาคอมมิตไม่เท่ากัน จึง ไม่สามารถกำหนดลำดับได้ก่อนคอมมิตเสร็จ
- ล็อกระดับโกลบอลจึงทำให้การคอมมิตของทรานแซกชันที่มีการแจ้งเตือนต้องเป็นแบบอนุกรม เพื่อกำหนดลำดับล่วงหน้า และให้ถูกเพิ่มลงคิวการแจ้งเตือนภายในด้วยลำดับเดียวกัน
การคอมมิตแบบอนุกรมจำกัดทรูพุตอย่างไร
- เพราะการเขียนสตรีมทุกครั้งเรียก NOTIFY ผ่านทริกเกอร์ ทรานแซกชันการเขียนแต่ละครั้งจึงถือล็อกระดับโกลบอลตลอดช่วงคอมมิตและการ flush ลงดิสก์
- เมื่อทรานแซกชันคอมมิตทีละตัว จึงไม่สามารถใช้ group commit ของ Postgres ที่จัดการหลายทรานแซกชันด้วย
fsync()ครั้งเดียวได้ - ทรูพุตจึงไม่อาจสูงเกินความเร็วที่ Postgres คอมมิตทรานแซกชันเดี่ยวได้ และงานต่างๆ ต้องรอล็อก ทำให้ทั้ง CPU และดิสก์ถูกใช้งานได้ไม่เต็มที่
- แพตช์ที่จะรวมใน Postgres 19 ไม่ได้ลบล็อกระดับโกลบอลออก จึงไม่สามารถแก้คอขวดนี้ได้
- แต่จะไปปรับแต่งกรณีเฉพาะที่มีหลายช่องทางการแจ้งเตือน และแต่ละ listener รอเพียงช่องทางเดียว
การบัฟเฟอร์การแจ้งเตือนและส่งแบบเป็นชุด
- สำหรับการใช้งาน LISTEN/NOTIFY หลายแบบรวมถึงสตรีม การแจ้งเตือนไม่ใช่ แหล่งความจริง แต่เป็นเพียงสัญญาณให้ไปตรวจตารางที่เก็บข้อมูลจริง
- ในโครงสร้างแบบนี้ ตัวการแจ้งเตือนเองไม่จำเป็นต้องมีลำดับโกลบอลที่สมบูรณ์แบบหรือความทนทานครบถ้วน จึงสามารถบัฟเฟอร์ไว้ในหน่วยความจำแล้วส่งเป็นระยะผ่านทรานแซกชันแบบแบตช์เดียวได้
- ล็อกระดับโกลบอลจะถูกเข้ายึดไม่ใช่ทุกครั้งที่เขียนสตรีม แต่เฉพาะตอน ระบายบัฟเฟอร์ เท่านั้น
- การเขียนแต่ละครั้งจึงดำเนินไปได้รวดเร็วโดยแยกจากการส่งการแจ้งเตือนเบื้องหลัง และใช้ประโยชน์จากการปรับแต่งของ Postgres อย่าง group commit เพื่อเพิ่มทรูพุตได้
การโพลความถี่ต่ำเพื่อชดเชยการแจ้งเตือนที่สูญหาย
- หากโปรเซสหยุดทำงานขณะที่การแจ้งเตือนยังอยู่ในหน่วยความจำ การแจ้งเตือนนั้นอาจไม่ถูกส่งออกไป
- โปรเซสฝั่งอ่านจึงรอการแจ้งเตือนไปพร้อมกับคิวรีฐานข้อมูลเป็นระยะ เพื่อตรวจว่ามี ข้อมูลสตรีมที่ถูกบันทึกโดยไม่มีการแจ้งเตือน หรือไม่
- เพราะการโพลนี้เป็นเพียงวิธีเสริมเพื่อกู้คืนเฉพาะการแจ้งเตือนที่หายไป จึงสามารถทำที่ความถี่ต่ำและแทบไม่กระทบต่อประสิทธิภาพ
ทรูพุตและค่าหน่วง
- อิมพลีเมนเทชันที่ปรับแต่งแล้วสามารถรองรับ การเขียนสตรีมสูงสุด 60,000 ครั้งต่อวินาที ในสภาพแวดล้อมที่มีโปรเซสอ่านพร้อมกัน ให้ทรูพุตสูงกว่าอิมพลีเมนเทชันแรก 20 เท่า
- แม้เพิ่มทรูพุตแล้ว ค่าหน่วงก็ยังอยู่ในช่วง 15~100ms
- ที่ทรูพุตสูงสุด CPU ของ Postgres ถูกใช้จนเต็ม แสดงว่าไม่ได้ติดที่การแย่งล็อกอีกต่อไป แต่แตะจุดอิ่มตัวจริงของฐานข้อมูลแล้ว
- ดูโค้ดเบนช์มาร์กทั้งหมดได้ที่ dbos-postgres-benchmark
1 ความคิดเห็น
ความเห็นจาก Hacker News
ความสามารถในการขยายระบบเป็นสเกลต่อเนื่อง ไม่ใช่เรื่องแบบมีหรือไม่มี 6 หมื่นรายการต่อวินาทีอาจมากกว่าที่บางระบบต้องการถึง 1 แสนเท่า แต่ก็อาจน้อยกว่าที่อีกระบบต้องการอยู่ 1 แสนเท่าได้ ความผิดพลาดที่นักพัฒนาพบได้บ่อย อยากยกให้เป็น การเลือกเทคโนโลยีที่มีคุณสมบัติด้านการขยายระบบไม่ตรงโจทย์ มากกว่า “การ optimize เร็วเกินไป”
เทคโนโลยีที่เล็กเกินไป เมื่อเกินขีดจำกัดแล้วจะล้มเหลวอย่างชัดเจน แต่เทคโนโลยีที่ขยายได้มากเกินความจำเป็นก็เพิ่มภาระการดูแลและข้อจำกัดเช่นกัน การนำเทคโนโลยีแบบนี้ไปใช้กับระบบขนาดเล็กที่สามารถลดความพยายามในการพัฒนาได้มากด้วยโมเดลที่สมบูรณ์กว่าก็เป็นทางเลือกที่ไม่ดีเช่นกัน
ข้อจำกัดของ LISTEN/NOTIFY ต่ำพอที่จะต้องระวัง ดังนั้นแม้จะคำนวณโหลดสูงสุดแบบมองโลกในแง่ร้ายแล้ว ก็ควรเผื่อไว้อย่างน้อย 10 เท่า แต่สำหรับหลายโปรเจกต์ก็ยังเพียงพอ ด้วยข้อดีเรื่องการผสานกับฐานข้อมูล, ความพร้อมใช้งาน, และไม่ต้องดูแลบริการแยก จึงไม่ใช่ตัวเลือกที่ควรตัดทิ้งไปเลย และแม้แต่ 2 พันรายการต่อวินาทีที่ยกมาก่อนหน้านี้ ก็ยังถือว่าสูงมากสำหรับระบบที่ประมวลผลข้อความละ 1 วินาที
ทางที่ดีกว่าคือออกแบบให้เหมาะกับขนาดที่คาดจริงพร้อมเผื่อพอสมควร แล้วค่อยเลือกมากกว่านั้นเฉพาะเมื่อความสามารถในการขยายเพิ่มขึ้นแทบจะฟรีเท่านั้น ถ้าแค่จ่ายเพิ่มอีกไม่กี่พันดอลลาร์เพื่อซื้อฮาร์ดแวร์ที่ใหญ่ขึ้น หรือเป็นตัวเลือกที่เทียบเท่ากันทุกอย่างนอกจากการขยายระบบ ก็เลือกตัวที่ใหญ่กว่าก็ได้
เคยประสบความสำเร็จมากกับการใช้ LISTEN/NOTIFY ร่วมกับ Rust GraphQL subscription broker จำนวน subscription มีเป็นหลักหลายหมื่น แต่การเชื่อมต่อ LISTEN มีเพียงโฮสต์ละหนึ่ง รวมทั้งหมดแค่ 3–4 การเชื่อมต่อ
ส่งทุกการเปลี่ยนแปลงไปยังแต่ละโฮสต์ แล้วให้โฮสต์จัดการ subscription ของผู้ใช้จริงและตัดสินใจว่าจะ publish อะไร หากเปลี่ยนจากโฮสต์ Ruby หรือ Node หลายร้อยเครื่องมาเป็นโฮสต์ Rust ไม่กี่เครื่อง ก็ทำให้สถาปัตยกรรมง่ายลงมาก และแนวทางที่มักถูกมองว่าขยายไม่ได้ก็ยังทำงานได้ดีทีเดียว
ตอนทำงานเป็น CTO เคยเริ่มจากประมาณ 1 แสนรายการต่อวันในทุกบริการ แล้วเติบโตเป็นหลักหลายล้าน จนสุดท้ายเป็นหลักหลายสิบล้าน ระหว่างทาง วิศวกรคนหนึ่งสร้างคิวบนความหมายเชิงพฤติกรรมของ LISTEN/NOTIFY เพื่อใช้ประโยชน์จาก ความสอดคล้องที่เข้มงวด กับ data model มันไม่ได้เข้าใจยาก และในตอนนั้นก็ดูสมเหตุสมผลเพราะช่วยตัดชั้นการจัดเก็บและส่งต่อแยกต่างหากออกไปได้
แต่พอต้องขยายฟังก์ชันที่ทำขึ้นเอง ก็อึดอัดมากเพราะต้องหลบเลี่ยงการทำงานภายในของ PostgreSQL และควรย้ายไปใช้ระบบอื่นให้เร็วกว่านั้น อีกทั้งยังขยายได้ไม่ดี ทำให้บน RDS มีการแย่ง disk อย่างหนักจนหาสาเหตุได้ยาก และการทำ VACUUM ของตารางนั้นก็เป็นฝันร้าย การนำคิวที่คุ้นเคยไปทำใหม่ในรูปแบบแปลกประหลาดด้วยฟีเจอร์ภายใน PostgreSQL ทำให้วิศวกรคนอื่นกลัว ไม่อยาก debug และไม่อยากเป็นเจ้าของมัน
บทเรียนสำคัญที่ไม่เกี่ยวกับรายละเอียดอย่าง schema หรือ index คือ ควรเริ่มจาก เทคโนโลยีที่เรียบง่ายและคาดเดาได้ เสมอ หากไม่จำเป็นต้องมีความสอดคล้องของข้อมูลที่เข้มงวดมาก การเพิ่มองค์ประกอบในอินฟราฯ อีกหนึ่งตัวก็มักคุ้มกว่า ถ้าแลกกับการใช้คิวที่มีสัญญา API เรียบง่ายอย่าง SQS หรือ Redis queue แล้วปรับส่วนที่เหลือให้เข้ากับมัน ยิ่ง data store หลักตัวเดียวมีภาระเชิงกลไกน้อยเท่าไร ก็ยิ่งดี
เมื่อ ecosystem ของ distributed systems พัฒนาขึ้น เราก็เข้าใจมากขึ้นว่าแต่ละองค์ประกอบทำอะไรได้บ้าง การเริ่มต้นด้วย องค์ประกอบให้น้อย แล้วค่อยเพิ่มเมื่อจำเป็นจริง ๆ จะทำให้ระบบแข็งแรงกว่า
ยังคงชอบ DBOS ที่ใช้ Postgres และตอนนี้รวมถึง SQLite ได้อย่างเหมาะสม มันแทบใส่เข้าไปในสแตก CRUD เดิมได้โดยไม่ต้องออกแรงมาก
พอเริ่มใช้ durable workflow ก็จะเห็นจุดที่เอาไปใช้ได้เพิ่มขึ้นเรื่อย ๆ ช่วงนี้กำลังลองมองอีเมลแต่ละฉบับเป็น durable workflow โดยให้ผู้ใช้, คู่สนทนา, เอเจนต์, และเครื่องมืออย่าง GitHub หรือ Attio เข้าร่วมใน flow ตามลำดับ
https://housecat.com/blog/gmail-durable-workflows-sandbox-vm
บทความแบบนี้โดยมากคือผลจากการที่แต่ละคนประเมินปัญหา ความเข้าใจ และทางแก้ของตัวเองอย่างอิสระ จะเรียกว่าไม่เชี่ยวชาญเพียงเพราะคาดหวัง performance บางอย่างจากค่าเริ่มต้นของเครื่องมือก็กำกวม และทุกคนก็เรียนรู้ต่อไปผ่านความล้มเหลว
การใช้เซิร์ฟเวอร์ฐานข้อมูล 96 คอร์·RAM 384GB ในการทดลอง (https://github.com/dbos-inc/dbos-postgres-benchmark/blob/mai...) เป็นประเด็นสำคัญมากและควรระบุให้ชัดเจน ฐานข้อมูลสามารถ scale up แนวตั้งได้ แต่ก็มีขีดจำกัดเช่นกัน ใครเชื่อมต่อจากที่ไหนก็ส่งผลต่อ performance และ latency โดยรวม
6 หมื่นรายการต่อวินาทีอาจดูมาก แต่สิ่งที่ทำให้ระบบจริงล่มมักเป็น การพุ่งขึ้นของทราฟฟิกแบบฉับพลัน มากกว่าทราฟฟิกปกติ หากไม่ใช่บริษัทใหญ่ ก็คงไม่เริ่มต้นด้วยเซิร์ฟเวอร์ขนาดใหญ่อย่างนี้ และถ้ารวม read replica กับการทำซ้ำข้าม region แล้ว database cluster สำหรับ production หนึ่งชุดก็มีค่าใช้จ่ายเกิน 1 แสนดอลลาร์
น่าจะเป็นโพสต์ที่เกี่ยวข้อง: Postgres LISTEN/NOTIFY does not scale - https://news.ycombinator.com/item?id=44490510 - กรกฎาคม 2025, ความคิดเห็น 321 รายการ
ดูเหมือนว่าบทความจะขาดส่วนที่สำคัญที่สุด คือวิธี กำหนดออฟเซ็ตหรือหมายเลขลำดับ เพื่อให้ติดตามได้ว่าผู้บริโภคอ่านถึงไหนแล้ว และให้ไปดึงข้อความใหม่จากเส้นทางสำรองได้ มีหลายวิธี แต่แก้ให้ได้โดยไม่มีความซับซ้อนหรือการแย่งล็อกนั้นไม่ง่าย และถ้าทำผิดก็อาจเกิด race condition กับฝั่งผู้บริโภคได้ โดยทั่วไปผู้เขียนคงต้องล็อกแถวเดียวในตารางสถานะหรืออะไรทำนองนั้น เพื่อกำหนดหมายเลขถัดไปของหัวข้ออีเวนต์
สงสัยว่าวิธีที่ดีที่สุดคืออะไร เครื่องมือที่อ่าน change data capture (CDC) แล้วเขียนหมายเลขอีเวนต์ที่จัดสรรแล้วลงอีกตารางหนึ่งก็ดูอาจใช้ได้ แต่ latency อาจสูงขึ้น และตัวประมวลผล CDC นั้นก็ต้องทำ NOTIFY ด้วย
ฝั่งผู้บริโภคเองก็มีกรณีใช้งานที่สามารถเพิ่ม throughput ได้มากด้วยการ ประมวลผลแบบแบตช์ ในกรณีนี้ไม่ต้องใช้ LISTEN/NOTIFY ก็ได้ แค่รันผู้บริโภควนซ้ำและประมวลผลข้อความใหม่ที่ยังไม่ถูกจัดการทั้งหมดในแต่ละรอบ แล้วบันทึกหมายเลขลำดับล่าสุดไว้ระหว่างรอบ
เท่าที่จำได้ ในรีลีสแรกที่รองรับ LISTEN/NOTIFY นั้น การทำล็อกยังไม่ดีนักจึงมีปัญหาด้านประสิทธิภาพ และบทความเก่าที่ถูกวิจารณ์ที่นี่ก็แก้ไขเรื่องนี้ไว้แล้วในคำชี้แจงหลังย่อหน้าแรก
ถ้าวันที่แก้ไขคือ 8 พฤษภาคม ก็ต้องยอมรับว่าโพสต์วันที่ 24 กรกฎาคมที่บอกว่าฟีเจอร์นี้ขยายขนาดไม่ได้ อาจไม่ได้เขียนด้วยเจตนาร้าย และก็อาจไม่ได้ผิดตามบริบทในตอนนั้น
แต่เป็นการปรับให้เหมาะกับกรณีที่จำกัดมากกว่า คือมีช่องแจ้งเตือนจำนวนมาก และผู้รับแต่ละรายรอเพียงช่องใดช่องหนึ่งโดยเฉพาะ
ครั้งล่าสุดที่ตรวจสอบ LISTEN/NOTIFY มี เพดาน 8,000 ไบต์ สำหรับข้อมูลแจ้งเตือน ซึ่งเป็นมุมที่เห็นได้ชัดว่ามันขยายขนาดไม่ได้ ถ้าข้อมูลนั้นไม่สามารถเก็บเป็นแถวแล้วส่งแค่ ID แจ้งเตือนได้ ก็ใช้งานได้ยาก
อีเวนต์ของเว็บเกมเป็นข้อมูลชั่วคราวที่อธิบายการเปลี่ยนสถานะ จึงไม่มีเหตุผลจะเก็บไว้ในฐานข้อมูล และมันอาจเกิน 8,000 ไบต์ จึงไม่เหมาะกับงานลักษณะนี้
บทความพูดถึงการแย่งล็อกของคิวส่วนกลาง แต่ดูเหมือนจะไม่กล่าวถึงอีกปัญหาหนึ่งของ คิวส่วนกลางขนาดคงที่ คือผู้รับที่ช้าเพียงรายเดียวในช่องหนึ่ง สามารถบล็อกการเขียนของทุกช่องได้ อย่างน้อยเมื่อไม่กี่ปีก่อน ความล้มเหลวลักษณะนี้ยังเกิดขึ้นได้ และตอนนี้อาจเปลี่ยนไปแล้ว