Pemindaian tak terduga selama operasi penghapusan menggunakan WHERE IN

40

Saya punya pertanyaan seperti berikut:

DELETE FROM tblFEStatsBrowsers WHERE BrowserID NOT IN (
    SELECT DISTINCT BrowserID FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID IS NOT NULL
)

tblFEStatsBrowsers telah mendapatkan 553 baris.
tblFEStatsPaperHits telah mendapat baris 47.974.301.

tblFEStatsBrowsers:

CREATE TABLE [dbo].[tblFEStatsBrowsers](
    [BrowserID] [smallint] IDENTITY(1,1) NOT NULL,
    [Browser] [varchar](50) NOT NULL,
    [Name] [varchar](40) NOT NULL,
    [Version] [varchar](10) NOT NULL,
    CONSTRAINT [PK_tblFEStatsBrowsers] PRIMARY KEY CLUSTERED ([BrowserID] ASC)
)

tblFESatsPaperHits:

CREATE TABLE [dbo].[tblFEStatsPaperHits](
    [PaperID] [int] NOT NULL,
    [Created] [smalldatetime] NOT NULL,
    [IP] [binary](4) NULL,
    [PlatformID] [tinyint] NULL,
    [BrowserID] [smallint] NULL,
    [ReferrerID] [int] NULL,
    [UserLanguage] [char](2) NULL
)

Ada indeks berkerumun di tblFEStatsPaperHits yang tidak termasuk BrowserID. Karena itu, melakukan query dalam akan membutuhkan pemindaian tabel penuh dari tblFEStatsPaperHits - yang sepenuhnya OK.

Saat ini, pemindaian penuh dieksekusi untuk setiap baris di tblFEStatsBrowsers, yang berarti saya telah mendapatkan 553 pemindaian tabel penuh dari tblFEStatsPaperHits.

Menulis ulang menjadi WHERE EXISTS tidak mengubah rencana:

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
)

Namun, seperti yang disarankan oleh Adam Machanic, menambahkan opsi HASH JOIN menghasilkan rencana eksekusi yang optimal (hanya satu pemindaian tblFEStatsPaperHits):

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
) OPTION (HASH JOIN)

Sekarang ini bukan pertanyaan bagaimana memperbaikinya - saya bisa menggunakan OPTION (HASH JOIN) atau membuat tabel temp secara manual. Saya lebih bertanya-tanya mengapa pengoptimal kueri akan menggunakan paket yang saat ini dilakukannya.

Karena QO tidak memiliki statistik pada kolom BrowserID, saya menduga itu mengasumsikan yang terburuk - 50 juta nilai yang berbeda, sehingga membutuhkan meja kerja dengan memori / tempdb yang cukup besar. Dengan demikian, cara teraman adalah melakukan pemindaian untuk setiap baris di tblFEStatsBrowsers. Tidak ada hubungan kunci asing antara kolom BrowserID di dua tabel, sehingga QO tidak dapat mengurangi informasi dari tblFEStatsBrowsers.

Apakah ini, sesederhana kedengarannya, alasannya?

Perbarui 1
Untuk memberikan beberapa statistik: OPSI (BERGABUNG
DENGAN HASH ): 208,711 bacaan logis (12 scan)

OPSI (BERGABUNG
DENGAN LOOP, HASH GROUP): 11.008.698 bacaan logis (~ pindai per BrowserID (339))

Tidak ada opsi:
11.008.775 pembacaan logis (~ scan per BrowserID (339))

Perbarui 2
Jawaban luar biasa, kalian semua - terima kasih! Sulit untuk memilih satu saja. Meskipun Martin adalah yang pertama dan Remus memberikan solusi yang sangat baik, saya harus memberikannya kepada Kiwi untuk mengetahui detailnya :)

