ยอดการเข้าชมคือตัวเลขแรกที่ผู้เยี่ยมชมให้ความเชื่อถือ มันเป็นตัวบอกว่าวิดีโอนั้นคุ้มค่าที่จะสละเวลา 30 วินาที หรือ 30 นาทีของพวกเขาหรือไม่ ที่ TopVideoHub ตัวเลขนั้นเคลื่อนที่อย่างรวดเร็ว คลิปที่กำลังเป็นกระแสสามารถมียอดวิวพุ่งถึง 40,000 ครั้งภายในสิบนาที หากตัวเลขบนหน้าเว็บหยุดนิ่ง บรรยากาศจะดูเหมือนห้องที่ว่างเปล่า และผู้ใช้งานจะกดออกจากหน้าเว็บทันที

การทำให้ตัวเลขนั้นแสดงผลบนเบราว์เซอร์ฟังดูเหมือนเป็นเรื่องง่าย แต่มันไม่ใช่ วิธีแรกที่ทีมส่วนใหญ่นึกถึงคือการทำ polling ซึ่งมันเชื่อมต่อง่ายและทำงานได้ดีในสภาพแวดล้อม staging แต่ staging มักจะหลอกเราเสมอ

เมื่อ Polling กลายเป็นการทำ DDoS ใส่ตัวเอง

ทีมงาน TopVideoHub สร้าง JavaScript poller แบบง่ายๆ ขึ้นมา โดยมันจะดึงยอดการเข้าชมล่าสุดทุกๆ 5 วินาที ในสภาพแวดล้อมการทดสอบที่มีเบราว์เซอร์เปิดอยู่ 3 หน้าต่าง มันดูทำงานได้ยอดเยี่ยมมาก แต่ในสภาพแวดล้อม production มันกลับทำให้แพลตฟอร์มพังพินาศ

ผู้ชม 8,000 คนที่เข้าชมพร้อมกันและทำการรีเฟรชทุกๆ 5 วินาที ทำให้เกิด request ถึง 1,600 ครั้งต่อวินาที ทุกๆ request จะพุ่งตรงไปยังฐานข้อมูล ส่งผลให้ค่า replication lag พุ่งสูงขึ้น Read replicas ทำงานหนักจนแทบไม่ไหว และเลเยอร์ของ cache ถูกข้ามไป ทีมงานไม่ได้กำลังให้บริการวิดีโอ แต่พวกเขากำลังให้บริการภาระงานที่สร้างขึ้นมาทำร้ายตัวเอง

Polling นั้นไม่มีพิษมีภัยจนกว่าจะถึงจุดหนึ่ง สำหรับแดชบอร์ดที่มีทราฟฟิกต่ำหรือแผงควบคุมผู้ดูแลระบบ (admin panels) มันสามารถใช้งานได้ แต่สำหรับหน้าวิดีโอที่เป็นไวรัล มันคือระเบิดเวลา ทีมงานต้องการท่อส่งข้อมูลที่เชื่อมต่อค้างไว้จากเซิร์ฟเวอร์ไปยังเบราว์เซอร์ แต่พวกเขาไม่ต้องการความซับซ้อนของโปรโตคอลแบบ full-duplex

ทำไม SSE ถึงเหมาะกับท่อส่งข้อมูลทางเดียว

Server-Sent Events (SSE) ถูกสร้างมาเพื่อแก้ปัญหาในรูปแบบนี้โดยเฉพาะ นั่นคือเมื่อเซิร์ฟเวอร์มีข้อมูล และเบราว์เซอร์ต้องการเพียงแค่คอยรับฟังเท่านั้น

SSE ต่างจาก WebSockets ตรงที่มันทำงานบน HTTP ปกติ ซึ่งเรื่องนี้สำคัญกว่าที่คิด คุณไม่จำเป็นต้องตั้งค่า proxy rules ใหม่, ไม่ต้องอัปเกรด headers หรือต้องทำเรื่องยุ่งยากกับ load-balancer หากเซิร์ฟเวอร์ของคุณรองรับ HTTP/1.1 หรือ HTTP/2 การใช้งาน SSE ก็ทำงานได้ทันที การดีบั๊กก็ทำได้ง่ายเพราะสตรีมข้อมูลเป็นเพียงข้อความ คุณสามารถใช้ curl ชี้ไปที่ endpoint และดูตัวเลขที่เลื่อนผ่านไปแบบเรียลไทม์ ซึ่งดีกว่าการต้องมานั่งเดาว่าทำไมเฟรมของ binary socket ถึงทำงานผิดพลาด

