Mengambil n baris per grup


88

Saya sering perlu memilih sejumlah baris dari masing-masing kelompok dalam hasil yang ditetapkan.

Misalnya, saya mungkin ingin membuat daftar nilai pesanan terbaru 'n' tertinggi atau terendah per pelanggan.

Dalam kasus yang lebih kompleks, jumlah baris ke daftar mungkin bervariasi per grup (ditentukan oleh atribut catatan pengelompokan / induk). Bagian ini jelas opsional / untuk kredit tambahan dan tidak dimaksudkan untuk menghalangi orang untuk menjawab.

Apa opsi utama untuk memecahkan masalah-masalah ini di SQL Server 2005 dan yang lebih baru? Apa kelebihan dan kekurangan utama dari masing-masing metode?

Contoh AdventureWorks (untuk kejelasan, opsional)

  1. Sebutkan lima tanggal dan ID transaksi terbaru dari TransactionHistorytabel, untuk setiap produk yang dimulai dengan huruf dari M hingga R inklusif.
  2. Sama lagi, tetapi dengan ngaris riwayat per produk, di mana nlima kali DaysToManufactureatribut Produk.
  3. Sama, untuk kasus khusus di mana tepat satu baris riwayat per produk diperlukan (entri tunggal terbaru oleh TransactionDate, tie-break on TransactionID.

Jawaban:


71

Mari kita mulai dengan skenario dasar.

Jika saya ingin mendapatkan beberapa baris dari tabel, saya memiliki dua opsi utama: fungsi peringkat; atau TOP.

Pertama, mari kita pertimbangkan seluruh rangkaian dari Production.TransactionHistoryuntuk tertentu ProductID:

SELECT h.TransactionID, h.ProductID, h.TransactionDate
FROM Production.TransactionHistory h
WHERE h.ProductID = 800;

Ini mengembalikan 418 baris, dan rencana menunjukkan bahwa itu memeriksa setiap baris dalam tabel mencari ini - pemindaian Indeks Clustered tidak dibatasi, dengan Predikat untuk menyediakan filter. 797 berbunyi di sini, yang jelek.

Pemindaian Mahal dengan Predikat 'Sisa'

Jadi mari kita bersikap adil terhadapnya, dan buat indeks yang akan lebih berguna. Kondisi kami meminta pencocokan kesetaraan aktif ProductID, diikuti dengan pencarian yang terbaru oleh TransactionDate. Kita perlu TransactionIDkembali juga, jadi mari kita pergi dengan: CREATE INDEX ix_FindingMostRecent ON Production.TransactionHistory (ProductID, TransactionDate) INCLUDE (TransactionID);.

Setelah melakukan ini, rencana kami berubah secara signifikan, dan menurunkan bacaan menjadi hanya 3. Jadi kami sudah meningkatkan hal-hal lebih dari 250x atau lebih ...

Rencana yang ditingkatkan

Sekarang kita telah meratakan lapangan bermain, mari kita lihat opsi teratas - fungsi peringkat dan TOP.

WITH Numbered AS
(
SELECT h.TransactionID, h.ProductID, h.TransactionDate, ROW_NUMBER() OVER (ORDER BY TransactionDate DESC) AS RowNum
FROM Production.TransactionHistory h
WHERE h.ProductID = 800
)
SELECT TransactionID, ProductID, TransactionDate
FROM Numbered
WHERE RowNum <= 5;

SELECT TOP (5) h.TransactionID, h.ProductID, h.TransactionDate
FROM Production.TransactionHistory h
WHERE h.ProductID = 800
ORDER BY TransactionDate DESC;

Dua paket - TOP \ RowNum dasar

Anda akan melihat bahwa permintaan kedua ( TOP) jauh lebih sederhana dari yang pertama, baik dalam permintaan maupun dalam rencana. Namun sangat signifikan, keduanya digunakan TOPuntuk membatasi jumlah baris yang sebenarnya ditarik keluar dari indeks. Biaya hanya perkiraan dan layak diabaikan, tetapi Anda dapat melihat banyak kesamaan dalam dua paket, dengan ROW_NUMBER()versi melakukan sejumlah kecil pekerjaan ekstra untuk menetapkan angka dan memfilter sesuai, dan kedua pertanyaan akhirnya hanya melakukan 2 kali bacaan untuk dilakukan pekerjaan mereka. Pengoptimal Kueri tentu saja mengenali gagasan pemfilteran di suatu ROW_NUMBER()bidang, menyadari bahwa ia dapat menggunakan operator Top untuk mengabaikan baris yang tidak diperlukan. Kedua pertanyaan ini cukup baik - TOPtidak jauh lebih baik sehingga layak untuk diubah kode, tetapi lebih sederhana dan mungkin lebih jelas untuk pemula.

Jadi ini berfungsi di satu produk. Tetapi kita perlu mempertimbangkan apa yang terjadi jika kita perlu melakukan ini di berbagai produk.

Programer iteratif akan mempertimbangkan ide perulangan melalui produk-produk yang menarik, dan memanggil kueri ini beberapa kali, dan kita benar-benar bisa lolos dengan menulis kueri dalam bentuk ini - tidak menggunakan kursor, tetapi menggunakan APPLY. Saya menggunakan OUTER APPLY, mencari tahu bahwa kami mungkin ingin mengembalikan Produk dengan NULL, jika tidak ada Transaksi untuk itu.

SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM 
Production.Product p
OUTER APPLY (
    SELECT TOP (5) h.TransactionID, h.ProductID, h.TransactionDate
    FROM Production.TransactionHistory h
    WHERE h.ProductID = p.ProductID
    ORDER BY TransactionDate DESC
) t
WHERE p.Name >= 'M' AND p.Name < 'S';

Rencana untuk ini adalah metode programer yang berulang - Nested Loop, melakukan operasi Top dan Mencari (2 bacaan yang kami miliki sebelumnya) untuk setiap Produk. Ini memberikan 4 bacaan melawan Produk, dan 360 melawan TransactionHistory.

BERLAKU rencana

Menggunakan ROW_NUMBER(), metode ini digunakan PARTITION BYdalam OVERklausa, sehingga kami memulai kembali penomoran untuk setiap produk. Ini kemudian dapat difilter seperti sebelumnya. Rencananya menjadi sangat berbeda. Pembacaan logis sekitar 15% lebih rendah pada TransactionHistory, dengan Pemindaian Indeks penuh terjadi untuk mendapatkan baris keluar.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY h.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5;

Paket ROW_NUMBER

Namun, secara signifikan, paket ini memiliki operator Sort yang mahal. Gabung Gabung tampaknya tidak mempertahankan urutan baris di TransactionHistory, data harus terpaksa untuk dapat menemukan rownumber. Ini lebih sedikit dibaca, tetapi Sortir yang menghalangi ini bisa terasa menyakitkan. Dengan menggunakan APPLY, Nested Loop akan mengembalikan baris pertama dengan sangat cepat, setelah hanya beberapa yang dibaca, tetapi dengan Sort, ROW_NUMBER()hanya akan mengembalikan baris setelah sebagian besar pekerjaan selesai.

Menariknya, jika ROW_NUMBER()kueri menggunakan INNER JOINalih-alih LEFT JOIN, maka rencana yang berbeda muncul.

ROW_NUMBER () dengan INNER JOIN

Paket ini menggunakan Nested Loop, sama seperti dengan APPLY. Tetapi tidak ada operator Top, sehingga menarik semua transaksi untuk setiap produk, dan menggunakan lebih banyak membaca dari sebelumnya - 492 membaca melawan TransactionHistory. Tidak ada alasan bagus untuk tidak memilih opsi Gabung Bergabung di sini, jadi saya kira rencana itu dianggap 'Cukup Baik'. Tetap saja - itu tidak menghalangi, mana yang baik - hanya saja tidak sebaik APPLY.

The PARTITION BYkolom yang saya gunakan untuk ROW_NUMBER()itu h.ProductIDdalam kedua kasus, karena aku ingin memberikan QO pilihan menghasilkan nilai ROWNUM sebelum bergabung ke meja Produk. Jika saya gunakan p.ProductID, kami melihat bentuk rencana yang sama dengan INNER JOINvariasi.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY p.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5;

Tetapi operator Join mengatakan 'Left Outer Join' dan bukannya 'Inner Join'. Jumlah pembacaan masih di bawah 500 pembacaan terhadap tabel TransactionHistory.

PARTISI DENGAN pada p.ProductID bukan h.ProductID

Pokoknya - kembali ke pertanyaan yang ada ...

Kami telah menjawab pertanyaan 1 , dengan dua opsi yang dapat Anda pilih. Secara pribadi, saya suka APPLYpilihannya.

Untuk memperpanjang ini menggunakan nomor variabel ( pertanyaan 2 ), 5hanya perlu diubah sesuai. Oh, dan saya menambahkan indeks lain, sehingga ada indeks Production.Product.Nameyang menyertakan DaysToManufacturekolom.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, p.DaysToManufacture, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY h.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5 * DaysToManufacture;

SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM 
Production.Product p
OUTER APPLY (
    SELECT TOP (5 * p.DaysToManufacture) h.TransactionID, h.ProductID, h.TransactionDate
    FROM Production.TransactionHistory h
    WHERE h.ProductID = p.ProductID
    ORDER BY TransactionDate DESC
) t
WHERE p.Name >= 'M' AND p.Name < 'S';

Dan kedua rencana itu hampir identik dengan apa yang ada sebelumnya!

Baris variabel

Sekali lagi, abaikan perkiraan biaya - tapi saya masih suka skenario TOP, karena jauh lebih sederhana, dan rencana itu tidak memiliki operator pemblokiran. Bacaan kurang pada TransactionHistory karena tingginya angka nol di DaysToManufacture, tetapi dalam kehidupan nyata, saya ragu kita akan memilih kolom itu. ;)

