الوحدة 2 · التجميع والـ JOIN
الـ fan-out: حين يحسب الـ JOIN الشيء مرتين
17 دقراءة 4 دتمرين SQL
الدرس في لمحة
تعرّف على اللحظة التي يضاعف فيها الـ JOIN الصفوف، واشرح لماذا تتضخّم المجاميع على الجانب المتكرّر، وأصلح ذلك بتجميع كل جدول إلى مستوى التفصيل الصحيح قبل الربط.
- 1من أين يأتي المال الإضافي
- 2الإصلاح: جمّع أولًا، ثم اربط
- 3تضاعفان في آن واحد
- 4اكتشاف الـ fan-out قبل أن يصيبك
- 5عادة المطابقة
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
يرسل كريم من المالية سؤالًا سريعًا: «في 2025، حسب القناة، كم جمعنا من رسوم الشحن، وكم من إيراد العناصر؟». أنت تعرف الـ JOIN المطلوب. وهذا هو الـ query الذي يكتبه معظم الناس أولًا:
SELECT
o.channel,
ROUND(SUM(o.shipping_fee), 2) AS shipping,
ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount)), 2) AS item_revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status <> 'cancelled'
AND o.order_date >= '2025-01-01'
AND o.order_date < '2026-01-01'
GROUP BY o.channel;إيراد العناصر صحيح. أما الشحن فيقارب ضعف ما يجب: يعرض الويب 6,732 بينما الرقم الحقيقي 3,413. لم يظهر أي خطأ، ولا شيء يبدو سخيفًا من النظرة الأولى. هذا هو الـ fan-out (تضاعف الصفوف)، أغلى خطأ في التحليل ثمنًا، لأنه ينتج أرقامًا قابلة للتصديق.
من أين يأتي المال الإضافي
بين orders وorder_items علاقة واحد-إلى-متعدد: طلب واحد، وسطر واحد أو أكثر. حين تربطهما، يكون في النتيجة صف لكل سطر، ويحمل كل سطر نسخة من أعمدة طلبه، بما فيها shipping_fee.
نجا إيراد العناصر لأنه يقع في جانب «المتعدد»: إيراد كل سطر يظهر مرة واحدة بالضبط. أما الشحن فيقع في جانب «الواحد»، فيُحسب مرة لكل سطر. متوسط عدد السطور في طلبات Cartwheel هو 1.93، ولهذا تضاعف الشحن تقريبًا.
القاعدة العامة: الـ JOIN يغيّر مستوى التفصيل إلى مستوى تفصيل أكثر جداوله تفصيلًا، وأي رقم مخزّن بمستوى تفصيل أخشن يتكرّر.
الإصلاح: جمّع أولًا، ثم اربط
أوصل كل جدول إلى مستوى تفصيل الإجابة قبل الربط. هنا، اضغط order_items في صف واحد لكل طلب داخل subquery، ثم اربط النتيجة بـ orders. الآن في الجانبين صف واحد لكل طلب، ولا يتكرّر شيء:
SELECT
o.channel,
COUNT(*) AS orders,
ROUND(SUM(o.shipping_fee), 2) AS shipping,
ROUND(SUM(i.revenue), 2) AS item_revenue
FROM orders AS o
JOIN (
SELECT order_id, SUM(quantity * unit_price * (1 - discount)) AS revenue
FROM order_items
GROUP BY order_id
) AS i ON i.order_id = o.order_id
WHERE o.status <> 'cancelled'
AND o.order_date >= '2025-01-01'
AND o.order_date < '2026-01-01'
GROUP BY o.channel;الـ subquery داخل FROM (ويُسمّى جدولًا مشتقًّا) يتصرّف كجدول مؤقت بمستوى التفصيل الذي اخترته، وهو هنا صف واحد لكل order_id. الشحن الآن يطابق query على orders وحده، وإيراد العناصر لم يتغيّر، وCOUNT(*) يعدّ الطلبات، لأن الصفوف عادت طلبات. سيعرض القسم التالي طريقة أرتب لكتابة الشيء نفسه باستخدام WITH.
تضاعفان في آن واحد
الـ fan-out يتراكم. تسأل جوليا كم إيرادًا جاء من طلبات احتاجت إلى تذكرة دعم. اربط العناصر والتذاكر بالطلبات نفسها، فيتكرّر كل سطر مرة لكل تذكرة على طلبه؛ و110 طلبات لديها أكثر من تذكرة واحدة. المجموع الساذج يقول 166,562. أما الإجابة الصحيحة، التي تسأل هل توجد تذكرة بدل ربط كل تذكرة، فهي 140,675:
SELECT ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS revenue_with_tickets
FROM order_items
WHERE order_id IN (SELECT order_id FROM support_tickets);IN (subquery) يصفّي دون ربط، فلا يمكن أن يضاعف الصفوف أبدًا. هذا النمط، «هل يوجد تطابق؟»، ويُسمّى semi-join، يحظى بشرح كامل في الدرس التالي مع نقيضه.
اكتشاف الـ fan-out قبل أن يصيبك
لا داعي لأن تنتظر مجموعًا خاطئًا. قبل الربط، اسأل عن كل مفتاح ربط: هل هو فريد في هذا الجانب؟ order_id فريد في orders (فهو المفتاح الأساسي) لكنه ليس فريدًا في order_items ولا في support_tickets، فربط أيٍّ منهما بـ orders يضاعف صفوف الطلبات. فحص سريع يخبرك بذلك:
SELECT
COUNT(*) AS rows_after_join,
COUNT(DISTINCT order_id) AS distinct_orders
FROM order_items;3,991 صفًا مقابل 2,066 طلبًا: معامل تضاعف يقارب الاثنين. حين يختلف هذان الرقمان، فأي عمود تجمعه من جانب «الواحد» يحتاج إلى تجميع مسبق أو إلى إبعاده عن الـ JOIN. وحين يتساويان، يكون الـ JOIN واحد-إلى-واحد وآمنًا للجمع من أي جانب.
عادة المطابقة
في كل مرة تربط فيها جداول، طابِق مجموعًا واحدًا على الأقل مع نسخة من جدول واحد. الشحن حسب القناة من الـ query المربوط يجب أن يساوي الشحن حسب القناة من orders وحده؛ وإيراد العناصر يجب أن يساوي المجموع على order_items للطلبات نفسها. إذا تطابقا، فالـ JOIN لم يضاعف شيئًا. وإذا لم يتطابقا، فقد أمسكت بالخطأ قبل أن يصل إلى عرض كريم أمام مجلس الإدارة.
في الدرس التالي ستربط الجدول بنفسه، وتبحث عن صفوف لا تطابق لها على الإطلاق.
أهم النقاط
- ربط علاقة واحد-إلى-متعدد يعطي النتيجة مستوى تفصيل جانب «المتعدد»، ويكرّر كل عمود من جانب «الواحد».
- جمع عمود من الجانب المتكرّر، مثل رسوم شحن الطلب بعد ربطه بالعناصر، يحسبه مرة لكل صف مطابق.
- جمّع جانب «المتعدد» أولًا إلى مستوى تفصيل جانب «الواحد» داخل subquery، ثم اربط، حتى يُحسب كل رقم مرة واحدة بالضبط.
- قد ينقذ COUNT(DISTINCT key) عدًّا ما، لكن SUM(DISTINCT value) ليس إصلاحًا أبدًا لأنه يدمج أيضًا صفوفًا مختلفة تتشارك القيمة نفسها.
- طابِق كل مجموع بعد الـ JOIN مع المجموع نفسه محسوبًا من جدوله وحده.