Jacek Szajer

Jacek Szajer Współwłaściciel,
Business
Intelligence
Technologies SC

Temat: Ciekawe (chyba :)) zadanie z SQL-a

Witam wszystkich,

Parę dni temu mieliśmy do rozwiązania pewien problem z SQL-a, moim zdaniem trochę nietypowy (przynajmniej na tle codziennego użycia). Poradziliśmy sobie (ostatecznie zadanie okazało się proste), ale jednak zadanie uznałem za wystarczająco ciekawe, aby wrzucić je komuś do "pomyślenia" na poniedziałkowy poranek, a jednocześnie nie mam 100% pewności, że zastosowane przez nas rozwiązanie jest w pełni optymalne - chętnie zobaczę inne pomysły na rozwiązanie (oczywiście powiedzmy, że za 2-3dni przedstawię też uczciwie swoje rozwiązanie).
Co do oczekiwanej optymalności rozwiązania - docelowo tabele z danymi będą liczyć 100k+ rekordów każda, tabela-zapytanie 5-10 rekordów, częstość zapytań - do kliku tysięcy / dzień.

Wprowadzenie:
Mamy 3 tabele z danymi (nazewnictwo i cel biznesowy musiałem zmienić, ale sens zadania pozostaje) + tabela robocza:
- Studenci
- Zajecia
- Przypisania (studentów do poszczególnych zajęć)
- t (tabela robocza z listą studentów, o których pytamy)

Celem zadania jest sprawdzenie, czy występuje już grupa zajęciowa studentów o DOKŁADNIE takim samym składzie (co do 1 osoby) i zwrócenie jej ID (jeśli to dla kogoś istotne można przyjąć uproszczenie, że jest maksymalnie tylko 1 taka grupa), czyli przykładowo mamy:
Zajecia:
Z1
Z2
Z3
Z4

Studenci:
A
B
C
D

Przypisania:
Z1, A
Z1, B

Z2, A
Z2, B
Z2, C

Z3, B

Z4, A
Z4, C

Pytamy o grupę studentów o składzie:
A
B

Oczekiwana odpowiedź:
Z1 (i tylko taka)

Żeby nie trzeba było klepać, tabele do bazy testowej:
--------------------------------------------------------

create table Zajecia
(SetId int)

create table Studenci
(StudenciId int,
StudenciDesc varchar(100)
)

create table Przypisania
(SetId int,
StudenciId int)

create table t
(StudenciId int)

---------------------
insert into Zajecia(SetID)
values(1)
insert into Zajecia(SetID)
values(2)
insert into Zajecia(SetID)
values(3)
insert into Zajecia(SetID)
values(4)

insert into Studenci(StudenciId)
values(1)
insert into Studenci(StudenciId)
values(2)
insert into Studenci(StudenciId)
values(3)
insert into Studenci(StudenciId)
values(4)

insert into Przypisania(SetId, StudenciId)
values(1,1)
insert into Przypisania(SetId, StudenciId)
values(1,2)
insert into Przypisania(SetId, StudenciId)
values(2,1)
insert into Przypisania(SetId, StudenciId)
values(2,2)
insert into Przypisania(SetId, StudenciId)
values(2,3)
insert into Przypisania(SetId, StudenciId)
values(3,2)
insert into Przypisania(SetId, StudenciId)
values(4,1)
insert into Przypisania(SetId, StudenciId)
values(4,3)

insert into t
values(1)
insert into t
values(2)

select * from Zajecia
select * from Studenci
select * from Przypisania
select * from t

-- oczekiwany wynik: Zajecia.SetID = 1
Jacek Szajer edytował(a) ten post dnia 31.10.11 o godzinie 12:32
Michał Dziubek

Michał Dziubek Programista,
INFORM\'1

Temat: Ciekawe (chyba :)) zadanie z SQL-a

Dla warunków które testowałem się sprawdza, ale też nie bardzo chce mi się przewidywać wszystkie kombinacje:

SELECT
setid
FROM
(
SELECT
przypisania.setID
,studenciID
,il
FROM
przypisania
inner join (
Select
setid
,count(StudenciId) il
from
przypisania
group by
setid) i on i.setID=przypisania.setID
) grupy
left join (
select
StudenciId
,ileS
,1 as sztuka
from
t
inner join (select count(*) ileS from t) i on 1=1
) poszukiwana on poszukiwana.studenciid = grupy.studenciid
group by
setID
having
max(il)=max(ileS)
and max(il)=sum(isnull(sztuka,0))
Michał Dziubek edytował(a) ten post dnia 31.10.11 o godzinie 12:22