Salah satu cara untuk menghindari blok adalah dengan membuat rencana yang menangani ROW_NUMBER()bit ke kanan (dalam rencana) dari join. Kami dapat meyakinkan ini untuk terjadi dengan melakukan join di luar CTE.

WITH Numbered AS
(
SELECT h.TransactionID, h.ProductID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY TransactionDate DESC) AS RowNum
FROM Production.TransactionHistory h
)
SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM Production.Product p
LEFT JOIN Numbered t ON t.ProductID = p.ProductID
    AND t.RowNum <= 5 * p.DaysToManufacture
WHERE p.Name >= 'M' AND p.Name < 'S';

Rencananya di sini terlihat lebih sederhana - ini tidak menghalangi, tetapi ada bahaya yang tersembunyi.

Bergabung di luar CTE

Perhatikan Compute Scalar yang menarik data dari tabel Produk. Ini sedang 5 * p.DaysToManufacturemenghitung nilainya. Nilai ini tidak diteruskan ke cabang yang menarik data dari tabel TransactionHistory, ini digunakan di Gabung Gabung. Sebagai Residual.

Residual licik!

Jadi Gabung Gabung mengkonsumsi SEMUA baris, bukan hanya yang pertama namun banyak yang dibutuhkan, tetapi semuanya dan kemudian melakukan pemeriksaan sisa. Ini berbahaya karena jumlah transaksi meningkat. Saya bukan penggemar skenario ini - sisa predikat di Gabung Bergabung dapat dengan cepat meningkat. Alasan lain mengapa saya lebih suka APPLY/TOPskenario.

Dalam kasus khusus di mana tepatnya satu baris, untuk pertanyaan 3 , kami jelas dapat menggunakan kueri yang sama, tetapi dengan 1alih - alih 5. Tetapi kemudian kita memiliki opsi tambahan, yaitu menggunakan agregat reguler.

SELECT ProductID, MAX(TransactionDate)
FROM Production.TransactionHistory
GROUP BY ProductID;

Kueri seperti ini akan menjadi awal yang bermanfaat, dan kami dapat dengan mudah memodifikasinya untuk menarik TransactionID juga untuk tujuan tie-break (menggunakan gabungan yang kemudian akan diuraikan), tetapi kami melihat keseluruhan indeks, atau kita menyelami produk demi produk, dan kita tidak benar-benar mendapatkan peningkatan besar pada apa yang kita miliki sebelumnya dalam skenario ini.

Tetapi saya harus menunjukkan bahwa kita sedang melihat skenario tertentu di sini. Dengan data nyata, dan dengan strategi pengindeksan yang mungkin tidak ideal, jarak tempuh dapat sangat bervariasi. Terlepas dari kenyataan bahwa kita telah melihat yang APPLYkuat di sini, itu bisa lebih lambat dalam beberapa situasi. Meskipun jarang memblokir, karena memiliki kecenderungan untuk menggunakan Nested Loops, yang banyak orang (termasuk saya) merasa sangat menarik.

Saya belum mencoba untuk mengeksplorasi paralelisme di sini, atau menyelam sangat sulit ke pertanyaan 3, yang saya lihat sebagai kasus khusus yang jarang orang inginkan berdasarkan pada kerumitan penggabungan dan pemisahan. Hal utama yang perlu dipertimbangkan di sini adalah bahwa kedua opsi ini sangat kuat.

Saya lebih suka APPLY. Jelas, ia menggunakan operator Top dengan baik, dan jarang menyebabkan pemblokiran.


45

Cara khas untuk melakukan ini di SQL Server 2005 dan ke atas adalah dengan menggunakan CTE dan fungsi windowing. Untuk n top per kelompok Anda hanya dapat menggunakan ROW_NUMBER()dengan PARTITIONklausa, dan menyaring melawan bahwa dalam permintaan luar. Jadi, misalnya, 5 pesanan teratas terbaru per pelanggan dapat ditampilkan dengan cara ini:

DECLARE @top INT;
SET @top = 5;

;WITH grp AS 
(
   SELECT CustomerID, OrderID, OrderDate,
     rn = ROW_NUMBER() OVER
     (PARTITION BY CustomerID ORDER BY OrderDate DESC)
   FROM dbo.Orders
)
SELECT CustomerID, OrderID, OrderDate
  FROM grp
  WHERE rn <= @top
  ORDER BY CustomerID, OrderDate DESC;

Anda juga dapat melakukan ini dengan CROSS APPLY:

DECLARE @top INT;
SET @top = 5;

