Проста дефиниция на медианата е:
Медианата е средната стойност в сортиран списък с числаЗа да уточним това малко, можем да намерим медианата на списък с числа, като използваме следната процедура:
- Сортирайте числата (във възходящ или низходящ ред, няма значение кои).
- Средното число (по позиция) в сортирания списък е медианата.
- Ако има две „равно средни“ числа, медианата е средната стойност на двете средни стойности.
Aaron Bertrand преди това е тествал няколко начина за изчисляване на медианата в SQL Server:
- Кой е най-бързият начин за изчисляване на медианата?
- Най-добри подходи за групирана медиана
Роб Фарли наскоро добави друг подход, насочен към инсталации преди 2012 г.:
- Медиани преди SQL 2012
Тази статия представя нов метод, използващ динамичен курсор.
Методът OFFSET-FETCH от 2012 г.
Ще започнем с разглеждане на най-ефективното изпълнение, създадено от Питър Ларсон. Той използва SQL Server 2012 OFFSET разширение към ORDER BY клауза за ефективно намиране на един или два средни реда, необходими за изчисляване на медианата.
ИЗМЕСТВАНЕ Единична медиана
Първата статия на Аарон тества изчисляването на една медиана върху таблица от десет милиона редове:
CREATE TABLE dbo.obj
(
id integer NOT NULL IDENTITY(1,1),
val integer NOT NULL
);
INSERT dbo.obj WITH (TABLOCKX)
(val)
SELECT TOP (10000000)
AO.[object_id]
FROM sys.all_columns AS AC
CROSS JOIN sys.all_objects AS AO
CROSS JOIN sys.all_objects AS AO2
WHERE AO.[object_id] > 0
ORDER BY
AC.[object_id];
CREATE UNIQUE CLUSTERED INDEX cx
ON dbo.obj(val, id);
Решението на Питър Ларсон с помощта на OFFSET разширението е:
DECLARE @Start datetime2 = SYSUTCDATETIME();
DECLARE @Count bigint = 10000000
--(
-- SELECT COUNT_BIG(*)
-- FROM dbo.obj AS O
--);
SELECT
Median = AVG(1.0 * SQ1.val)
FROM
(
SELECT O.val
FROM dbo.obj AS O
ORDER BY O.val
OFFSET (@Count - 1) / 2 ROWS
FETCH NEXT 1 + (1 - @Count % 2) ROWS ONLY
) AS SQ1;
SELECT Peso = DATEDIFF(MILLISECOND, @Start, SYSUTCDATETIME()); Кодът по-горе твърдо кодира резултата от преброяването на редовете в таблицата. Всички тествани методи за изчисляване на медианата се нуждаят от този брой, за да се изчислят средните номера на редовете, така че това е постоянна цена. Оставянето на операцията за броене на редове извън времето избягва един възможен източник на вариации.
Планът за изпълнение на OFFSET решението е показано по-долу:

Операторът Top бързо прескача ненужните редове, като предава само един или два реда, необходими за изчисляване на медианата на Stream Aggregate. Когато се изпълнява с топъл кеш и изключено събиране на планове за изпълнение, тази заявка се изпълнява за 910 ms средно на моя лаптоп. Това е машина с процесор Intel i7 740QM, работещ на 1,73 GHz с деактивирано Turbo (за последователност).
ОТМЕСТВА Групирана медиана
Втората статия на Аарон тества ефективността на изчисляване на медиана за група, като се използва таблица за продажби от милион реда с десет хиляди вписвания за всеки от сто продавачи:
CREATE TABLE dbo.Sales
(
SalesPerson integer NOT NULL,
Amount integer NOT NULL
);
WITH X AS
(
SELECT TOP (100)
V.number
FROM master.dbo.spt_values AS V
GROUP BY
V.number
)
INSERT dbo.Sales WITH (TABLOCKX)
(
SalesPerson,
Amount
)
SELECT
X.number,
ABS(CHECKSUM(NEWID())) % 99
FROM X
CROSS JOIN X AS X2
CROSS JOIN X AS X3;
CREATE CLUSTERED INDEX cx
ON dbo.Sales
(SalesPerson, Amount);
Отново, най-ефективното решение използва OFFSET :
DECLARE @s datetime2 = SYSUTCDATETIME();
DECLARE @Result AS table
(
SalesPerson integer PRIMARY KEY,
Median float NOT NULL
);
INSERT @Result
SELECT d.SalesPerson, w.Median
FROM
(
SELECT SalesPerson, COUNT(*) AS y
FROM dbo.Sales
GROUP BY SalesPerson
) AS d
CROSS APPLY
(
SELECT AVG(0E + Amount)
FROM
(
SELECT z.Amount
FROM dbo.Sales AS z WITH (PAGLOCK)
WHERE z.SalesPerson = d.SalesPerson
ORDER BY z.Amount
OFFSET (d.y - 1) / 2 ROWS
FETCH NEXT 2 - d.y % 2 ROWS ONLY
) AS f
) AS w(Median);
SELECT Peso = DATEDIFF(MILLISECOND, @s, SYSUTCDATETIME()); Важната част от плана за изпълнение е показана по-долу:

Най-горният ред на плана е свързан с намирането на броя на груповите редове за всеки продавач. Долният ред използва същите елементи на плана, които се виждат за едногруповото медианно решение, за да се изчисли медианата за всеки търговец. Когато се изпълнява с топъл кеш и изключени планове за изпълнение, тази заявка се изпълнява за 320 ms средно на моя лаптоп.
Използване на динамичен курсор
Може да изглежда лудо дори да се мисли за използване на курсор за изчисляване на медианата. Transact SQL курсорите имат (предимно заслужена) репутация на бавни и неефективни. Също така често се смята, че динамичните курсори са най-лошият тип курсор. Тези точки са валидни при някои обстоятелства, но не винаги.
Transact SQL курсорите са ограничени до обработка на един ред в даден момент, така че те наистина могат да бъдат бавни, ако много редове трябва да бъдат извлечени и обработени. Това обаче не е така за медианното изчисление:всичко, което трябва да направим, е да намерим и извлечем една или две средни стойности ефективно . Динамичният курсор е много подходящ за тази задача, както ще видим.
Единичен среден динамичен курсор
Решението за динамичен курсор за едно изчисление на медиана се състои от следните стъпки:
- Създайте динамичен курсор с възможност за превъртане върху подредения списък с елементи.
- Изчислете позицията на първия среден ред.
- Преместете курсора, като използвате
FETCH RELATIVE. - Решете дали е необходим втори ред за изчисляване на медианата.
- Ако не, върнете незабавно единичната средна стойност.
- В противен случай извлечете втората стойност с помощта на
FETCH NEXT. - Изчислете средната стойност на двете стойности и върнете.
Забележете колко точно този списък отразява простата процедура за намиране на медианата, дадена в началото на тази статия. Пълна реализация на Transact SQL код е показана по-долу:
-- Dynamic cursor
DECLARE @Start datetime2 = SYSUTCDATETIME();
DECLARE
@RowCount bigint, -- Total row count
@Row bigint, -- Median row number
@Amount1 integer, -- First amount
@Amount2 integer, -- Second amount
@Median float; -- Calculated median
SET @RowCount = 10000000;
--(
-- SELECT COUNT_BIG(*)
-- FROM dbo.obj AS O
--);
DECLARE Median CURSOR
LOCAL
SCROLL
DYNAMIC
READ_ONLY
FOR
SELECT
O.val
FROM dbo.obj AS O
ORDER BY
O.val;
OPEN Median;
-- Calculate the position of the first median row
SET @Row = (@RowCount + 1) / 2;
-- Move to the row
FETCH RELATIVE @Row
FROM Median
INTO @Amount1;
IF @Row = (@RowCount + 2) / 2
BEGIN
-- No second row, median is the single value we have
SET @Median = @Amount1;
END
ELSE
BEGIN
-- Get the second row
FETCH NEXT
FROM Median
INTO @Amount2;
-- Calculate the median value from the two values
SET @Median = (@Amount1 + @Amount2) / 2e0;
END;
SELECT Median = @Median;
SELECT DynamicCur = DATEDIFF(MILLISECOND, @Start, SYSUTCDATETIME());
Планът за изпълнение на FETCH RELATIVE операторът показва динамичния курсор, който ефективно се препозиционира към първия ред, необходим за изчислението на медиана:

Планът за FETCH NEXT (изисква се само ако има втори среден ред, както в тези тестове) е извличане на един ред от запазената позиция на курсора:

Предимствата от използването на динамичен курсор тук са:
- Избягва преминаването на целия набор (четенето спира след намиране на средните редове); и
- В tempdb не се прави временно копие на данните , както би било за статичен курсор или курсор с набор от клавиши.
Обърнете внимание, че не можем да посочим FAST_FORWARD курсора тук (оставяйки избора на статичен или динамичен план на оптимизатора), защото курсорът трябва да може да се превърта, за да поддържа FETCH RELATIVE . Dynamic е оптималният избор тук така или иначе.
Когато се изпълнява с топъл кеш и изключено събиране на планове за изпълнение, тази заявка се изпълнява за 930 ms средно на моята тестова машина. Това е малко по-бавно от 910 ms за OFFSET решение, но динамичният курсор е значително по-бърз от другите методи, тествани от Аарон и Роб, и не изисква SQL Server 2012 (или по-нова версия).
Тук няма да повтарям тестването на другите методи преди 2012 г., но като пример за размера на разликата в производителността, следното решение за номериране на редове отнема 1550 ms средно (70% по-бавно):
DECLARE @Start datetime2 = SYSUTCDATETIME();
DECLARE @Count bigint = 10000000
--(
-- SELECT COUNT_BIG(*)
-- FROM dbo.obj AS O
--);
SELECT AVG(1.0 * SQ1.val) FROM
(
SELECT
O.val,
rn = ROW_NUMBER() OVER (
ORDER BY O.val)
FROM dbo.obj AS O WITH (PAGLOCK)
) AS SQ1
WHERE
SQ1.rn BETWEEN (@Count + 1)/2 AND (@Count + 2)/2;
SELECT RowNumber = DATEDIFF(MILLISECOND, @Start, SYSUTCDATETIME());