Temat: Ciekawe (chyba :)) zadanie z SQL-a


select
y.SetID
from
(
select p.SetID from Przypisania p
outer apply (select 1 a from t where p.StudenciId = t.StudenciId) x
group by p.SetID
having max(case when x.a is null then 1 end) is null
) y
cross join t t
outer apply (select 1 a from Przypisania p where y.SetID = p.SetID and t.StudenciId = p.StudenciId) y3
group by y.SetID
having max(case when y3.a is null then 1 end) is null

Temat: Ciekawe (chyba :)) zadanie z SQL-a

Aby oba zbiory były równe, muszą być spełnione jednocześnie dwa warunki:
1. są równoliczne
2. zawierają wspólnych studentów

Wystarczy zatem znaleźć liczności grup zajęć, wybrać grupy równoliczne z grupą testową, następnie znaleźć liczby wspólnych par w grupach zajęć i testowej, a na koniec "nałożyć" oba wyniki na siebie.

SELECT A.SetId
FROM
(
SELECT liczby.SetId, liczba_t.l
FROM (SELECT p.SetId, COUNT(*) liczba_studentow
FROM Przypisania p
GROUP BY p.SetId) liczby

INNER JOIN (SELECT COUNT(*) l FROM t) liczba_t
ON liczby.liczba_studentow=liczba_t.l
) A
INNER JOIN
(
SELECT p.SetId, COUNT(*) l
FROM Przypisania p
INNER JOIN t ON t.StudenciId=p.StudenciId
GROUP BY p.SetId
) B
ON A.SetId = B.SetId
AND A.l=B.l


Wyniki kolejnych zapytań (A, B, A IJ B):

Obrazek
Adrian Olszewski edytował(a) ten post dnia 31.10.11 o godzinie 14:00

Temat: Ciekawe (chyba :)) zadanie z SQL-a

W długi listopadowy wieczór :) postanowiłem sprawdzić jeszcze, jak wygląda czasowe porównanie trzech zaprezentowanych metod. W końcu Autor nie bez powodu wspomniał o tym, że zapytanie będzie wykonywane często. Skleciłem więc quick'n'dirty "framework" do testów, generujący 100 tys. rekordów we wszystkich trzech tabelach oraz tworzący losowe połączenia między kursem a studentami. Jest to mało wydajne i trwa kilka minut, ale nie boli :)

Wpisy w tabelach (dobrze, że nie ma constraintów na kluczach :) )
DECLARE @i INT = 0

WHILE @i < 100000
BEGIN
INSERT INTO Studenci(StudenciId) VALUES(@i)
INSERT INTO Zajecia VALUES(@i)
INSERT INTO Przypisania(SetId, StudenciId) VALUES( (CAST(RAND()*100000 AS INT)), CAST(RAND()*100000 AS INT))
SET @i = @i + 1
END

CREATE TABLE Czasy (Czas INT)


Testy... W odpowiednim miejscu trzeba wkleić jeden z podanych kodów i wykonać kilka razy dla uśrednienia czasu wykonania. Dla każdej metody trzeba wyczyścić tabelę Czasy:
SELECT (SELECT COUNT(*) FROM Studenci) Ile_stud, (SELECT COUNT(*) FROM Zajecia) Ile_zajec, (SELECT COUNT(*) FROM Przypisania) Ile_przypis

DECLARE @SetID INT, @Cnt INT, @tmp VARCHAR(250) = '', @Czas DATETIME

SELECT TOP 1 @SetId = p.SetId, @Cnt = COUNT(*) FROM Przypisania p GROUP BY p.SetId ORDER BY 2 DESC
SELECT @SetID as SetID, @Cnt as CNT

DELETE FROM t;
INSERT INTO t SELECT p.StudenciId FROM Przypisania p WHERE p.SetId=@SetID

SELECT @tmp = @tmp + cast(t.StudenciId AS VARCHAR(6)) + ', ' FROM t
SELECT SUBSTRING(@tmp, 1, LEN(@tmp) - 1) AS ID_Studentów

SELECT @Czas=SYSDATETIME()
---------------------------
SELECT A.SetId FROM ( SELECT liczby.SetId, liczba_t.l FROM (SELECT p.SetId, COUNT(*) liczba_studentow FROM Przypisania p
GROUP BY p.SetId) liczby INNER JOIN (SELECT COUNT(*) l FROM t) liczba_t ON liczby.liczba_studentow=liczba_t.l
) A INNER JOIN ( SELECT p.SetId, COUNT(*) l FROM Przypisania p INNER JOIN t ON t.StudenciId=p.StudenciId GROUP BY p.SetId
) B ON A.SetId = B.SetId AND A.l=B.l
---------------------------
INSERT INTO Czasy SELECT DATEDIFF(MS, @Czas, SYSDATETIME())
SELECT AVG(c.czas) AS 'Średni czas [ms]', COUNT(*) AS Ile_prob FROM Czasy c