SELECT c.CustomerID, o.OrderID, o.OrderDate
FROM dbo.Customers AS c
CROSS APPLY 
(
    SELECT TOP (@top) OrderID, OrderDate 
    FROM dbo.Orders AS o
    WHERE CustomerID = c.CustomerID
    ORDER BY OrderDate DESC
) AS o
ORDER BY c.CustomerID, o.OrderDate DESC;

Dengan opsi tambahan yang ditentukan Paul, katakanlah tabel Pelanggan memiliki kolom yang menunjukkan berapa banyak baris untuk disertakan per pelanggan:

;WITH grp AS 
(
   SELECT CustomerID, OrderID, OrderDate,
     rn = ROW_NUMBER() OVER
     (PARTITION BY CustomerID ORDER BY OrderDate DESC)
   FROM dbo.Orders
)
SELECT c.CustomerID, grp.OrderID, grp.OrderDate
  FROM grp 
  INNER JOIN dbo.Customers AS c
  ON grp.CustomerID = c.CustomerID
  AND grp.rn <= c.Number_of_Recent_Orders_to_Show
  ORDER BY c.CustomerID, grp.OrderDate DESC;

Dan lagi, menggunakan CROSS APPLYdan menggabungkan opsi tambahan bahwa jumlah baris untuk pelanggan ditentukan oleh beberapa kolom di tabel pelanggan:

SELECT c.CustomerID, o.OrderID, o.OrderDate
FROM dbo.Customers AS c
CROSS APPLY 
(
    SELECT TOP (c.Number_of_Recent_Orders_to_Show) OrderID, OrderDate 
    FROM dbo.Orders AS o
    WHERE CustomerID = c.CustomerID
    ORDER BY OrderDate DESC
) AS o
ORDER BY c.CustomerID, o.OrderDate DESC;

Perhatikan bahwa ini akan tampil berbeda tergantung pada distribusi data dan ketersediaan indeks pendukung, sehingga mengoptimalkan kinerja dan mendapatkan rencana terbaik akan sangat tergantung pada faktor-faktor lokal.

Secara pribadi, saya lebih suka solusi CTE dan windowing daripada CROSS APPLY/ TOPkarena mereka memisahkan logika lebih baik dan lebih intuitif (bagi saya). Secara umum (baik dalam kasus ini dan dalam pengalaman umum saya), pendekatan CTE menghasilkan rencana yang lebih efisien (contoh di bawah), tetapi ini tidak boleh dianggap sebagai kebenaran universal - Anda harus selalu menguji skenario Anda, terutama jika indeks telah berubah atau data condong secara signifikan.


Contoh AdventureWorks - tanpa perubahan apa pun

  1. Sebutkan lima tanggal dan ID transaksi terbaru dari TransactionHistorytabel, untuk setiap produk yang dimulai dengan huruf dari M hingga R inklusif.
-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn <= 5;

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (5) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Perbandingan kedua metrik runtime ini:

masukkan deskripsi gambar di sini

CTE / OVER()rencana:

masukkan deskripsi gambar di sini

CROSS APPLY rencana:

masukkan deskripsi gambar di sini

Rencana CTE terlihat lebih rumit, tetapi sebenarnya jauh lebih efisien. Berikan sedikit perhatian pada perkiraan biaya% angka, tetapi fokus pada pengamatan aktual yang lebih penting , seperti bacaan yang jauh lebih sedikit dan durasi yang jauh lebih rendah. Saya juga menjalankan ini tanpa paralelisme, dan ini bukan perbedaannya. Metrik runtime dan rencana CTE ( CROSS APPLYrencana itu tetap sama):

masukkan deskripsi gambar di sini

masukkan deskripsi gambar di sini

  1. Sama lagi, tetapi dengan ngaris riwayat per produk, di mana nlima kali DaysToManufactureatribut Produk.

Diperlukan sedikit perubahan di sini. Untuk CTE, kami dapat menambahkan kolom ke kueri dalam, dan memfilter pada kueri luar; untuk itu CROSS APPLY, kita bisa melakukan perhitungan di dalam yang berkorelasi TOP. Anda akan berpikir ini akan memberikan efisiensi pada CROSS APPLYsolusi, tetapi itu tidak terjadi dalam kasus ini. Pertanyaan:

-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, p.DaysToManufacture, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn <= (5 * DaysToManufacture);

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (5 * p.DaysToManufacture) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Hasil Runtime:

masukkan deskripsi gambar di sini

CTE / OVER()rencana paralel :

masukkan deskripsi gambar di sini

CTE / OVER()rencana ulir tunggal :

masukkan deskripsi gambar di sini

CROSS APPLY rencana:

masukkan deskripsi gambar di sini

  1. Sama, untuk kasus khusus di mana tepat satu baris riwayat per produk diperlukan (entri tunggal terbaru oleh TransactionDate, tie-break on TransactionID.

Sekali lagi, perubahan kecil di sini. Dalam solusi CTE, kita menambahkan TransactionIDke OVER()klausa, dan mengubah filter luar untuk rn = 1. Untuk itu CROSS APPLY, kami mengubah TOPto TOP (1), dan menambahkan TransactionIDke inner ORDER BY.

-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC, TransactionID DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn = 1;

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (1) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC, TransactionID DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Hasil Runtime:

masukkan deskripsi gambar di sini

CTE / OVER()rencana paralel :

masukkan deskripsi gambar di sini

Paket CTE / OVER () ulir tunggal:

masukkan deskripsi gambar di sini

CROSS APPLY rencana:

masukkan deskripsi gambar di sini

Fungsi windowing tidak selalu merupakan alternatif terbaik (coba saja COUNT(*) OVER()), dan ini bukan satu-satunya dua pendekatan untuk menyelesaikan n baris per masalah grup, tetapi dalam kasus khusus ini - diberikan skema, indeks yang ada, dan distribusi data - CTE bernasib lebih baik dengan semua akun yang berarti.


Contoh AdventureWorks - dengan fleksibilitas untuk menambahkan indeks

Namun, jika Anda menambahkan indeks pendukung, mirip dengan yang Paul sebutkan dalam komentar tetapi dengan kolom 2 dan 3 dipesan DESC:

CREATE UNIQUE NONCLUSTERED INDEX UQ3 ON Production.TransactionHistory 
  (ProductID, TransactionDate DESC, TransactionID DESC);

Anda benar-benar akan mendapatkan rencana yang jauh lebih baik di sekitar, dan metrik akan berubah untuk mendukung CROSS APPLYpendekatan dalam ketiga kasus:

masukkan deskripsi gambar di sini

Jika ini adalah lingkungan produksi saya, saya mungkin akan puas dengan durasi dalam hal ini, dan tidak akan repot untuk mengoptimalkan lebih lanjut.


Ini semua jauh lebih jelek di SQL Server 2000, yang tidak mendukung APPLYatau OVER()klausa.


24

Dalam DBMS, seperti MySQL, yang tidak memiliki fungsi jendela atau CROSS APPLY, cara untuk melakukan ini adalah dengan menggunakan SQL standar (89). Cara lambat akan menjadi salib segitiga bergabung dengan agregat. Cara yang lebih cepat (tapi tetap dan mungkin tidak seefisien menggunakan lintas berlaku atau fungsi row_number) akan menjadi apa yang saya sebut "orang miskin CROSS APPLY" . Akan menarik untuk membandingkan kueri ini dengan yang lain:

Asumsi: Orders (CustomerID, OrderDate)memiliki UNIQUEkendala:

DECLARE @top INT;
SET @top = 5;

SELECT o.CustomerID, o.OrderID, o.OrderDate
  FROM dbo.Customers AS c
    JOIN dbo.Orders AS o
      ON  o.CustomerID = c.CustomerID
      AND o.OrderID IN
          ( SELECT TOP (@top) oi.OrderID
            FROM dbo.Orders AS oi
            WHERE oi.CustomerID = c.CustomerID
            ORDER BY oi.OrderDate DESC
          )
  ORDER BY CustomerID, OrderDate DESC ;

Untuk masalah tambahan baris atas khusus per grup:

SELECT o.CustomerID, o.OrderID, o.OrderDate
  FROM dbo.Customers AS c
    JOIN dbo.Orders AS o
      ON  o.CustomerID = c.CustomerID
      AND o.OrderID IN
          ( SELECT TOP (c.Number_of_Recent_Orders_to_Show) oi.OrderID
            FROM dbo.Orders AS oi
            WHERE oi.CustomerID = c.CustomerID
            ORDER BY oi.OrderDate DESC
          )
  ORDER BY CustomerID, OrderDate DESC ;

Catatan: Di MySQL, alih-alih yang AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...)akan digunakan AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1)). SQL-Server menambahkan FETCH / OFFSETsintaks dalam versi 2012. Kueri di sini disesuaikan dengan IN (TOP...)agar berfungsi dengan versi sebelumnya.