เบราว์เซอร์จะจัดการส่วนที่ยุ่งยากให้ฟรีๆ หากการเชื่อมต่อหลุด SSE จะทำการเชื่อมต่อใหม่โดยอัตโนมัติพร้อมกับส่ง header Last-Event-ID เพื่อให้เซิร์ฟเวอร์รู้ว่าต้องเริ่มส่งข้อมูลต่อจากจุดไหน การใช้งาน API ใน JavaScript นั้นง่ายมาก เพียงแค่สร้าง EventSource, แนบ handler onmessage ก็เสร็จเรียบร้อย

Caching คือหัวใจสำคัญของสถาปัตยกรรม

ข้อผิดพลาดทางสถาปัตยกรรมที่ใหญ่ที่สุดในระบบตัวนับแบบเรียลไทม์ คือการปฏิบัติกับทุกการเชื่อมต่อของเบราว์เซอร์เสมือนเป็นเหตุผลในการคิวรีฐานข้อมูล หากมีคน 8,000 คนดูวิดีโอเดียวกัน การรัน 8,000 คิวรีทุกๆ สองวินาทีคือความบ้าคลั่ง ฐานข้อมูลของคุณจะไม่สามารถทนต่อทราฟฟิกที่เป็นไวรัลได้

TopVideoHub แก้ปัญหานี้ด้วย APCu ซึ่งเป็น opcode และ user cache ในหน่วยความจำของ PHP ขั้นตอนนั้นง่ายมาก กระบวนการเบื้องหลัง (background process)—หรือการเรียกใช้งาน endpoint น้ำหนักเบาตามช่วงเวลาที่กำหนด—จะเขียนยอดการเข้าชมปัจจุบันลงใน APCu ทุกๆ สองวินาที ส่วน SSE endpoint ที่เบราว์เซอร์หลายพันเครื่องเปิดค้างไว้ จะอ่านข้อมูลจาก APCu เพียงอย่างเดียวเท่านั้น

ผลลัพธ์ที่ได้คือ: ฐานข้อมูลจะถูกเรียกใช้งานเพียงครั้งเดียวในทุกๆ สองวินาที ไม่ว่าจะมีผู้ชมกี่คนก็ตาม Cache จะกลายเป็นตัวดูดซับแรงกระแทก APCu ไม่ใช่เรื่องแปลกใหม่ มันมาพร้อมกับ PHP, อยู่ในหน่วยความจำที่ใช้ร่วมกัน (shared memory) และอ่านข้อมูลได้เร็วกว่าการรับส่งข้อมูลผ่านเครือข่ายใดๆ สำหรับตัวเลขเพียงตัวเดียวที่เปลี่ยนแปลงบ่อยแต่ไม่จำเป็นต้องเป็นแบบทันทีทันใด มันคือเครื่องมือที่เหมาะสมที่สุด

หากคุณไม่ได้ใช้ APCu การใช้ Redis หรือ Memcached ก็สามารถทำงานได้เช่นกัน หลักการยังคงเดิม คือการแยกเส้นทางการอ่านข้อมูลที่ใช้งานหนัก (hot read path) ออกจากฐานข้อมูล

การตั้งค่า PHP, LiteSpeed และ Cloudflare ให้รองรับการ Stream

PHP มักจะต้องการทำงานให้เสร็จแล้วจบไป ส่วนเว็บเซิร์ฟเวอร์ก็ต้องการบัฟเฟอร์เอาต์พุต (buffer output) เพื่อส่งการตอบสนองที่เรียบร้อย แต่ SSE ต้องการสิ่งที่ตรงกันข้าม นั่นคือการเชื่อมต่อที่ต้องเปิดค้างไว้และส่งข้อมูล (flush bytes) ทันทีที่มาถึง หากไม่ระวัง "สตรีม" ของคุณจะกลายเป็นข้อมูลก้อนเดียวที่ส่งมาหลังจากผ่านไป 30 วินาที ซึ่งทำให้เสียวัตถุประสงค์ของการใช้งาน

