SQLمتوسط

توابع پنجره‌ای در SQL: جمع تجمعی، رتبه‌بندی و میانگین متحرک

GROUP BY ردیف‌ها را در هم فشرده می‌کند؛ توابع پنجره‌ای روی چند ردیف محاسبه می‌کنند و همهٔ آن‌ها را نگه می‌دارند. OVER، PARTITION BY، ROW_NUMBER، RANK، LAG و قاب‌های پنجره را با مثال‌های کوچک و قابل اجرا یاد بگیرید.

جدولی از ردیف‌های فروش که به دو پارتیشن تقسیم شده و در هر کدام یک جمع تجمعی از بالا به پایین محاسبه می‌شود
در این صفحه

دیر یا زود گزارشی چیزی می‌خواهد که از GROUP BY برنمی‌آید. هر فروش را کنار مجموعِ منطقه‌اش نشان بده. فروشنده‌ها را در هر منطقه رتبه‌بندی کن. درآمد این ماه را کنار ماه قبل نشان بده. با GROUP BY برای هر گروه یک ردیف می‌گیرید و جزئیات از دست می‌رود؛ آدم‌ها معمولاً سراغ self-join، زیرکوئری یا یک حلقه در کد برنامه می‌روند.

توابع پنجره‌ای (window functions) همهٔ این‌ها را در یک کوئری حل می‌کنند. آن‌ها مقدارها را روی مجموعه‌ای از ردیف‌های مرتبط (همان پنجره) محاسبه می‌کنند، بدون اینکه آن‌ها را در هم فشرده کنند: هر ردیفِ ورودی همچنان در خروجی هست و مقدارِ محاسبه‌شده به شکل یک ستون جدید به آن اضافه می‌شود.

این توابع در PostgreSQL، MySQL 8 به بعد، SQL Server، Oracle و SQLite 3.25 به بعد کار می‌کنند. مثال‌های زیر همان‌طور که هستند در SQLite اجرا می‌شوند. اگر هنوز با JOINها راحت نیستید، از JOINها در SQL با مثال‌های ساده شروع کنید.

داده‌های نمونهٔ ما#

SQL
CREATE TABLE sales (rep TEXT, region TEXT, amount INTEGER);
INSERT INTO sales VALUES
  ('Ana', 'North', 900), ('Ben', 'North', 700), ('Cleo', 'North', 700), ('Dev', 'North', 400),
  ('Eve', 'South', 1200), ('Finn', 'South', 650), ('Gus', 'South', 300);

CREATE TABLE monthly (month TEXT, revenue INTEGER);
INSERT INTO monthly VALUES
  ('2026-01', 12000), ('2026-02', 15000), ('2026-03', 11000),
  ('2026-04', 18000), ('2026-05', 21000), ('2026-06', 19500);

مقایسهٔ GROUP BY با پنجره#

این مجموعِ هر منطقه با GROUP BY است. هفت ردیف وارد می‌شود و دو ردیف بیرون می‌آید:

SQL
SELECT region, SUM(amount) AS total
FROM sales
GROUP BY region;
region total
North 2700
South 2150

حالا همان SUM، اما با یک OVER بعد از آن، که آن را به یک تابع پنجره‌ای تبدیل می‌کند:

SQL
SELECT rep, region, amount,
       SUM(amount) OVER (PARTITION BY region) AS region_total,
       ROUND(100.0 * amount / SUM(amount) OVER (PARTITION BY region), 1) AS pct_of_region
FROM sales
ORDER BY region, amount DESC;
rep region amount region_total pct_of_region
Ana North 900 2700 33.3
Ben North 700 2700 25.9
Cleo North 700 2700 25.9
Dev North 400 2700 14.8
Eve South 1200 2150 55.8
Finn South 650 2150 30.2
Gus South 300 2150 14.0