21

Saya mengambil pendekatan yang sedikit berbeda, terutama untuk melihat bagaimana teknik ini akan dibandingkan dengan yang lain, karena memiliki opsi yang baik, bukan?

Pengujian

Mengapa kita tidak mulai dengan hanya melihat bagaimana berbagai metode saling menumpuk. Saya melakukan tiga set tes:

  1. Set pertama berjalan tanpa modifikasi DB
  2. Set kedua berjalan setelah indeks dibuat untuk mendukung TransactionDatepermintaan berbasiskan terhadap Production.TransactionHistory.
  3. Set ketiga membuat asumsi yang sedikit berbeda. Karena ketiga tes berjalan terhadap daftar Produk yang sama, bagaimana jika kita men-cache daftar itu? Metode saya menggunakan cache di memori sementara metode lain menggunakan tabel temp yang setara. Indeks pendukung yang dibuat untuk set tes kedua masih ada untuk set tes ini.

Detail tes tambahan:

  • Pengujian dijalankan terhadap AdventureWorks2012SQL Server 2012, SP2 (Edisi Pengembang).
  • Untuk setiap tes yang saya beri label jawaban siapa saya mengambil kueri dari dan yang kueri tertentu
  • Saya menggunakan opsi "Buang hasil setelah eksekusi" pada Opsi Kueri | Hasil.
  • Harap dicatat bahwa untuk dua set tes pertama, RowCountstampaknya "tidak aktif" untuk metode saya. Ini karena metode saya menjadi implementasi manual dari apa CROSS APPLYyang dilakukan: ini menjalankan kueri awal terhadap Production.Productdan mendapatkan 161 baris kembali, yang kemudian digunakan untuk kueri terhadap Production.TransactionHistory. Oleh karena itu, RowCountnilai untuk entri saya selalu 161 lebih dari entri lainnya. Pada set tes ketiga (dengan caching) jumlah baris sama untuk semua metode.
  • Saya menggunakan SQL Server Profiler untuk menangkap statistik alih-alih mengandalkan rencana eksekusi. Aaron dan Mikael sudah melakukan pekerjaan yang bagus untuk menunjukkan rencana untuk pertanyaan mereka dan tidak perlu mereproduksi informasi itu. Dan maksud dari metode saya adalah untuk mengurangi pertanyaan menjadi bentuk yang sederhana sehingga tidak terlalu masalah. Ada alasan tambahan untuk menggunakan Profiler, tetapi itu akan disebutkan kemudian.
  • Daripada menggunakan Name >= N'M' AND Name < N'S'konstruk, saya memilih untuk menggunakan Name LIKE N'[M-R]%', dan SQL Server memperlakukan mereka sama.

Hasil

Tidak Ada Indeks Pendukung

Ini pada dasarnya AdventureWorks2012 out-of-the-box. Dalam semua kasus, metode saya jelas lebih baik daripada yang lain, tetapi tidak pernah sebagus metode 1 atau 2 teratas.

Tes 1 Tes 1 Hasil-tanpa indeks
CTE Harun jelas merupakan pemenang di sini.

Tes 2 Tes 2 Hasil-tanpa indeks
CTE Harun (lagi) dan apply row_number()metode kedua Mikael adalah yang kedua.

Tes 3 Tes 3 Hasil-tanpa indeks
CTE Harun (lagi) adalah pemenangnya.

Kesimpulan
Ketika tidak ada indeks pendukung aktif TransactionDate, metode saya lebih baik daripada melakukan standar CROSS APPLY, tapi tetap saja, menggunakan metode CTE jelas merupakan cara yang harus dilakukan.

Dengan Indeks Pendukung (tanpa Caching)

Untuk rangkaian tes ini saya menambahkan indeks yang jelas TransactionHistory.TransactionDatesejak semua pertanyaan mengurutkan pada bidang itu. Saya mengatakan "jelas" karena sebagian besar jawaban lain juga menyetujui hal ini. Dan karena semua kueri menginginkan tanggal terbaru, TransactionDatebidang itu harus dipesan DESC, jadi saya hanya mengambil CREATE INDEXpernyataan di bagian bawah jawaban Mikael dan menambahkan secara eksplisit FILLFACTOR:

CREATE INDEX [IX_TransactionHistoryX]
    ON Production.TransactionHistory (ProductID ASC, TransactionDate DESC)
    WITH (FILLFACTOR = 100);

Setelah indeks ini berada di tempat, hasilnya berubah sedikit.

Tes 1 Tes 1 Hasil-dengan indeks pendukung
Kali ini metode saya yang keluar, paling tidak dalam hal Logical Reads. The CROSS APPLYmetode, sebelumnya pemain terburuk untuk Test 1, menang pada Durasi dan bahkan mengalahkan metode CTE pada Logical Membaca.

Tes 2 Tes 2 Hasil-dengan indeks pendukung
Kali ini adalah apply row_number()metode pertama Mikael yang menjadi pemenang ketika melihat Reads, sedangkan sebelumnya itu adalah salah satu yang berkinerja terburuk. Dan sekarang metode saya masuk di tempat kedua yang sangat dekat ketika melihat Baca. Bahkan, di luar metode CTE, sisanya cukup dekat dalam hal Baca.

Tes 3 Uji 3 Hasil-dengan indeks pendukung
Di sini CTE masih menjadi pemenang, tetapi sekarang perbedaan antara metode lain hampir tidak terlihat dibandingkan dengan perbedaan drastis yang ada sebelum membuat indeks.

Kesimpulan
Penerapan metode saya lebih jelas sekarang, meskipun kurang tangguh untuk tidak memiliki indeks yang tepat di tempat.

Dengan Indeks Pendukung DAN Caching

