62 คะแนน โดย xguru 2023-11-09 | 9 ความคิดเห็น | แชร์ทาง WhatsApp

Elixir ในฐานะระบบ fanout

  • ทุกครั้งที่มีเหตุการณ์เกิดขึ้นใน Discord เช่น มีการส่งข้อความหรือมีคนเข้าร่วมช่องเสียง จะต้องอัปเดต UI บนไคลเอนต์ของผู้ใช้ออนไลน์ทุกคนที่อยู่ในเซิร์ฟเวอร์เดียวกัน (หรือที่เรียกว่า "guild")
  • ใช้ Elixir process หนึ่งตัวต่อ guild เป็นจุด routing กลางของทุกเหตุการณ์ที่เกิดขึ้นในเซิร์ฟเวอร์นั้น และใช้อีก process หนึ่ง ("session") สำหรับไคลเอนต์ของผู้ใช้ที่เชื่อมต่อแต่ละราย
  • guild process มีหน้าที่ติดตาม session ของผู้ใช้ที่เป็นสมาชิกของ guild นั้น และกระจายงานไปยัง session เหล่านั้น
  • เมื่อ session ได้รับอัปเดต ก็จะส่งต่อไปยังไคลเอนต์ผ่านการเชื่อมต่อ WebSocket
  • งานบางอย่างมีผลกับทุกคนในเซิร์ฟเวอร์ แต่บางอย่างต้องตรวจสอบสิทธิ์ จึงจำเป็นต้องรู้ทั้งข้อมูล role และ channel ของเซิร์ฟเวอร์นั้น รวมถึง role ของผู้ใช้ด้วย
  • ปริมาณกิจกรรมของ guild แปรผันตามจำนวนคนในเซิร์ฟเวอร์ และปริมาณงานที่ต้องใช้ในการ fanout ข้อความหนึ่งครั้งก็แปรผันตามจำนวนผู้ใช้ออนไลน์ของเซิร์ฟเวอร์นั้น
    • กล่าวคือ ปริมาณงานที่ต้องใช้ในการจัดการ Discord server จะเพิ่มขึ้นตามกำลังสี่ของขนาดเซิร์ฟเวอร์
    • ถ้ามีคนออนไลน์ 1,000 คนในเซิร์ฟเวอร์หนึ่ง และทุกคนพูดว่า "ฉันชอบเยลลี่" คนละหนึ่งครั้ง นั่นหมายถึงต้องประมวลผลการแจ้งเตือน 1 ล้านครั้ง
    • ถ้าเป็น 10,000 คน จะเกิดการแจ้งเตือน 100 ล้านครั้ง และถ้าเป็น 100,000 คน จะต้องส่งต่อการแจ้งเตือนถึง 10,000 ล้านครั้ง
  • นอกเหนือจากปัญหา throughput โดยรวมแล้ว เมื่อเซิร์ฟเวอร์ใหญ่ขึ้น งานบางอย่างก็อาจช้าลงด้วย
  • เพื่อให้เซิร์ฟเวอร์รู้สึกว่าตอบสนองได้ดี เช่น เมื่อมีการส่งข้อความ คนอื่นต้องเห็นได้ทันที หรือเมื่อมีคนเข้าร่วมช่องเสียงก็ต้องเริ่มเข้าร่วมได้ทันที งานแทบทั้งหมดจึงต้องถูกประมวลผลอย่างรวดเร็ว
  • หากการจัดการงานที่มีต้นทุนสูงใช้เวลาหลายวินาที ประสบการณ์ผู้ใช้จะลดลง
  • แม้จะมีปัญหาเหล่านี้ Discord ก็ยังต้องรองรับเซิร์ฟเวอร์ Midjourney ที่มีสมาชิกมากกว่า 10 ล้านคน และมีมากกว่า 1 ล้านคนออนไลน์อยู่ตลอดเวลาได้อย่างไร?
    • อย่างแรกคือจำเป็นต้องทำความเข้าใจประสิทธิภาพของระบบก่อน
    • เมื่อมีข้อมูลแล้ว ก็จึงมองหาโอกาสในการปรับปรุงทั้ง throughput และ responsiveness

