Lesson 0022 · Database · ±30 menit (15 konsep + kuis, 15 hands-on) · Hari 4

Index: clustered vs non-clustered, composite, dan covering (INCLUDE)

Pertanyaan database yang hampir pasti muncul, dijawab dengan angka logical reads dari lab sendiri

Target lesson ini: Anda bisa menjawab "Apa beda clustered dan non-clustered index? Query daftar Product per Category lambat: index apa yang Anda buat, dan kapan index justru merugikan?" tanpa catatan: dengan analogi buku, urutan kolom composite index yang benar, INCLUDE untuk menghilangkan key lookup, dan bukti sebelum/sesudah berupa logical reads dan execution plan yang sudah Anda lihat sendiri pada 100.000 Product.

Sebelum mulai: setup lab sudah dijalankan (ROADMAP, "Hari 4"). Setup sengaja hanya membuat dua index di dbo.Product: PK_Product (clustered, dari PRIMARY KEY) dan UQ_Product_Name (non-clustered, dari UNIQUE). Tidak ada index pada CategoryId, karena FOREIGN KEY tidak membuat index otomatis (lesson 0019). Padanan Node: ini sama dengan @@index([categoryId]) di schema Prisma atau CREATE INDEX di migration knex; bedanya, di interview .NET Anda diharapkan bisa menjelaskan apa yang terjadi di dalamnya.

1. Clustered vs non-clustered: analogi buku

Microsoft sendiri memakai analogi buku: index adalah "a sorted list of keywords and next to each keyword is a set of page numbers". Index rowstore disimpan sebagai B+ tree. [1]

Clustered indexNon-clustered index
Analogibuku telepon: isinya sendiri sudah urut namaindeks di belakang buku: kata kunci urut + nomor halaman
Isi leaf leveldata pages tabel itu sendiri, semua kolomkey index + penunjuk ke baris (row locator)
Jumlah per tabelsatu: "the data rows themselves can be stored in only one order"banyak
Penunjuk ke baris—tabel ber-clustered index: clustered key (di lab: Id); heap: RID
Di labPK_Product (Id)UQ_Product_Name (Name)

Kutipan tabel: [2]

Latihan 1 — Clustered atau non-clustered?

Jawab tanpa scroll ke atas. Soal yang sama akan muncul lagi, diacak, di review database 0024.

Kuis di lesson ini adalah feedback cepat, bukan bukti penguasaan; yang dicatat hanya hasil review 0024.

2. Key lookup, dan kenapa index kadang diabaikan

CREATE INDEX IX_Product_CategoryId ON dbo.Product (CategoryId);

SELECT Id, Name, Price FROM dbo.Product WHERE CategoryId = 6;   -- 20 baris

Index menemukan 20 entri Category 6 dengan Index Seek, tapi entri itu hanya berisi CategoryId dan Id. Name dan Price ada di clustered index, jadi engine "melompat ke halamannya" sekali per baris: Key Lookup. "The Key Lookup operator is a bookmark lookup on a table with a clustered index ... Key Lookup is always accompanied by a Nested Loops operator." [3]

Latihan 2 — Membaca plan

3. Covering index dengan INCLUDE

CREATE INDEX IX_Product_CategoryId_Covering
    ON dbo.Product (CategoryId)          -- key: kolom predikat, diurutkan, bisa di-seek
    INCLUDE (Name, Price);               -- hanya di leaf: diambil, tidak difilter

Latihan 3 — Covering

4. Composite index: urutan kolom menentukan segalanya

-- Query: 10 Product termahal di satu Category
-- SELECT TOP (10) Name, Price FROM dbo.Product WHERE CategoryId = @c ORDER BY Price DESC;
CREATE INDEX IX_Product_CategoryId_Price
    ON dbo.Product (CategoryId, Price DESC)
    INCLUDE (Name);

Index composite diurutkan seperti buku telepon: dulu per kolom pertama, lalu per kolom kedua di dalam setiap nilai kolom pertama. Contoh resmi: index (LastName, FirstName) berguna untuk WHERE LastName = 'Smith', tapi "the query optimizer wouldn't use the index for a query that searched only on WHERE FirstName = 'Jane'". [1]

Query terhadap (CategoryId, Price DESC)Hasil
WHERE CategoryId = 2Seek (kolom pertama)
WHERE CategoryId = 2 AND Price >= 4900Seek (kolom pertama + rentang kolom kedua)
WHERE CategoryId = 2 ORDER BY Price DESCSeek, tanpa Sort: baris sudah urut
WHERE Price >= 4900Tidak bisa seek: kolom kedua tanpa kolom pertama → scan