Untuk serangkaian tes ini saya memanfaatkan caching karena, well, mengapa tidak? Metode saya memungkinkan untuk menggunakan cache dalam memori yang metode lain tidak dapat mengakses. Jadi agar adil, saya membuat tabel temp berikut yang digunakan Product.Productuntuk semua referensi dalam metode-metode lain di ketiga tes. The DaysToManufacturelapangan hanya digunakan dalam Uji Nomor 2, tapi itu lebih mudah untuk konsisten di seluruh skrip SQL untuk menggunakan meja yang sama dan tidak ada salahnya untuk memilikinya di sana.

CREATE TABLE #Products
(
    ProductID INT NOT NULL PRIMARY KEY,
    Name NVARCHAR(50) NOT NULL,
    DaysToManufacture INT NOT NULL
);

INSERT INTO #Products (ProductID, Name, DaysToManufacture)
    SELECT  p.ProductID, p.Name, p.DaysToManufacture
    FROM    Production.Product p
    WHERE   p.Name >= N'M' AND p.Name < N'S'
    AND    EXISTS (
                    SELECT  *
                    FROM    Production.TransactionHistory th
                    WHERE   th.ProductID = p.ProductID
                );

ALTER TABLE #Products REBUILD WITH (FILLFACTOR = 100);

Tes 1 Tes 1 Hasil-dengan indeks pendukung DAN caching
Semua metode tampaknya mendapat manfaat yang sama dari caching, dan metode saya masih keluar di depan.

Tes 2 Tes 2 Hasil-dengan indeks pendukung DAN caching
Di sini kita sekarang melihat perbedaan dalam formasi karena metode saya keluar sedikit di depan, hanya 2 Membaca lebih baik daripada apply row_number()metode pertama Mikael , sedangkan tanpa caching metode saya di belakang oleh 4 Membaca.

Tes 3 Uji 3 Hasil-dengan indeks pendukung DAN caching
Silakan lihat pembaruan di bagian bawah (di bawah garis) . Di sini kita kembali melihat beberapa perbedaan. Rasa "parameter" dari metode saya sekarang hampir tidak terbawa oleh 2 Baca dibandingkan dengan metode SALING SALIB Harun (tanpa caching mereka sama). Tetapi hal yang sangat aneh adalah bahwa untuk pertama kalinya kita melihat metode yang dipengaruhi secara negatif oleh caching: Metode CTE Harun (yang sebelumnya merupakan yang terbaik untuk Tes Nomor 3). Tapi, saya tidak akan mengambil kredit di mana tidak jatuh tempo, dan karena tanpa caching metode CTE Harun masih lebih cepat daripada metode saya di sini dengan caching, pendekatan terbaik untuk situasi khusus ini tampaknya adalah metode CTE Aaron.

Kesimpulan Silakan lihat pembaruan di bagian bawah (di bawah garis)
Situasi yang membuat penggunaan berulang hasil kueri sekunder sering dapat (tetapi tidak selalu) mendapat manfaat dari caching hasil tersebut. Tetapi ketika caching bermanfaat, menggunakan memori untuk kata caching memiliki beberapa keuntungan dibandingkan menggunakan tabel sementara.

Metode

Umumnya

Saya memisahkan kueri "header" (yaitu mendapatkan ProductIDs, dan dalam satu kasus juga DaysToManufacture, berdasarkan Namepermulaan dengan huruf-huruf tertentu) dari kueri "detail" (yaitu mendapatkan TransactionIDs dan TransactionDates). Konsepnya adalah melakukan kueri yang sangat sederhana dan tidak membuat pengoptimal menjadi bingung ketika BERGABUNG dengannya. Jelas ini tidak selalu menguntungkan karena juga melarang pengoptimal dari, yah, mengoptimalkan. Tetapi seperti yang kita lihat di hasil, tergantung pada jenis permintaan, metode ini memang memiliki kelebihan.

Perbedaan antara berbagai rasa dari metode ini adalah:

  • Konstanta: Kirim nilai yang dapat diganti sebagai konstanta sebaris alih-alih sebagai parameter. Ini akan merujuk ProductIDpada ketiga tes dan juga jumlah baris untuk kembali dalam Tes 2 karena itu adalah fungsi "lima kali DaysToManufactureatribut Produk". Sub-metode ini berarti bahwa masing-masing ProductIDakan mendapatkan rencana pelaksanaannya sendiri, yang dapat bermanfaat jika ada variasi luas dalam distribusi data untuk ProductID. Tetapi jika ada sedikit variasi dalam distribusi data, biaya pembuatan rencana tambahan kemungkinan tidak akan sepadan.

  • Parameterisasi: Kirim setidaknya ProductIDsebagai @ProductID, memungkinkan untuk caching rencana dan penggunaan kembali. Ada opsi tes tambahan untuk juga memperlakukan jumlah variabel baris untuk kembali untuk Uji 2 sebagai parameter.

  • Optimalkan Tidak Diketahui: Ketika merujuk ProductIDsebagai @ProductID, jika ada variasi luas distribusi data, maka mungkin untuk men-cache rencana yang memiliki efek negatif pada ProductIDnilai - nilai lain sehingga akan baik untuk mengetahui apakah menggunakan Petunjuk Kueri ini membantu.

  • Cache Products: Daripada menanyakan Production.Producttabel setiap kali, hanya untuk mendapatkan daftar yang sama persis, jalankan kueri sekali (dan sementara kami melakukannya, filter semua ProductIDyang bahkan tidak ada dalam TransactionHistorytabel sehingga kami tidak membuang apa pun sumber daya di sana) dan cache daftar itu. Daftar harus menyertakan DaysToManufacturebidang. Menggunakan opsi ini ada klik awal yang sedikit lebih tinggi pada Logical Reads untuk eksekusi pertama, tetapi setelah itu hanya TransactionHistorytabel yang ditanyai.

Secara khusus

Ok, tapi begitu, um, bagaimana mungkin untuk mengeluarkan semua sub-kueri sebagai permintaan terpisah tanpa menggunakan CURSOR dan membuang setiap hasil yang disetel ke tabel sementara atau variabel tabel? Jelas melakukan metode CURSOR / Tabel Temp akan mencerminkan cukup jelas dalam Baca dan Tulis. Nah, dengan menggunakan SQLCLR :). Dengan membuat prosedur tersimpan SQLCLR, saya bisa membuka set hasil dan pada dasarnya mengalirkan hasil dari setiap sub-query ke sana, sebagai set hasil yang berkelanjutan (dan bukan beberapa set hasil). Di luar Informasi produk (yaitu ProductID, Name, danDaysToManufacture), tidak ada hasil sub-query yang harus disimpan di mana saja (memori atau disk) dan baru saja dilewati sebagai set hasil utama dari prosedur tersimpan SQLCLR. Ini memungkinkan saya untuk melakukan permintaan sederhana untuk mendapatkan info Produk dan kemudian memutarnya, mengeluarkan pertanyaan yang sangat sederhana TransactionHistory.

Dan, inilah mengapa saya harus menggunakan SQL Server Profiler untuk menangkap statistik. Prosedur tersimpan SQLCLR tidak mengembalikan rencana eksekusi, baik dengan menetapkan Opsi Permintaan "Sertakan Rencana Eksekusi Aktual", atau dengan mengeluarkan SET STATISTICS XML ON;.

