EduFlow
SQL للمحلّلين
⁦0%⁩

الوحدة 2 · التجميع والـ JOIN

الـ fan-out: حين يحسب الـ JOIN الشيء مرتين

17 دقراءة 4 دتمرين SQL

الدرس في لمحة

تعرّف على اللحظة التي يضاعف فيها الـ JOIN الصفوف، واشرح لماذا تتضخّم المجاميع على الجانب المتكرّر، وأصلح ذلك بتجميع كل جدول إلى مستوى التفصيل الصحيح قبل الربط.

  1. 1من أين يأتي المال الإضافي
  2. 2الإصلاح: جمّع أولًا، ثم اربط
  3. 3تضاعفان في آن واحد
  4. 4اكتشاف الـ fan-out قبل أن يصيبك
  5. 5عادة المطابقة

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

يرسل كريم من المالية سؤالًا سريعًا: «في 2025، حسب القناة، كم جمعنا من رسوم الشحن، وكم من إيراد العناصر؟». أنت تعرف الـ JOIN المطلوب. وهذا هو الـ query الذي يكتبه معظم الناس أولًا:

SQL
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.

الطلب 1001 برسوم شحن 7.99 يرتبط بأربعة سطور عناصر. في النتيجة بعد الربط أربعة صفوف، يعرض كلٌّ منها 7.99، فيعطي جمع الرسوم 31.96 بدل 7.99. orders (واحد) 1001 fee 7.99 order_items (متعدد) line 2 line 3 line 4 line 5 الصفوف بعد الربط 1001 7.99 line 2 1001 7.99 line 3 1001 7.99 line 4 1001 7.99 line 5 SUM(fee) = 31.96
ربط طلب واحد بأربعة عناصر يكرّر رسومه أربع مرات

نجا إيراد العناصر لأنه يقع في جانب «المتعدد»: إيراد كل سطر يظهر مرة واحدة بالضبط. أما الشحن فيقع في جانب «الواحد»، فيُحسب مرة لكل سطر. متوسط عدد السطور في طلبات Cartwheel هو 1.93، ولهذا تضاعف الشحن تقريبًا.

القاعدة العامة: الـ JOIN يغيّر مستوى التفصيل إلى مستوى تفصيل أكثر جداوله تفصيلًا، وأي رقم مخزّن بمستوى تفصيل أخشن يتكرّر.

الإصلاح: جمّع أولًا، ثم اربط

أوصل كل جدول إلى مستوى تفصيل الإجابة قبل الربط. هنا، اضغط order_items في صف واحد لكل طلب داخل subquery، ثم اربط النتيجة بـ orders. الآن في الجانبين صف واحد لكل طلب، ولا يتكرّر شيء:

SQL
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:

SQL
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 يضاعف صفوف الطلبات. فحص سريع يخبرك بذلك:

SQL
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 مع المجموع نفسه محسوبًا من جدوله وحده.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

رسوم شحن الطلب 1001 هي 7.99 وفيه أربعة سطور عناصر. بعد ربط orders بـ order_items، كم يساهم SUM(o.shipping_fee) من هذا الطلب؟

السؤال 2 من 3

يصلح زميلك مجموع شحن متضخّمًا بكتابة SUM(DISTINCT o.shipping_fee). ما الخطأ في ذلك؟

السؤال 3 من 3

تحتاج إلى إيراد العناصر وعدد التذاكر لكل طلب. في order_items وsupport_tickets كليهما صفوف كثيرة لكل طلب. ما الطريقة الأكثر أمانًا؟

تدريب

تمرين SQL

الشحن حسب الدولة، محسوبًا مرة واحدة

يريد كريم التقسيم نفسه حسب دولة العميل لعام 2025 (الطلبات غير الملغاة): country، وorders (عدد الطلبات)، وshipping_total (مجموع shipping_fee مقرّبًا إلى منزلتين عشريتين)، وitem_revenue (مقرّبًا إلى منزلتين عشريتين). ترتيب الصفوف لا يهم.

إيراد العناصر في الكود المبدئي صحيح، لكن أعداد الطلبات ومجاميع الشحن متضخّمة. أصلح مستوى التفصيل.

الجداول
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- grain: one row per country (2025, non-cancelled orders)
SELECT
  c.country,
  COUNT(*) AS orders,
  ROUND(SUM(o.shipping_fee), 2) AS shipping_total,
  ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount)), 2) AS item_revenue
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
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 c.country;جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.