ทำความเข้าใจประสิทธิภาพของระบบ

  • Wall time analysis :
    • ใช้ Process.info(pid, :current_stacktrace) สำหรับ stack tracing
    • วัด event processing loop เพื่อบันทึกจำนวนข้อความที่รับในแต่ละประเภท และเวลาสูงสุด/ต่ำสุด/เฉลี่ย/รวมที่ใช้ในการจัดการ
    • งานที่ใช้เวลารวมไม่ถึง 1% ของเวลาทั้งหมดจะถูกมองข้ามทั้งหมด เว้นแต่จะเกิดการพุ่งสูงแบบผิดปกติ
    • ตัดงานราคาถูกออกไป และเน้นงานที่มีต้นทุนสูงที่สุด
  • Process Heap Memory Analysis
    • การเข้าใจว่าระบบใช้หน่วยความจำอย่างไรก็สำคัญเช่นกัน
    • แทนที่จะดูทุกองค์ประกอบทีละตัว ได้เขียน helper library ที่สุ่มตัวอย่าง map และ list ขนาดใหญ่ (ที่ไม่ใช่ struct) เพื่อประมาณการใช้หน่วยความจำ
    • ไลบรารีนี้ไม่เพียงช่วยให้เข้าใจประสิทธิภาพของ GC แต่ยังมีประโยชน์ในการหาว่าฟิลด์ใดควรโฟกัสเพื่อทำ optimization และฟิลด์ใดไม่เกี่ยวข้องในท้ายที่สุด
  • หลังจากระบุได้ว่า guild process ใช้เวลาไปกับตรงไหน ก็สามารถวางกลยุทธ์เพื่อไม่ให้ guild process ยุ่ง 100% ตลอดเวลาได้
    • ในบางกรณี แค่เขียน implementation ที่ไม่มีประสิทธิภาพใหม่ให้มีประสิทธิภาพมากขึ้นก็เพียงพอ
    • แต่แนวทางนี้ไปได้แค่ระดับหนึ่ง จำเป็นต้องมีการเปลี่ยนแปลงที่ลึกกว่านั้น

Passive session - หลีกเลี่ยงงานที่ไม่จำเป็น

  • หนึ่งในวิธีที่ดีที่สุดในการแก้คอขวดด้าน throughput คือการลดงานลง
  • วิธีหนึ่งคือพิจารณาความต้องการของแอปพลิเคชันฝั่งไคลเอนต์
  • ใน topology เดิม ผู้ใช้ทุกคนจะได้รับทุกกิจกรรมที่ตนสามารถมองเห็นได้จากทุก guild ที่ตนสังกัด
  • แต่ผู้ใช้บางคนอยู่ในหลาย guild และอาจไม่ได้แม้แต่คลิกเข้าไปดูว่าเกิดอะไรขึ้นในบาง guild
  • ถ้าไม่ส่งทุกอย่างจนกว่าผู้ใช้จะคลิกล่ะ? ก็จะไม่ต้องตรวจสอบสิทธิ์สำหรับทุกข้อความทีละรายการ และสุดท้ายปริมาณข้อมูลที่ส่งไปยังไคลเอนต์ก็จะลดลงมาก
  • Discord เรียกสิ่งนี้ว่า connection แบบ 'Passive' และเก็บไว้ในรายการแยกจาก connection แบบ 'Active' ที่ต้องรับข้อมูลทั้งหมด
  • ผลคือ ในเซิร์ฟเวอร์ขนาดใหญ่ ประมาณ 90% ของการเชื่อมต่อผู้ใช้-กับ-guild เป็นแบบ passive ทำให้ต้นทุนของงาน fanout ลดลง 90%
  • สิ่งนี้ช่วยผ่อนแรงไปได้มาก แต่เมื่อชุมชนยังเติบโตต่อไป มันก็ไม่เพียงพออย่างหลีกเลี่ยงไม่ได้
    (เมื่อปริมาณงานลดลง 10 เท่า จะได้ประโยชน์ราว 3 เท่าในขนาดคอมมูนิตี้สูงสุด)