هر هفت ردیف می‌مانند. PARTITION BY region ردیف‌ها را مثل GROUP BY گروه‌بندی می‌کند، اما به‌جای اینکه هر گروه را در یک ردیف فشرده کند، به هر ردیف دسترسی به کلِ گروهش را می‌دهد. با یک OVER () خالی، پنجره کلِ نتیجه است.

repamount⁧مجموعِ منطقه⁩⁧جمع تجمعی⁩ PARTITION BY regionPARTITION BY regionORDER BY amount DESC, rep North Ana 900 2700 900 Ben 700 2700 1600 Cleo 700 2700 2300 Dev 400 2700 2700 South Eve 1200 2150 1200 Finn 650 2150 1850 Gus 300 2150 2150 ⁧همهٔ ردیف‌ها می‌مانند: پنجره فقط یک مقدار به هر کدام اضافه می‌کند⁩
PARTITION BY ردیف‌ها را به یک پنجرهٔ North و یک پنجرهٔ South تقسیم می‌کند؛ هر ردیف مقدارهای خودش را نگه می‌دارد و مجموعِ پنجره‌اش به آن اضافه می‌شود، و با ORDER BY این مجموع به جمع تجمعی تبدیل می‌شود

ساختار OVER#

SQL
function(...) OVER (
    PARTITION BY ...   -- which rows belong together (optional)
    ORDER BY ...       -- the order of rows inside each partition (optional)
    ROWS BETWEEN ...   -- which of those rows to use for this row (optional)
)

همهٔ بخش‌ها اختیاری‌اند و هر کدام نتیجه را تغییر می‌دهد. یکی‌یکی سراغشان می‌رویم.

رتبه‌بندی با ROW_NUMBER، RANK و DENSE_RANK#

توابع رتبه‌بندی داخل پنجره به یک ORDER BY نیاز دارند. تنها تفاوتشان در رفتار با مقدارهای مساوی است، و داده‌های ما یک تساوی دارد: Ben و Cleo هر دو ۷۰۰ فروخته‌اند.

SQL
SELECT rep, amount,
       ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num,
       RANK()       OVER (ORDER BY amount DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk
FROM sales
ORDER BY amount DESC, rep;
rep amount row_num rnk dense_rnk
Eve 1200 1 1 1
Ana 900 2 2 2
Ben 700 3 3 3
Cleo 700 4 3 3
Finn 650 5 5 4
Dev 400 6 6 5
Gus 300 7 7 6
  • ROW_NUMBER همیشه ۱، ۲، ۳، ۴… می‌دهد، بدون تساوی. اینکه Ben یا Cleo عدد ۳ را بگیرد به پایگاه داده بستگی دارد، مگر اینکه یک معیارِ دوم مثل ORDER BY amount DESC, rep اضافه کنید.
  • RANK به مقدارهای مساوی یک عدد می‌دهد و بعد عدد را جا می‌اندازد: ۳، ۳، ۵. مثل مسابقه‌ای که دو دونده با هم مدال برنز می‌گیرند.
  • DENSE_RANK به مقدارهای مساوی یک عدد می‌دهد و جا نمی‌اندازد: ۳، ۳، ۴.

N تای برتر در هر گروه#

«دو فروشندهٔ برتر در هر منطقه» یکی از رایج‌ترین سؤال‌های گزارش‌گیری است و توابع پنجره‌ای آن را ساده می‌کنند. اما نمی‌توانید مستقیماً روی یک تابع پنجره‌ای فیلتر کنید:

SQL
SELECT rep FROM sales WHERE ROW_NUMBER() OVER (ORDER BY amount) <= 2;
-- Error: misuse of window function ROW_NUMBER()

توابع پنجره‌ای بعد از WHERE، GROUP BY و HAVING محاسبه می‌شوند، درست قبل از ORDER BY و LIMIT نهایی. پس رتبه را در یک زیرکوئری یا CTE حساب می‌کنید و بعد نتیجه را فیلتر می‌کنید:

SQL
WITH ranked AS (
  SELECT rep, region, amount,
         ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC, rep) AS pos
  FROM sales
)
SELECT region, pos, rep, amount
FROM ranked
WHERE pos <= 2
ORDER BY region, pos;
region pos rep amount
North 1 Ana 900
North 2 Ben 700
South 1 Eve 1200
South 2 Finn 650

