- Jepsen ทดสอบความทนทานและความสอดคล้องของระบบการส่งข้อความแบบกระจาย NATS JetStream ในสภาวะความล้มเหลวที่หลากหลาย
- ผลการทดสอบพบว่ามีการสูญเสียข้อมูลและเกิดปรากฏการณ์ split-brain จาก ความเสียหายของไฟล์ (.blk, snapshot) และการ จำลองเหตุการณ์ไฟฟ้าดับ
- JetStream ทำ
fsync โดยค่าเริ่มต้นเพียงทุก 2 นาที ทำให้ข้อความที่ได้รับการอนุมัติล่าสุดอาจยังไม่ถูกเขียนลงดิสก์
- แม้เพียง การ crash ของ OS โหนดเดียว ก็สามารถทำให้เกิดการสูญเสียข้อมูลและความไม่สอดคล้องของ replica ได้
- Jepsen แนะนำให้เปลี่ยนค่าเริ่มต้นของ NATS เป็น
fsync=always หรือทำการ ระบุความเสี่ยงการสูญเสียข้อมูลไว้ในเอกสารอย่างชัดเจน
1. ภูมิหลัง
- NATS เป็นระบบสตรีมมิ่งยอดนิยมที่ใช้ในการ publish/subscribe ข้อความแบบ stream
- JetStream ใช้อัลกอริทึมฉันทามติ Raft เพื่อทำซ้ำข้อมูล และรับประกันการส่งอย่างน้อยหนึ่งครั้ง (at-least-once)
- JetStream ระบุในเอกสารว่ามีความสอดคล้องแบบ Linearizable และ ความพร้อมใช้งานเสมอ แต่ตาม กฎ CAP ไม่สามารถทำได้ทั้งสองเงื่อนไขพร้อมกัน
- ตามเอกสารของ NATS สตรีม 3 โหนดสามารถทนต่อการสูญเสียเซิร์ฟเวอร์ได้ 1 ตัว และสตรีม 5 โหนดสามารถทนต่อการสูญเสียได้ 2 ตัว
- ข้อความถูกถือว่า “บันทึกสำเร็จ” เมื่อเซิร์ฟเวอร์ได้ acknowledge คำขอ
publish แล้ว
- การรักษาความสอดคล้องข้อมูลต้องการโหนด ส่วนใหญ่ (quorum) และในคลัสเตอร์ 5 โหนด ต้องมีอย่างน้อย 3 โหนดทำงานเพื่อบันทึกข้อความใหม่ได้
2. การออกแบบการทดสอบ
- Jepsen ทำการทดสอบโดยใช้ไคลเอนต์ JNATS 2.24.0 และคอนเทนเนอร์ Debian 12 LXC
- บางการทดสอบใช้ Docker image อย่างเป็นทางการของ NATS บนสภาพแวดล้อม Antithesis
- สร้าง JetStream stream เดี่ยว (replication 5) และใส่ความล้มเหลว เช่น การหยุด/ครัชกระบวนการ, network partition, packet loss, ความเสียหายไฟล์ ฯลฯ
- ใช้ระบบไฟล์ LazyFS เพื่อจำลองเหตุการณ์ไฟฟ้าดับที่ทำให้การเขียนที่ยังไม่ถูก
fsync สูญหาย
- แต่ละ process จะ publish ข้อความเฉพาะตัวเอง และหลังการทดสอบเสร็จ จะตรวจสอบการมีอยู่ของข้อความที่ได้รับการ acknowledge ในทุกโหนด
- ถ้าข้อความมีอยู่ในบางโหนดเท่านั้น จะถูกจัดว่าเป็น divergence (ความไม่ตรงกันของการทำซ้ำ)
3. ผลลัพธ์หลัก
3.1 การสูญเสียข้อมูลทั้งหมดใน NATS 2.10.22 (#6888)
- พบปรากฏการณ์ที่ stream ของ JetStream หายไปทั้งหมด แม้มีเพียงการ crash process อย่างเดียว
- หลังเกิดข้อผิดพลาด
No matching streams for subject แล้วไม่สามารถกู้คืนได้เป็นเวลาหลายชั่วโมง
- สาเหตุเกิดจาก การกลับด้าน snapshot ของ leader, การลบสถานะ Raft เป็นต้น และได้รับการแก้ไขในเวอร์ชัน 2.10.23
3.2 การสูญเสียข้อมูลเมื่อไฟล์ .blk เสียหาย (#7549)
- เมื่อไฟล์
.blk ของ JetStream มี ข้อผิดพลาดเพียง 1 บิตหรือการตัดทอน (truncation) จะสูญเสียการเขียนที่ได้รับการอนุมัติได้เป็นจำนวนแสนรายการ
- ตัวอย่าง: สูญหาย 679,153 จาก 1,367,069 รายการ
- แม้ความเสียหายเกิดในโหนดบางตัวเท่านั้นก็เกิดการสูญเสียข้อมูลขนาดใหญ่และ split-brain ได้
- ตัวอย่าง: โหนด
n1, n3, n5 สูญเสียข้อความสูงสุดถึง 78%
- NATS กำลังดำเนินการตรวจสอบประเด็นนี้
3.3 การลบข้อมูลทั้งหมดเมื่อไฟล์ snapshot เสียหาย (#7556)
- เมื่อไฟล์ snapshot ใน
data/jetstream/$SYS/_js_/ เสียหาย โหนดจะมองว่า stream เป็น orphaned และ ลบข้อมูลทั้งหมด
- แม้เพียงโหนดบางส่วนเสียหาย ก็อาจทำให้คลัสเตอร์ไม่ถึง quorum และ stream ใช้งานไม่ได้ถาวร
- ตัวอย่าง: โหนด
n3, n5 เสียหาย → n3 ถูกเลือกเป็น leader และลบ jepsen-stream ทั้งหมด
- Jepsen ชี้ให้เห็นความเสี่ยงว่าในช่วงการเลือก leader โหนดที่เสียหายอาจถูกเลือกเป็น leader ได้
3.4 การสูญเสียข้อมูลจากการตั้งค่า fsync เริ่มต้น (#7564)
- JetStream ทำ
fsync ทุก 2 นาทีเป็นค่าเริ่มต้น และยืนยันข้อความทันทีหลังรับข้อมูล
- ส่งผลให้ข้อความที่ได้รับการอนุมัติเมื่อเร็ว ๆ นี้อาจยังไม่ถูกเขียนลงดิสก์
- เมื่อเกิด การสูญหายไฟฟ้าหรือ kernel crash อาจทำให้การสูญเสียข้อความที่ได้รับการอนุมัติได้หลายสิบวินาที
- ตัวอย่าง: สูญหาย 131,418 จาก 930,005 รายการ
- การล้มเหลวของโหนดเดี่ยวต่อเนื่องกันก็อาจนำไปสู่การลบ stream ทั้งหมดได้
- เอกสารแทบไม่กล่าวถึงพฤติกรรมนี้อย่างชัดเจน
- Jepsen แนะนำให้เปลี่ยนเป็น
fsync=always โดยค่าเริ่มต้น หรือมี คำเตือนชัดเจนเกี่ยวกับความเสี่ยงการสูญเสียข้อมูล
3.5 split-brain จาก OS crash ของโหนดเดียว (#7567)
- การเกิด การดับไฟฟ้าหรือ kernel crash ของโหนดเดียวก็สามารถทำให้เกิด การสูญเสียข้อมูลและความไม่สอดคล้องกันของการทำซ้ำ ได้
- ในโครงสร้าง leader-follower เมื่อตัวอย่างบางโหนด commit ไว้ในหน่วยความจำเท่านั้นแล้วล้มเหลว
หลายโหนดจึงสูญเสียการเขียนนั้นและดำเนินไปยังสถานะแบบใหม่
- ในการทดสอบ หลังไฟดับโหนดเดียวเกิดขึ้นพบ split-brain ที่คงอยู่นาน
- พบร่องรอยการสูญเสียข้อความที่ได้รับการอนุมัติในช่วงเวลาแตกต่างกันระหว่างโหนดต่าง ๆ
- Jepsen อ้างถึงกรณีใน Kafka ที่คล้ายกัน และเน้นว่า ความเสี่ยงเดียวกันยังคงมีในระบบฐาน Raft
4. การอภิปรายและข้อสรุป
- ปัญหาการสูญเสียข้อมูลทั้งหมดใน 2.10.22 ได้รับการแก้ใน 2.10.23 แล้ว
- ใน 2.12.1 ยังคงเกิด การสูญเสียข้อมูลและ split-brain จาก ความเสียหายไฟล์และ OS crash อยู่
- เมื่อไฟล์
.blk และ snapshot เสียหาย พบการขาดหายข้อความที่บางโหนดหรือการลบ stream ทั้งหมด
- เนื่องจากช่วงเวลารัน
fsync เริ่มต้นยาว การสูญเสียข้อมูลที่ได้รับการยืนยันอาจเกิดเมื่อมีการล้มเหลวโหนดหลายตัวพร้อมกัน
- Jepsen เสนอให้ตั้ง
fsync=always หรือระบุ คำเตือนความเสี่ยงอย่างชัดแจ้งในเอกสาร
- ข้ออ้างว่า JetStream “พร้อมใช้งานตลอดเวลา” ขัดกับ กฎ CAP จึงต้องแก้ไขเอกสาร
- Jepsen ระบุว่า การพิสูจน์การมี bug เป็นเรื่องที่ทำได้ แต่ไม่สามารถพิสูจน์การขาดความปลอดภัยได้
4.1 บทบาทของ LazyFS
- ใช้ LazyFS เพื่อจำลองการสูญเสียการเขียนที่ยังไม่ผ่าน
fsync
- การเกิดไฟฟ้าดับสามารถจำลองข้อผิดพลาด storage หลายแบบ เช่น torn write และความเสียหายอื่น ๆ ได้
- งานวิจัยที่เกี่ยวข้อง When Amnesia Strikes (VLDB 2024) รายงานข้อบกพร่องแบบคล้ายกันใน PostgreSQL, Redis, ZooKeeper และระบบอื่น ๆ
4.2 ภารกิจต่อไป
- การสูญเสียข้อความในระดับ consumer รายตัว, ลำดับข้อความ, และการยืนยัน Linearizable/Serializable ยังไม่ได้ถูกทดสอบ
- การรับประกันการส่ง exactly-once (หนึ่งครั้งอย่างแม่นยำ) ยังเป็นหัวข้อวิจัยในอนาคต
- พบข้อผิดพลาดเอกสารและ ขั้นตอน health check สำคัญที่ขาดหาย ในการเพิ่มหรือลบโหนด (#7545)
- แนวทางการเปลี่ยนแปลงโครงสร้างคลัสเตอร์ให้ปลอดภัยยังไม่ชัดเจน
1 ความคิดเห็น
ความเห็นจาก Hacker News
ตอนนี้เลยเริ่มสงสัยว่า AI อาจจะอ่านเอกสารโปรเจกต์แล้วทำนาย ความเป็นไปได้ของการสูญหายของข้อมูล จากแค่วลีการตลาดได้หรือเปล่า
คนชอบพูดกันเสมอว่า “ทฤษฎีนั้นถูกให้ความสำคัญเกินไป” หรือ “การแฮ็กดีกว่าการเรียนในโรงเรียน” แต่สุดท้ายก็มักสะดุดขาตัวเองใน problem space ที่ถูกบันทึกไว้เป็นเอกสาร
มันจัดการ รายละเอียดการสเกลที่ละเอียดอ่อน ได้ดีด้วย
แต่ไม่เคยใช้ persistence มาก่อน เลยไม่คิดว่ามันจะเปราะบางได้ถึงขนาดนี้
ตกใจที่มันอ่อนแอแม้กระทั่งต่อความเสียหายของไฟล์แบบบิตเดียว
เป็นแหล่งอ้างอิงที่ดีมาก → Jepsen Glossary
เพิ่งเจอ aphyr.com ไม่นานนี้ เลยคาดหวังว่าจะมีข้อมูลเชิงลึกเยอะมาก
หลังจากนั้น jepsen.io ก็พัฒนาเป็น โปรเจกต์แบบมืออาชีพ และเริ่มดำเนินการอย่างจริงจังราว 10 ปีก่อน
เป็นเพราะต้องการเพิ่มผล benchmark ให้สูงขึ้นหรือเปล่า? ในคลัสเตอร์เล็ก ๆ การตั้งค่าแบบนี้มักจะ เป็นต้นตอของปัญหา
แอปพลิเคชันจำนวนมากไม่ได้ต้องการความทนทานแบบเต็มรูปแบบ ดังนั้น lazy fsync อาจมีประโยชน์ได้
แต่การตั้งให้เป็นค่าเริ่มต้นยังเป็นเรื่องที่ถกเถียงกันได้
ดูเหมือนว่าน่าจะแก้ด้วยการทำ batch แบบเดียวกับ TCP corking ได้
เนื่องจากความล้มเหลวจาก lazy fsync มักจะไม่เกิดขึ้นพร้อมกันในทุกโหนด
ข้อดี: รองรับ สตรีมไม่จำกัด พร้อมความทนทานระดับ object storage
ข้อเสีย: ยังไม่มีฟีเจอร์ consumer group
ถ้ามีหลายโหนดล้มพร้อมกัน ก็อาจเกิด การสูญหายของข้อมูลที่ถูก commit แล้ว ได้
ทำให้นึกถึงการตลาด “เว็บสเกล” ในยุคแรก ๆ ของ MongoDB
ผมคิดว่าค่าเริ่มต้นควรเป็น ตัวเลือกที่ปลอดภัยที่สุด เสมอ
ซึ่งผมกลับชอบจุดนั้น เพราะสามารถออกแบบระบบต่อยอดบนมันได้
ตอนที่ใช้ในปี 2018 มันทั้งเร็วและดูแลง่าย
เช่น ระดับ transaction isolation เริ่มต้นของ PostgreSQL คือ read committed
Redis เองก็ทำ fsync ทุก 1 วินาทีเป็นค่าเริ่มต้น
แม้ใน standalone Redis ก็สามารถตั้งให้ ack หลัง fsync ได้ แต่เพราะ OS buffering จึงยังรับประกันเต็มที่ได้ยาก
สุดท้ายแล้วสิ่งสำคัญคือการเข้าใจ ความหมายของ ack ให้ชัดเจน
ถ้ายึดแต่ค่าเริ่มต้นที่ปลอดภัยอย่างเดียว ประสิทธิภาพก็จะตกลงมาก และภาระในการจูนเองก็จะไปอยู่ที่ผู้ใช้
เช่น ระดับ isolation เริ่มต้นของ Postgres ก็อ่อนพอที่จะทำให้เกิด race condition ได้
อ้างอิง: บทความทดสอบ Hermitage
ในยุค SSD ขั้นกลางอย่าง group-commit หายไปแล้ว และตอนนี้ ต้นทุนการสลับ syscall กลายเป็นคอขวด
2 นาทีถือว่านานเกินไป (และควรพิจารณาความต่างระหว่าง fdatasync กับ fsync ด้วย)
น่าจะใช้ Redpanda ไปเลยดีกว่า
ถ้าทำ batch flush เป็นช่วง ๆ latency อาจเพิ่มขึ้น แต่ throughput น่าจะยังรักษาไว้ได้
วิธีนี้คล้ายกับการรวม รอบของ Paxos เข้าด้วยกัน
ควรประมวลผลโดยพอรอบหนึ่งจบก็เริ่ม batch ถัดไปทันที