Mark S. Rasmussen
sumber
5
Bisakah Anda skrip statistik sesuai Salin statistik dari satu server ke yang lain sehingga kami dapat mereplikasi?
Mark Storey-Smith
2
@ MarkStorey-Smith Sure - pastebin.com/9HHRPFgK Dengan anggapan Anda menjalankan skrip dalam database kosong, ini memungkinkan saya untuk mereproduksi kueri yang bermasalah ketika menyertakan menunjukkan rencana eksekusi. Kedua pertanyaan dimasukkan di akhir skrip.
Mark S. Rasmussen

Jawaban:

61

"Saya lebih bertanya-tanya mengapa pengoptimal query akan menggunakan rencana yang saat ini dilakukannya."

Dengan kata lain, pertanyaannya adalah mengapa rencana berikut terlihat paling murah bagi pengoptimal, dibandingkan dengan alternatif (yang ada banyak ).

Paket Asli

Sisi dalam dari gabungan pada dasarnya menjalankan kueri dari formulir berikut untuk setiap nilai berkorelasi BrowserID:

DECLARE @BrowserID smallint;

SELECT 
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Pemindaian Hit Kertas

Perhatikan bahwa perkiraan jumlah baris adalah 185.220 (bukan 289.013 ) karena perbandingan kesetaraan tidak termasuk NULL(kecuali ANSI_NULLSada OFF). Perkiraan biaya rencana di atas adalah 206,8 unit.

Sekarang mari kita tambahkan TOP (1)klausa:

DECLARE @BrowserID smallint;

SELECT TOP (1)
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Dengan TOP (1)

Perkiraan biaya sekarang 0,00452 unit. Penambahan operator fisik Top menetapkan tujuan baris 1 baris di operator Top. Pertanyaannya kemudian menjadi bagaimana memperoleh 'sasaran baris' untuk Pemindaian Indeks Berkelompok; yaitu, berapa banyak baris yang harus diproses oleh pemindaian sebelum satu baris cocok dengan BrowserIDpredikat?

Informasi statistik yang tersedia menunjukkan 166BrowserID nilai berbeda (1 / [Semua Kepadatan] = 1 / 0,006024096 = 166). Biaya mengasumsikan bahwa nilai-nilai yang berbeda didistribusikan secara seragam di atas baris fisik, sehingga tujuan baris pada Pemindaian Indeks Cluster diatur ke 166,302 (memperhitungkan perubahan kardinalitas tabel sejak statistik sampel dikumpulkan).

Perkiraan biaya pemindaian diharapkan 166 baris ini tidak sangat besar (bahkan dieksekusi 339 kali, sekali untuk setiap perubahan BrowserID) - menunjukkan Clustered Indeks Scan perkiraan biaya 1,3219 unit, menunjukkan efek skala dari tujuan baris. Biaya operator yang tidak dihitung untuk I / O dan CPU masing-masing ditampilkan sebagai 153.931 , dan 52.8698 :

Taksiran Biaya Perkiraan Sasaran Baris

Dalam praktiknya, sangat tidak mungkin bahwa 166 baris pertama yang dipindai dari indeks (dalam urutan apa pun yang akan dikembalikan) akan berisi masing-masing nilai yang mungkin BrowserID. Namun demikian, DELETEpaket tersebut dihitung biayanya dengan total 1.40921 unit, dan dipilih oleh pengoptimal karena alasan itu. Bart Duncan menunjukkan contoh lain dari jenis ini dalam posting terbaru berjudul Row Goals Gone Rogue .

Menarik juga untuk dicatat bahwa operator Top dalam rencana eksekusi tidak terkait dengan Anti Semi Join (khususnya Martin yang disebut "korsleting"). Kita dapat mulai melihat dari mana datangnya Top dengan terlebih dahulu menonaktifkan aturan eksplorasi yang disebut GbAggToConstScanOrTop :

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

GbAggToConstScanOrTop Dinonaktifkan

