در بسیاری از پروژههای سازمانی، شمارهگذاری ردیفها در سطح کل جدول پاسخگوی نیازهای واقعی کسبوکار نیست. برای مثال ممکن است لازم باشد آخرین سفارش هر مشتری، جدیدترین نسخه هر سند یا آخرین وضعیت هر کالا در هر انبار شناسایی شود. در چنین سناریوهایی شمارهگذاری باید بهصورت مستقل برای هر گروه از داده انجام شود، نه برای کل نتیجه Query. پیش از معرفی توابع Window در SQL Server، پیادهسازی این منطق معمولاً با استفاده از Subqueryهای همبسته، Self Join یا حتی Cursor انجام میشد که علاوه بر پیچیدگی بیشتر، نگهداری و توسعه Query را نیز دشوار میکرد.
تابع ROW_NUMBER در کنار بندهای OVER و PARTITION BY راهکاری استاندارد برای حل این مسئله ارائه میکند. این ترکیب امکان شمارهگذاری مستقل هر گروه از داده را در قالب یک Query فراهم میکند و در بسیاری از سناریوها نسبت به الگوهای قدیمی، خوانایی بیشتری دارد و میتواند برنامه اجرایی مناسبتری نیز تولید کند. البته عملکرد نهایی همچنان به عواملی مانند حجم داده، طراحی Indexها، آمار جدول و تصمیم Query Optimizer وابسته است.
در این مقاله بررسی میکنیم ROW_NUMBER دقیقاً چگونه کار میکند، PARTITION BY چه نقشی در تفکیک گروههای داده دارد، این ترکیب چه تفاوتی با سایر توابع Ranking مانند RANK و DENSE_RANK دارد، چگونه میتوان از آن برای حذف رکوردهای تکراری استفاده کرد و چه نکاتی از منظر Performance و طراحی Index باید رعایت شود.
تعریف ROW_NUMBER و بند OVER
ROW_NUMBER یکی از توابع Window در SQL Server است که به هر ردیف نتیجه یک Query، یک شماره ترتیبی و یکتا اختصاص میدهد. برخلاف توابع تجمیعی مانند SUM یا COUNT که چندین ردیف را در یک مقدار واحد خلاصه میکنند، توابع Window مقدار هر ردیف را حفظ میکنند و در کنار آن یک مقدار محاسبهشده اضافه میکنند.
ساختار پایه این تابع به شکل زیر است.
SELECT
OrderID,
CustomerID,
OrderDate,
ROW_NUMBER() OVER (ORDER BY OrderDate DESC) AS RowNum
FROM Orders;
در این Query، به هر ردیف جدول Orders بر اساس ترتیب نزولی تاریخ سفارش، یک شماره یکتا از یک تا تعداد کل ردیفها اختصاص داده میشود. بند OVER مشخص میکند این شمارهگذاری بر چه اساسی انجام شود و ORDER BY داخل آن، ترتیب منطقی شمارهگذاری را تعیین میکند.
نکته مهمی که باید در نظر گرفت این است که ORDER BY داخل بند OVER کاملاً مستقل از ORDER BY نهایی Query است. حتی اگر Query نهایی هیچ ORDER BY نداشته باشد، شمارهگذاری ROW_NUMBER بر اساس ترتیب مشخصشده در OVER انجام میشود، اما ترتیب نمایش نتیجه نهایی تضمینشده نیست مگر آنکه یک ORDER BY صریح در انتهای Query نیز نوشته شود.
نقش PARTITION BY در تفکیک گروههای شمارهگذاری
بدون PARTITION BY، ROW_NUMBER کل نتیجه Query را بهعنوان یک مجموعه واحد در نظر میگیرد و شمارهگذاری از یک تا انتها ادامه مییابد. اما در بسیاری از سناریوهای واقعی، نیاز داریم شمارهگذاری در هر گروه از داده از نو شروع شود. PARTITION BY دقیقاً همین کار را انجام میدهد.
SELECT
OrderID,
CustomerID,
OrderDate,
ROW_NUMBER() OVER (
PARTITION BY CustomerID
ORDER BY OrderDate DESC
) AS RowNum
FROM Orders;
در این Query، دادهها ابتدا بر اساس CustomerID به گروههای مجزا تقسیم میشوند و سپس در هر گروه، شمارهگذاری بر اساس تاریخ سفارش بهصورت نزولی از یک آغاز میشود. یعنی برای هر مشتری، جدیدترین سفارش شماره یک، سفارش قبلی شماره دو و به همین ترتیب ادامه مییابد.
تحلیل مسیر منطقی اجرا
داده خام جدول Orders
│
▼
PARTITION BY CustomerID
│
▼
تفکیک به گروههای مستقل بر اساس مشتری
│
▼
ORDER BY OrderDate DESC در هر گروه
│
▼
ROW_NUMBER در هر گروه از یک شروع میشود
از دید منطقی میتوان این فرآیند را به این صورت تصور کرد که ابتدا دادهها بر اساس مقدار CustomerID به گروههای مستقل تقسیم میشوند، سپس هر گروه بر اساس OrderDate مرتب شده و در نهایت شمارهگذاری از یک برای همان گروه آغاز میشود. البته این صرفاً ترتیب منطقی پردازش است و به معنای اجرای جداگانه Query برای هر مشتری یا استفاده از حلقههای تکرار نیست. SQL Server کل این عملیات را در قالب یک برنامه اجرایی واحد انجام میدهد و نحوه اجرای داخلی آن را Query Optimizer بر اساس ساختار داده و Indexهای موجود تعیین میکند.
تفاوت ROW_NUMBER با RANK و DENSE_RANK
سه تابع ROW_NUMBER، RANK و DENSE_RANK از نظر Syntax بسیار شبیه هم هستند، اما در برخورد با مقادیر تکراری رفتار متفاوتی دارند. این تفاوت یکی از رایجترین منابع خطا در تیمهای توسعه است.
فرض کنید جدول امتیازات فروشندگان یک شعبه را داریم.
| SalesPerson | SalesAmount |
|---|---|
| Ali | 5000 |
| Reza | 5000 |
| Sara | 4200 |
| Nima | 3000 |
SELECT
SalesPerson,
SalesAmount,
ROW_NUMBER() OVER (ORDER BY SalesAmount DESC) AS RowNum,
RANK() OVER (ORDER BY SalesAmount DESC) AS RankNum,
DENSE_RANK() OVER (ORDER BY SalesAmount DESC) AS DenseRankNum
FROM SalesPersonPerformance;
نتیجه این Query به شکل زیر خواهد بود.
| SalesPerson | SalesAmount | RowNum | RankNum | DenseRankNum |
|---|---|---|---|---|
| Ali | 5000 | 1 | 1 | 1 |
| Reza | 5000 | 2 | 1 | 1 |
| Sara | 4200 | 3 | 3 | 2 |
| Nima | 3000 | 4 | 4 | 3 |
ROW_NUMBER بدون توجه به تساوی مقادیر، همیشه شماره یکتا و پیوسته تولید میکند. RANK به رکوردهای همارزش شماره یکسان میدهد اما در شماره بعدی یک جهش متناسب با تعداد رکوردهای تکراری ایجاد میکند. DENSE_RANK نیز به رکوردهای همارزش شماره یکسان میدهد، اما بر خلاف RANK هیچ جهشی در شماره بعدی ایجاد نمیکند.
انتخاب میان این سه تابع باید بر اساس نیاز واقعی گزارش انجام شود. اگر هدف صرفاً شناسایی یک ردیف مشخص در هر گروه است، مانند آخرین سفارش هر مشتری، ROW_NUMBER انتخاب درستی است زیرا هیچگاه دو ردیف شماره یکسان نمیگیرند. اگر هدف رتبهبندی واقعی با در نظر گرفتن تساوی امتیاز است، RANK یا DENSE_RANK مناسبتر هستند.
کاربرد عملی: شناسایی آخرین رکورد هر گروه
یکی از پرکاربردترین الگوهای استفاده از ROW_NUMBER با PARTITION BY، یافتن آخرین یا اولین رکورد مربوط به هر گروه است. این سناریو در سیستمهای سفارشگیری، مدیریت اسناد و سیستمهای Log بسیار رایج است.
فرض کنید در یک سیستم فروش سازمانی نیاز داریم آخرین سفارش ثبتشده هر مشتری را استخراج کنیم.
WITH RankedOrders AS
(
SELECT
OrderID,
CustomerID,
OrderDate,
Amount,
ROW_NUMBER() OVER (
PARTITION BY CustomerID
ORDER BY OrderDate DESC
) AS RowNum
FROM Orders
)
SELECT OrderID, CustomerID, OrderDate, Amount
FROM RankedOrders
WHERE RowNum = 1;
در این Query ابتدا با استفاده از یک Common Table Expression برای هر سفارش، شمارهای مستقل در محدوده مشتری مربوطه تولید میشود. سپس تنها رکوردهایی انتخاب میشوند که مقدار RowNum آنها برابر یک است، یعنی جدیدترین سفارش هر مشتری. این الگو نسبت به روشهای قدیمی مانند استفاده از MAX(OrderDate) و سپس Join مجدد با جدول اصلی، معمولاً خواناتر است و در بسیاری از سناریوها برنامه اجرایی مناسبی نیز تولید میکند. با این حال، عملکرد نهایی هر دو روش به عواملی مانند طراحی Index، توزیع دادهها و تصمیم Query Optimizer بستگی دارد و نباید بدون بررسی Execution Plan درباره برتری یکی نسبت به دیگری قضاوت کرد.
کاربرد عملی: حذف رکوردهای تکراری با ROW_NUMBER
یکی دیگر از سناریوهای بسیار رایج در پروژههای سازمانی، شناسایی و حذف رکوردهای تکراری در یک جدول است. این مسئله معمولاً زمانی رخ میدهد که داده از چند منبع مختلف Import شده و به دلیل نبود کنترل صحیح در سطح Constraint، رکوردهای تکراری وارد جدول شدهاند.
فرض کنید جدول Customers به دلیل خطای Import، شامل چند رکورد تکراری برای برخی مشتریان بر اساس ایمیل است.
WITH DuplicateCustomers AS
(
SELECT
CustomerID,
Email,
ROW_NUMBER() OVER (
PARTITION BY Email
ORDER BY CustomerID
) AS RowNum
FROM Customers
)
DELETE FROM DuplicateCustomers
WHERE RowNum > 1;
در این Query، رکوردها بر اساس ستون Email گروهبندی میشوند و در هر گروه، رکوردی که کوچکترین CustomerID را دارد شماره یک میگیرد و بهعنوان رکورد اصلی حفظ میشود. سایر رکوردهای هر گروه که شماره بزرگتر از یک دارند، حذف میشوند.
این الگو باید با احتیاط زیاد در محیط عملیاتی اجرا شود. توصیه میشود پیش از اجرای واقعی DELETE، همین Query با SELECT بهجای DELETE اجرا شود تا رکوردهایی که قرار است حذف شوند بهطور دقیق بررسی شوند.
SELECT *
FROM
(
SELECT
CustomerID,
Email,
ROW_NUMBER() OVER (
PARTITION BY Email
ORDER BY CustomerID
) AS RowNum
FROM Customers
) AS DuplicateCheck
WHERE RowNum > 1;
کاربرد عملی: Paging و صفحهبندی نتایج
یکی دیگر از کاربردهای شناختهشده ROW_NUMBER، پیادهسازی Paging در گزارشها و رابطهای کاربری است، بهخصوص در نسخههای قدیمیتر SQL Server که OFFSET FETCH هنوز در دسترس نبود.
WITH OrderedOrders AS ( SELECT OrderID, CustomerID, OrderDate, ROW_NUMBER() OVER (ORDER BY OrderDate DESC) AS RowNum FROM Orders ) SELECT OrderID, CustomerID, OrderDate FROM OrderedOrders WHERE RowNumBETWEEN21 AND 30;
این Query دقیقاً صفحه سوم نتایج را با فرض ده رکورد در هر صفحه بازمیگرداند. با اینکه در نسخههای جدید SQL Server معمولاً OFFSET FETCH برای همین هدف ترجیح داده میشود، اما در سناریوهایی که نیاز به شماره صریح ردیف در خروجی نیز وجود دارد، یا زمانی که Paging باید در ترکیب با PARTITION BY انجام شود، ROW_NUMBER همچنان گزینه مناسبی است.
ترکیب PARTITION BY با چند ستون
PARTITION BY محدود به یک ستون نیست و میتواند شامل چند ستون باشد. این قابلیت در سناریوهایی که گروهبندی باید بر اساس ترکیبی از چند بعد داده انجام شود، اهمیت زیادی دارد.
فرض کنید در یک سیستم انبارداری چند شعبهای، نیاز داریم آخرین وضعیت موجودی هر کالا را به تفکیک هر شعبه شناسایی کنیم.
SELECT
WarehouseID,
ProductID,
StockDate,
Quantity,
ROW_NUMBER() OVER (
PARTITION BY WarehouseID, ProductID
ORDER BY StockDate DESC
) AS RowNum
FROM InventoryHistory;
در این Query، شمارهگذاری برای هر ترکیب منحصربهفرد از WarehouseID و ProductID بهصورت مستقل انجام میشود. یعنی برای هر کالا در هر شعبه، جدیدترین رکورد شماره یک میگیرد. این الگو در گزارشهای Enterprise که نیاز به تحلیل چندبعدی دارند بسیار پرکاربرد است.
تأثیر ROW_NUMBER بر Execution Plan و Performance
از منظر موتور SQL Server، اجرای ROW_NUMBER همراه با PARTITION BY نیازمند یک عملیات Sort است، مگر آنکه یک Index مناسب از قبل داده را به همان ترتیب موردنیاز فراهم کرده باشد. این نکته یکی از مهمترین جنبههای بهینهسازی این الگو در پروژههای سازمانی است.
نقش Index در حذف عملیات Sort
اگر روی جدول Orders یک Index ترکیبی متناسب با ستونهای PARTITION BY و ORDER BY تعریف شده باشد، موتور میتواند بدون نیاز به Sort مجزا، مستقیماً از ترتیب موجود در Index استفاده کند.
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID_OrderDate
ON Orders (CustomerID, OrderDate DESC)
INCLUDE (Amount);
با وجود این Index، Query زیر معمولاً میتواند بدون Sort اضافی در Execution Plan اجرا شود، زیرا داده از قبل بر اساس CustomerID و سپس OrderDate به ترتیب نزولی مرتب شده است.
SELECT
OrderID,
CustomerID,
OrderDate,
ROW_NUMBER() OVER (
PARTITION BY CustomerID
ORDER BY OrderDate DESC
) AS RowNum
FROM Orders;
بدون این Index، موتور مجبور است پیش از شمارهگذاری، کل داده را بر اساس CustomerID و OrderDate مرتب کند، عملیاتی که در جدولهای بزرگ میتواند هزینه محسوسی به Query تحمیل کند و از نظر مصرف Tempdb نیز قابل توجه باشد.
بررسی Execution Plan برای تشخیص Sort غیرضروری
هنگام تحلیل Execution Plan، مشاهده عملگر Sort پیش از اپراتورهای مرتبط با Window Function معمولاً نشان میدهد که موتور برای مرتبسازی دادهها نیاز به انجام یک عملیات اضافی داشته است. بسته به نسخه SQL Server و نوع Query، این بخش از برنامه اجرایی ممکن است شامل عملگرهایی مانند Sequence Project، Segment یا سایر اپراتورهای مرتبط با Window Function باشد. اگر بتوان با طراحی یک Index مناسب ترتیب موردنیاز PARTITION BY و ORDER BY را از قبل فراهم کرد، در بسیاری از موارد این عملیات Sort حذف یا هزینه آن به میزان قابل توجهی کاهش پیدا میکند.
تفاوت هزینه بین شمارهگذاری کل جدول و شمارهگذاری گروهی
نکتهای که گاهی نادیده گرفته میشود این است که وجود PARTITION BY لزوماً هزینه اجرا را کاهش نمیدهد، اما ساختار Sort موردنیاز را تغییر میدهد. در حالت بدون PARTITION BY، موتور کل نتیجه را یکبار مرتب میکند. در حالت وجود PARTITION BY، مرتبسازی هم بر اساس ستون Partition و هم ستون Order انجام میشود، اما همچنان در قالب یک عملیات Sort واحد روی کل داده صورت میگیرد، نه بهصورت جداگانه برای هر گروه. به همین دلیل طراحی Index ترکیبی که هر دو بخش را پوشش دهد، اهمیت زیادی برای Performance دارد.
مقایسه ROW_NUMBER با روشهای سنتی حذف رکورد تکراری
پیش از معرفی توابع Window، برای شناسایی رکورد تکراری معمولاً از الگویی مانند زیر استفاده میشد که نیازمند Self Join یا Subquery همبسته بود.
DELETE c1
FROM Customers c1
INNER JOIN Customers c2
ON c1.Email = c2.Email
AND c1.CustomerID > c2.CustomerID;
روش مبتنی بر Self Join همچنان یکی از راهکارهای معتبر برای حذف رکوردهای تکراری است و SQL Server بسته به طراحی Indexها و ویژگیهای داده میتواند برنامه اجرایی مناسبی برای آن تولید کند. با این حال، استفاده از ROW_NUMBER معمولاً Query را سادهتر، خواناتر و نگهداری آن را آسانتر میکند، زیرا منطق انتخاب رکورد اصلی و رکوردهای قابل حذف بهصورت مستقیم در یک Window Function بیان میشود. در عمل، انتخاب سریعترین روش باید بر اساس بررسی Execution Plan و شرایط واقعی محیط عملیاتی انجام شود، نه صرفاً بر اساس نوع Syntax مورد استفاده.
اشتباهات رایج در استفاده از ROW_NUMBER
فراموش کردن ORDER BY مناسب در بند OVER
اگر ORDER BY داخل OVER بهدرستی انتخاب نشود، شمارهگذاری ممکن است بر اساس ترتیبی انجام شود که از نظر منطقی برای هدف Query مناسب نیست. برای مثال در سناریوی یافتن آخرین سفارش، اگر به اشتباه ORDER BY بر اساس OrderID بهجای OrderDate نوشته شود و این دو ستون همیشه همراستا نباشند، نتیجه میتواند نادرست باشد.
فرض غلط از یکتا بودن ترتیب بدون Tie Breaker
اگر ستون ORDER BY مقادیر تکراری داشته باشد، ترتیب دقیق شمارهگذاری بین رکوردهای همارزش از نظر موتور تضمینشده نیست، مگر آنکه یک ستون اضافه بهعنوان Tie Breaker در ORDER BY لحاظ شود.
ROW_NUMBER() OVER (
PARTITION BY CustomerID
ORDER BY OrderDate DESC, OrderID DESC
) AS RowNum
افزودن OrderID بهعنوان معیار دوم، تضمین میکند حتی اگر چند سفارش دقیقاً در یک لحظه ثبت شده باشند، ترتیب شمارهگذاری همیشه یکسان و قابل پیشبینی باقی بماند.
استفاده از ROW_NUMBER در جایی که COUNT یا EXISTS کافی است
گاهی توسعهدهندگان برای بررسی وجود بیش از یک رکورد در یک گروه، از ROW_NUMBER استفاده میکنند، در حالی که یک Query سادهتر مبتنی بر GROUP BY و HAVING COUNT میتواند همان نتیجه را با هزینه کمتر ارائه دهد. ROW_NUMBER باید برای سناریوهایی رزرو شود که واقعاً نیاز به شناسایی یک ردیف مشخص در هر گروه وجود دارد، نه صرفاً شمارش تعداد رکوردهای هر گروه.
Best Practiceهای استفاده از ROW_NUMBER در پروژههای سازمانی
انتخاب ستونهای PARTITION BY باید دقیقاً منطبق با مرز منطقی گروهبندی موردنیاز کسبوکار باشد. اگر مرز گروهبندی نادرست انتخاب شود، نتیجه از نظر Syntax صحیح اجرا میشود اما از نظر تحلیلی گمراهکننده خواهد بود.
طراحی Index متناسب با ترکیب PARTITION BY و ORDER BY باید پیش از استقرار نهایی Query در محیط عملیاتی بررسی شود، بهخصوص در Queryهایی که روی جداول با میلیونها رکورد اجرا میشوند.
همیشه باید یک Tie Breaker مناسب در ORDER BY داخل OVER لحاظ شود تا رفتار Query در برابر مقادیر تکراری قابل پیشبینی و پایدار باقی بماند.
پیش از اجرای عملیات DELETE مبتنی بر ROW_NUMBER در محیط عملیاتی، باید همان منطق ابتدا با SELECT بررسی شود تا از حذف ناخواسته داده جلوگیری شود.
در انتخاب میان ROW_NUMBER، RANK و DENSE_RANK، باید نیاز واقعی گزارش از نظر برخورد با مقادیر تکراری بهدقت تحلیل شود، زیرا انتخاب نادرست میتواند منجر به گزارشهای نادرست از نظر کسبوکار شود، حتی اگر Query از نظر فنی بدون خطا اجرا شود.
جمعبندی
ترکیب ROW_NUMBER با PARTITION BY یکی از ابزارهای کلیدی SQL Server برای شمارهگذاری داده در سطح گروه است که کاربردهای گستردهای از شناسایی آخرین رکورد هر گروه، حذف رکوردهای تکراری، Paging نتایج تا تحلیلهای چندبعدی دارد. این تابع در مقایسه با روشهای سنتی مبتنی بر Self Join یا Subquery همبسته، معمولاً کد سادهتر و Performance بهتری در جدولهای حجیم ارائه میدهد.
با این حال، بهرهگیری صحیح از این ابزار نیازمند توجه دقیق به انتخاب ستونهای PARTITION BY و ORDER BY، طراحی Index متناسب برای جلوگیری از Sort غیرضروری و در نظر گرفتن Tie Breaker مناسب است. در پروژههای سازمانی با حجم داده بالا، این جزئیات تفاوت میان یک Query بهینه و یک Query کندکننده داشبورد را رقم میزند.
سوالات متداول FAQ
تفاوت ROW_NUMBER و RANK در برخورد با مقادیر تکراری چیست؟
ROW_NUMBER همیشه شمارههای یکتا و پیوسته تولید میکند، حتی اگر مقادیر ORDER BY تکراری باشند. RANK به مقادیر تکراری شماره یکسان میدهد و در شماره بعدی متناسب با تعداد تکرارها جهش ایجاد میکند.
آیا PARTITION BY میتواند شامل چند ستون باشد؟
بله. PARTITION BY میتواند ترکیبی از چند ستون باشد و در این حالت گروهبندی بر اساس تمام مقادیر منحصربهفرد آن ترکیب انجام میشود.
چرا Query مبتنی بر ROW_NUMBER گاهی کند اجرا میشود؟
معمولاً دلیل اصلی، نبود Index متناسب با ستونهای PARTITION BY و ORDER BY است که باعث میشود موتور مجبور به انجام یک عملیات Sort پرهزینه روی کل داده شود.
آیا میتوان از ROW_NUMBER برای حذف مستقیم رکوردهای تکراری استفاده کرد؟
بله، با استفاده از یک Common Table Expression که شماره ردیف را در هر گروه محاسبه میکند و سپس اجرای DELETE روی رکوردهایی که شماره بزرگتر از یک دارند. توصیه میشود پیش از اجرای واقعی، همین منطق با SELECT بررسی شود.
چه زمانی باید از DENSE_RANK بهجای ROW_NUMBER استفاده کرد؟
زمانی که هدف رتبهبندی واقعی داده با در نظر گرفتن تساوی مقادیر است و نیاز است رکوردهای همارزش رتبه یکسان بگیرند بدون آنکه در رتبه بعدی جهش ایجاد شود، DENSE_RANK گزینه مناسبتری نسبت به ROW_NUMBER است.
بهینهسازی Performance در SQL Server با لاندا
اگر Queryهای SQL Server شما با وجود استفاده از توابعی مانند ROW_NUMBER، RANK یا سایر Window Functionها همچنان با کندی اجرا میشوند، احتمالاً گلوگاه اصلی در طراحی Index، Execution Plan یا ساختار Query قرار دارد. تیم توسعه فناوری اطلاعات لاندا با تحلیل تخصصی Execution Plan، طراحی Indexهای بهینه، بازنویسی Queryهای پیچیده و بهینهسازی Performance در محیطهای عملیاتی، به سازمانها کمک میکند تا سرعت پردازش داده و پایداری سامانههای خود را به شکل محسوسی افزایش دهند.


No comment