Групиран среден динамичен тест на курсора
Лесно е да се разшири решението за единичен среден динамичен курсор, за да се изчислят групирани медиани. За последователност ще използвам вложени курсори (да, наистина):
- Отворете статичен курсор върху продавачите и броя на редовете.
- Изчислете медианата за всеки човек, като използвате нов динамичен курсор всеки път.
- Запазвайте всеки резултат в променлива на таблица, докато вървим.
Кодът е показан по-долу:
-- Timing
DECLARE @s datetime2 = SYSUTCDATETIME();
-- Holds results
DECLARE @Result AS table
(
SalesPerson integer PRIMARY KEY,
Median float NOT NULL
);
-- Variables
DECLARE
@SalesPerson integer, -- Current sales person
@RowCount bigint, -- Current row count
@Row bigint, -- Median row number
@Amount1 float, -- First amount
@Amount2 float, -- Second amount
@Median float; -- Calculated median
-- Row counts per sales person
DECLARE SalesPersonCounts
CURSOR
LOCAL
FORWARD_ONLY
STATIC
READ_ONLY
FOR
SELECT
SalesPerson,
COUNT_BIG(*)
FROM dbo.Sales
GROUP BY SalesPerson
ORDER BY SalesPerson;
OPEN SalesPersonCounts;
-- First person
FETCH NEXT
FROM SalesPersonCounts
INTO @SalesPerson, @RowCount;
WHILE @@FETCH_STATUS = 0
BEGIN
-- Records for the current person
-- Note dynamic cursor
DECLARE Person CURSOR
LOCAL
SCROLL
DYNAMIC
READ_ONLY
FOR
SELECT
S.Amount
FROM dbo.Sales AS S
WHERE
S.SalesPerson = @SalesPerson
ORDER BY
S.Amount;
OPEN Person;
-- Calculate median row 1
SET @Row = (@RowCount + 1) / 2;
-- Move to median row 1
FETCH RELATIVE @Row
FROM Person
INTO @Amount1;
IF @Row = (@RowCount + 2) / 2
BEGIN
-- No second row, median is the single value
SET @Median = @Amount1;
END
ELSE
BEGIN
-- Get the second row
FETCH NEXT
FROM Person
INTO @Amount2;
-- Calculate the median value
SET @Median = (@Amount1 + @Amount2) / 2e0;
END;
-- Add the result row
INSERT @Result (SalesPerson, Median)
VALUES (@SalesPerson, @Median);
-- Finished with the person cursor
CLOSE Person;
DEALLOCATE Person;
-- Next person
FETCH NEXT
FROM SalesPersonCounts
INTO @SalesPerson, @RowCount;
END;
---- Results
--SELECT
-- R.SalesPerson,
-- R.Median
--FROM @Result AS R;
-- Tidy up
CLOSE SalesPersonCounts;
DEALLOCATE SalesPersonCounts;
-- Show elapsed time
SELECT NestedCur = DATEDIFF(MILLISECOND, @s, SYSUTCDATETIME()); Външният курсор е умишлено статичен, тъй като всички редове в този набор ще бъдат докоснати (също не е наличен динамичен курсор поради операцията за групиране в основната заявка). Този път няма нищо особено ново или интересно да се види в плановете за изпълнение.
Интересното е изпълнението. Въпреки многократното създаване и освобождаване на вътрешния динамичен курсор, това решение се представя много добре върху набора от тестови данни. С изключен топъл кеш и планове за изпълнение, скриптът на курсора завършва за 330 ms средно на моята тестова машина. Това отново е малко по-бавно от 320 ms записано от OFFSET групирана медиана, но с голяма разлика надминава другите стандартни решения, изброени в статиите на Аарон и Роб.
Отново, като пример за разликата в производителността спрямо другите методи извън 2012 г., следното решение за номериране на редове работи за 485 ms средно на моя тестов стенд (50% по-лошо):
DECLARE @s datetime2 = SYSUTCDATETIME();
DECLARE @Result AS table
(
SalesPerson integer PRIMARY KEY,
Median numeric(38, 6) NOT NULL
);
INSERT @Result
SELECT
S.SalesPerson,
CA.Median
FROM
(
SELECT
SalesPerson,
NumRows = COUNT_BIG(*)
FROM dbo.Sales
GROUP BY SalesPerson
) AS S
CROSS APPLY
(
SELECT AVG(1.0 * SQ1.Amount) FROM
(
SELECT
S2.Amount,
rn = ROW_NUMBER() OVER (
ORDER BY S2.Amount)
FROM dbo.Sales AS S2 WITH (PAGLOCK)
WHERE
S2.SalesPerson = S.SalesPerson
) AS SQ1
WHERE
SQ1.rn BETWEEN (S.NumRows + 1)/2 AND (S.NumRows + 2)/2
) AS CA (Median);
SELECT RowNumber = DATEDIFF(MILLISECOND, @s, SYSUTCDATETIME());

Резюме на резултатите
В единичния среден тест динамичният курсор работи за 930 ms срещу 910 ms за OFFSET метод.
В групирания среден тест вложеният курсор работи за 330 ms срещу 320 ms за OFFSET .
И в двата случая методът на курсора беше значително по-бърз от другия не-OFFSET методи. Ако трябва да изчислите единична или групирана медиана за екземпляр отпреди 2012 г., динамичен курсор или вложен курсор наистина може да бъде оптималният избор.
Ефективност на хладния кеш
Някои от вас може да се чудят за производителността на студения кеш. Изпълнявайте следното преди всеки тест:
CHECKPOINT; DBCC DROPCLEANBUFFERS;
Това са резултатите за единичния среден тест:
OFFSET метод:940 ms
Динамичен курсор:955 ms
За групираната медиана:
OFFSET метод:380 ms
Вложени курсори:385 ms
Последни мисли
Решенията за динамичен курсор наистина са значително по-бързи от не-OFFSET методи както за единични, така и за групирани медиани, поне с тези примерни набори от данни. Умишлено избрах да използвам повторно тестовите данни на Аарон, така че наборите от данни да не бяха умишлено изкривени към динамичния курсор. Има може да бъдат други разпределения на данни, за които динамичният курсор не е добра опция. Въпреки това показва, че все още има моменти, когато курсорът може да бъде бързо и ефективно решение на правилния вид проблем. Дори динамични и вложени курсори.
Читателите с орлови очи може да са забелязали PAGLOCK намек в OFFSET групиран среден тест. Това е необходимо за най-добра производителност, поради причини, които ще разгледам в следващата си статия. Без него решението от 2012 г. всъщност губи от вложения курсор с прилична разлика (590 мс срещу 330 мс ).