اگر می‌خواهید فروشنده‌های هم‌امتیاز هر دو در فهرست بیایند، به‌جای ROW_NUMBER() از RANK() استفاده کنید.

جمع تجمعی#

به پنجرهٔ یک تابع تجمیعی ORDER BY اضافه کنید تا تجمعی شود: هر ردیف همه‌چیز را از ابتدای پارتیشن تا خودش جمع می‌زند.

SQL
SELECT month, revenue,
       SUM(revenue) OVER (ORDER BY month) AS running_total
FROM monthly;
month revenue running_total
2026-01 12000 12000
2026-02 15000 27000
2026-03 11000 38000
2026-04 18000 56000
2026-05 21000 77000
2026-06 19500 96500

با اضافه کردن PARTITION BY، جمع تجمعی برای هر گروه از نو شروع می‌شود؛ مثلاً مجموعِ «از ابتدای سال تا امروز» که هر ژانویه صفر می‌شود.

مقایسه با ردیف قبلی: LAG و LEAD#

LAG(column) مقدار را از ردیفِ قبلی در پنجره برمی‌گرداند و LEAD(column) از ردیفِ بعدی. تغییرِ ماه‌به‌ماه به یک تفریقِ ساده تبدیل می‌شود:

SQL
SELECT month, revenue,
       LAG(revenue) OVER (ORDER BY month) AS prev_month,
       revenue - LAG(revenue) OVER (ORDER BY month) AS change
FROM monthly;
month revenue prev_month change
2026-01 12000 NULL NULL
2026-02 15000 12000 3000
2026-03 11000 15000 -4000
2026-04 18000 11000 7000
2026-05 21000 18000 3000
2026-06 19500 21000 -1500

ماه اول ردیفِ قبلی ندارد، پس LAG مقدار NULL برمی‌گرداند. LAG(revenue, 1, 0) به‌جای آن 0 برمی‌گرداند، و LAG(revenue, 12) دوازده ردیف عقب می‌رود که برای مقایسهٔ سال‌به‌سال به کار می‌آید.

قاب‌های پنجره: میانگین متحرک#

قاب (frame) تعیین می‌کند کدام ردیف‌های پارتیشن برای ردیفِ فعلی حساب شوند. میانگینِ متحرکِ سه‌ماهه از ردیف فعلی و دو ردیفِ قبل از آن استفاده می‌کند:

SQL
SELECT month, revenue,
       ROUND(AVG(revenue) OVER (
         ORDER BY month
         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ), 1) AS avg_3m
FROM monthly;
month revenue avg_3m
2026-01 12000 12000.0
2026-02 15000 13500.0
2026-03 11000 12666.7
2026-04 18000 14666.7
2026-05 21000 16666.7
2026-06 19500 19500.0

دو ماه اول میانگینِ ردیف‌های کمتری را می‌گیرند، چون قبل از آن‌ها چیزی نیست. قاب‌های مفیدِ دیگر ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING (پنجره‌ای که ردیف فعلی وسطش است) و ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING (کلِ پارتیشن) هستند.

قابِ پیش‌فرض یک غافلگیری دارد

با ORDER BY و بدون قاب، پیش‌فرض RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW است. RANGE ردیف‌هایی را که مقدارِ ORDER BY مساوی دارند یک قدم حساب می‌کند. یک جمع تجمعی روی amount DESC اجرا کنید تا ببینید Ben و Cleo، که هر دو ۷۰۰ دارند، هر دو ۳۵۰۰ نشان می‌دهند، چون هر کدام دیگری را هم در بر می‌گیرد. وقتی جمعِ دقیقاً ردیف‌به‌ردیف می‌خواهید، ROWS UNBOUNDED PRECEDING بنویسید.

