تعداد بازدید اولین عددی است که یک بازدیدکننده به آن اعتماد میکند. این عدد به آنها میگوید که آیا تماشای یک ویدیو ارزش سی ثانیه از وقتشان را دارد یا سی دقیقه را. در TopVideoHub، این عدد با سرعت حرکت میکند. یک کلیپ ترند میتواند در عرض ده دقیقه ۴۰,۰۰۰ بازدید جذب کند. اگر شمارنده در صفحه فریز شود، فضا خالی به نظر میرسد. کاربران سایت را ترک میکنند.
رساندن آن عدد به مرورگر ساده به نظر میرسد، اما اینطور نیست. اولین راهکاری که اکثر تیمها به سراغ آن میروند، Polling است. راهاندازی آن آسان است و در محیط Staging به خوبی کار میکند. اما محیط Staging دروغ میگوید.
وقتی Polling تبدیل به یک حمله DDoS علیه خودتان میشود
تیم TopVideoHub یک poller ساده با JavaScript ساخت. این ابزار هر پنج ثانیه آخرین تعداد بازدید را واکشی میکرد. در یک محیط تست با سه مرورگر باز، همه چیز عالی به نظر میرسید. اما در محیط Production، پلتفرم را از کار انداخت.
هشت هزار بیننده همزمان که هر پنج ثانیه صفحه را رفرش میکردند، ۱,۶۰۰ درخواست در ثانیه ایجاد کردند. هر درخواست مستقیماً به دیتابیس ضربه میزد. تأخیر در تکثیر (Replication lag) اوج گرفت. Read replicaها تحت فشار قرار گرفتند. لایههای کش دور زده شدند. تیم در حال سرو کردن ویدیو نبود؛ آنها در حال سرو کردن بار اضافیِ خودساخته بودند.
Polling تا زمانی که مشکلی پیش نیاید، بیگناه است. برای داشبوردهای کمترافیک یا پنلهای ادمین، مشکلی ندارد. اما برای یک صفحه ویدیوی وایرال، یک بمب ساعتی است. تیم به یک لوله دائمی از سرور به مرورگر نیاز داشت، اما نیازی به پیچیدگی یک پروتکل full-duplex نداشت.
چرا SSE برای لولههای یکطرفه مناسب است
Server-Sent Events (SSE) دقیقاً برای همین نوع از مشکلات ساخته شده است: سرور داده دارد و مرورگر فقط نیاز به گوش دادن دارد.
برخلاف WebSockets، پروتکل SSE بر پایه HTTP ساده عمل میکند. این موضوع مهمتر از آن چیزی است که به نظر میرسد. شما نیازی به قوانین جدید پروکسی، هدرهای upgrade یا ژیمناستیکهای Load-balancer ندارید. اگر سرور شما از HTTP/1.1 یا HTTP/2 پشتیبانی میکند، SSE کار میکند. دیباگ کردن آن بدون درد است، زیرا استریم فقط متن است. میتوانید curl را به سمت endpoint نشانه بروید و بالا آمدن اعداد را به صورت real-time تماشا کنید، که بسیار بهتر از حدس زدن دلیل بههمریختگی یک فریم باینری در socket است.
مرورگر بخشهای خستهکننده را به صورت رایگان مدیریت میکند. اگر اتصال قطع شود، SSE با استفاده از هدر Last-Event-ID به طور خودکار دوباره متصل میشود تا سرور بداند از کجا باید ادامه دهد. سطح API در JavaScript بسیار کوچک است: یک EventSource ایجاد کنید، یک handler از نوع onmessage به آن متصل کنید و تمام.
کش کردن (Caching) معماری واقعی است
بزرگترین اشتباه معماری در شمارندههای زنده، این است که هر اتصال مرورگر را دلیلی برای کوئری گرفتن از دیتابیس بدانید. اگر ۸,۰۰۰ نفر در حال تماشای یک ویدیو باشند، اجرای ۸,۰۰۰ کوئری در هر دو ثانیه دیوانگی است. دیتابیس شما از ترافیک وایرال جان سالم به در نخواهد برد.
TopVideoHub این مشکل را با APCu حل کرد، که کش در حافظه (in-memory) برای opcode و کاربر در PHP است. جریان کار ساده است. یک فرآیند پسزمینه — یا یک endpoint سبک که با یک تایمر فراخوانی میشود — هر دو ثانیه یک بار، تعداد بازدید فعلی را در APCu مینویسد. endpoint مربوط به SSE، که ممکن است هزاران مرورگر آن را باز نگه داشته باشند، منحصراً از APCu میخواند.
نتیجه: دیتابیس هر دو ثانیه فقط یک بار مورد ضربه قرار میگیرد، فرقی نمیکند چند بیننده در حال تماشا باشند. کش به عنوان ضربهگیر عمل میکند. APCu چیز عجیبی نیست؛ همراه با PHP عرضه میشود، در حافظه مشترک (shared memory) قرار دارد و سریعتر از هر رفت و برگشت شبکهای (network round-trip) خوانده میشود. برای یک عدد واحد که مکرراً تغییر میکند اما نه به صورت لحظهای، این ابزار مناسب است.
اگر از APCu استفاده نمیکنید، Redis یا Memcached نیز کار میکنند. اصل کار ثابت میماند: مسیر خواندنِ پرفشار (hot read path) را از دیتابیس جدا کنید.
متقاعد کردن PHP، LiteSpeed و Cloudflare برای استریم کردن
PHP میخواهد کار را تمام کند و برود خانه. وبسرورها میخواهند خروجی را بافر کنند و یک پاسخ مرتب تحویل دهند. SSE برعکس نیاز دارد: اتصالی که باز بماند و بایتها را به محض رسیدن، تخلیه (flush) کند. بدون دقت کافی، "استریم" شما با سی ثانیه تأخیر به صورت یک توده واحد (blob) میرسد و هدف اصلی از بین میرود.
در اینجا نحوه باز نگه داشتن لوله توسط TopVideoHub آمده است:
حذف بافر خروجی (Kill output buffering). در ابتدای اسکریپت SSE، هر لایه از بافرینگ را که ممکن است PHP فعال کرده باشد، غیرفعال کنید. اگر بافری فعال است، ob_end_flush() را فراخوانی کنید و پس از ارسال هدرها، با ob_implicit_flush(true) تخلیه ضمنی را خاموش کنید.
به پروکسیها بگویید عقب بنشینند. هدر X-Accel-Buffering: no را ارسال کنید. Nginx به آن گوش میدهد. LiteSpeed هم به آن گوش میدهد. این هدر سیگنال میدهد که پاسخ نباید بافر یا به صورت یک توده قابل کش شدن، فشرده شود.
یک طول عمر کوتاه تعیین کنید. هر اتصال SSE یک PHP worker را اشغال میکند. TopVideoHub استریمها را در ۵۵ ثانیه محدود میکند. وقتی تایمر به پایان میرسد، سرور یک کامنت نهایی میفرستد، استریم را میبندد و مرورگر به طور خودکار دوباره متصل میشود. این اتصال مجدد روی یک worker تازه قرار میگیرد و از اشغال دائمی توسط یک فرآیند واحد جلوگیری میکند.
پینگ برای زنده نگه داشتن اتصال. هر بیست ثانیه یک خط کامنت ارسال کنید—چیزی شبیه به : ping. کامنتها در SSE توسط هندلر پیام مرورگر نادیده گرفته میشوند، اما اتصال TCP را فعال (warm) نگه میدارند. لود بالانسرها و CDNها اغلب اتصالات ساکت را پس از سی یا شصت ثانیه قطع میکنند. یک کاراکتر خط جدید (newline) ارزانقیمت، شما را از این تبر نجات میدهد.
به تب کاربر احترام بگذارید. وقتی بازدیدکننده تب را کوچک (minimize) یا مخفی میکند، اتصال را قطع کنید. به رویداد visibilitychange در مرورگر گوش دهید و eventSource.close() را فراخوانی کنید. سرور نیز باید قطع شدن اتصال کلاینت را تشخیص داده و حلقه را خاتمه دهد. PHP میتواند در داخل یک حلقه، connection_aborted() را بررسی کند. اجازه ندهید اتصالات شبحوار (ghost connections)، ورکرها را برای افرادی که ده دقیقه پیش رفتهاند، هدر دهند.
سقف سخت: ورکرهای PHP
SSE در PHP در مورد محدودیتهایش صادق است. هر اتصال SSE باز، یک ورکر PHP را مصرف میکند. اگر استخر (pool) شما صد ورکر داشته باشد، صد استریم دارید. تمام. تا زمانی که در مدل پردازشی Apache یا PHP-FPM هستید، راهکار ناهمگام (async) وجود ندارد. میتوانید pm.max_children را تنظیم کنید، اما حافظه و CPU مرز واقعی را تعیین میکنند.
اگر از ورکرها برای بارگذاری صفحات معمولی، فراخوانیهای API و تولید داراییها (assets) نیز استفاده میکنید، این محدودیت خیلی زود خود را نشان میدهد. اشباع ورکرها را با دقت مانیتور کنید. اگر نقطه انتهایی (endpoint) SSE شما به دلیل اینکه تمام ورکرها در استریمهای بیست دقیقهای قفل شدهاند، شروع به صفبندی (queuing) کند، کل سایت شما کند میشود.
وقتی اعداد دیگر پاسخگو نیستند، مهاجرت کنید. Go معمولاً قدم بعدی است، هرچند Rust، Node.js یا Erlang نیز میتوانند همان نقش را ایفا کنند. گوروتینهای Go کلید کار هستند. هزینه یک goroutine تنها چند کیلوبایت است. میتوانید دهها هزار استریم را روی سختافزاری معمولی بدون هیچ زحمتی نگه دارید. منطق اصلی ثابت میماند—خواندن از کش، نوشتن در سوکت—اما زمان اجرا (runtime) از فرآیندهای سنگین به تردهای سبک تغییر میکند.
با این حال، از آنجا شروع نکنید. PHP شما را به طرز شگفتآوری جلو میبرد. ابتدا محصول را اعتبارسنجی کنید. وقتی صفحه متریکها به جای فشار بیش از حد به دیتابیس، اتمام ورکرها را نشان میدهد، یعنی از این پشته (stack) فراتر رفتهاید. این یک مشکل خوب است.
نکته کلیدی
شمارندههای زنده (Live counters) بحث تکنولوژی محض نیستند؛ بحث محافظت از دیتابیس در برابر کاربران خودتان است. با SSE شروع کنید چون سادهتر از آن چیزی است که به نظر میرسد. بین استریم و دیتابیس به شدت از کش استفاده کنید تا تعداد اتصالات به تعداد کوئریها تبدیل نشود. محدودیتهای ورکر خود را مثل یک شاهین زیر نظر بگیرید. و ساده شروع کنید. PHP تا زمانی که کافی باشد، کافی است؛ و تا آن زمان دقیقاً خواهید دانست که چرا در حال بازنویسی هستید.
برای پروژه بعدی خود:
- زمانی از SSE استفاده کنید که جریان داده یکطرفه است، از سرور به مرورگر.
- یک لایه کش جلوی دیتابیس قرار دهید. یک کوئری در هر چند ثانیه، بر هزاران کوئری برتری دارد.
- زمان اتصالات SSE را به زیر یک دقیقه محدود کنید و اجازه دهید مرورگر دوباره متصل شود.
- با مخفی شدن تب، استریمها را ببندید. اجازه ندهید اتصالات بیکار، ورکرهای فعال شما را مصرف کنند.
- میزان استفاده از ورکرهای PHP را مانیتور کنید. وقتی به حداکثر رسیدید، لایه استریمینگ را به Go منتقل کنید.