Relay - แบ่ง fanout ข้ามหลายเครื่อง

  • หนึ่งในเทคนิคมาตรฐานสำหรับขยายขีดจำกัด throughput ของ single core คือการแบ่งงานออกเป็นหลาย thread (หรือในคำศัพท์ของ Elixir คือหลาย process)
  • จากแนวคิดนี้ จึงสร้างระบบที่เรียกว่า 'relay' ไว้ระหว่าง guild กับ user session
  • แทนที่จะให้ process เดียวจัดการงานทั้งหมดของ session ก็แบ่งออกไปยัง relay หลายตัว ทำให้ guild เดียวใช้ทรัพยากรได้มากขึ้นเพื่อให้บริการคอมมูนิตี้ขนาดใหญ่
  • แม้งานบางอย่างยังคงต้องทำใน main guild process แต่วิธีนี้ก็ทำให้รองรับคอมมูนิตี้ที่มีสมาชิกหลายแสนคนได้
  • การทำเช่นนี้ต้องระบุให้ได้ว่างานสำคัญใดควรทำใน relay งานใดควรทำใน guild และงานใดที่ทำได้ในทั้งสองระบบ
  • เมื่อเข้าใจสิ่งที่ต้องการแล้ว ก็เริ่มงาน refactor เพื่อดึง logic ที่แชร์กันระหว่างระบบออกมา
    • ตัวอย่างเช่น logic ส่วนใหญ่เกี่ยวกับวิธีทำ fanout ถูก refactor ให้เป็น library ที่ใช้ร่วมกันทั้ง guild และ relay
    • logic บางส่วนที่แชร์กันไม่ได้ต้องใช้วิธีอื่น และการจัดการสถานะเสียงถูกทำโดยพื้นฐานให้ relay proxy ข้อความทั้งหมดไปยัง guild โดยแก้ไขให้น้อยที่สุด
  • หนึ่งในการตัดสินใจด้านการออกแบบที่น่าสนใจตอนเปิดตัว relay ครั้งแรก คือการใส่รายชื่อสมาชิกทั้งหมดไว้ใน state ของ relay แต่ละตัว
    • นี่เป็นการตัดสินใจที่ดีในแง่ความเรียบง่าย เพราะข้อมูลสมาชิกที่จำเป็นทั้งหมดพร้อมใช้งาน
    • แต่เมื่อถึงขนาด Midjourney ที่มีสมาชิกหลายล้านคน การออกแบบนี้ก็เริ่มไม่มีเหตุผลมากขึ้นเรื่อย ๆ
  • ไม่เพียงแต่ข้อมูลสมาชิกหลายสิบล้านรายการจะถูกเก็บเป็นสำเนาหลายสิบชุดใน RAM เท่านั้น แต่การสร้าง relay ใหม่ยังต้อง serialize ข้อมูลสมาชิกทั้งหมดแล้วส่งไปยัง relay ใหม่นั้น ทำให้ guild เกิดความหน่วงนานหลายสิบวินาที
  • เพื่อแก้ปัญหานี้ จึงเพิ่ม logic สำหรับระบุสมาชิกที่ relay ต้องใช้จริงในการทำงาน ซึ่งมีเพียงส่วนน้อยมากเมื่อเทียบกับสมาชิกทั้งหมด