นี่คือวิธีที่ TopVideoHub ทำให้ท่อส่งข้อมูลไม่เกิดการอุดตัน

ปิดการทำ output buffering: ที่จุดเริ่มต้นของสคริปต์ SSE ให้ปิดการทำ buffering ทุกเลเยอร์ที่ PHP อาจเปิดใช้งานไว้ เรียกใช้ ob_end_flush() หากมีการเปิด buffer อยู่ และปิดการทำ implicit flushing ด้วย ob_implicit_flush(true) หลังจากส่ง headers เรียบร้อยแล้ว

บอกให้ proxy หยุดแทรกแซง: ส่ง header X-Accel-Buffering: no ไปด้วย Nginx จะรับฟังคำสั่งนี้ LiteSpeed ก็เช่นกัน มันเป็นการส่งสัญญาณว่าการตอบสนองไม่ควรถูกบัฟเฟอร์หรือบีบอัดให้กลายเป็นก้อนข้อมูลเพื่อเก็บลง cache

กำหนดอายุการใช้งานให้สั้น: การเชื่อมต่อ SSE แต่ละครั้งจะจอง PHP worker ไว้ TopVideoHub จึงจำกัดระยะเวลาสตรีมไว้ที่ 55 วินาที เมื่อถึงเวลาที่กำหนด เซิร์ฟเวอร์จะส่ง comment สุดท้าย ปิดสตรีม และเบราว์เซอร์จะทำการเชื่อมต่อใหม่โดยอัตโนมัติ การเชื่อมต่อใหม่นั้นจะไปลงที่ worker ตัวใหม่ ช่วยป้องกันไม่ให้โปรเซสใดโปรเซสหนึ่งจองค้างไว้ตลอดกาล

ส่ง Ping เพื่อรักษาการเชื่อมต่อ ส่งบรรทัดคอมเมนต์ เช่น : ping ทุกๆ ยี่สิบวินาที คอมเมนต์ใน SSE จะถูกข้ามโดย message handler ของเบราว์เซอร์ แต่จะช่วยรักษาการเชื่อมต่อ TCP ให้ยังคงทำงานอยู่ (keep the TCP connection warm) Load balancers และ CDNs มักจะตัดการเชื่อมต่อที่ไม่มีการเคลื่อนไหว (silent connections) หลังจากผ่านไป 30 หรือ 60 วินาที การส่ง newline ง่ายๆ เพียงตัวเดียวจะช่วยป้องกันปัญหานี้ได้

เคารพแท็บของผู้ใช้ เมื่อผู้เข้าชมย่อหน้าต่างหรือซ่อนแท็บ ให้หยุดการทำงานทันที คอยดักฟัง visibilitychange ในเบราว์เซอร์แล้วเรียกใช้ eventSource.close() ฝั่งเซิร์ฟเวอร์ควรตรวจจับการตัดการเชื่อมต่อของไคลเอนต์และยุติลูปการทำงานด้วย PHP สามารถตรวจสอบ connection_aborted() ภายในลูปได้ อย่าปล่อยให้การเชื่อมต่อที่ค้างอยู่ (ghost connections) มาสิ้นเปลือง worker สำหรับคนที่ออกไปเมื่อสิบนาทีที่แล้ว

ขีดจำกัดที่เลี่ยงไม่ได้: PHP Workers

SSE ใน PHP นั้นมีข้อจำกัดที่ชัดเจน ทุกๆ การเชื่อมต่อ SSE ที่เปิดอยู่จะใช้ PHP worker หนึ่งตัว หาก pool ของคุณมี worker 100 ตัว คุณก็จะมี stream ได้เพียง 100 streams เท่านั้น จบแค่นั้นเลย ไม่มีทางเลี่ยงแบบ async ได้ตราบใดที่คุณยังอยู่ในโมเดลโปรเซสของ Apache หรือ PHP-FPM คุณสามารถปรับแต่ง pm.max_children ได้ แต่หน่วยความจำ (memory) และ CPU คือขอบเขตที่แท้จริง