Untuk caching Info Produk, saya menggunakan readonly staticDaftar Generik (yaitu _GlobalProductsdalam kode di bawah). Tampaknya menambahkan ke koleksi tidak melanggar readonlyopsi, maka kode ini berfungsi ketika majelis memiliki PERMISSON_SETof SAFE:), bahkan jika itu kontra-intuitif.

Kueri yang Dihasilkan

Pertanyaan yang dihasilkan oleh prosedur tersimpan SQLCLR ini adalah sebagai berikut:

Info produk

Nomor Tes 1 dan 3 (tanpa Caching)

SELECT prod1.ProductID, prod1.Name, 1 AS [DaysToManufacture]
FROM   Production.Product prod1
WHERE  prod1.Name LIKE N'[M-R]%';

Tes Nomor 2 (tanpa Caching)

;WITH cte AS
(
    SELECT prod1.ProductID
    FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
    WHERE  prod1.Name LIKE N'[M-R]%'
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

Nomor Tes 1, 2, dan 3 (Caching)

;WITH cte AS
(
    SELECT prod1.ProductID
    FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
    WHERE  prod1.Name LIKE N'[M-R]%'
    AND    EXISTS (
                SELECT *
                FROM Production.TransactionHistory th
                WHERE th.ProductID = prod1.ProductID
                  )
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

Info Transaksi

Bilangan Tes 1 dan 2 (Konstanta)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = 977
ORDER BY th.TransactionDate DESC;

Bilangan Tes 1 dan 2 (Parameterisasi)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
;

Bilangan Tes 1 dan 2 (Parameter + MENGOPTIMALKAN TIDAK DIKENAL)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

Test Number 2 (Parameterisasi Keduanya)

SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
;

Tes Nomor 2 (Parameterisasi Keduanya + OPTIMASI TIDAK DIKETAHUI)

SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

Tes Nomor 3 (Konstanta)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = 977
ORDER BY th.TransactionDate DESC, th.TransactionID DESC;

Tes Nomor 3 (Parameter)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC, th.TransactionID DESC
;

Test Number 3 (Parameterized + MENGOPTIMALKAN TIDAK DIKETAHUI)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC, th.TransactionID DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

Kode

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;

public class ObligatoryClassName
{
    private class ProductInfo
    {
        public int ProductID;
        public string Name;
        public int DaysToManufacture;

        public ProductInfo(int ProductID, string Name, int DaysToManufacture)
        {
            this.ProductID = ProductID;
            this.Name = Name;
            this.DaysToManufacture = DaysToManufacture;

            return;
        }
    }

    private static readonly List<ProductInfo> _GlobalProducts = new List<ProductInfo>();

    private static void PopulateGlobalProducts(SqlBoolean PrintQuery)
    {
        if (_GlobalProducts.Count > 0)
        {
            if (PrintQuery.IsTrue)
            {
                SqlContext.Pipe.Send(String.Concat("I already haz ", _GlobalProducts.Count,
                            " entries :)"));
            }

            return;
        }

        SqlConnection _Connection = new SqlConnection("Context Connection = true;");
        SqlCommand _Command = new SqlCommand();
        _Command.CommandType = CommandType.Text;
        _Command.Connection = _Connection;
        _Command.CommandText = @"
   ;WITH cte AS
   (
     SELECT prod1.ProductID
     FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
     WHERE  prod1.Name LIKE N'[M-R]%'
     AND    EXISTS (
                     SELECT *
                     FROM Production.TransactionHistory th
                     WHERE th.ProductID = prod1.ProductID
                   )
   )
   SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
   FROM   Production.Product prod2
   INNER JOIN cte
           ON cte.ProductID = prod2.ProductID;
";

        SqlDataReader _Reader = null;

        try
        {
            _Connection.Open();

            _Reader = _Command.ExecuteReader();

            while (_Reader.Read())
            {
                _GlobalProducts.Add(new ProductInfo(_Reader.GetInt32(0), _Reader.GetString(1),
                                                    _Reader.GetInt32(2)));
            }
        }
        catch
        {
            throw;
        }
        finally
        {
            if (_Reader != null && !_Reader.IsClosed)
            {
                _Reader.Close();
            }

            if (_Connection != null && _Connection.State != ConnectionState.Closed)
            {
                _Connection.Close();
            }

            if (PrintQuery.IsTrue)
            {
                SqlContext.Pipe.Send(_Command.CommandText);
            }
        }

        return;
    }


    [Microsoft.SqlServer.Server.SqlProcedure]
    public static void GetTopRowsPerGroup(SqlByte TestNumber,
        SqlByte ParameterizeProductID, SqlBoolean OptimizeForUnknown,
        SqlBoolean UseSequentialAccess, SqlBoolean CacheProducts, SqlBoolean PrintQueries)
    {
        SqlConnection _Connection = new SqlConnection("Context Connection = true;");
        SqlCommand _Command = new SqlCommand();
        _Command.CommandType = CommandType.Text;
        _Command.Connection = _Connection;

        List<ProductInfo> _Products = null;
        SqlDataReader _Reader = null;

        int _RowsToGet = 5; // default value is for Test Number 1
        string _OrderByTransactionID = "";
        string _OptimizeForUnknown = "";
        CommandBehavior _CmdBehavior = CommandBehavior.Default;

        if (OptimizeForUnknown.IsTrue)
        {
            _OptimizeForUnknown = "OPTION (OPTIMIZE FOR (@ProductID UNKNOWN))";
        }

        if (UseSequentialAccess.IsTrue)
        {
            _CmdBehavior = CommandBehavior.SequentialAccess;
        }

        if (CacheProducts.IsTrue)
        {
            PopulateGlobalProducts(PrintQueries);
        }
        else
        {
            _Products = new List<ProductInfo>();
        }


        if (TestNumber.Value == 2)
        {
            _Command.CommandText = @"
   ;WITH cte AS
   (
     SELECT prod1.ProductID
     FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
     WHERE  prod1.Name LIKE N'[M-R]%'
   )
   SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
   FROM   Production.Product prod2
   INNER JOIN cte
           ON cte.ProductID = prod2.ProductID;
";
        }
        else
        {
            _Command.CommandText = @"
     SELECT prod1.ProductID, prod1.Name, 1 AS [DaysToManufacture]
     FROM   Production.Product prod1
     WHERE  prod1.Name LIKE N'[M-R]%';
";
            if (TestNumber.Value == 3)
            {
                _RowsToGet = 1;
                _OrderByTransactionID = ", th.TransactionID DESC";
            }
        }

        try
        {
            _Connection.Open();

            // Populate Product list for this run if not using the Product Cache
            if (!CacheProducts.IsTrue)
            {
                _Reader = _Command.ExecuteReader(_CmdBehavior);

                while (_Reader.Read())
                {
                    _Products.Add(new ProductInfo(_Reader.GetInt32(0), _Reader.GetString(1),
                                                  _Reader.GetInt32(2)));
                }

                _Reader.Close();

                if (PrintQueries.IsTrue)
                {
                    SqlContext.Pipe.Send(_Command.CommandText);
                }
            }
            else
            {
                _Products = _GlobalProducts;
            }

            SqlDataRecord _ResultRow = new SqlDataRecord(
                new SqlMetaData[]{
                    new SqlMetaData("ProductID", SqlDbType.Int),
                    new SqlMetaData("Name", SqlDbType.NVarChar, 50),
                    new SqlMetaData("TransactionID", SqlDbType.Int),
                    new SqlMetaData("TransactionDate", SqlDbType.DateTime)
                });

            SqlParameter _ProductID = new SqlParameter("@ProductID", SqlDbType.Int);
            _Command.Parameters.Add(_ProductID);
            SqlParameter _RowsToReturn = new SqlParameter("@RowsToReturn", SqlDbType.Int);
            _Command.Parameters.Add(_RowsToReturn);

            SqlContext.Pipe.SendResultsStart(_ResultRow);

            for (int _Row = 0; _Row < _Products.Count; _Row++)
            {
                // Tests 1 and 3 use previously set static values for _RowsToGet
                if (TestNumber.Value == 2)
                {
                    if (_Products[_Row].DaysToManufacture == 0)
                    {
                        continue; // no use in issuing SELECT TOP (0) query
                    }

                    _RowsToGet = (5 * _Products[_Row].DaysToManufacture);
                }

                _ResultRow.SetInt32(0, _Products[_Row].ProductID);
                _ResultRow.SetString(1, _Products[_Row].Name);

                switch (ParameterizeProductID.Value)
                {
                    case 0x01:
                        _Command.CommandText = String.Format(@"
   SELECT TOP ({0}) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = @ProductID
   ORDER BY th.TransactionDate DESC{2}
   {1};
", _RowsToGet, _OptimizeForUnknown, _OrderByTransactionID);

                        _ProductID.Value = _Products[_Row].ProductID;
                        break;
                    case 0x02:
                        _Command.CommandText = String.Format(@"
   SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = @ProductID
   ORDER BY th.TransactionDate DESC
   {0};
", _OptimizeForUnknown);

                        _ProductID.Value = _Products[_Row].ProductID;
                        _RowsToReturn.Value = _RowsToGet;
                        break;
                    default:
                        _Command.CommandText = String.Format(@"
   SELECT TOP ({0}) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = {1}
   ORDER BY th.TransactionDate DESC{2};
", _RowsToGet, _Products[_Row].ProductID, _OrderByTransactionID);
                        break;
                }


                _Reader = _Command.ExecuteReader(_CmdBehavior);

                while (_Reader.Read())
                {
                    _ResultRow.SetInt32(2, _Reader.GetInt32(0));
                    _ResultRow.SetDateTime(3, _Reader.GetDateTime(1));

                    SqlContext.Pipe.SendResultsRow(_ResultRow);
                }
                _Reader.Close();
            }

        }
        catch
        {
            throw;
        }
        finally
        {
            if (SqlContext.Pipe.IsSendingResults)
            {
                SqlContext.Pipe.SendResultsEnd();
            }

            if (_Reader != null && !_Reader.IsClosed)
            {
                _Reader.Close();
            }

            if (_Connection != null && _Connection.State != ConnectionState.Closed)
            {
                _Connection.Close();
            }

            if (PrintQueries.IsTrue)
            {
                SqlContext.Pipe.Send(_Command.CommandText);
            }
        }


    }
}

Pertanyaan Tes

Tidak ada cukup ruang untuk memposting tes di sini jadi saya akan menemukan lokasi lain.

Kesimpulannya

Untuk skenario tertentu, SQLCLR dapat digunakan untuk memanipulasi aspek kueri tertentu yang tidak dapat dilakukan dalam T-SQL. Dan ada kemampuan untuk menggunakan memori untuk caching daripada tabel temp, meskipun itu harus dilakukan dengan hemat dan hati-hati karena memori tidak secara otomatis dilepaskan kembali ke sistem. Metode ini juga bukan sesuatu yang akan membantu permintaan ad hoc, meskipun dimungkinkan untuk membuatnya lebih fleksibel daripada yang saya tunjukkan di sini hanya dengan menambahkan parameter untuk menyesuaikan lebih banyak aspek permintaan yang dieksekusi.


MEMPERBARUI

Tes Tambahan Tes
asli saya yang menyertakan indeks pendukung TransactionHistorymenggunakan definisi berikut:

ProductID ASC, TransactionDate DESC

Saya telah memutuskan pada saat itu untuk melupakan termasuk TransactionId DESCpada akhirnya, memperkirakan bahwa sementara itu mungkin dapat membantu Tes Nomor 3 (yang menentukan pengikatan tie-on pada yang terbaru - TransactionIdyah, "paling baru" diasumsikan karena tidak dinyatakan secara eksplisit, tetapi semua orang tampaknya untuk menyetujui asumsi ini), kemungkinan tidak akan ada ikatan yang cukup untuk membuat perbedaan.

Tapi, kemudian Aaron menguji ulang dengan indeks pendukung yang memasukkan TransactionId DESCdan menemukan bahwa CROSS APPLYmetode tersebut adalah pemenang di ketiga tes. Ini berbeda dari pengujian saya yang menunjukkan bahwa metode CTE terbaik untuk Tes Nomor 3 (ketika tidak ada caching yang digunakan, yang mencerminkan tes Aaron). Jelas bahwa ada variasi tambahan yang perlu diuji.

Saya menghapus indeks pendukung saat ini, membuat yang baru dengan TransactionId, dan membersihkan cache rencana (hanya untuk memastikan):

DROP INDEX [IX_TransactionHistoryX] ON Production.TransactionHistory;

CREATE UNIQUE INDEX [UIX_TransactionHistoryX]
    ON Production.TransactionHistory (ProductID ASC, TransactionDate DESC, TransactionID DESC)
    WITH (FILLFACTOR = 100);

DBCC FREEPROCCACHE WITH NO_INFOMSGS;

Saya menjalankan kembali Tes Nomor 1 dan hasilnya sama, seperti yang diharapkan. Saya kemudian menjalankan kembali Tes Nomor 3 dan hasilnya memang berubah:

Tes 3 Hasil-dengan indeks pendukung (dengan TransactionId DESC)
Hasil di atas adalah untuk tes standar, non-caching. Kali ini, tidak hanya CROSS APPLYmengalahkan CTE (seperti yang ditunjukkan oleh tes Harun), tetapi SQLCLR proc memimpin dengan 30 Reads (woo hoo).

Tes 3 Hasil-dengan indeks pendukung (dengan TransactionId DESC) DAN caching
Hasil di atas untuk pengujian dengan caching diaktifkan. Kali ini kinerja CTE tidak menurun, meskipun CROSS APPLYmasih mengalahkannya. Namun, sekarang SQLCLR proc memimpin dengan 23 Reads (woo hoo, lagi).

Ambil Aways

  1. Ada berbagai opsi untuk digunakan. Yang terbaik adalah mencoba beberapa karena mereka masing-masing memiliki kekuatan. Tes yang dilakukan di sini menunjukkan varians yang agak kecil dalam Baca dan Durasi antara yang terbaik dan yang terburuk di semua tes (dengan indeks pendukung); variasi dalam Bacaan sekitar 350 dan Durasi adalah 55 ms. Sementara SQLCLR proc memang menang di semua kecuali 1 tes (dalam hal Baca), hanya menyimpan beberapa Baca biasanya tidak sebanding dengan biaya pemeliharaan untuk pergi rute SQLCLR. Namun dalam AdventureWorks2012, Producttabel hanya memiliki 504 baris dan TransactionHistoryhanya memiliki 113.443 baris. Perbedaan kinerja di seluruh metode ini mungkin menjadi lebih jelas ketika jumlah baris meningkat.

  2. Sementara pertanyaan ini khusus untuk mendapatkan satu set baris tertentu, tidak boleh diabaikan bahwa faktor tunggal terbesar dalam kinerja adalah pengindeksan dan bukan SQL tertentu. Indeks yang baik harus ada sebelum menentukan metode mana yang benar-benar terbaik.

  3. Pelajaran paling penting yang ditemukan di sini bukan tentang CROSS APPLY vs CTE vs SQLCLR: ini tentang MENGUJI. Jangan berasumsi. Dapatkan ide dari beberapa orang dan uji skenario sebanyak mungkin.


2
Lihat edit saya untuk jawaban Mikael untuk alasan bacaan ekstra logis yang terkait dengan berlaku.
Paul White

18

APPLY TOPatau ROW_NUMBER()? Apa yang mungkin bisa dikatakan tentang hal itu?

Rekap singkat perbedaan dan untuk membuatnya singkat, saya hanya akan menunjukkan rencana untuk opsi 2 dan saya telah menambahkan indeks Production.TransactionHistory.

create index IX_TransactionHistoryX on 
  Production.TransactionHistory(ProductID, TransactionDate)

The row_number()permintaan :.

with C as
(
  select T.TransactionID,
         T.TransactionDate,
         P.DaysToManufacture,
         row_number() over(partition by P.ProductID order by T.TransactionDate desc) as rn
  from Production.Product as P
    inner join Production.TransactionHistory as T
      on P.ProductID = T.ProductID
  where P.Name >= N'M' and
        P.Name < N'S'
)
select C.TransactionID,
       C.TransactionDate
from C
where C.rn <= 5 * C.DaysToManufacture;

masukkan deskripsi gambar di sini

The apply topVersi:

select T.TransactionID, 
       T.TransactionDate
from Production.Product as P
  cross apply (
              select top(cast(5 * P.DaysToManufacture as bigint))
                T.TransactionID,
                T.TransactionDate
              from Production.TransactionHistory as T
              where P.ProductID = T.ProductID
              order by T.TransactionDate desc
              ) as T
where P.Name >= N'M' and
      P.Name < N'S';

masukkan deskripsi gambar di sini

Perbedaan utama antara ini adalah bahwa apply topfilter pada ekspresi teratas di bawah loop bersarang bergabung di mana row_numberversi menyaring setelah bergabung. Itu berarti ada lebih banyak bacaan dari yang Production.TransactionHistorysebenarnya diperlukan.

Jika hanya ada cara untuk mendorong operator yang bertanggung jawab untuk menghitung baris ke cabang bawah sebelum bergabung maka row_numberversi mungkin lebih baik.

Jadi masukkan apply row_number()versi.

select T.TransactionID, 
       T.TransactionDate
from Production.Product as P
  cross apply (
              select T.TransactionID,
                     T.TransactionDate
              from (
                   select T.TransactionID,
                          T.TransactionDate,
                          row_number() over(order by T.TransactionDate desc) as rn
                   from Production.TransactionHistory as T
                   where P.ProductID = T.ProductID
                   ) as T
              where T.rn <= cast(5 * P.DaysToManufacture as bigint)
              ) as T
where P.Name >= N'M' and
      P.Name < N'S';

masukkan deskripsi gambar di sini

Seperti yang Anda lihat, apply row_number()hampir sama dengan apply tophanya sedikit lebih rumit. Waktu eksekusi juga hampir sama atau sedikit lebih lambat.

Jadi mengapa saya repot-repot mencari jawaban yang tidak lebih baik dari yang sudah kita miliki? Nah, Anda memiliki satu hal lagi untuk dicoba di dunia nyata dan sebenarnya ada perbedaan dalam bacaan. Satu yang saya tidak punya penjelasan untuk *.

APPLY - ROW_NUMBER
(961 row(s) affected)
Table 'TransactionHistory'. Scan count 115, logical reads 230, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Product'. Scan count 1, logical reads 15, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

APPLY - TOP
(961 row(s) affected)
Table 'TransactionHistory'. Scan count 115, logical reads 268, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Product'. Scan count 1, logical reads 15, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Sementara saya melakukannya saya mungkin juga melempar row_number()versi kedua yang dalam kasus tertentu mungkin cara untuk pergi. Kasus-kasus tertentu akan ketika Anda mengharapkan Anda benar-benar membutuhkan sebagian besar baris dari Production.TransactionHistorykarena di sini Anda mendapatkan gabungan antara Production.Productdan yang disebutkan Production.TransactionHistory.

with C as
(
  select T.TransactionID,
         T.TransactionDate,
         T.ProductID,
         row_number() over(partition by T.ProductID order by T.TransactionDate desc) as rn
  from Production.TransactionHistory as T
)
select C.TransactionID,
       C.TransactionDate
from C
 inner join Production.Product as P
      on P.ProductID = C.ProductID
where P.Name >= N'M' and
      P.Name < N'S' and
      C.rn <= 5 * P.DaysToManufacture;

masukkan deskripsi gambar di sini

Untuk mendapatkan bentuk di atas tanpa operator pengurutan Anda juga harus mengubah indeks pendukung untuk memesan dengan TransactionDatemenurun.

create index IX_TransactionHistoryX on 
  Production.TransactionHistory(ProductID, TransactionDate desc)

* Sunting: Bacaan ekstra logis karena loop bersarang prefetching digunakan dengan apply-top. Anda dapat menonaktifkan ini dengan TF 8744 yang tidak terdokumentasi (dan / atau 9115 pada versi yang lebih baru) untuk mendapatkan jumlah pembacaan logis yang sama. Prefetching bisa menjadi keuntungan dari alternatif menerapkan-top dalam keadaan yang tepat. - Paul White


11

Saya biasanya menggunakan kombinasi fungsi CTE dan windowing. Anda dapat mencapai jawaban ini menggunakan sesuatu seperti berikut:

;WITH GiveMeCounts
AS (
    SELECT CustomerID
        ,OrderDate
        ,TotalAmt

        ,ROW_NUMBER() OVER (
            PARTITION BY CustomerID ORDER BY 
            --You can change the following field or sort order to whatever you'd like to order by.
            TotalAmt desc
            ) AS MySeqNum
    )
SELECT CustomerID, OrderDate, TotalAmt
FROM GiveMeCounts
--Set n per group here
where MySeqNum <= 10

Untuk porsi kredit tambahan, di mana grup yang berbeda mungkin ingin mengembalikan jumlah baris yang berbeda, Anda bisa menggunakan tabel terpisah. Katakanlah menggunakan kriteria geografis seperti negara:

+-------+-----------+
| State | MaxSeqnum |
+-------+-----------+
| AK    |        10 |
| NY    |         5 |
| NC    |        23 |
+-------+-----------+

Untuk mencapai ini di mana nilainya mungkin berbeda, Anda harus bergabung dengan CTE Anda ke tabel Negara yang serupa dengan ini:

SELECT [CustomerID]
    ,[OrderDate]
    ,[TotalAmt]
    ,[State]
FROM GiveMeCounts gmc
INNER JOIN StateTable st ON gmc.[State] = st.[State]
    AND gmc.MySeqNum <= st.MaxSeqNum
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.