รักษาความตอบสนองของเซิร์ฟเวอร์

  • นอกจากต้องอยู่ภายในขีดจำกัด throughput แล้ว ยังต้องรักษา responsiveness ของเซิร์ฟเวอร์ไว้ด้วย
  • ตรงนี้การดูข้อมูลด้านเวลาก็ยังมีประโยชน์
  • การโฟกัสที่งานซึ่งใช้เวลานานต่อการเรียกหนึ่งครั้ง มีประสิทธิภาพกว่าการดูแค่ระยะเวลารวม
  • Worker process + ETS
    • หนึ่งในสาเหตุใหญ่ของการไม่ตอบสนองคือ งานที่รันใน guild และต้องวนลูปสมาชิกทั้งหมด
    • กรณีแบบนี้เกิดไม่บ่อย แต่ก็เกิดขึ้นได้ เช่น ถ้ามีคนทำการ ping ทุกคน ก็ต้องรู้ว่าใครบ้างในเซิร์ฟเวอร์ที่มองเห็นข้อความนั้นได้
    • แต่การตรวจสอบแบบนี้อาจใช้เวลาหลายวินาที จะจัดการอย่างไรดี?
    • ทางที่ดีที่สุดคือให้ logic นี้ทำงานไปพร้อมกับที่ guild ยังจัดการงานอื่นต่อได้ แต่ Elixir process ไม่ได้แชร์หน่วยความจำกันได้ดีนัก จึงต้องหาวิธีอื่น
    • หนึ่งในเครื่องมือของ Erlang/Elixir ที่ใช้เก็บข้อมูลไว้ในหน่วยความจำที่ process ต่าง ๆ แชร์กันได้ คือ ETS
    • มันคือฐานข้อมูลในหน่วยความจำที่รองรับการเข้าถึงอย่างปลอดภัยจากหลาย Elixir process
    • แม้จะมีประสิทธิภาพน้อยกว่าการเข้าถึงข้อมูลใน process heap แต่ก็ยังเร็วมาก และยังมีข้อดีคือลดขนาด process heap ทำให้ latency ของ garbage collection ลดลงด้วย
    • จึงตัดสินใจสร้างโครงสร้างแบบ hybrid สำหรับเก็บรายชื่อสมาชิก:
      • เก็บรายชื่อสมาชิกไว้ใน ETS เพื่อให้อ่านจาก process อื่นได้ แต่ก็เก็บการเปลี่ยนแปลงล่าสุด (insert, update, delete) ไว้ใน process heap ด้วย
      • เพราะสมาชิกส่วนใหญ่ไม่ได้ถูกอัปเดตอยู่ตลอด ชุดของการเปลี่ยนแปลงล่าสุดจึงเป็นเพียงส่วนน้อยมากของสมาชิกทั้งหมด
    • ตอนนี้จึงสามารถสร้าง worker process โดยใช้สมาชิกจาก ETS และส่งตัวระบุ ETS table ให้เพื่อใช้ทำงานเมื่อมีงานต้นทุนสูงเกิดขึ้น
    • worker process สามารถจัดการส่วนที่มีต้นทุนสูงได้ ในขณะที่ guild ยังคงทำงานอื่นต่อไปได้ และยังมีวิธีง่าย ๆ สำหรับทำสิ่งนี้ด้วย (ในต้นฉบับมี code snippet)
    • ตัวอย่างหนึ่งของการใช้แนวทางนี้ คือเมื่อจำเป็นต้องย้าย guild process จากเครื่องหนึ่งไปยังอีกเครื่องหนึ่ง (โดยปกติเพื่อ maintenance หรือ deployment)
    • ในกระบวนการนี้ จะสร้าง process ใหม่บนเครื่องใหม่เพื่อจัดการ guild จากนั้นคัดลอก state ของ guild process เดิมไปยัง process ใหม่ เชื่อม session ที่เชื่อมต่ออยู่ทั้งหมดกลับไปยัง guild process ใหม่ แล้วประมวลผล backlog ที่สะสมระหว่างการย้าย
    • การใช้ worker process ช่วยให้ส่งข้อมูลสมาชิกส่วนใหญ่ได้ (ซึ่งอาจมีขนาดหลาย GB) ในขณะที่ guild process เดิมยังทำงานต่อไปได้ จึงลดเวลา delay ระดับหลายนาทีที่เคยเกิดขึ้นทุกครั้งที่ handoff
  • Manifold offload
    • อีกแนวคิดหนึ่งเพื่อปรับปรุง responsiveness และก้าวข้ามขีดจำกัด throughput คือการขยาย manifold ให้ใช้ process "sender" แยกต่างหากเพื่อทำ fanout ไปยัง recipient node แทนที่จะให้ guild process ทำ fanout เอง
    • วิธีนี้ไม่เพียงลดภาระของ guild process แต่ยังป้องกันผลกระทบจาก backpressure ของ BEAM ได้ด้วย หากการเชื่อมต่อเครือข่ายระหว่าง guild กับ relay ตัวใดตัวหนึ่งมีการติดค้างชั่วคราว (BEAM คือ virtual machine ที่รันโค้ด Elixir)
    • ในทางทฤษฎีดูเหมือนจะเป็นสิ่งที่แก้ได้ง่าย แต่เมื่อทดลองใช้ฟีเจอร์นี้ (ที่เรียกว่า manifold offload) กลับพบว่าประสิทธิภาพแย่ลงอย่างมาก
    • เป็นไปได้อย่างไร? ในทางทฤษฎีงานลดลง แต่ process กลับยุ่งกว่าเดิม?
    • เมื่อตรวจสอบลึกลงไป ก็พบว่างานส่วนใหญ่ที่เพิ่มขึ้นเกี่ยวข้องกับ garbage collection
    • ตอนนั้นฟังก์ชัน erlang.trace เข้ามาเป็นเหมือนผู้กอบกู้
    • ฟังก์ชันนี้ทำให้เก็บข้อมูลได้ทุกครั้งที่ guild process ทำ garbage collection จึงได้ insight ไม่เพียงว่ามันเกิดบ่อยแค่ไหน แต่ยังรู้ด้วยว่าอะไรเป็นตัวกระตุ้นให้เกิด garbage collection
    • จากข้อมูล trace นี้ เมื่อลงไปดูโค้ด garbage collection ของ BEAM ก็พบว่าเมื่อเปิดใช้ manifold offload เงื่อนไขหลักที่กระตุ้น major (full) garbage collection คือ virtual binary heap
    • virtual binary heap เป็นฟีเจอร์ที่ออกแบบมาเพื่อให้ process สามารถคืนหน่วยความจำที่ถูกใช้โดย string ซึ่งไม่ได้เก็บอยู่ใน process heap ภายในได้ แม้ในกรณีที่ process นั้นไม่จำเป็นต้องทำ garbage collection ก็ตาม
    • แต่น่าเสียดายที่รูปแบบการใช้งานของ Discord หมายความว่าระบบกระตุ้น garbage collection ซ้ำ ๆ เพื่อกู้คืนหน่วยความจำเพียงไม่กี่ร้อยกิโลไบต์ โดยต้องแลกกับการคัดลอก heap ขนาดหลายกิกะไบต์ ซึ่งเห็นได้ชัดว่าไม่คุ้ม
    • โชคดีที่ใน BEAM สามารถปรับพฤติกรรมนี้ได้ด้วย process flag min_bin_vheap_size
    • เมื่อเพิ่มค่านี้ขึ้นเป็นระดับไม่กี่เมกะไบต์ พฤติกรรม garbage collection ที่ผิดปกติก็หายไป และสามารถเปิดใช้ manifold offload พร้อมเห็นการปรับปรุงประสิทธิภาพอย่างชัดเจน