Rencana itu memiliki perkiraan biaya 364.912 , dan menunjukkan bahwa Top mengganti Grup Dengan Agregat (pengelompokan berdasarkan kolom yang berkorelasi BrowserID). Agregat bukan karena redundan DISTINCTdalam teks kueri: agregat yang dapat diperkenalkan oleh dua aturan eksplorasi, LASJNtoLASJNonDist dan LASJOnLclDist . Menonaktifkan keduanya juga menghasilkan rencana ini:

DBCC RULEOFF ('LASJNtoLASJNonDist');
DBCC RULEOFF ('LASJOnLclDist');
DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('LASJNtoLASJNonDist');
DBCC RULEON ('LASJOnLclDist');
DBCC RULEON ('GbAggToConstScanOrTop');

Paket Spool

Rencana itu memiliki perkiraan biaya 40729,3 unit.

Tanpa transformasi dari Group By ke Top, pengoptimal 'secara alami' memilih rencana bergabung hash dengan BrowserIDagregasi sebelum anti bergabung:

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

Tidak Ada Rencana DOP 1 Teratas

Dan tanpa batasan MAXDOP 1, rencana paralel:

Tidak Ada Rencana Paralel Teratas

Cara lain untuk 'memperbaiki' permintaan asli adalah dengan membuat indeks yang hilang pada BrowserIDlaporan rencana eksekusi. Loop bersarang bekerja paling baik ketika sisi bagian dalam diindeks. Memperkirakan kardinalitas untuk setengah bergabung adalah tantangan di saat terbaik. Tidak memiliki pengindeksan yang tepat (tabel besar bahkan tidak memiliki kunci unik!) Tidak akan membantu sama sekali.

Saya menulis lebih banyak tentang ini di Row Goals, Bagian 4: The Anti Join Anti Pattern .

Paul White mengatakan GoFundMonica
sumber
3
Saya tunduk kepada Anda, Anda baru saja memperkenalkan saya pada beberapa konsep baru yang belum pernah saya temui sebelumnya. Hanya ketika Anda merasa Anda tahu sesuatu, seseorang di luar sana akan menjatuhkan Anda - dengan cara yang baik :) Menambahkan indeks pasti akan membantu. Namun, selain operasi satu kali ini, bidang ini tidak pernah diakses / dikumpulkan oleh kolom BrowserID dan jadi saya lebih suka menyimpan byte tersebut karena tabelnya cukup besar (ini hanya salah satu dari banyak basis data yang identik). Tidak ada kunci unik di atas meja karena tidak ada keunikan alami untuk itu. Semua pilihan adalah oleh PaperID dan opsional periode.
Mark S. Rasmussen
22

Ketika saya menjalankan skrip Anda untuk membuat basis data hanya statistik dan kueri dalam pertanyaan saya mendapatkan paket berikut.

Rencana

Tabel Kardinalitas yang ditunjukkan dalam rencana adalah

  • tblFEStatsPaperHits: 48063400
  • tblFEStatsBrowsers : 339

Jadi diperkirakan perlu melakukan pemindaian tblFEStatsPaperHitssebanyak 339 kali. Setiap pemindaian memiliki predikat berkorelasi tblFEStatsBrowsers.BrowserID=tblFEStatsPaperHits.BrowserID AND tblFEStatsPaperHits.BrowserID IS NOT NULLyang didorong ke dalam operator pemindaian.

Rencana itu tidak berarti bahwa akan ada 339 pemindaian penuh. Karena berada di bawah operator anti semi join segera setelah baris pertama yang cocok pada setiap pemindaian ditemukan, ia dapat membuat hubungan pendek sisanya. Perkiraan biaya subtree untuk simpul ini adalah 1.32603dan seluruh paket dihitung biayanya 1.41337.

Untuk Gabung Hash itu memberikan rencana di bawah ini

Hash Bergabung

Paket keseluruhan dihitung biayanya 418.415(sekitar 300 kali lebih mahal daripada paket loop bersarang) dengan pemindaian indeks berkerumun tunggal penuh dengan tblFEStatsPaperHitsbiaya 206.8saja. Bandingkan ini dengan 1.32603perkiraan untuk 339 pemindaian parsial yang diberikan sebelumnya (Biaya pemindaian parsial rata-rata = 0.003911592).