ขีดจำกัดนี้จะส่งผลกระทบทันทีหากคุณต้องใช้ worker สำหรับการโหลดหน้าเว็บปกติ, การเรียก API และการสร้าง asset ด้วย เฝ้าติดตามการทำงานที่หนาแน่น (saturation) ของ worker อย่างระมัดระวัง หาก SSE endpoint ของคุณเริ่มเกิดคิว (queuing) เพราะ worker ทั้งหมดถูกล็อกไว้กับ stream ที่ยาวนานถึงยี่สิบนาที เว็บไซต์ทั้งเว็บของคุณก็จะช้าลง

เมื่อตัวเลขเริ่มไม่ไหว ให้ขยับขยาย Go มักจะเป็นขั้นตอนถัดไปที่นิยมใช้กัน แม้ว่า Rust, Node.js หรือ Erlang จะสามารถทำหน้าที่เดียวกันได้ก็ตาม หัวใจสำคัญคือ goroutines ของ Go ซึ่งใช้ทรัพยากรเพียงไม่กี่กิโลไบต์ คุณสามารถรองรับ stream ได้เป็นหมื่นๆ โดยไม่ต้องใช้ฮาร์ดแวร์ที่แรงมาก ตรรกะหลักยังคงเหมือนเดิม คือการอ่านจาก cache และเขียนลง socket แต่ runtime จะเปลี่ยนจากโปรเซสที่หนักอึ้งมาเป็นเธรด (threads) ที่เบาบางแทน

แต่อย่าเพิ่งเริ่มที่ตรงนั้น PHP สามารถพาคุณไปได้ไกลอย่างน่าประหลาดใจ ให้ตรวจสอบความต้องการของผลิตภัณฑ์ (validate the product) ให้แน่ใจก่อน เมื่อหน้า metrics แสดงว่า worker หมด (exhaustion) แทนที่จะเป็นฐานข้อมูลโหลดหนัก (overload) นั่นแสดงว่าคุณใช้งานเกินขีดความสามารถของ stack นี้แล้ว ซึ่งนั่นถือเป็นปัญหาที่ดี

บทสรุป

การทำตัวนับแบบเรียลไทม์ (live counters) ไม่ใช่เรื่องของเทคโนโลยีเพียงอย่างเดียว แต่เป็นเรื่องของการปกป้องฐานข้อมูลของคุณจากผู้ใช้งานของคุณเอง เริ่มด้วย SSE เพราะมันง่ายกว่าที่เห็น ใช้การ cache อย่างเต็มที่ระหว่าง stream และฐานข้อมูล เพื่อไม่ให้จำนวนการเชื่อมต่อกลายเป็นจำนวนการ query เฝ้าดูขีดจำกัดของ worker อย่างใกล้ชิด และเริ่มจากสิ่งที่เรียบง่าย PHP นั้นเพียงพอจนกว่ามันจะไม่พอ และเมื่อถึงเวลานั้น คุณจะรู้เหตุผลที่แน่ชัดว่าทำไมคุณถึงต้องเขียนโค้ดใหม่

สำหรับโปรเจกต์ถัดไปของคุณ:

  • ใช้ SSE เมื่อข้อมูลไหลไปในทิศทางเดียว จากเซิร์ฟเวอร์ไปยังเบราว์เซอร์
  • วางเลเยอร์ cache ไว้หน้าฐานข้อมูล การ query หนึ่งครั้งในทุกๆ ไม่กี่วินาที ดีกว่าการ query เป็นพันๆ ครั้ง
  • จำกัดระยะเวลาการเชื่อมต่อ SSE ให้ไม่เกินหนึ่งนาที และปล่อยให้เบราว์เซอร์ทำการเชื่อมต่อใหม่ (reconnect)
  • ปิด stream เมื่อมีการซ่อนแท็บ อย่าปล่อยให้การเชื่อมต่อที่ไม่ได้ใช้งาน (idle connections) มาดึงทรัพยากร worker ไปโดยเปล่าประโยชน์
  • เฝ้าติดตามการใช้งาน PHP worker เมื่อใช้งานจนเต็มขีดจำกัด ให้ย้ายเลเยอร์การสตรีม (streaming layer) ไปใช้ Go