Aturan urutan dari Microsoft: kolom yang dipakai di predikat equality, inequality, BETWEEN, atau join "should be placed first", lalu kolom lain "from the most distinct to the least distinct". [1] Versi praktis untuk interview: equality dulu, lalu range atau ORDER BY, lalu kolom yang hanya diambil masuk INCLUDE.

Latihan 4 — Urutan kolom

5. Kapan index merugikan

Desain index adalah "a complex balancing act between query speed, index update cost, and storage cost." [1]

MasalahKenapa
Biaya tulis"A large number of indexes on a table affect the performance of INSERT, UPDATE, DELETE, and MERGE statements because data in indexes might have to change as data in the table changes."
Index spekulatif"A common design mistake is to create many indexes speculatively to 'give the optimizer choices'." Hasilnya overindexing yang memperlambat perubahan data dan bisa memicu masalah concurrency.
Selektivitas rendahKolom dengan sedikit nilai berbeda: "a physical telephone directory sorted alphabetically on family name doesn't expedite locating a person if all people in the city are named Smith or Jones." Di lab: CategoryId = 1 cocok dengan 90% baris.
Tabel kecilMenelusuri index bisa lebih lama "than to perform a base table scan".
Index tak terpakai"Monitor index usage, and remove unused indexes over time" lewat sys.dm_db_index_usage_stats.

Semua kutipan tabel: [1]. Ingat juga: "Don't always equate index usage with good performance".

Latihan 5 — Index atau bukan?

6. Hands-on (±15 menit): ukur sebelum dan sesudah

Alat ukurnya SET STATISTICS IO, TIME ON. Bandingkan logical reads ("Number of pages read from the data cache" [4]), bukan waktu: logical reads tidak berubah karena cache atau beban laptop, sedangkan milidetik dari STATISTICS TIME [5] naik-turun. Setiap page berukuran 8 KiB [6], jadi 800 logical reads ≈ 6,4 MB data yang disentuh.

  1. Container jalan (docker start sql2022) dan setup lab sudah dijalankan.
  2. Buka dotnet/lab/0022-index.sql di Azure Data Studio, VS Code mssql, atau SSMS. Nyalakan actual execution plan.
  3. Jalankan per bagian (blok sampai GO). Sebelum menjalankan setiap query, tebak dulu operatornya dan apakah logical reads naik atau turun. Catat angkanya di tabel ini:
QueryTanpa index(CategoryId)CoveringComposite
CategoryId = 6 (20 baris)… reads… reads… reads—
CategoryId = 2 (±2.500 baris)… reads… reads… reads—
TOP (10) … ORDER BY Price DESC——Sort?Sort?

Lewat sqlcmd: docker cp dotnet/lab/0022-index.sql sql2022:/tmp/0022.sql lalu docker exec sql2022 /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Dev_Passw0rd!' -C -i /tmp/0022.sql (logical reads tampil, plan tidak). Skrip menghapus semua index buatannya di bagian 6, jadi lab kembali ke kondisi setup untuk lesson 0023.

-- Lesson 0022 · Index: clustered vs non-clustered, key lookup, covering (INCLUDE), composite, biaya index.
-- Jalankan setelah dotnet/lab/00-setup-product-catalog-lab.sql. Boleh dijalankan ulang.
-- Jalankan per bagian (blok sampai GO). Nyalakan "Include Actual Execution Plan"
-- (Ctrl+M di SSMS, tombol "Explain"/"Actual plan" di Azure Data Studio / VS Code mssql),
-- lalu bandingkan dua hal untuk setiap query: operator di plan dan "logical reads" di tab Messages.
-- Angka di komentar adalah perkiraan; yang penting arah perubahannya.

USE ProductCatalogLab;
SET NOCOUNT ON;
GO

-- 0. Kondisi awal ---------------------------------------------------------------------------
-- Hapus sisa index lesson ini kalau skrip pernah dijalankan setengah jalan.
DROP INDEX IF EXISTS IX_Product_CategoryId ON dbo.Product;
DROP INDEX IF EXISTS IX_Product_CategoryId_Covering ON dbo.Product;
DROP INDEX IF EXISTS IX_Product_CategoryId_Price ON dbo.Product;
GO

-- Harus hanya dua: PK_Product (CLUSTERED, dari PRIMARY KEY) dan UQ_Product_Name (NONCLUSTERED, dari UNIQUE).
-- Perhatikan: FK_Product_Category TIDAK membuat index pada CategoryId.
SELECT i.name, i.type_desc, i.is_unique, i.is_primary_key
FROM sys.indexes AS i
WHERE i.object_id = OBJECT_ID(N'dbo.Product') AND i.index_id > 0
ORDER BY i.index_id;
GO