Jadi ini akan menunjukkan bahwa setiap pemindaian parsial biayanya 53.000 kali lebih murah daripada pemindaian penuh. Jika penetapan biaya harus linier dengan jumlah baris maka itu berarti bahwa diasumsikan bahwa rata-rata hanya perlu memproses 900 baris pada setiap iterasi sebelum menemukan baris yang cocok dan dapat membuat hubungan pendek.

Saya tidak berpikir penetapan biaya skala dalam cara linier itu. Saya pikir mereka juga memasukkan beberapa elemen biaya startup tetap. Mencoba berbagai nilai TOPdalam kueri berikut

SELECT TOP 147 BrowserID 
FROM [dbo].[tblFEStatsPaperHits] 

147memberikan perkiraan biaya subtree terdekat ke 0.003911592pada 0.0039113. Either way jelas bahwa itu mendasarkan penetapan biaya pada asumsi bahwa setiap pemindaian hanya perlu memproses sebagian kecil dari tabel, dalam urutan ratusan baris daripada jutaan.

Saya tidak yakin matematika apa yang mendasari asumsi ini dan itu tidak benar-benar cocok dengan perkiraan jumlah baris di sisa rencana (236 estimasi baris yang keluar dari loop bersarang bergabung akan menyiratkan bahwa ada 236 kasus di mana tidak ada baris yang cocok ditemukan sama sekali dan pemindaian penuh diperlukan). Saya berasumsi ini hanya kasus di mana asumsi pemodelan yang dibuat jatuh agak dan meninggalkan rencana loop bersarang secara signifikan di bawah biaya.

Martin Smith
sumber
20

Dalam buku saya, bahkan satu pemindaian baris 50M tidak dapat diterima ... Trik saya yang biasa adalah untuk mewujudkan nilai yang berbeda dan mendelegasikan mesin dengan menjaganya agar tetap terbaru:

create view [dbo].[vwFEStatsPaperHitsBrowserID]
with schemabinding
as
select BrowserID, COUNT_BIG(*) as big_count
from [dbo].[tblFEStatsPaperHits]
group by [BrowserID];
go

create unique clustered index [cdxVwFEStatsPaperHitsBrowserID] 
  on [vwFEStatsPaperHitsBrowserID]([BrowserID]);
go

Ini memberi Anda indeks terwujud satu baris per BrowserID, sehingga tidak perlu memindai 50 juta baris. Mesin akan mempertahankannya untuk Anda dan QO akan menggunakannya 'apa adanya' dalam pernyataan yang Anda poskan (tanpa petunjuk atau permintaan penulisan ulang).

Kelemahannya tentu saja pertengkaran. Setiap operasi menyisipkan atau menghapus di tblFEStatsPaperHits(dan saya kira adalah tabel logging dengan sisipan berat) harus membuat serial akses ke BrowserID yang diberikan. Ada beberapa cara yang membuat ini bisa dilakukan (pembaruan tertunda, 2 tahapan logging dll) jika Anda bersedia membelinya.

Remus Rusanu
sumber
Saya mendengar Anda, setiap pemindaian yang besar umumnya tidak dapat diterima. Dalam hal ini untuk beberapa kali operasi pembersihan data jadi saya memilih untuk tidak membuat indeks tambahan (dan tidak dapat melakukannya sementara karena akan mengganggu sistem). Saya tidak memiliki EE tetapi mengingat ini hanya sekali, petunjuk akan baik-baik saja. Keingintahuan utama saya adalah bagaimana QO bangun dengan rencana itu :) Tabelnya adalah tabel logging dan ada sisipan yang berat. Ada tabel log asinkron terpisah yang kemudian memperbarui baris di tblFEStatsPaperHits sehingga saya bisa mengelolanya sendiri, jika perlu.
Mark S. Rasmussen