همین پیش‌فرض یک معمای کلاسیک را توضیح می‌دهد: LAST_VALUE(revenue) OVER (ORDER BY month) مقدارِ ردیفِ فعلی را برمی‌گرداند، نه ماهِ آخر را، چون قابِ پیش‌فرض در ردیف فعلی تمام می‌شود. با ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING کلِ پارتیشن را به آن بدهید.

پنجره روی نتایجِ گروه‌بندی‌شده#

توابع پنجره‌ای بعد از GROUP BY اجرا می‌شوند، پس می‌توانند روی خودِ گروه‌ها کار کنند. اینجا سهمِ هر منطقه از کلِ فروش را حساب می‌کنیم: SUM(amount) داخلی مجموعِ منطقه است و SUM(...) OVER () بیرونی این مجموع‌ها را روی همهٔ منطقه‌ها جمع می‌زند:

SQL
SELECT region,
       SUM(amount) AS total,
       ROUND(100.0 * SUM(amount) / SUM(SUM(amount)) OVER (), 1) AS pct_of_all
FROM sales
GROUP BY region;
region total pct_of_all
North 2700 55.7
South 2150 44.3

پنجره‌ای را که چند بار استفاده می‌کنید نام‌گذاری کنید

اگر چند ستون پنجرهٔ یکسانی دارند، آن را یک بار با بخشِ WINDOW بعد از WHERE/GROUP BY/HAVING تعریف کنید: SELECT ..., FIRST_VALUE(revenue) OVER w, LAG(revenue) OVER w FROM monthly WINDOW w AS (ORDER BY month);

برگهٔ تقلب#

چه می‌خواهید از چه استفاده کنید
مجموعِ گروه کنار هر ردیف SUM(x) OVER (PARTITION BY g)
شمارهٔ ردیفِ یکتا در هر گروه ROW_NUMBER() OVER (PARTITION BY g ORDER BY x)
رتبه‌بندی با در نظر گرفتن تساوی RANK() یا DENSE_RANK()
جمع تجمعی SUM(x) OVER (ORDER BY t ROWS UNBOUNDED PRECEDING)
مقدارِ ردیفِ قبلی / بعدی LAG(x) / LEAD(x)
میانگین متحرک AVG(x) OVER (ORDER BY t ROWS BETWEEN n PRECEDING AND CURRENT ROW)
N تای برتر در هر گروه ROW_NUMBER() در یک CTE، بعد WHERE pos <= N

جمع‌بندی#

  • تابع پنجره‌ای یک تابعِ معمولی است که بعد از آن OVER (...) می‌آید. روی ردیف‌های مرتبط محاسبه می‌کند اما همهٔ ردیف‌ها را نگه می‌دارد.
  • PARTITION BY ردیف‌ها را گروه‌بندی می‌کند، ORDER BY آن‌ها را داخل هر گروه مرتب می‌کند و قاب (ROWS BETWEEN ...) ردیف‌هایی را که در هر محاسبه به کار می‌روند انتخاب می‌کند.
  • ROW_NUMBER، RANK و DENSE_RANK فقط در رفتار با تساوی‌ها فرق دارند.
  • توابع پنجره‌ای بعد از WHERE و GROUP BY اجرا می‌شوند؛ پس فیلتر روی آن‌ها را در یک CTE یا زیرکوئری انجام دهید.
  • حواستان به قابِ پیش‌فرض باشد: برای جمع تجمعیِ دقیق از ROWS استفاده کنید و کلِ پارتیشن را به LAST_VALUE بدهید.

وقتی کوئری‌هایتان میلیون‌ها ردیف را خلاصه می‌کنند، سرعت مهم می‌شود؛ ایندکس‌ها و پلن کوئری در SQL قدم طبیعیِ بعدی است.