SET STATISTICS IO, TIME ON;
GO

-- 1. SEBELUM index --------------------------------------------------------------------------
-- Category 6 = 20 Product, Category 2 = ±2.500 Product, dari 100.000.
-- Plan: Clustered Index Scan. Logical reads = hampir semua page tabel (ratusan), walau hasilnya 20 baris.
SELECT Id, Name, Price FROM dbo.Product WHERE CategoryId = 6;
SELECT Id, Name, Price FROM dbo.Product WHERE CategoryId = 2;
GO

-- 2. Non-clustered index biasa: Index Seek + Key Lookup -------------------------------------
-- Index ini hanya berisi CategoryId (+ Id, clustered key yang otomatis ikut sebagai penunjuk).
CREATE INDEX IX_Product_CategoryId ON dbo.Product (CategoryId);
GO

-- Category 6: Index Seek + Nested Loops + Key Lookup. Name dan Price tidak ada di index,
-- jadi diambil dari clustered index untuk SETIAP baris. Logical reads turun jauh (puluhan).
SELECT Id, Name, Price FROM dbo.Product WHERE CategoryId = 6;

-- Category 2: kemungkinan besar tetap Clustered Index Scan. ±2.500 key lookup (masing-masing
-- beberapa page) lebih mahal daripada membaca seluruh tabel sekali. Index ada, tapi tidak dipakai.
SELECT Id, Name, Price FROM dbo.Product WHERE CategoryId = 2;

-- Hanya kolom yang ada di index (CategoryId, Id): Index Seek saja, tanpa Key Lookup, juga untuk Category 2.
SELECT Id FROM dbo.Product WHERE CategoryId = 2;
GO

-- 3. Covering index dengan INCLUDE ----------------------------------------------------------
-- Kolom predikat di key, kolom yang hanya diambil di INCLUDE (disimpan di leaf, tidak diurutkan).
-- Id tidak perlu ditulis: clustered key otomatis ada di setiap non-clustered index.
DROP INDEX IF EXISTS IX_Product_CategoryId ON dbo.Product;
CREATE INDEX IX_Product_CategoryId_Covering ON dbo.Product (CategoryId) INCLUDE (Name, Price);
GO

-- Keduanya sekarang Index Seek saja, tanpa Key Lookup. Category 2 akhirnya memakai index,
-- dengan logical reads jauh di bawah scan di bagian 1.
SELECT Id, Name, Price FROM dbo.Product WHERE CategoryId = 6;
SELECT Id, Name, Price FROM dbo.Product WHERE CategoryId = 2;

-- Masih covering, tapi hasilnya belum urut menurut Price: perhatikan operator Sort (atau Top N Sort) di plan.
SELECT TOP (10) Name, Price FROM dbo.Product WHERE CategoryId = 2 ORDER BY Price DESC;
GO

-- 4. Composite index dan urutan kolom -------------------------------------------------------
-- CategoryId di depan (equality), Price kedua supaya baris di dalam satu Category sudah urut harga.
DROP INDEX IF EXISTS IX_Product_CategoryId_Covering ON dbo.Product;
CREATE INDEX IX_Product_CategoryId_Price ON dbo.Product (CategoryId, Price DESC) INCLUDE (Name);
GO

-- Index Seek + Top, TANPA Sort: engine membaca 10 entri pertama Category 2 dan berhenti.
SELECT TOP (10) Name, Price FROM dbo.Product WHERE CategoryId = 2 ORDER BY Price DESC;

-- Kolom pertama + rentang di kolom kedua: tetap Index Seek.
SELECT Name, Price FROM dbo.Product WHERE CategoryId = 2 AND Price >= 4900;

-- Hanya kolom kedua: TIDAK bisa seek (leftmost prefix). Plan menjadi scan
-- (Index Scan atas index ini, atau Clustered Index Scan), logical reads naik lagi.
SELECT Name, Price FROM dbo.Product WHERE Price >= 4900;
GO

SET STATISTICS IO, TIME OFF;
GO

-- 5. Biaya index ----------------------------------------------------------------------------
-- Storage: setiap index adalah salinan kolomnya sendiri. Bandingkan page index buatan dengan PK_Product.
SELECT i.name, i.type_desc, ps.used_page_count, ps.row_count
FROM sys.dm_db_partition_stats AS ps
JOIN sys.indexes AS i ON i.object_id = ps.object_id AND i.index_id = ps.index_id
WHERE ps.object_id = OBJECT_ID(N'dbo.Product')
ORDER BY i.index_id;