Tak wygląda wyjście:

Obrazek


Dla 16 przebiegów uzyskałem takie wyniki:
Michał: 298ms
Leszek: 160ms
moje: 61ms

Wartości bezwzględne u każdego będą nieco inne, więc lepiej porównywać ich stosunki.
Michał Dziubek

Michał Dziubek Programista,
INFORM\'1

Temat: Ciekawe (chyba :)) zadanie z SQL-a

W takim razie czekamy na rozwiązanie autora wątku, można założyć, że najwięcej czasu poświęcił na dopieszczenie, więc sam jestem bardzo ciekaw efektu....
Jacek Szajer

Jacek Szajer Współwłaściciel,
Business
Intelligence
Technologies SC

Temat: Ciekawe (chyba :)) zadanie z SQL-a

czas nadszedł ...

Słowo wyjaśnienia: rozwiązanie nie jest dopieszczone - sam fakt, że tu napisałem z pytaniem świadczy, że nie mam przekonania o jego "doskonałości". Działa zadowalająco szybko, wyniki daje dobre - that's all :)
Wyjaśnienie nr 2 - testy robiłem na SQL2005

Dzięki dla Adriana za przygotowanie "frameworka testowego" - mnie się oczywiście nie chciało. Jego wykorzystanie przyniosło co najmniej ciekawe wyniki.

Po kolei - najpierw moje rozwiązanie:


select SetId
from przypisania
left join t on przypisania.StudenciId = t.StudenciId
group by SetId
having count(*) = (select count(*) from t)
and sum(case when t.StudenciId is null then 1 else 0 end)=0


Jeszcze przed "frameworkiem" (czyli na bazie jak w treści zadania - pojedyncze rekordy) na bieżąco testowałem Wasze rozwiązania i moje (tylko execution plan - "subtree cost") i dostałem:

Michał: 0,0371611
Leszek: 0,0544706
Adrian: 0,0835277 (tak, tak! :)
Jacek: 0,0369626

Już po frameworku (po 100.000 rekordów w tabelach):
Michał: 4,44938
Leszek: 14,6621
Adrian: 1,93676 (no właśnie)
Jacek: 3,57577

WNIOSEK nr.1 - sprawdzaj zawsze na docelowej wielkości bazy :)

Czasy wykonania dostałem natomiast takie:
Michał: 105ms
Leszek: 616ms
Adrian: 66ms
Jacek: 145ms

WNIOSEK nr.2 - Execution plan a rzeczywisty czas wykonania nie zawsze idą w parze

I od razu pytanie - dlaczego czas dla Leszka (znacznie na minus) i Michała (na plus) na mojej bazie aż tak odbiega od tego co uzyskał Adrian u siebie? Czyżby różnica wersji MS SQL?

Najlepsze na koniec - operacje I/O dla poszczególnych zapytań (set statistics io on):

Michał:
Table 't'. Scan count 2, logical reads 4, physical reads 1, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Przypisania'. Scan count 10, logical reads 458, physical reads 0, read-ahead reads 229, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Warning: Null value is eliminated by an aggregate or other SET operation.

Leszek:
Table 't'. Scan count 7, logical reads 200008, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Przypisania'. Scan count 6, logical reads 458, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 32, logical reads 201615, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Warning: Null value is eliminated by an aggregate or other SET operation.

Adrian:
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Przypisania'. Scan count 2, logical reads 458, physical reads 0, read-ahead reads 229, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 't'. Scan count 2, logical reads 4, physical reads 1, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Jacek:
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Przypisania'. Scan count 1, logical reads 229, physical reads 0, read-ahead reads 229, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 't'. Scan count 2, logical reads 4, physical reads 1, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.


Ktoś ma logiczne wytłumaczenie tego, że mniej operacji (u mnie) trwa dłużej niż więcej operacji u Adriana?

BTW: Przy testach, jeśli chcemy wyeliminować wpływ wcześniejszych wykonań, cache itp. - można użyć DBCC DROPCLEANBUFFERSJacek Szajer edytował(a) ten post dnia 02.11.11 o godzinie 01:36



Wyślij zaproszenie do