9 ความคิดเห็น

 
roxie 2023-11-18

Elixir สู้ๆ

 
arfwene 2023-11-10

เซสชันแบบพาสซีฟในทางเทคนิคอาจไม่ได้มีอะไรพิเศษมาก แต่ดูเหมือนจะเป็นไอเดียที่ดีครับ
มันน่าจะช่วยลดภาระได้อย่างชัดเจนเลย

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

 
mhj5730 2023-11-10

สุดยอดมากเลย สุดๆ

 
abhidhamma 2023-11-09

ทุกวันนี้ ดูเหมือนว่าจุดหมายปลายทางของ streaming SSR ใน Next.js ที่กำลังโด่งดัง ก็คือเฟรมเวิร์ก Phoenix ของ Elixir นั่นเอง ในหลายแง่มุม Elixir ดูจะเป็นแนวหน้าแห่งโลกภาษาโปรแกรมสมัยใหม่จริง ๆ

 
papillon 2023-11-09

Elixir สู้ๆ

 
n1ghtc4t 2023-11-09

เมื่อหลายปีก่อน ผมได้นำ Elixir มาใช้กับบริการเรียลไทม์โดยอ้างอิงจากบล็อกเทคนิคของ Discord และสามารถเปิดตัวบริการได้อย่างน่าพึงพอใจมากทั้งในด้านความเร็วในการพัฒนาและความเสถียร จนทั้งตัวผมและผู้บริหารที่รับผิดชอบต่างก็มีความทรงจำที่ดีมากครับ

 
kotlinc 2023-11-09

หวังว่า Elixir จะได้รับความนิยมมากขึ้น

 
[ความคิดเห็นนี้ถูกซ่อน]
 
damtet 2023-11-10

ช่วงนี้ดูเหมือนจะไม่ใช่ระดับ Naver-Kakao-Line-Coupang อีกต่อไป แต่กลับเป็นสตาร์ตอัปขนาดเล็กและกลางที่ดูเหมือนผูกขาดกับ Spring มากกว่า เพราะผู้จัดการของสตาร์ตอัปเหล่านั้นส่วนใหญ่เป็นผู้เชี่ยวชาญด้าน Spring ก็เลยช่วยไม่ได้

ความไม่มีประสิทธิภาพทั้งหมดแก้ได้ด้วยเงินและขนาดองค์กร เพราะสุดท้ายแล้วบริษัทก็ไม่ได้รู้อะไรมากอยู่ดี