-- Biaya tulis: UPDATE Price harus mengubah clustered index DAN IX_Product_CategoryId_Price.
-- Dibungkus transaction + ROLLBACK supaya data lab tidak berubah (transaction: lesson 0021).
BEGIN TRANSACTION;
UPDATE dbo.Product SET Price = Price + 1 WHERE CategoryId = 2;
ROLLBACK TRANSACTION;

-- Pemakaian index sejak server terakhir start: seek/scan/lookup (dibaca) vs user_updates (ditulis).
-- Index dengan user_updates tinggi tapi seek/scan nol hanya menambah biaya: kandidat dihapus.
SELECT i.name, us.user_seeks, us.user_scans, us.user_lookups, us.user_updates
FROM sys.indexes AS i
LEFT JOIN sys.dm_db_index_usage_stats AS us
    ON us.object_id = i.object_id AND us.index_id = i.index_id AND us.database_id = DB_ID()
WHERE i.object_id = OBJECT_ID(N'dbo.Product') AND i.index_id > 0
ORDER BY i.index_id;
GO

-- 6. Kembalikan lab ke kondisi setup --------------------------------------------------------
DROP INDEX IF EXISTS IX_Product_CategoryId ON dbo.Product;
DROP INDEX IF EXISTS IX_Product_CategoryId_Covering ON dbo.Product;
DROP INDEX IF EXISTS IX_Product_CategoryId_Price ON dbo.Product;
GO

-- Harus kembali hanya PK_Product dan UQ_Product_Name.
SELECT i.name, i.type_desc
FROM sys.indexes AS i
WHERE i.object_id = OBJECT_ID(N'dbo.Product') AND i.index_id > 0
ORDER BY i.index_id;
GO

7. Latihan tulis query

Tulis sendiri di editor SQL, jalankan di lab, baru buka jawaban. Tidak dinilai otomatis; bandingkan plan, logical reads, dan bentuk query-nya.

A. Endpoint "Product habis per Category" menjalankan SELECT Name, Stock FROM dbo.Product WHERE CategoryId = 5 AND Stock = 0 ORDER BY Name; Ukur dulu, lalu buat satu index yang membuat plan-nya Index Seek tanpa Key Lookup. Ukur lagi, lalu hapus index-nya.

Lihat jawaban
SET STATISTICS IO ON;
SELECT Name, Stock FROM dbo.Product WHERE CategoryId = 5 AND Stock = 0 ORDER BY Name;
-- Clustered Index Scan, logical reads = seluruh tabel

CREATE INDEX IX_Product_CategoryId_Stock
    ON dbo.Product (CategoryId, Stock)
    INCLUDE (Name);

SELECT Name, Stock FROM dbo.Product WHERE CategoryId = 5 AND Stock = 0 ORDER BY Name;
-- Index Seek (+ Sort kecil), tanpa Key Lookup, hanya beberapa logical reads
SET STATISTICS IO OFF;

DROP INDEX IF EXISTS IX_Product_CategoryId_Stock ON dbo.Product;

Hasilnya 19 baris (menurut rumus seed). Kedua predikat equality, jadi keduanya di key; Name hanya diambil, jadi INCLUDE dan index tetap covering. Sort untuk 19 baris murah. Kalau ingin tanpa Sort sama sekali, jadikan Name kolom key ketiga: (CategoryId, Stock, Name); urutan baris di dalam CategoryId dan Stock yang sama lalu sudah urut nama. (Stock, CategoryId) juga bisa seek untuk query ini, tapi tidak membantu query lain yang hanya memfilter CategoryId.

B. Tulis query katalog yang menampilkan setiap index di dbo.Product beserta kolomnya: nama index, jenis (CLUSTERED/NONCLUSTERED), nama kolom, posisi di key, dan apakah kolom itu kolom INCLUDE. Jalankan saat index dari latihan A masih ada.

Lihat jawaban
SELECT i.name AS IndexName, i.type_desc, c.name AS ColumnName,
       ic.key_ordinal, ic.is_descending_key, ic.is_included_column
FROM sys.indexes AS i
JOIN sys.index_columns AS ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE i.object_id = OBJECT_ID(N'dbo.Product')
ORDER BY i.index_id, ic.is_included_column, ic.key_ordinal;

Kolom INCLUDE punya key_ordinal = 0 dan is_included_column = 1. Perhatikan bahwa Id tidak muncul di baris index non-clustered walau secara fisik ikut tersimpan sebagai row locator: katalog hanya mencatat kolom yang Anda tulis.

