في مباراة نهائية يكتب آلاف المشاهدين «هدف» في الدردشة في نفس الثانية، ويرسل بعضهم هدايا تظهر رسومها فوق الفيديو، والمذيع يرد عليهم بالاسم، وكل هذا يعمل فقط إذا كان البث متأخراً عن الحدث ثانية أو ثانيتين، لأن المذيع الذي يرد على تعليق كتب قبل نصف دقيقة يبدو وكأنه يتحدث مع نفسه. وهنا يختلف البث المباشر عن منصة الفيديو عند الطلب التي شرحناها في كيف تبني منصة فيديو عند الطلب مثل Netflix: هناك كانت المعالجة تحدث مرة واحدة وتنتظر دقائق، وهنا يجب أن تحدث المعالجة والتوصيل والدردشة كلها في الوقت الحقيقي، ولا يوجد ملف كامل تحسن ترميزه ولا ليلة تملأ فيها ال Cache.
وسوف نأخذ مثالين: JACO جاكو، وهو تطبيق بث مباشر عربي تذكر صفحته في App Store السعودي أن ناشره شركة JACO ARABIAN COMPANY FOR INFORMATION TECHNOLOGY، وأنه نشر على المتجر في 16 يونيو 2023، ويصف نفسه بأنه منصة اجتماعية للبث المباشر والفيديوهات القصيرة فيها الرياضة والألعاب والموسيقى والترفيه، مع مشاهدة مباريات كرة القدم السعودية مجاناً، والبث من الهاتف أو من برنامج OBS، ومواجهات بين المذيعين يسميها PK، ومحادثات صوتية جماعية ومحادثات خاصة، وتعرض الصفحة نفسها مشتريات داخل التطبيق على شكل حزم عملات Coins، وتذكر ملاحظات الإصدارات هدايا Gifts يرسلها المشاهدون. والمثال الثاني Twitch، وهي منصة بث يرسل لها المذيع بثه بروتوكول RTMP كما تشرح وثائق المطورين فيها، ولها دردشة لكل قناة ولها واجهات للبوتات والإشراف، وعملة اسمها Bits يستخدمها المشاهد للتشجيع Cheering كما في صفحة المساعدة. ولن نتحدث عن تفاصيل داخلية في أي من التطبيقين لم تنشرها الشركتان، وإنما نبني تطبيقاً بنفس الميزات من قطع مفتوحة المصدر.
ومفاهيم البث نفسها، مثل RTMP وSRT وHLS وزمن التأخير من جهة المشغل، لها مقال مستقل في قسم الفيديو والبث المباشر، فلن نكرر شرحها هنا:
دليلكيف يعمل بث الفيديو المباشر وعند الطلب؟كيف يصل الفيديو إلى كل مشاهد بجودة تناسب سرعة اتصاله، ولماذا يتأخر البث المباشر عشرين ثانية أحياناً وأقل من ثانية أحياناً أخرى.
دليلمفاهيم الفيديو: الحاوية والترميز Codec ومعدل البتما الذي يحدد حجم ملف الفيديو وجودته، ولماذا يعمل ملف في متصفح ولا يعمل في آخر، مع تجربة حقيقية تقيس الفرق بين الترميزات.وسوف نناقش في هذا المقال ما يلي:
- الصورة الكاملة لتطبيق البث المباشر، ولماذا يحتاج مسارين للتوصيل.
- المعمارية الكاملة: المكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو، وماذا يحدث عندما يتعطل كل جزء.
- الاستقبال Ingest ببروتوكولات RTMP وSRT وWHIP، والتحويل في الوقت الحقيقي Real-time Transcoding.
- التوصيل بزمن تأخير قصير: LL-HLS عبر ال CDN، وWebRTC عبر خوادم SFU، ومتى تختار كلاً منهما.
- الدردشة لآلاف المشاهدين بتوزيع الرسائل Fan-out على WebSocket.
- الهدايا والعملات كدفتر حسابات، والإشراف على المحتوى، والتوسع عند الذروة، والتكلفة.
- تجربة عملية: بث من ffmpeg بدل OBS إلى MediaMTX، ومشاهدته عبر WebRTC وLL-HLS جنباً إلى جنب مع قياس التأخير، ودردشة على سيرفرين نرسل فيها الرسائل إلى آلاف المشاهدين الوهميين ونقيس زمن وصولها.
الصورة الكاملة: من كاميرا المذيع إلى آلاف الشاشات
المذيع يرسل بثاً واحداً بأعلى جودة يتحملها اتصاله، والسيرفر يستقبله ويحوله في نفس اللحظة إلى عدة جودات، ثم يوزعه في مسارين: مسار WebRTC بتأخير أقل من ثانية للتفاعل الحي، مثل الضيوف على المسرح ومواجهات المذيعين، ومسار LL-HLS عبر ال CDN بتأخير ثوان قليلة يتحمل أي عدد من المشاهدين بتكلفة أقل، وحول الفيديو خدمات لا تقل أهمية عنه: الدردشة والهدايا والإشراف والمقاييس والتسجيل، والصورة التالية تبين ذلك:

ولاحظ أن الفيديو في البث المباشر هو أسهل جزء في التطبيق من حيث عدد الأسطر، فخوادم الوسائط مفتوحة المصدر تقوم بأغلبه، أما الأجزاء التي سوف تكتبها أنت فهي الدردشة والهدايا والإشراف، وهي التي تتعطل في الذروة إذا لم تصمم من البداية لعدد كبير من المتصلين.
المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟
الصورة السابقة تبين طريق الفيديو، أما التطبيق الكامل فيحتاج أن تعرف أين تعمل كل خدمة، وأين تحفظ بياناتها، وأي اتصال ينتظر الرد وأي حدث يذهب إلى قناة أو طابور ويكمل وحده، لأن البث المباشر يجمع نوعين مختلفين تماماً من الحركة: وسائط Media تتدفق كل ثانية ولا تمر بقاعدة بيانات، وطلبات تحكم Control مثل مفتاح البث والهدايا والدردشة. والصورة التالية تبين المعمارية في خمسة أعمدة: الكلاينت Clients، ثم الحافة Edge، ثم الخدمات Services، ثم البيانات Data، ثم ال Workers والمزودين الخارجيين:

وفي الصورة أعلاه لاحظ ثلاث قواعد: أن الفيديو لا يمر بأي خدمة كتبتها أنت، فهو يدخل إلى نقطة الاستقبال ثم خادم الوسائط ثم يخرج إلى ال SFU وال CDN، وخدماتك تتدخل مرة واحدة فقط عند بداية البث، عندما يسأل خادم الوسائط واجهة القنوات هل مفتاح البث صحيح. وأن الدردشة لا تكتب في قاعدة بيانات، فالرسالة تمر في قناة Pub/Sub وتختفي، كما شرحنا في قسم الدردشة. وأن الهدية هي الشيء الوحيد الذي يكتب بمعاملة Transaction في قاعدة بيانات قبل أن يراه أحد، ثم ينشر حدثها في قناة الغرفة.
المكونات ومسؤولية كل منها
| المكون | مسؤوليته | أداة مفتوحة المصدر مقترحة | لماذا هي |
|---|---|---|---|
| برنامج المذيع | ترميز الكاميرا والشاشة وإرسال البث مع مفتاحه | OBS Studio (GPL-2.0)، ومكتبة بث داخل تطبيق الهاتف | أشهر برنامج بث على الحاسوب، ويرسل RTMP وSRT وWHIP |
| الاستقبال وخادم الوسائط Ingest + Origin | استقبال البث، والتحويل الحي، وصنع أجزاء LL-HLS، وخدمة WHEP، والتسجيل | MediaMTX (MIT) | ملف تنفيذي واحد يفعل كل ذلك كما في التجربة، ويسأل خدمتك عن المفتاح بطريقة المصادقة عبر HTTP |
| التحويل الحي Live Transcoding | صنع 480p و360p من بث المذيع في الوقت الحقيقي | FFmpeg بإعدادات zerolatency | يشغله MediaMTX بأمر runOnAvailable كما في التجربة |
| خادم WebRTC SFU | توزيع البث بأقل من ثانية على الضيوف والمواجهات | LiveKit (Apache-2.0) | يدعم طبقات Simulcast، ومعه حزم SDK للهاتف والمتصفح |
| ال CDN | توزيع أجزاء LL-HLS على الجمهور الكبير | nginx (BSD-2-Clause) كنقطة Cache أمام ال Origin في البداية، ثم CDN تجارية | الأجزاء ملفات صغيرة تحفظ لثوان، لكن اختبر الطلبات المعلقة على أي Cache قبل أن تعتمد عليه |
| بوابة ال API Gateway | TLS، والتحقق من التوكن، وحدود الطلبات، وتمرير اتصالات WebSocket | Traefik (MIT) | يمرر WebSocket دون إعداد خاص، ويصدر الشهادات تلقائياً |
| واجهة القنوات Channel API | مفاتيح البث، وحالة القناة، والمتابعة، والرد على سؤال خادم الوسائط عن المفتاح | كودك، ومعه Keycloak (Apache-2.0) لتسجيل الدخول | منطق خاص بمنتجك، والهوية جاهزة |
| سيرفرات الدردشة | اتصالات WebSocket، وتوزيع الرسائل على كل من في الغرفة، وحد الرسائل لكل مستخدم | كودك كما في التجربة، أو Centrifugo (Apache-2.0) | الثاني خادم جاهز للرسائل اللحظية بالقنوات، إذا لم ترد كتابة التوزيع بنفسك |
| قنوات الغرف Pub/Sub | ربط سيرفرات الدردشة ببعضها، ونشر أحداث الهدايا | Valkey Pub/Sub (BSD-3-Clause) | استهلك 0.28% من المعالج في التجربة مع 4,000 مشاهد |
| الهدايا والمحفظة | شراء العملات بعد التحقق من الإيصال، وإرسال الهدية بمعاملة واحدة، وتعليق أرباح المذيع | كودك على PostgreSQL (PostgreSQL License) | المعاملات والقيود الفريدة هي ما يمنع الخصم المكرر، كما في مقال المحفظة الرقمية |
| التسجيلات Recordings | حفظ البث لإعادة المشاهدة والمراجعة | Garage (AGPL-3.0) | تخزين كائنات متوافق مع S3 على أجهزتك |
| الإشراف Moderation | عينات من الإطارات على مصنفات، وطابور مراجعة بشرية، وإيقاف البث | قائمة في Valkey وWorkers بمصنفاتك، وفريق مراجعة | المصنفات تفصل الواضح، والإنسان يقرر في غير الواضح |
| متاجر التطبيقات | التحقق من إيصالات شراء العملات | خدمة خارجية من Apple وGoogle، ولا يوجد بديل مفتوح لها | لذلك رسمناها بإطار متقطع في الصورة |
نموذج البيانات
في التجربة العملية كان المسار في MediaMTX اسمه live، والنسخة المحولة live/480p، وغرفة الدردشة match-1 وقناتها في Valkey room:match-1، والرسالة JSON فيها user وtext وsent_ns وvia. وفي الإنتاج يصبح لكل قناة اسم قصير Slug واحد يستخدم في المسار والغرفة معاً، وتحفظ القنوات والبث والعملات في PostgreSQL، والعملات بطريقة القيد المزدوج التي شرحناها في مقال المحفظة الرقمية:
-- PostgreSQL: channels, streams and the coins ledger (journal + postings, as in the wallet article)
CREATE TABLE channels (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id uuid NOT NULL,
slug text NOT NULL UNIQUE, -- media path live/<slug>, chat room room:<slug>
stream_key_hash text NOT NULL UNIQUE, -- a hash, never the key itself
status text NOT NULL DEFAULT 'offline' CHECK (status IN ('offline', 'live', 'suspended'))
);
CREATE TABLE streams ( -- one row per broadcast
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
channel_id bigint NOT NULL REFERENCES channels (id),
protocol text NOT NULL CHECK (protocol IN ('rtmp', 'srt', 'whip')),
started_at timestamptz NOT NULL DEFAULT now(),
ended_at timestamptz,
recording_key text, -- recordings/<slug>/<stream id>/
peak_viewers int
);
CREATE TABLE journal ( -- one row per purchase, gift, payout or reversal
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
idempotency_key text NOT NULL UNIQUE, -- sent by the app with every gift
kind text NOT NULL CHECK (kind IN ('purchase', 'gift', 'payout', 'reversal')),
stream_id bigint REFERENCES streams (id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE postings ( -- never updated: a gift is -100 for the viewer, +100 held for the streamer
journal_id bigint NOT NULL REFERENCES journal (id),
account text NOT NULL, -- coins:<user>, earnings_held:<streamer>, store:clearing
amount bigint NOT NULL CHECK (amount <> 0)
);
CREATE TABLE purchases ( -- the same store receipt is credited once
store text NOT NULL CHECK (store IN ('app_store', 'google_play')),
transaction_id text NOT NULL,
user_id uuid NOT NULL,
coins int NOT NULL CHECK (coins > 0),
journal_id bigint NOT NULL REFERENCES journal (id),
PRIMARY KEY (store, transaction_id)
);
-- Valkey: room:<slug> pub/sub channel, messages {"user", "text", "sent_ns", "via"} (lab: room:match-1)
-- moderation:frames list of sampled frames waiting for the classifiers
-- viewers:<slug> viewer counter shown in the app
-- MediaMTX: live/<slug> from the streamer, live/<slug>/480p from the transcoder (lab: live and live/480p)في الإعداد أعلاه لاحظ التالي:
- رسائل الدردشة ليست في أي جدول، فإذا احتجت سجلاً لها للإشراف أو للأنظمة فاكتبها بشكل غير متزامن إلى سجل أحداث، ولا تجعل الكتابة جزءاً من طريق الرسالة إلى المشاهدين.
- مفتاح البث محفوظ كهاش Hash، فإذا تسربت القاعدة لا يستطيع أحد أن يبث على قنوات غيره.
- المفتاح الأساسي
(store, transaction_id)فيpurchasesيمنع إضافة نفس الإيصال مرتين، وidempotency_keyالفريد يمنع خصم نفس الهدية مرتين.
الواجهات الرئيسية
| الطريقة والمسار | ما تفعله | ملاحظات |
|---|---|---|
POST /channels/{id}/stream-key | تصدر مفتاح بث جديداً وتبطل القديم، وتعيد المفتاح مرة واحدة فقط | لصاحب القناة، والقاعدة تحفظ الهاش |
POST /internal/ingest-auth | يسألها MediaMTX عند كل بث أو قراءة، فتتحقق من المفتاح وحالة القناة، والرد 20x يعني السماح | داخلية فقط، ولا تمر بالبوابة العامة |
rtmp://ingest/live/{slug} وsrt://…?streamid=publish:live/{slug} وPOST /live/{slug}/whip | إرسال البث بأحد البروتوكولات الثلاثة | المسارات نفسها التي في التجربة، مع اسم القناة |
GET /live/{slug}/index.m3u8 وPOST /live/{slug}/whep | المشاهدة عبر LL-HLS من ال CDN، أو عبر WebRTC من ال SFU | الأول للجمهور، والثاني لمن يحتاج أقل من ثانية |
WS /chat/{slug} | اتصال الدردشة، وكل رسالة تنشر في room:{slug} | حد للرسائل لكل مستخدم، وحد لحجم الرسالة كما في chat.py |
POST /gifts | تخصم العملات من المشاهد وتضيفها معلقة للمذيع في معاملة واحدة، ثم تنشر حدث الهدية | ترويسة Idempotency-Key إلزامية، والطلب المكرر يعيد النتيجة الأولى |
POST /purchases/verify | تتحقق من الإيصال مع المتجر، ثم تضيف العملات مرة واحدة | الإيصال المكرر يعيد نفس النتيجة دون إضافة |
POST /reports | بلاغ من مشاهد على بث أو رسالة، يذهب إلى طابور المراجعة | البلاغات الكثيرة على بث واحد ترفع أولويته |
تقدير السعة
لنفرض تطبيقاً في بدايته الناجحة، وكل رقم هنا افتراض نكتبه صراحة، إلا ما أخذناه من التجربة العملية، فغيره بأرقامك قبل أن تعتمد النتيجة:
- افتراض: 200 قناة تبث في نفس الوقت في ساعة الذروة، و5,000 مشاهد في نفس الوقت، وغرفة مباراة كبيرة فيها 10,000 مشاهد يكتبون 20 رسالة في الثانية كما في قسم الدردشة.
- افتراض: المذيع يرسل 2.5 ميجابت في الثانية كما في التجربة، ومتوسط ما يستقبله المشاهد 1.5 ميجابت لخليط الجودات.
- افتراض: نحول فقط القنوات التي فيها 20 مشاهداً أو أكثر، ونقدرها ب 60 قناة، ولكل منها جودتان إضافيتان.
- من التجربة: تحويل جودة واحدة استهلك قرابة 0.9 من نواة، وسيرفر الدردشة بلغة Python امتلأت نواته عند قرابة 20,000 رسالة مكتوبة في الثانية.
- افتراض: هدية واحدة لكل 100 مشاهد في الدقيقة، وتتضاعف 20 مرة عند الهدف، وساعتان من البث في اليوم لكل قناة من 200 قناة.
ingest bandwidth 200 channels x 2.5 Mbit/s = 500 Mbit/s in
transcoding 60 channels x 2 renditions x ~0.9 core = ~108 cores (only while they are live)
egress at peak 5,000 viewers x 1.5 Mbit/s = 7.5 Gbit/s
egress per peak hour 7.5 Gbit/s x 3600 / 8 = ~3.4 TB
LL-HLS requests 5,000 x (5 parts/s + 5 playlist reloads/s) = ~50,000 requests/s at the CDN
chat fan-out 10,000 viewers x 20 messages/s = 200,000 writes/s to sockets
chat servers 200,000 / 20,000 per full core = 10 cores at 100%, so plan ~20 processes
Valkey pub/sub 20 messages/s x 20 chat servers = 400 deliveries/s (the lab: 0.28% CPU)
gifts, normal 5,000 / 100 per minute = ~0.8 gifts/s
gifts, goal spike 0.8 x 20 = ~17 transactions/s = 17 journal + 34 postings rows/s
recordings 200 x 2 h x 2.5 Mbit/s x 3600 / 8 = ~450 GB per day = ~164 TB per year if kept foreverولاحظ أين تقع الأرقام الكبيرة: التحويل الحي يكلف أنوية ما دامت القنوات تبث، والنقل الخارج 3.4 تيرابايت في كل ساعة ذروة، والدردشة 200,000 كتابة في الثانية في غرفة واحدة، أما قاعدة البيانات فتكتب 17 معاملة في الثانية في أسوأ لحظة، وهذا لا يحتاج أكثر من PostgreSQL واحد جيد. والتسجيلات تحتاج سياسة حذف من اليوم الأول، فالاحتفاظ بها 30 يوماً بدل الأبد يجعلها قرابة 13.5 تيرابايت بدل 164.
مراحل النمو
| الجزء | MVP: سيرفر أو اثنان | النمو: آلاف المشاهدين في نفس الوقت | الحجم الكبير: ملايين المشاهدين |
|---|---|---|---|
| الوسائط | MediaMTX واحد يستقبل ويحول جودة واحدة ويخدم LL-HLS، أو Owncast لقناة واحدة | نقاط استقبال منفصلة، وسيرفرات تحويل للقنوات التي فيها مشاهدون فقط، وCDN أمام ال Origin، وLiveKit للضيوف | نقاط استقبال في عدة مناطق قريبة من المذيعين، وسلسلة SFU، وأكثر من CDN، وترميز في العتاد |
| الدردشة | سيرفر واحد مع Valkey | عدة سيرفرات خلف قناة Pub/Sub، وحد للرسائل لكل مستخدم | توزيع الغرف على عدة عقد Valkey، وعرض عينة من الرسائل في الغرف الضخمة |
| الهدايا | تؤجل حتى تراجع أنظمة الدفع، أو دفتر بسيط بقيد مزدوج | خدمة محفظة منفصلة، وتحقق من الإيصالات، وتعليق الأرباح قبل الصرف | فريق لمكافحة الاحتيال، ومطابقة يومية مع المتاجر |
| الإشراف | زر بلاغ، وإيقاف البث يدوياً من لوحة الإدارة | عينات إطارات على مصنفات، وطابور مراجعة بأولويات | فريق مراجعة على مدار اليوم، وسياسات بحسب الدولة |
| قاعدة البيانات | PostgreSQL واحد ونسخة احتياطية يومية | نسخة قراءة للقنوات والمتابعة، والدفتر في قاعدة منفصلة | فصل الدفتر في خدمة مستقلة بفريقها، وتقسيم بيانات المتابعة |
ولا تقم ببناء ما في العمود الأخير من اليوم الأول، فلا تعط WebRTC لكل المشاهدين، ولا تبن نقاط استقبال في عدة مناطق قبل أن يكون لديك مذيعون فيها، ولا تطلق الهدايا قبل أن تراجع أنظمة المدفوعات وشروط المتاجر، والسبب أن كل واحد من هذه يضيف تكلفة ثابتة أو التزاماً مالياً قبل أن يوجد جمهور يبرره.
ماذا لو تعطل جزء؟
| ماذا لو تعطل...؟ | الأثر | كيف تخففه |
|---|---|---|
| نقطة الاستقبال | ينقطع بث كل المذيعين المتصلين بها | أكثر من نقطة خلف اسم واحد في ال DNS، والمفتاح صالح على أي منها، وإعادة الاتصال التلقائي في برنامج البث |
| التحويل الحي أبطأ من الزمن الحقيقي | يزيد التأخير ثانية بعد ثانية حتى يصبح البث بلا معنى | راقب سرعة التحويل، وعند الضغط احذف جودة وأرسل الأصل كما هو |
| ال CDN أو ال Origin | يتوقف مشاهدو LL-HLS | CDN ثانية يعرفها المشغل، وOrigin ثان يستقبل نفس البث |
| قناة Valkey Pub/Sub | كل سيرفر دردشة يوزع رسائله على من اتصل به فقط | نسخة احتياطية مع تحويل تلقائي، وليس هناك ما تستعيده لأن الدردشة لا تحفظ أصلاً |
| سيرفرات الدردشة عند الامتلاء | الرسائل تتأخر ثوان، كما رأينا مع 4,000 مشاهد في التجربة | سيرفرات إضافية قبل الحدث، وعينة من الرسائل في الغرف الضخمة |
| قاعدة المحفظة | تفشل الهدايا والشراء | وضع متدهور يخفي زر الهدايا، والتطبيق يعيد الطلب بنفس Idempotency-Key، ولا تظهر الرسوم قبل نجاح المعاملة |
الاستقبال Ingest: كيف يصل بث المذيع إلى السيرفر؟
المذيع يرسل بثه إلى نقطة استقبال Ingest، ومعه مفتاح البث Stream Key الذي يعرف القناة ويثبت أن المرسل صاحبها، ويتحقق منه خادم الوسائط بسؤال واجهة القنوات Channel API في المعمارية أعلاه، فاعتبر هذا المفتاح كلمة مرور، وغيره إذا ظهر على الشاشة بالخطأ. وهناك ثلاثة بروتوكولات منتشرة، ويستطيع خادم الوسائط الواحد أن يستقبلها كلها:
- RTMP: الأقدم والأكثر انتشاراً، وتدعمه كل برامج البث مثل OBS، وTwitch تستقبل به كما في وثائقها، ويعمل فوق TCP، لذلك يتأخر ويتراكم عندما تفقد الشبكة بعض الحزم.
- SRT: بروتوكول مفتوح المصدر يعمل فوق UDP، ويصحح الحزم المفقودة بإعادة الإرسال خلال مهلة تحددها أنت، لذلك يناسب البث من شبكات غير مستقرة مثل الهاتف في الملعب، ومكتبته الرسمية Haivision/srt برخصة MPL-2.0.
- WHIP: معيار من IETF صدر في مارس 2025 باسم RFC 9725، يرسل البث بتقنية WebRTC بطلب HTTP واحد، فيمكن للمتصفح نفسه أن يبث دون أي برنامج، وبتأخير منخفض جداً من البداية.
وفي تطبيق مثل JACO يبث أغلب المذيعين من الهاتف، فيدمج تطبيق الهاتف مكتبة بث ترسل بأحد هذه البروتوكولات، بينما يستخدم محترفو الألعاب برنامج OBS على الحاسوب، وهذا ما تذكره صفحة التطبيق من دعم OBS.
التحويل في الوقت الحقيقي Real-time Transcoding
المشاهد على شبكة ضعيفة يحتاج جودة أقل من التي أرسلها المذيع، لذلك يحول السيرفر البث إلى عدة جودات كما في منصة الفيديو عند الطلب، لكن بفرق كبير: التحويل هنا يجب أن يكون أسرع من الزمن الحقيقي دائماً، فكل ثانية من البث يجب أن تنتهي معالجتها في أقل من ثانية، وإلا تراكم التأخير، لذلك تستخدم إعدادات أسرع وأقل كفاءة في الضغط مثل -preset veryfast -tune zerolatency في x264. وفي التجربة العملية سوف ترى أن تحويل بث 720p إلى 480p واحدة استهلك قرابة نواة معالج كاملة، وهذا هو الرقم الذي تحسب به التكلفة: كل قناة نشطة تحتاج من الأنوية بعدد الجودات تقريباً، طوال مدة البث، سواء شاهدها ألف شخص أو شخص واحد. لذلك تحول أغلب المنصات القنوات الكبيرة فقط، أو تستخدم شرائح الترميز في العتاد Hardware Encoders، أو تترك المذيع يرسل عدة جودات بنفسه كما سنرى في طبقات Simulcast.
التوصيل بزمن تأخير قصير
زمن التأخير الذي يهمك هو من الكاميرا إلى الشاشة Glass-to-Glass Latency، وهو مجموع زمن الترميز والإرسال والتحويل والتجزئة والتوصيل والذاكرة المؤقتة في المشغل، والصورة التالية تقارن ثلاث طرق للتوصيل:

والسبب في تأخر HLS التقليدي أن المعيار RFC 8216 يطلب من المشغل في البث المباشر ألا يبدأ من مقطع أقرب من ثلاثة أضعاف المدة المستهدفة Target Duration من نهاية القائمة، حتى لا يتوقف، فمع مقاطع 6 ثوان يكون المشاهد متأخراً 18 ثانية على الأقل قبل أن تحسب زمن الترميز وكتابة المقطع.
LL-HLS: نفس HLS بأجزاء أصغر
الحل الأول هو HLS منخفض التأخير Low-Latency HLS، وهو موصوف في مسودة الإصدار الثاني من معيار HLS، وفكرته أن يقسم كل مقطع إلى أجزاء Parts صغيرة، مثل 200 مللي ثانية، يطلبها المشغل فور كتابتها، وأن يعلق السيرفر طلب قائمة التشغيل حتى يظهر الجزء التالي بدل أن يسأل المشغل كل ثانية، وأن تذكر القائمة اسم الجزء القادم مسبقاً حتى يطلبه المشغل قبل أن يكتمل، والصورة التالية تبين ذلك بأسماء الملفات الحقيقية من التجربة:

ومزيته الكبرى أنه ما زال HTTP عادياً بملفات تحفظها ال CDN، فيتحمل أي عدد من المشاهدين بنفس طريقة الفيديو عند الطلب، لكن تأخيره بضع ثوان، والسبب أن المشغل يحتفظ بهامش من الأجزاء حتى لا يتوقف، والطلبات المعلقة تحتاج CDN تفهمها.
WebRTC: أقل من ثانية، وثمن مختلف
الحل الثاني هو WebRTC، وهو نفس التقنية التي تعمل بها مكالمات الفيديو، فلا توجد مقاطع ولا ملفات، وإنما حزم RTP تصل كما هي ويعرضها المتصفح فوراً. وقد شرحنا كيف يجد الطرفان طريقاً بينهما خلف NAT، ولماذا تحتاج سيرفر TURN، والفرق بين Mesh وSFU وMCU، في مكالمات WebRTC خلف NAT، فلن نكرره هنا. وفي البث يستخدم دائماً خادم SFU، أي Selective Forwarding Unit، وهو سيرفر يستقبل البث مرة ويرسل نسخة منه لكل مشاهد دون أن يعيد ترميزه. وللمشاهدين بالآلاف تضاف فكرتان، تبينهما الصورة التالية:

- طبقات Simulcast: يرسل المذيع نفسه البث بثلاث جودات في نفس الوقت، فيختار ال SFU لكل مشاهد الطبقة المناسبة دون أن يحول شيئاً، وهذا ينقل تكلفة التحويل من السيرفر إلى جهاز المذيع.
- سلسلة الخوادم Cascading: سيرفر SFU واحد له حد في عدد الاتصالات وال Bandwidth، فيمرر البث إلى خوادم SFU أخرى قريبة من المشاهدين، وكل منها يوزع على من حوله.
وقد يتساءل البعض: إذا كان WebRTC أسرع فلماذا لا نستخدمه للجميع؟ والإجابة أن كل مشاهد في WebRTC اتصال دائم مع حالة على السيرفر، ومشفر بمفاتيح خاصة به، فلا تستطيع CDN عادية أن تحفظه وتوزعه كما تفعل مع مقاطع HLS، وبالتالي تحتاج خوادم SFU بعدد المشاهدين تقريباً. لذلك فالتصميم الشائع أن يكون WebRTC لمن يتفاعل فعلاً، مثل المذيع وضيوفه والمواجهات بين مذيعين، وLL-HLS لبقية الجمهور، ومشغل يستطيع الانتقال من الأول إلى الثاني إذا لم يصل اتصال WebRTC بسبب جدار حماية. وللتشغيل عبر WebRTC تستخدم المشغلات معيار WHEP، وهو المقابل ل WHIP في جهة المشاهدة، وما زال مسودة في IETF لكنه مدعوم في خوادم الوسائط الحديثة.
الدردشة: رسالة واحدة تنسخ لكل مشاهد
الدردشة في البث المباشر ليست تطبيق محادثة عادياً، فالرسالة الواحدة لا تذهب إلى شخص واحد وإنما إلى كل من في الغرفة، فإذا كان في الغرفة 10,000 مشاهد وكتب منهم 20 رسالة في الثانية، فالسيرفرات يجب أن تكتب 200,000 رسالة في الثانية، وهذا ما يسمى التوزيع Fan-out. والحل البسيط سيرفر WebSocket واحد يحفظ قائمة الاتصالات في ذاكرته ويرسل لهم، ويعمل حتى يمتلئ معالج السيرفر، ثم تضيف سيرفراً ثانياً فتكتشف أن رسالة من مشاهد متصل بالسيرفر الأول لا تصل إلى مشاهدي السيرفر الثاني. والحل الأنسب أن تمر كل رسالة على قناة نشر واشتراك Pub/Sub مشتركة، فيستلمها كل سيرفر ويوزعها على من اتصل به، والصورة التالية تبين ذلك كما في التجربة:

وهنا يناسب Pub/Sub بالضبط لأن رسالة الدردشة لا قيمة لها بعد ثوان، فإذا كان المشاهد غير متصل لحظتها فلن يحتاجها، وهذا هو الفرق الذي شرحناه بين الطابور وسجل الأحداث في الطابور أم بث الأحداث: الدردشة الحية تحتاج توزيعاً فورياً بلا حفظ، أما الأحداث التي يجب ألا تضيع، مثل الهدايا والبلاغات، فمكانها سجل أو قاعدة بيانات. وانتبه لثلاثة أشياء في سيرفر الدردشة: ألا يوقف مشاهد بطيء الإرسال للبقية، وأن تحدد معدلاً لكل مستخدم Rate Limit يمنع الإغراق، وأن تخفف الرسائل عندما تزدحم الغرفة جداً، فتعرض لكل مشاهد عينة من الرسائل بدل كلها، لأن عيناً بشرية لا تقرأ 200 رسالة في الثانية.
الهدايا والعملات: دفتر حسابات قبل أن تكون رسوماً متحركة
الهدية في تطبيق البث تبدو رسماً متحركاً فوق الفيديو، لكنها في الحقيقة عملية مالية: المشاهد يشتري عملات بمال حقيقي، ثم يحولها إلى المذيع، والمذيع يصرف أرباحه يوماً ما، وأي خطأ في هذه السلسلة يعني مالاً ضائعاً أو مكرراً، والصورة التالية تبين الخطوات:

- الشراء: في تطبيقات الهاتف تباع العملات الرقمية عبر نظام الشراء داخل التطبيق In-App Purchase في متجر التطبيقات، وتسمح إرشادات مراجعة App Store باستخدام عملات الشراء داخل التطبيق ليمنح المستخدمون «إكرامية» لمقدمي المحتوى الرقمي، وصفحة JACO في المتجر تعرض هذه الحزم بالريال. ولا تقم بإضافة العملات بمجرد أن يقول التطبيق إن الدفع نجح، والسبب أن الإيصال يمكن تزويره، فتحقق منه في السيرفر مع المتجر قبل أي إضافة.
- الدفتر Ledger: لا تحفظ الرصيد كرقم تعدله، وإنما كقيود تضاف ولا تعدل، والرصيد مجموعها، فإذا ظهر خلاف عرفت من أين جاء، وقد وضعنا جداوله في نموذج البيانات.
- الإرسال: خصم العملات من المشاهد وإضافتها للمذيع في معاملة Transaction واحدة، مع مفتاح يمنع التكرار Idempotency Key يرسله التطبيق، فإذا ضغط المشاهد الزر مرتين أو أعاد التطبيق الطلب بعد انقطاع الشبكة لا تخصم الهدية مرتين.
- الإعلان: بعد نجاح المعاملة فقط ينشر حدث الهدية في قناة الغرفة لتظهر الرسوم، وليس قبلها.
- الصرف: أرباح المذيع تبقى معلقة مدة للمراجعة ومكافحة الاحتيال، مثل شراء عملات ببطاقات مسروقة وإهدائها لحساب شريك، قبل أن تصرف.
وقبل أن تطلق الهدايا والعملات اقرأ أنظمة المدفوعات وحماية المستهلك في كل دولة تعمل فيها، وشروط متاجر التطبيقات التي تنشر عليها، لأن هذه الشروط تختلف بين الدول وتتغير، ولا يشرحها هذا المقال.
الإشراف على البث المباشر
الإشراف على البث المباشر أصعب من الفيديو المرفوع، لأن المخالفة تصل إلى المشاهدين لحظة حدوثها، ولا توجد مرحلة مراجعة قبل النشر، لذلك يبنى على طبقات: فلاتر آلية على الدردشة للكلمات والروابط والإغراق، ومشرفون من المذيع نفسه يستطيعون حذف الرسائل وحظر المستخدمين في قناته، وأدوات للمنصة تستطيع إيقاف بث كامل بضغطة واحدة وإبطال مفتاح البث، وعينات دورية من صور البث تمر على مصنفات آلية وتنبه فريق المراجعة، وزر بلاغ سهل للمشاهدين، وقد شرحنا طبقات المراجعة الآلية والبشرية بتفصيل أكثر في مقال تطبيق الفيديو القصير.
التوسع عند الذروة
الذروة في البث المباشر لها شكل خاص، فالمشاهدون لا يزيدون ببطء وإنما يدخلون في نفس الدقيقة عند بداية المباراة، ثم يكتبون في نفس الثانية عند الهدف، لذلك:
- جهز السيرفرات قبل الحدث المعروف بدل أن تنتظر التوسع التلقائي، فالتوسع التلقائي يحتاج دقائق والمشاهدون يدخلون في ثوان.
- اجعل المشغل يعيد الاتصال بانتظار عشوائي يزيد مع كل محاولة، وإلا عاد كل المشاهدين في نفس الثانية بعد أي انقطاع وأسقطوا السيرفر من جديد.
- قس كل طبقة على حدة باختبار أحمال قبل الحدث، كما شرحنا في اختبار الأحمال والضغط، ففي التجربة العملية سوف ترى أن عنق الزجاجة في الدردشة كان معالج السيرفر وليس Valkey.
- اجعل للتطبيق وضعاً متدهوراً Degraded Mode، مثل إيقاف الرسوم المتحركة للهدايا أو تخفيف الدردشة، بدل أن يسقط كله.
التكلفة
في البث المباشر ثلاثة بنود تكبر بشكل مختلف: التحويل يكبر مع عدد القنوات النشطة وعدد جوداتها، لا مع عدد المشاهدين، والنقل الخارج يكبر مع عدد المشاهدين ومعدل البت، وهو نفس بند Egress الذي شرحناه في التكاليف الخفية في السحابة، فبث بمعدل 2.5 ميجابت يشاهده ألف شخص ساعة واحدة ينقل قرابة 1,125 جيجابايت، وقد حسبنا هذه البنود لتطبيق كامل في تقدير السعة. وخوادم WebRTC تكبر مع عدد المشاهدين المتصلين بها، وكل مشاهد عليها أغلى من مشاهد على CDN، لأنه اتصال دائم ومشفر لا يحفظ. وهذا يقودك إلى قرارات عملية: حول القنوات التي فيها مشاهدون فقط، وأعط المشاهدين الصامتين LL-HLS، واحتفظ ب WebRTC لمن يتفاعل.
القطع مفتوحة المصدر
| الأداة | الرخصة | ما تقوم به |
|---|---|---|
| MediaMTX | MIT | خادم وسائط في ملف تنفيذي واحد يستقبل وينشر عبر RTMP وSRT وWebRTC وRTSP وHLS، ويحول بين البروتوكولات تلقائياً ويسجل، وهو ما نستخدمه في التجربة |
| LiveKit | Apache-2.0 | خادم WebRTC SFU موزع للتطبيقات التي فيها صوت وفيديو تفاعلي مع حزم SDK للهواتف والمتصفح |
| Owncast | MIT | خادم بث مباشر ودردشة لقناة واحدة تستضيفه بنفسك، فهو قناتك الخاصة وليس منصة لكثير من المذيعين |
| SRS | MIT | خادم وسائط آخر منتشر يدعم RTMP وHLS وWebRTC وSRT |
| Valkey Pub/Sub | BSD-3-Clause | القناة المشتركة بين سيرفرات الدردشة |
| hls.js | Apache-2.0 | مشغل HLS وLL-HLS في المتصفح |
وإذا كانت حاجتك قناة بث واحدة لمؤسستك أو مسجدك أو فعالياتك فابدأ ب Owncast، وإذا كانت تطبيقاً فيه غرف صوت وفيديو تفاعلية فابدأ ب LiveKit، وسوف نشرح تثبيت الاثنين بإذن الله في دليلين ضمن هذا القسم. أما إذا كانت منصة لكثير من المذيعين بدردشة وهدايا فهي ما نبنيه هنا من قطع.
تجربة عملية: بث ومشاهدة بطريقتين ودردشة لآلاف المشاهدين
الآن سوف نشغل MediaMTX 1.21.2 مع ffmpeg، ونرسل إليه بثاً من ffmpeg يقوم بدور OBS، عليه ساعة المرسل بالمللي ثانية مطبوعة فوق الصورة، ثم نشاهده في صفحة واحدة عبر WebRTC وLL-HLS معاً لنقيس التأخير، ثم نشغل سيرفري دردشة مع Valkey ونرسل الرسائل إلى آلاف الاتصالات. وكل المنافذ على 127.0.0.1 فقط، والتجربة كلها على جهاز واحد، فالأرقام توضيحية لفهم الفروق وليست مقياساً لأداء الأدوات.
ملفات التجربة
أول ملف هو Dockerfile لأدوات التجربة: ffmpeg مع خط لكتابة الساعة، ومكتبة websockets للدردشة:
FROM python:3.13.16-alpine3.24
RUN apk add --no-cache ffmpeg font-dejavu
RUN pip install --no-cache-dir websockets==17.2 redis==8.1.0
WORKDIR /app
COPY chat.py load.py compare.html ./ثم إعداد MediaMTX في mediamtx.yml، ونغير فيه ما نحتاجه فقط ونترك بقية القيم الافتراضية:
logLevel: info
api: true
apiAddress: :9997
rtmpAddress: :1935 # OBS and ffmpeg publish here
srtAddress: :8890 # SRT ingest (UDP), for unstable networks
hlsAddress: :8888
hlsVariant: lowLatency # LL-HLS: 1 s segments split into 200 ms parts
hlsSegmentDuration: 1s
hlsPartDuration: 200ms
webrtcAddress: :8889 # WHEP playback page and endpoint
webrtcLocalUDPAddress: :38923
webrtcIPsFromInterfaces: false
webrtcAdditionalHosts: [127.0.0.1]
paths:
live:
# real-time transcoding: when the stream starts, ffmpeg makes a 480p copy at live/480p
runOnAvailable: >
ffmpeg -hide_banner -loglevel error -rtsp_transport tcp -i rtsp://localhost:$RTSP_PORT/$MTX_PATH
-map 0:v -c:v libx264 -preset veryfast -tune zerolatency -vf scale=-2:480
-b:v 900k -maxrate 900k -bufsize 900k -g 30 -map 0:a -c:a libopus -b:a 64k
-f rtsp rtsp://localhost:$RTSP_PORT/$MTX_PATH/480p
runOnAvailableRestart: yes
all_others:في الإعداد أعلاه لاحظ التالي:
hlsVariant: lowLatencyهو LL-HLS بمقاطع ثانية وأجزاء 200 مللي ثانية، وهي القيم الافتراضية في هذا الإصدار، وكتبناها لتكون واضحة.- WebRTC يحتاج أن يعرف العنوان الذي يصل إليه المتصفح، فأوقفنا جمع العناوين من واجهات ال Container وأعطيناه 127.0.0.1، ونشرنا منفذ ال UDP بنفس رقمه داخل ال Container وخارجه، لأن الرقم يرسل للمتصفح داخل عرض SDP.
- عند وصول بث إلى المسار
liveيشغل MediaMTX أمرrunOnAvailable، وهو ffmpeg يقرأ البث ويحوله في الوقت الحقيقي إلى 480p مع صوت Opus وينشره في المسارlive/480p، وهذا هو التحويل في الوقت الحقيقي، والصوت Opus هو ما يفهمه WebRTC.
ثم publish.sh، وهو يقوم بدور OBS، فيرسل صورة اختبار 720p وعليها آخر ست خانات من ساعة المرسل بالمللي ثانية:
# Plays the role of OBS: a 720p test picture with the sender clock (last 6 digits of epoch ms) burned in,
# sent over RTMP with the usual live settings: x264 zerolatency, a keyframe every second, AAC audio.
START=$(python -c 'import time; print(int(time.time() * 1000) % 1000000)')
exec ffmpeg -hide_banner -loglevel error -re \
-f lavfi -i testsrc2=size=1280x720:rate=30 -f lavfi -i sine=frequency=440 \
-vf "drawtext=fontfile=/usr/share/fonts/dejavu/DejaVuSansMono-Bold.ttf:fontsize=64:fontcolor=yellow:box=1:[email protected]:x=40:y=40:text='sender %{eif\:mod($START+t*1000\,1000000)\:d}'" \
-c:v libx264 -preset veryfast -tune zerolatency -b:v 2500k -maxrate 2500k -bufsize 2500k -g 30 -pix_fmt yuv420p \
-c:a aac -b:a 128k -f flv rtmp://media:1935/liveثم صفحة المقارنة compare.html، وفيها ساعة المشاهد في الأعلى، ومشغل WebRTC يتصل ب WHEP بطلب POST واحد، ومشغل hls.js:
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>lab-live compare</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
<style>body{margin:12px;background:#111;color:#eee;font:15px system-ui} .row{display:flex;gap:12px}
.box{flex:1} video{width:100%;background:#000} h2{font:600 15px system-ui;margin:6px 0}
#clock{font:700 34px Consolas,monospace;color:#ffd54a;margin-bottom:8px}</style></head>
<body>
<div id="clock"></div>
<div class="row">
<div class="box"><h2>WebRTC (WHEP)</h2><video id="rtc" autoplay muted playsinline></video></div>
<div class="box"><h2>LL-HLS (hls.js)</h2><video id="hls" autoplay muted playsinline></video></div>
</div>
<script>
const clock = document.getElementById('clock');
setInterval(() => clock.textContent = 'viewer clock (ms): ' + (Date.now() % 1000000), 16);
// WebRTC playback with WHEP: one POST with the SDP offer, the answer comes back in the response
(async () => {
const pc = new RTCPeerConnection();
pc.addTransceiver('video', { direction: 'recvonly' });
pc.ontrack = (e) => { document.getElementById('rtc').srcObject = e.streams[0]; };
await pc.setLocalDescription(await pc.createOffer());
await new Promise(r => { if (pc.iceGatheringState === 'complete') r(); pc.onicegatheringstatechange = () => pc.iceGatheringState === 'complete' && r(); setTimeout(r, 2000); });
const res = await fetch('http://127.0.0.1:38922/live/whep', { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp });
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
window.rtcState = () => pc.connectionState;
})();
const hls = window.hls = new Hls({ lowLatencyMode: true });
hls.loadSource('http://127.0.0.1:38921/live/index.m3u8');
hls.attachMedia(document.getElementById('hls'));
</script>
</body></html>ثم سيرفر الدردشة chat.py:
# Chat server: WebSocket clients join a room; every message goes through Valkey pub/sub,
# so a message sent to one server reaches the viewers connected to every other server.
import asyncio, json, os
import redis.asyncio as redis
from websockets.asyncio.server import serve, broadcast
rooms = {} # room -> set of local connections
db = redis.Redis(host=os.environ["VALKEY_HOST"])
NAME = os.environ.get("SERVER_NAME", "chat")
async def handler(ws):
room = ws.request.path.strip("/") or "lobby"
rooms.setdefault(room, set()).add(ws)
try:
async for raw in ws:
msg = json.loads(raw)
if len(msg.get("text", "")) > 300: # a cheap guard; real moderation comes later
continue
msg["via"] = NAME
await db.publish(f"room:{room}", json.dumps(msg))
finally:
rooms[room].discard(ws)
async def fan_out():
pubsub = db.pubsub()
await pubsub.psubscribe("room:*")
async for m in pubsub.listen():
if m["type"] == "pmessage":
room = m["channel"].decode()[5:]
# broadcast() writes to every socket without waiting; a slow client cannot stall the others
broadcast(rooms.get(room, ()), m["data"].decode())
async def main():
asyncio.create_task(fan_out())
# compression=None: with per-message deflate every socket compresses the same message again
async with serve(handler, "0.0.0.0", 8765, max_size=4096, ping_interval=20, compression=None):
print(f"{NAME} listening on :8765", flush=True)
await asyncio.Future()
asyncio.run(main())ثم load.py الذي يقوم بدور المشاهدين والمرسل، وكل رسالة تحمل وقت إرسالها، فيحسب كل مشاهد كم تأخرت عنده:
# Chat load test. watch: N viewers split over the chat servers record the delay of every message.
# send: one user posts M messages at R per second.
# All containers run on the same Docker host and read the same clock, so the delays are comparable.
import asyncio, json, sys, time
from websockets.asyncio.client import connect
role, servers = sys.argv[1], sys.argv[2].split(",")
URL = "ws://{}:8765/match-1"
async def watch(viewers, messages, out):
delays, gate = [], asyncio.Semaphore(200) # at most 200 handshakes at a time
async def viewer(i, ready):
async with gate:
ws = await connect(URL.format(servers[i % len(servers)]), max_size=4096, open_timeout=60, compression=None)
ready.set()
async with ws:
for _ in range(messages):
msg = json.loads(await ws.recv())
delays.append((time.time_ns() - msg["sent_ns"]) / 1e6)
events = [asyncio.Event() for _ in range(viewers)]
tasks = [asyncio.create_task(viewer(i, e)) for i, e in enumerate(events)]
await asyncio.gather(*(e.wait() for e in events))
print(f"{viewers} viewers connected", flush=True)
await asyncio.wait_for(asyncio.gather(*tasks), 300)
with open(out, "w") as f:
f.write("\n".join(f"{d:.2f}" for d in delays))
async def send(messages, rate):
async with connect(URL.format(servers[0])) as ws:
for n in range(messages):
await ws.send(json.dumps({"user": "u1", "text": f"goal {n}", "sent_ns": time.time_ns()}))
await asyncio.sleep(1 / rate)
if role == "watch":
asyncio.run(watch(int(sys.argv[3]), int(sys.argv[4]), sys.argv[5]))
else:
asyncio.run(send(int(sys.argv[3]), float(sys.argv[4])))ثم run-load.sh الذي يشغل أربعة Containers للمشاهدين ومرسلاً واحداً، و stats.py الذي يجمع النتائج:
# usage: sh run-load.sh <total viewers> (4 watcher containers + 1 sender, 50 messages at 10 per second)
N=$1; W=$(pwd); rm -rf out && mkdir out
for i in 1 2 3 4; do
docker rm -f lab-live-load-$i >/dev/null 2>&1; docker run -d --name lab-live-load-$i --network lab-live_default -v "$W/out:/out" -v "$W/load.py:/app/load.py:ro" \
lab-live-tools:1 python load.py watch lab-live-chat-1,lab-live-chat-2 $((N / 4)) 50 /out/w$i.txt > /dev/null
done
until [ "$(for i in 1 2 3 4; do docker logs lab-live-load-$i 2>&1; done | grep -c connected)" = 4 ]; do sleep 1; done
docker run --rm --name lab-live-send --network lab-live_default -v "$W/load.py:/app/load.py:ro" \
lab-live-tools:1 python load.py send lab-live-chat-1 50 10
until [ "$(ls out/*.txt 2>/dev/null | wc -l)" = 4 ]; do sleep 1; done
python stats.py out
docker rm -f lab-live-load-1 lab-live-load-2 lab-live-load-3 lab-live-load-4 > /dev/nullimport glob, sys
d = sorted(float(x) for f in glob.glob(sys.argv[1] + "/*.txt") for x in open(f).read().split())
q = lambda p: d[min(len(d) - 1, int(p * len(d)))]
print(f"deliveries={len(d)} p50={q(.5):.1f} ms p95={q(.95):.1f} ms p99={q(.99):.1f} ms max={d[-1]:.1f} ms")وأخيراً compose.yml:
name: lab-live
services:
media:
image: bluenviron/mediamtx:1.21.2-ffmpeg
volumes: ["./mediamtx.yml:/mediamtx.yml:ro"]
ports:
- "127.0.0.1:38920:1935" # RTMP ingest
- "127.0.0.1:38921:8888" # LL-HLS
- "127.0.0.1:38922:8889" # WebRTC (WHEP)
- "127.0.0.1:38923:38923/udp" # WebRTC media (ICE)
valkey:
image: valkey/valkey:9.1.2-alpine
chat:
build: .
image: lab-live-tools:1
command: ["python", "chat.py"]
environment: { VALKEY_HOST: valkey }
deploy: { replicas: 2 }
web:
image: lab-live-tools:1
command: ["python", "-m", "http.server", "8000"]
ports: ["127.0.0.1:38924:8000"]البث إلى MediaMTX
docker compose up -d --build
docker run -d --name lab-live-obs --network lab-live_default \
-v "$PWD/publish.sh:/publish.sh:ro" --entrypoint sh lab-live-tools:1 /publish.sh
docker compose logs media | tail -9والمخرج سوف يكون كما يلي:
media-1 | 2026/10/09 00:10:18 INF [RTMP] [conn 192.168.112.7:35630] opened
media-1 | 2026/10/09 00:10:20 INF [path live] runOnAvailable command started
media-1 | 2026/10/09 00:10:20 INF [path live] stream is available and online, 2 tracks (H264, MPEG-4 Audio)
media-1 | 2026/10/09 00:10:20 INF [RTMP] [conn 192.168.112.7:35630] is publishing to path 'live'
media-1 | 2026/10/09 00:10:20 INF [RTSP] [conn [::1]:60448] opened
media-1 | 2026/10/09 00:10:20 INF [RTSP] [session 78423577] is reading from path 'live', with TCP, 2 tracks (H264, MPEG-4 Audio)
media-1 | 2026/10/09 00:10:23 INF [path live/480p] stream is available and online, 2 tracks (H264, Opus)
media-1 | 2026/10/09 00:10:23 INF [RTSP] [session e9675527] is publishing to path 'live/480p'
media-1 | 2026/10/09 00:10:23 INF [path live/480p] RTP packets are too big (1460 > 1440), remuxing them into smaller onesالبث وصل بال RTMP إلى المسار live، فبدأ أمر التحويل وقرأه بال RTSP، وبعد ثلاث ثوان ظهر المسار live/480p بصوت Opus. ونسأل واجهة MediaMTX عن المسارات، ونقيس استهلاك المعالج:
docker exec lab-live-media-1 wget -qO- http://localhost:9997/v3/paths/list | python3 -c "
import sys, json
for p in json.load(sys.stdin)['items']:
print(p['name'], p['source']['type'], p['tracks'], 'readers:', len(p['readers']), 'bytesReceived:', p['bytesReceived'])"
docker stats --no-stream --format '{{.Name}} CPU {{.CPUPerc}} MEM {{.MemUsage}}' lab-live-media-1 lab-live-obslive rtmpConn ['H264', 'MPEG-4 Audio'] readers: 1 bytesReceived: 28460886
live/480p rtspSession ['H264', 'Opus'] readers: 0 bytesReceived: 10329062
lab-live-media-1 CPU 89.25% MEM 128.4MiB / 31.29GiB
lab-live-obs CPU 91.94% MEM 94.8MiB / 31.29GiBلاحظ أن القارئ الوحيد للمسار live هو أمر التحويل نفسه، وأن ال Container الذي فيه MediaMTX والتحويل استهلك قرابة 89% من نواة واحدة، وأغلبها لفك ترميز 720p وترميز 480p واحدة، فقناة بثلاث جودات تحتاج قرابة ثلاث أنوية طوال البث، وهذا هو رقم التكلفة الذي تكلمنا عنه. والمرسل أيضاً استهلك نواة كاملة، لأنه يرمز 720p بنفسه كما يفعل هاتف المذيع.
ونفس الخادم يستقبل SRT دون أي إعداد إضافي، فالأمر التالي يرسل 6 ثوان بال SRT من شبكة غير مستقرة مثلاً، بمهلة 200 مللي ثانية لإعادة إرسال الحزم المفقودة:
docker run --rm --network lab-live_default --name lab-live-srt --entrypoint sh lab-live-tools:1 -c \
"ffmpeg -hide_banner -loglevel error -re -f lavfi -i testsrc2=size=1280x720:rate=30 -t 6 \
-c:v libx264 -preset veryfast -tune zerolatency -g 30 -f mpegts 'srt://media:8890?streamid=publish:field-cam&latency=200000'"
docker compose logs media | grep SRT | tail -3media-1 | 2026/10/09 00:16:17 INF [SRT] [conn 192.168.112.8:38912] opened
media-1 | 2026/10/09 00:16:18 INF [SRT] [conn 192.168.112.8:38912] is publishing to path 'field-cam'
media-1 | 2026/10/09 00:16:23 INF [SRT] [conn 192.168.112.8:38912] closed: EOFماذا يرى مشغل LL-HLS؟
MediaMTX يرد على الطلب الأول بإعادة توجيه تضع Cookie، لذلك نستخدم -L مع ملف Cookies:
curl -s -L -c /tmp/cj -b /tmp/cj http://127.0.0.1:38921/live/index.m3u8
curl -s -L -c /tmp/cj -b /tmp/cj http://127.0.0.1:38921/live/video1_stream.m3u8 | head -9
curl -s -L -c /tmp/cj -b /tmp/cj http://127.0.0.1:38921/live/video1_stream.m3u8 | tail -16#EXTM3U
#EXT-X-VERSION:10
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="audio2",AUTOSELECT=YES,DEFAULT=YES,URI="audio2_stream.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=2491144,AVERAGE-BANDWIDTH=2491144,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=30.000,AUDIO="audio"
video1_stream.m3u8
#EXTM3U
#EXT-X-VERSION:10
#EXT-X-TARGETDURATION:1
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=0.50000,CAN-SKIP-UNTIL=6.00000
#EXT-X-PART-INF:PART-TARGET=0.20000
#EXT-X-MEDIA-SEQUENCE:41
#EXT-X-MAP:URI="c0fa877fa860_video1_init.mp4"
#EXTINF:1.00000,
c0fa877fa860_video1_seg41.mp4
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part9.mp4"
#EXTINF:1.00000,
c0fa877fa860_video1_seg8.mp4
#EXT-X-PROGRAM-DATE-TIME:2026-10-09T00:11:48.997Z
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part10.mp4",INDEPENDENT=YES
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part11.mp4"
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part12.mp4"
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part13.mp4"
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part14.mp4"
#EXTINF:1.00000,
c0fa877fa860_video1_seg9.mp4
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part15.mp4",INDEPENDENT=YES
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part16.mp4"
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part17.mp4"
#EXT-X-PART:DURATION=0.20000,URI="c0fa877fa860_video1_part18.mp4"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="c0fa877fa860_video1_part19.mp4"في المخرج أعلاه لاحظ التالي:
CAN-BLOCK-RELOAD=YESيعني أن السيرفر يقبل الطلب المعلق، وPART-HOLD-BACK=0.5يعني أن المشغل يبدأ على بعد نصف ثانية من آخر جزء.- كل مقطع مدته ثانية واحدة مقسم إلى خمسة أجزاء مدة كل منها 0.2 ثانية، والجزء الذي عليه
INDEPENDENT=YESيبدأ بإطار مفتاحي، فيستطيع المشغل أن يبدأ منه. EXT-X-PRELOAD-HINTيعطي اسم الجزء القادم قبل أن يكتب، فيطلبه المشغل مسبقاً ويرد السيرفر عندما يجهز.- الأمرين الثاني والثالث نفذا في لحظتين مختلفتين، لذلك تختلف أرقام المقاطع بينهما، فالقائمة تتحرك مع البث.
WebRTC وLL-HLS جنباً إلى جنب
افتح http://127.0.0.1:38924/compare.html في المتصفح، والصورة التالية لقطة من التجربة في Chrome:

ساعة المشاهد في اللقطة 886618، وساعة المرسل في فيديو WebRTC هي 885602، أي أن التأخير قرابة 1.0 ثانية، وفي فيديو LL-HLS هي 882336، أي قرابة 4.3 ثانية، وفي لقطة ثانية بعد 7 ثوان كانت الأرقام 893515 و892536 و889236، أي نفس الفرق تقريباً. والفرق بين المشغلين قرابة 3.3 ثانية، وهو رقم لا يتأثر بأي فرق بين ساعة الجهاز وساعة ال Containers. ولاحظ أن ثانية WebRTC تشمل ترميز المرسل والذاكرة المؤقتة في المتصفح، وأن LL-HLS هنا أبطأ من أفضل ما يمكن أن يصل إليه، لأن hls.js يحتفظ بهامش أمان، وهذا ما تريده لجمهور كبير على شبكات مختلفة.
الدردشة لآلاف المشاهدين
الآن نشغل نفس الاختبار ثلاث مرات: 100 مشاهد ثم 1,000 ثم 4,000، موزعين على سيرفري الدردشة، ومرسل واحد يرسل 50 رسالة بمعدل 10 رسائل في الثانية، وكل مشاهد يسجل تأخير كل رسالة:
sh run-load.sh 100
sh run-load.sh 1000deliveries=5000 p50=4.1 ms p95=7.2 ms p99=10.4 ms max=11.8 ms
deliveries=50000 p50=36.3 ms p95=87.5 ms p99=149.6 ms max=226.3 msمع 100 مشاهد وصلت 99% من الرسائل خلال 10 مللي ثوان، ومع ألف خلال 150 مللي ثانية، وكلاهما أسرع بكثير مما يلاحظه إنسان. ثم 4,000 مشاهد، ونقيس المعالج أثناء الاختبار:
(sleep 25; docker stats --no-stream --format '{{.Name}} {{.CPUPerc}}' \
lab-live-chat-1 lab-live-chat-2 lab-live-load-1 lab-live-load-2 lab-live-valkey-1) &
sh run-load.sh 4000lab-live-chat-1 100.70% lab-live-chat-2 89.13% lab-live-load-1 101.47% lab-live-load-2 92.68% lab-live-valkey-1 0.28%
deliveries=200000 p50=2306.2 ms p95=4857.2 ms p99=5475.8 ms max=5844.6 msهنا انهار الزمن إلى ثانيتين في المتوسط، والسبب واضح من سطر المعالج: كل سيرفر دردشة بلغة Python يعمل على نواة واحدة وصل إلى 100%، لأنه يكتب 20,000 رسالة في الثانية، وكذلك برامج المشاهدين الوهميين نفسها، أما Valkey فاستهلك 0.28% فقط لأنه ينقل 10 رسائل في الثانية لكل سيرفر لا لكل مشاهد. والدرس أن التوزيع يكلف في السيرفر الذي يكتب للمشاهدين لا في القناة المشتركة، والحل أن تضيف سيرفرات دردشة بعدد الأنوية، وهذا سهل لأن القناة المشتركة تربطها، أو تكتب السيرفر بلغة تستخدم كل الأنوية، أو تخفف الرسائل في الغرف المزدحمة. ولاحظ أن الأرقام تختلف بين مرة وأخرى عندما يمتلئ الجهاز، ففي تشغيل سابق لنفس الاختبار كان المتوسط قرابة 5.9 ثانية.
نكسر التجربة: ضغط كل رسالة
مكتبة websockets تفعل ضغط الرسائل permessage-deflate افتراضياً، كما تذكر وثائق المكتبة، وهذا مفيد في اتصال واحد ينقل رسائل كبيرة. احذف compression=None من chat.py ومن load.py، وأعد بناء ال Image وتشغيل الاختبار:
deliveries=5000 p50=44.7 ms p95=142.6 ms p99=176.8 ms max=245.0 ms
deliveries=50000 p50=4253.6 ms p95=7602.9 ms p99=8183.3 ms max=8370.0 msمع ألف مشاهد صار المتوسط أكثر من 4 ثوان بدل 36 مللي ثانية، أي أبطأ أكثر من مئة مرة، والسبب أن كل اتصال له حالة ضغط خاصة به، فتضغط نفس الرسالة من جديد لكل مشاهد، والرسالة أصلاً بضعة عشرات من البايتات لا يوفر ضغطها شيئاً. والقاعدة في الدردشة الجماعية: أوقف الضغط لكل رسالة، أو اجعل الرسائل كبيرة الحجم فقط هي التي تضغط.
حذف المختبر
docker rm -f lab-live-obs
docker compose down -v
docker image rm lab-live-tools:1أخطاء شائعة
- مقاطع HLS بطول 6 ثوان في بث تفاعلي، ثم الاستغراب من أن المذيع يرد على تعليقات قديمة.
- WebRTC لكل المشاهدين، فتصبح تكلفة الخوادم أضعاف تكلفة CDN مع LL-HLS.
- تحويل كل القنوات إلى كل الجودات حتى القنوات التي لا يشاهدها أحد.
- سيرفر دردشة واحد يحفظ المتصلين في ذاكرته دون قناة مشتركة، فلا تستطيع إضافة سيرفر ثان.
- تعديل رصيد العملات كرقم واحد دون دفتر قيود، أو دون مفتاح يمنع تكرار الهدية.
- إضافة العملات قبل التحقق من إيصال الشراء في السيرفر.
- مفتاح البث في لقطة شاشة أو مقطع تعليمي، فيبث غيرك على قناتك.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- تطبيق البث المباشر بث واحد يدخل بال RTMP أو SRT أو WHIP، ويحول في الوقت الحقيقي، ويخرج في مسارين: WebRTC للتفاعل بأقل من ثانية، وLL-HLS عبر ال CDN للجمهور الكبير بثوان قليلة.
- التحويل الحي يكلف أنوية بعدد الجودات طوال البث، لذلك حول ما يشاهد فقط، أو استخدم طبقات Simulcast من جهاز المذيع.
- HLS التقليدي متأخر بثلاثة مقاطع على الأقل بحسب المعيار، وLL-HLS يقلل ذلك بأجزاء صغيرة وطلب معلق، وفي التجربة كان الفرق بينه وبين WebRTC قرابة 3.3 ثانية.
- الدردشة توزيع رسالة واحدة على كل الغرفة، وقناة Pub/Sub مشتركة تربط سيرفراتها، والعمل الحقيقي في السيرفر الذي يكتب للمشاهدين، وضغط كل رسالة جعلها أبطأ مئة مرة.
- الهدايا دفتر حسابات بمعاملة واحدة ومفتاح يمنع التكرار، والشراء يتحقق منه السيرفر.
- جهز السيرفرات قبل الأحداث المعروفة، واجعل للتطبيق وضعاً متدهوراً بدل السقوط الكامل.
وفي المقال التالي من هذا القسم نبني تطبيق فيديو قصير بخلاصة لا تنتهي ونظام توصية يتعلم من كل ثانية مشاهدة:
دليلكيف تبني تطبيق فيديو قصير مثل TikTokكيف يجهز تطبيق الفيديو القصير المقطع التالي قبل أن تسحب الشاشة، وكيف يختار ما يناسبك من إشارات المشاهدة.
دليلكيف تبني منصة فيديو عند الطلب مثل Netflixكيف يتحول فيديو مرفوع إلى بث يناسب كل شاشة وسرعة اتصال، وما الذي تحتاجه منصتك من التحويل إلى التوصيل والتكلفة.
دليلمكالمات WebRTC خلف NAT: شرح STUN وTURN وICEلماذا تتصل المكالمة ولا يصل الصوت، وكيف تحل STUN وTURN المشكلة دون أن تفتح باباً إلى شبكتك الداخلية.
دليلالطابور Queue أم بث الأحداث Event Streaming؟ما يحدث للرسالة بعد قراءتها هو الفرق بين طابور مثل RabbitMQ وسجل أحداث مثل Kafka، ومنه تعرف أيهما يناسب مشروعك.
دليلاختبار الأحمال والضغط Load Testing للمبتدئينلماذا يعمل تطبيقك مع خمسة مستخدمين وينهار مع خمسمئة، وكيف تكتشف ذلك وتصلحه قبل يوم الإطلاق.
دليلالتكاليف الخفية في السحابة وكيف تخفضهاالبنود التي لا تظهر في حساب يوم التصميم ثم تملأ فاتورة السحابة بعد أشهر، وكيف تحسب كل بند وتخفضه.سجل التحديثات
- أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع MediaMTX 1.21.2 وFFmpeg 8.1.2 وValkey 9.1.2 وPython 3.13.16 مع websockets 17.2 وhls.js 1.7.3 في Chrome.