Latihan 6 — Ucapkan dengan suara keras

Pasang timer dan jawab dengan suara keras, seolah sedang interview. Jangan buka jawaban contoh sebelum selesai.

"Apa beda clustered dan non-clustered index? Jelaskan dengan analogi." (60 detik)
Bandingkan dengan jawaban contoh dan checklist-nya

"Clustered index adalah tabelnya sendiri: baris data disimpan urut menurut key-nya, seperti buku telepon yang isinya sudah urut nama. Karena data hanya bisa diurutkan satu cara, satu tabel cuma punya satu clustered index, dan secara default itu dibuat dari primary key, misalnya Id IDENTITY yang sempit dan selalu naik. Non-clustered index adalah struktur terpisah, seperti indeks di belakang buku: berisi key yang diurutkan plus penunjuk ke baris, yaitu clustered key. Boleh ada banyak. Kalau query butuh kolom yang tidak ada di index, engine harus melompat ke clustered index untuk setiap baris, itu key lookup. Keduanya B+ tree, dan setiap index menambah biaya tulis."

Checklist: apakah Anda menyebut…

  • clustered = data tabel itu sendiri, disimpan urut menurut key (leaf = data pages)?
  • hanya satu clustered index per tabel karena data hanya bisa diurutkan satu cara?
  • PRIMARY KEY default menjadi clustered; key ideal sempit, unik, selalu naik (IDENTITY)?
  • non-clustered = struktur terpisah berisi key + penunjuk (clustered key), boleh banyak?
  • kolom di luar index diambil lewat key lookup ke clustered index?
  • analogi buku: isi yang urut vs indeks di belakang buku yang menunjuk halaman?

Lulus kalau minimal 4 dari 6.

"Endpoint daftar Product per Category lambat. Query-nya SELECT Name, Price FROM dbo.Product WHERE CategoryId = @c ORDER BY Price DESC. Index apa yang Anda buat, bagaimana membuktikannya, dan apa risikonya?" (90 detik)
Bandingkan dengan jawaban contoh dan checklist-nya

"Pertama saya ukur dulu dengan SET STATISTICS IO ON dan execution plan: tanpa index, plan-nya Clustered Index Scan dan logical reads-nya sebanyak seluruh tabel. Saya buat CREATE INDEX IX_Product_CategoryId_Price ON dbo.Product (CategoryId, Price DESC) INCLUDE (Name). CategoryId di depan karena itu predikat equality, Price kedua supaya hasilnya sudah urut dan tidak perlu Sort, dan Name di INCLUDE karena hanya diambil, tidak difilter. Index ini covering, jadi tidak ada key lookup. Lalu saya bandingkan lagi: plan menjadi Index Seek tanpa Key Lookup dan logical reads turun jauh. Risikonya, setiap INSERT dan UPDATE pada kolom itu sekarang juga memperbarui index ini, dan index makan storage, jadi saya tidak menambah index spekulatif dan memantau pemakaiannya."

Checklist: apakah Anda menyebut…

  • mengukur dulu: SET STATISTICS IO (logical reads) dan execution plan, sebelum dan sesudah?
  • CategoryId di depan sebagai kolom equality (leftmost prefix)?
  • Price sebagai kolom key kedua supaya ORDER BY tidak butuh Sort?
  • Name di INCLUDE karena hanya diambil: covering, tanpa key lookup?
  • bukti: Clustered Index Scan berubah menjadi Index Seek, logical reads turun?
  • risiko: biaya tulis dan storage, jangan menambah index spekulatif, pantau pemakaian?

Lulus kalau minimal 4 dari 6. Review 0024 akan menanyakan salah satunya lagi.

Bacaan utama (±15 menit) Microsoft Learn: Index architecture and design guide. Baca "Index basics", "Column considerations" (urutan kolom), dan "Use included columns in nonclustered indexes".

Ringkasan untuk dicetak: Reference: Index · Istilah: GLOSSARY.md (bagian "Database (SQL Server)")

Ada yang belum jelas? Tanyakan ke saya (agent) di terminal, misalnya: "kenapa GUID buruk sebagai clustered key?", "apa itu filtered index dan kapan dipakai?", atau "bagaimana EF Core membuat index lewat HasIndex dan IncludeProperties?"

Sumber: [1] Index architecture and design guide · [2] Clustered and nonclustered indexes described · [3] Showplan logical and physical operators reference · [4] SET STATISTICS IO · [5] SET STATISTICS TIME · [6] Pages and extents architecture guide · [7] CREATE INDEX