ایندکس چیست — و چه هزینهای داردWhat an index is — and what it costs you
به یک کتاب چاپی فکر کنید. برای پیدا کردن یک موضوع، همهٔ صفحهها را نمیخوانید — میروید سراغ فهرست پایان کتاب: موضوع، شمارهٔ صفحه، و یکراست میپرید همانجا. ایندکس دیتابیس دقیقاً همین کار را با جدول شما میکند. Think of a paper book. To find one topic, you don't read every page — you flip to the index at the back: topic, page number, jump straight there. A database index does exactly this for your table.
تعریفDefinition
ایندکس یک کپی کوچکتر و مرتب از ستونهای انتخابی جدول است که دیتابیس آن را نگه میدارد تا جستوجو در آن سریع باشد. هر خانهٔ ایندکس، مقدار ستون را دارد بههمراه اشارهای به سطر واقعی. An index is a smaller, sorted copy of chosen columns from your table, kept so the database can search them quickly. Each entry holds the column's value and a pointer back to the real row.
وقتی یک API با رشد جدول کند میشود، معمولاً اولین متهم نبودِ ایندکس است — حتی جلوتر از خودِ کد. در EF Core ایندکسها را با builder.HasIndex(...) داخل OnModelCreating میسازید و مثل بقیهٔ تغییرات با Migration ارسال میشوند.
When an API endpoint slows down as the table grows, a missing index is usually the first suspect — ahead of the code itself. In EF Core you create them with builder.HasIndex(...) inside OnModelCreating, shipped as migrations.
خواندنها سریع میشوندReads speed up
موتور بهجای خواندن کل جدول فقط چند صفحه را لمس میکند. معمولاً ۱۰۰ تا ۱۰٬۰۰۰ برابر سطرِ کمتر.The engine touches a handful of pages instead of the whole table. Often 100 to 10,000 times fewer rows.
نوشتنها کند میشوندWrites slow down
هر INSERT، UPDATE یا DELETE باید همهٔ ایندکسهای آن جدول را هم بهروز کند. ایندکس بیشتر = نوشتن کندتر.Every INSERT, UPDATE or DELETE must also update every index on that table. More indexes, slower writes.
دیسک و حافظه باد میکنندDisk and memory grow
ایندکس یک ساختار واقعی روی دیسک است و بخشهای پرمصرفش در RAM نگه داشته میشوند. هر دو پول دارند.An index is a real structure on disk, and hot parts are kept in RAM. Both cost money.
-- one column, one line
CREATE INDEX IX_Products_Name
ON dbo.Products(Name);جدول Productstable Products
۱٬۰۰۰٬۰۰۰ سطر.1,000,000 rows.
بدون ایندکسno index
WHERE Name = 'Kettle' ← بررسی ۱٬۰۰۰٬۰۰۰ سطر (ثانیهها)WHERE Name = 'Kettle' → examines 1,000,000 rows, seconds
ایندکس روی Nameindex on Name
جستوجو: ~۴ صفحه (≈ 1 ms)Lookup: ~4 pages (≈ 1 ms)
صورتحساب: اگر ۳ ایندکس داشته باشید، هر INSERT میشود 1 سطر + 3 ایندکس = 4 نوشتنThe bill: with 3 indexes, each INSERT becomes 1 row + 3 indexes = 4 writes
قاعدهٔ سرانگشتی: برای کوئریهایی ایندکس بسازید که واقعاً اجرا میشوند، نه «برای احتیاط». ایندکسی که استفاده نشود فقط خرج میتراشد. Rule of thumb: index for the queries you actually run, not "just in case". An unused index only costs you.
اسکن کامل در برابر جستوجوی ایندکسی — تفاوت را تماشا کنیدFull scan vs indexed lookup — watch the difference
دیتابیسها حافظه را در تکههایی با اندازه ثابت میخوانند که به آنها صفحه میگویند — در SQL Server هر صفحه ۸ کیلوبایت. برای جواب دادن به یک کوئری دو راه اساسی وجود دارد: Databases read storage in fixed-size chunks called pages — 8 KB each in SQL Server. There are two basic ways to answer a query:
اسکن کامل همهٔ صفحهها را پشتسرهم میخواند و هر سطر را چک میکند. ساده است، اما هزینهاش با اندازهٔ جدول رشد میکند.
جستوجوی ایندکسی (seek) از روی چند صفحهٔ ایندکس عبور میکند و یکراست به سطرهای منطبق میپرد. هزینهاش حتی روی جدولهای غولپیکر بهزحمت رشد میکند.
A full scan reads every page, one after another, and checks every row. Simple, but the cost grows with the table. An
index seek walks a few pages of the index, then jumps straight to the matching rows. Its cost barely grows, even on huge tables.
هر مربع کوچک یک صفحهٔ جدول است. هر دو موتور را اجرا کنید و ببینید کدام زودتر تمام میکند.Each little square is one table page. Run both engines and watch who finishes first.
اسکن کاملFull scan
جستوجوی ایندکسیIndex seek
در SSMS گزینهٔ SET STATISTICS IO ON را روشن کنید و logical reads را قبل و بعد از افزودن ایندکس مقایسه کنید. دیدنِ اینکه ۱۲٬۰۴۵ خواندن میشود ۹، نسخهٔ روزمرهٔ همین برد است؛ و برنامهٔ اجرا هم از Table Scan به Index Seek تغییر میکند.
Turn on SET STATISTICS IO ON in SSMS and compare logical reads before and after adding an index. Watching 12,045 reads drop to 9 is the everyday version of this win, and the plan switches from Table Scan to Index Seek.
SET STATISTICS IO ON;
SELECT Id, Total FROM dbo.Orders
WHERE CustomerId = 1042;
-- before: Table 'Orders'. Scan count 1, logical reads 12045
CREATE INDEX IX_Orders_CustomerId
ON dbo.Orders(CustomerId);
-- same query again:
-- after: Table 'Orders'. Scan count 1, logical reads 9ایندکس هش — رختکنِ جستوجوهاHash indexes — the coat-check of lookups
در رختکن، کتتان را تحویل میدهید و یک شماره میگیرید. بعداً همان شماره را نشان میدهید و کتتان را بیدرنگ پس میگیرید — کسی دنبال چیزی نمیگردد. شماره، مستقیم به قلابِ درست میرسد. ایندکس هش هم دقیقاً همینطور کار میکند. At a coat check, you hand over your coat and get a number. Later, that number leads straight to the right hook — no searching. A hash index works exactly like this.
یک تابع هش، کلید شما مثلاً user:42 را میگیرد و یک عدد از آن میسازد. دیتابیس این عدد را روی تعداد سطلها تقسیم میکند و باقیماندهٔ آن مشخص میکند که کلید در کدام سطل مینشیند؛ مثلاً سطل شمارهٔ ۵ از ۸. خودِ مقدار (یا اشارهگر به سطر) دقیقاً در همان سطل ذخیره میشود.
A hash function takes your key say user:42 and produces a number. The database divides this number by the bucket count and uses the remainder to decide which bucket the key lands in — for instance, bucket 5 out of 8. The value (or a pointer to the row) is stored right there in that bucket.
پیدا کردنِ یک کلیدِ مشخص با این روش، سرعت ثابتی دارد (O(1)). یعنی فرقی نمیکند جدول شما ۱۰۰ سطر داشته باشد یا ۱۰۰ میلیون — موتور همیشه فقط یک سطل را باز میکند و جواب را میگیرد.
Finding one exact key with this method is constant time (O(1)). Whether your table has 100 rows or 100 million, the engine always opens just one bucket and retrieves the answer.
این دموی همان فرمول است: تابع هش کلید را به عدد تبدیل میکند و باقیماندهٔ تقسیم بر ۸ مشخص میکند کلید در کدام سطل بنشیند. یک کلید بنویسید و جستوجو را بزنید؛ بعد دکمهٔ range را بزنید تا ببینید چرا برای محدودهها به درد نمیخورد. This demo is the formula in action: hash turns a key into a number, and that number modulo 8 picks the bucket. Type a key and press look up; then press range to see why it fails for ranges.
۸ سطل — هر کلید فقط در یکی مینشیند و هیچ ترتیبی بین سطلها نیست. 8 buckets — every key lands in exactly one, and there is no order between buckets.
اما یک گرفتاری بزرگ دارد: ایندکس هش نمیتواند کوئریهای range را جواب بدهد. پرسیدنِ «هر کلیدی بین ۱۰ تا ۲۰ را بده» نیاز دارد داده مرتب باشد، و تابع هش همین ترتیب را دور میریزد. سطل ۳ به هیچ معنای مفیدی «کنار» سطل ۴ نیست. یعنی نه > و نه <، نه BETWEEN، نه ORDER BY و نه پیشوندی مثل LIKE 'ab%'.
The big catch: hash indexes cannot answer range queries. Asking "every key between 10 and 20" needs sorted data, and hashing throws that order away. Bucket 3 is not "next to" bucket 4 in any useful sense — no >, no <, no BETWEEN, no ORDER BY, no LIKE 'ab%'.
این ساختار را هر روز استفاده میکنید: Dictionary<TKey,TValue> یعنی ایندکس هش در RAM. همان محدودیت را هم دارد — برای «همهٔ کلیدهای بزرگتر از X» باید همهٔ خانهها را بگردید. در SQL Server ایندکس HASH فقط روی جدولهای حافظهمحور (In-Memory OLTP) وجود دارد؛ جدولهای دیسکی معمولی از درخت B استفاده میکنند که در ایستگاه ۰۵ میرسیم.
You already use this structure daily: Dictionary<TKey,TValue> is a hash index in RAM, with the same limit. In SQL Server, HASH indexes exist only on memory-optimized tables; ordinary disk tables use B-trees (stop 05).
// Dictionary is a hash index in RAM
var price = new Dictionary<string, decimal>();
price["SKU-42"] = 19.99m; // write: O(1)
var p = price["SKU-42"]; // read: O(1)
// "all keys greater than SKU-42"?
// → no order stored. Walk EVERY entry.-- only on MEMORY_OPTIMIZED tables
CREATE TABLE dbo.SessionCache (
SessionId UNIQUEIDENTIFIER NOT NULL,
Payload NVARCHAR(4000),
INDEX IX_Session HASH (SessionId)
WITH (BUCKET_COUNT = 131072)
) WITH (MEMORY_OPTIMIZED = ON);SSTable و درخت LSM — ساختهشده برای طوفانِ نوشتنSSTables & LSM-Trees — built for write storms
اینجا سه اصطلاح یاد میگیرید:Three terms to learn here:
SSTable
SSTable مخفف Sorted String Table است؛ یعنی فایلی روی دیسک که کلیدهایش را مرتب نگه میدارد. این فایل یکبار نوشته میشود و بعد از آن، هیچکس آن را تغییر نمیدهد — به همین دلیل به آن تغییرناپذیر (Immutable) میگویند. هر SSTable مثل یک جلد کتاب است که بعد از چاپ، دیگر صفحهای به آن اضافه یا کم نمیشود. SSTable stands for Sorted String Table — a file on disk that keeps its keys in sorted order. Once written, it is never modified — that's why it's called immutable. Think of each SSTable as a printed book: no pages are added or removed after printing.
Memtable
Memtable یک ساختار مرتب در RAM (حافظهٔ اصلی) است. هر نوشتنِ جدید، اول در اینجا مینشیند تا وقتی که به اندازهٔ کافی پر شود. در این لحظه، موتور تمام محتوای آن را یکجا بهصورت یک SSTable روی دیسک مینویسد. به این کار Flush میگویند. مزیتش: هیچ نوشتهای مستقیماً به دیسک نمیرود، پس دیسک از شرِ عملیاتِ تصادفیِ پرهزینه در امان است. Memtable is a sorted structure kept in RAM. Every new write lands here first, until it fills up. When it's full, the engine flushes its entire content to disk as a single SSTable. This keeps random disk writes away — writes are cheap and sequential.
Compaction
Compaction یک فرایندِ همیشگی و پسزمینه است که چند SSTable را با هم ادغام میکند — درست مثل الگوریتم merge-sort. نتیجهٔ آن یک SSTableِ تازه و مرتبتر است که سطرهای بازنویسیشده یا حذفشده را ندارد. بدون compaction، تعداد فایلها روی دیسک مدام زیاد میشود و خواندنها کند میشوند. compaction باعث میشود دیسک مرتب و خواندنها سریع بمانند. Compaction is a continuous background process that merges several SSTables — exactly like merge-sort — into one fresh, compact SSTable. It discards overwritten or deleted rows. Without compaction, files would pile up and reads would slow down. Compaction keeps the disk tidy and reads fast.
RAM — Memtable
دیسک — SSTableها (تغییرناپذیر)Disk — SSTables (immutable)
جمعهٔ سیاه، ساعت ۲۱:۰۰ — ۵۰٬۰۰۰ نوشتنِ سبد خرید در ثانیه. در روشهای سنتی که هر نوشتن را مستقیم روی دیسک مینویسند، موتور باید صفحهی مربوطه را پیدا کند: یعنی IO تصادفی، صف دیسک، و جهشِ تأخیر. اما در LSM، نوشتنها اول در RAM مینشینند و بعد بهشکل یک جاروی ترتیبیِ بلند روی دیسک flush میشوند — و دیسکها عاشق نوشتنِ ترتیبیاند. Black Friday, 21:00 — 50,000 cart writes per second. In traditional approaches that write directly to disk, the engine must locate the right page: random IO, disk queues, latency spikes. In LSM, writes land in RAM first, then flush as one long sequential sweep — and disks love sequential writes.
بههمین دلیل، سیستمهایی که نوشتن در آنها حرف اول را میزند، از این خانواده استفاده میکنند. نامهایی مثل Cassandra، HBase، ScyllaDB، RocksDB و LevelDB را ممکن است شنیده باشید — همهی آنها موتورهایی LSM-محور هستند. نقطهضعفشان خواندن است، چون یک مقدار ممکن است در هرکدام از چندین SSTable باشد؛ پس هر خواندن شاید چند فایل را جستوجو کند. دو ترفند این مشکل را کم میکند: ۱ همیشه اول جدیدترین فایل را چک کن (چون دادهی جدیدتر برنده است)؛ ۲ از Bloom Filter استفاده کن — ساختاری کوچک که جواب میدهد «قطعاً این کلید در این فایل نیست» تا موتور بدون اینکه دیسک را لمس کند، آن فایل را رد کند. This is why write-heavy systems use this family. You may have heard names like Cassandra, HBase, ScyllaDB, RocksDB and LevelDB — all are LSM-based engines. Their weak spot is reads, because a value might be in any of several SSTables. Two tricks help: 1 always check the newest file first (newer data wins); 2 use a Bloom Filter — a tiny structure that says "this key is definitely not here", so the engine skips that file without touching disk.
برای اینکه در هنگامِ خرابی (crash) دادهای از دست نرود، هر نوشتن، علاوه بر memtable، به یک لاگِ کوچک و فقط-اضافهشونده (append-only) هم اضافه میشود. به این لاگ، WAL (Write-Ahead Log) میگویند. اگر برق برود، موتور هنگام راهاندازی مجدد، این لاگ را دوباره اجرا میکند و دادههای ازدسترفتهی RAM را بازیابی میکند. در ایستگاه ۰۵ (درخت B) دوباره با همین مفهوم WAL آشنا میشوید. For durability, every write is also appended to a small, append-only log called the WAL (Write-Ahead Log). If power fails, the engine replays this log on startup to recover any data lost from RAM. You'll meet the same WAL concept again at stop 05 (B-Trees).
در SQL Server خودتان، خبری از تنظیمات LSM نیست. اما همین بخش به شما نشان میدهد که چرا یک تیم برای سرویس ورودیِ عظیمِ IoT سراغ Cassandra میرود، یا چرا استورهای مبتنی بر RocksDB برای کشهای محلی با نوشتنهای سنگین، حسِ «آنِی» دارند. هدف این است که با شکلِ هر موتور آشنا شوید تا بتوانید برای هر نیاز، دیتابیسِ مناسب را انتخاب کنید. Inside SQL Server itself, you won't be configuring LSM. But this section explains why teams choose Cassandra for massive IoT ingestion, and why RocksDB-based stores feel instant for write-heavy local caches. Understand the shape of each engine so you can pick the right database for the right job.
درخت B — اسبِ کاری دیتابیسهای رابطهایB-Trees — the workhorse of relational databases
درخت B یک درخت است که از صفحه ساخته شده. هر گره، چند کلیدِ مرتب و چند اشارهگر به گرههای فرزند دارد. دو ویژگی، آن را از بقیهٔ ساختارها جدا میکند: A B-tree is a tree made of pages. Each node holds several sorted keys and pointers to child nodes. Two features set it apart:
همیشه متعادلAlways balanced
همهٔ برگها در یک عمق هستند. وقتی گرهای پر شد، به دو نیم تقسیم میشود و کلیدِ میانی به گرهٔ پدر میرود. درخت بهجای اینکه کج شود، از ریشه بلندتر میشود. هیچوقت نیازی به متعادلسازی دستی نیست — خودش همیشه مرتب است. All leaves are at the same depth. When a node fills up, it splits in two and the middle key moves up to the parent. Instead of tilting, the tree grows from the root. No manual rebalancing — ever.
شاخههای پرشمار (fan-out بالا)High fan-out
Fan-out یعنی تعداد فرزندانی که هر گره میتواند داشته باشد. در یک صفحهٔ ۸ کیلوبایتی SQL Server، این عدد معمولاً در حد چند صد است. همین باعث میشود درخت با وجود میلیاردها سطر، عمقِ بسیار کمی داشته باشد. Fan-out is how many children each node can have. On an 8 KB SQL Server page, this number is typically in the hundreds. That's why the tree stays shallow even with billions of rows.
پس با فقط ۴ سطح، میتوانیم ۸ میلیارد کلید را پوشش دهیم. این یعنی برای پیدا کردن هر کلیدی، فقط کافی است از ریشه شروع کنیم، ۳ بار تصمیم بگیریم، و به برگ برسیم — یعنی حداکثر ۴ صفحه خواندن. این همان جادویِ درخت B است: عمق با لگاریتمِ تعداد سطرها رشد میکند، نه با خودِ تعداد سطرها. So with just 4 levels, we can cover 8 billion keys. That means to find any key, we just start at the root, make 3 decisions, and reach the leaf — at most 4 page reads. That's the magic of the B-tree: depth grows with the logarithm of the row count, not with the row count itself.
فقط ۳ تا ۴ صفحه کافی است تا از ریشه به هر سطری در یک جدولِ میلیاردی برسید. هر سطح، تعداد کلیدها را در fan-out ضرب میکند — به همین دلیل عمقِ درخت با بزرگشدن جدول، خیلی کم رشد میکند. Just 3 to 4 pages are enough to go from the root to any row in a billion-row table. Each level multiplies the total by the fan-out — that's why the depth grows so slowly as the table grows.
کوئریهای محدوده (Range) چطور کار میکنند؟
فرض کنید میخواهید همهٔ سفارشهای بین تاریخ ۱ تا ۱۵ مرداد را پیدا کنید. موتور اول از ریشه شروع میکند و تا برگی پایین میرود که شروعِ محدوده (۱ مرداد) در آن قرار دارد. بعد از آن، بهجای اینکه دوباره از ریشه بالا برود، از زنجیرهٔ برگها (همان خطچینهایی که در شکل دیدید) به سمت راست حرکت میکند و برگها را یکییکی میخواند تا به انتهای محدوده (۱۵ مرداد) برسد. این زنجیره است که درخت B را برای BETWEEN، >، < و ORDER BY به یک ابزارِ بینظیر تبدیل میکند.
How range queries work:
Suppose you want all orders between August 1st and 15th. The engine starts at the root, descends to the leaf that holds the start of the range (Aug 1). Then, instead of climbing back up, it walks right along the leaf chain (the dashed lines you saw in the diagram), reading leaves one by one until it reaches the end of the range (Aug 15). This chain is what makes B-trees excellent at BETWEEN, >, <, and ORDER BY.
یک نکتهٔ حیاتی: درخت B صفحهها را درجا بهروز میکند — یعنی تغییر را مستقیماً روی همان صفحهٔ دیسک مینویسد. اگر در حین نوشتن، برق برود یا سیستم crash کند، آن صفحه ممکن است نصفهنیمه باقی بماند و خراب شود. برای جلوگیری از این اتفاق، دیتابیس قبل از اینکه هر صفحهای را تغییر دهد، آن تغییر را به یک فایلِ فقط-اضافهشونده به نام WAL (Write-Ahead Log) اضافه میکند. اگر crash رخ دهد، موتور هنگام راهاندازی مجدد، این لاگ را از اول میخواند و تغییراتِ ناتمام را دوباره اعمال میکند. در SQL Server به این لاگ، لاگ تراکنش (Transaction Log) میگویند — همان فایلی که گاهی بزرگ میشود و حالا میدانید که دقیقاً برای چیست. A critical point: B-trees update pages in place — they write changes directly to the disk page. If power fails mid-write, that page could be left half-finished and corrupted. To prevent this, the database appends every change to an append-only file called the WAL (Write-Ahead Log) before touching the actual page. If a crash occurs, the engine replays this log on startup to reapply any incomplete changes. In SQL Server, this is the transaction log — the file that sometimes grows large, and now you know exactly why.
هر ایندکسی که در SQL Server میسازید، یک درخت B است. فرقی نمیکند ایندکس خوشهای (Clustered) باشد یا غیرخوشهای (Non-Clustered) — همه درخت B هستند. حتی کلید اصلی (Primary Key) هم در SQL Server بهصورت پیشفرض یک ایندکس خوشهای از جنس درخت B است. Every index you create in SQL Server is a B-tree. Whether it's clustered or non-clustered — all of them are B-trees. Even the Primary Key is, by default, a clustered B-tree index in SQL Server.
وقتی در EF Core با builder.HasIndex(x => x.CustomerId) یک ایندکس تعریف میکنید، دقیقاً همین ساختار در پشتصحنه ساخته میشود. عمقِ این درخت معمولاً بین ۳ تا ۴ سطح است — حتی اگر جدول شما میلیونها سطر داشته باشد. به همین دلیل است که یک Index Seek همیشه سریع است، بدون توجه به بزرگیِ جدول.
When you define an index in EF Core with builder.HasIndex(x => x.CustomerId), this exact structure is built behind the scenes. The depth of this tree is typically 3 to 4 levels — even if your table has millions of rows. That's why an Index Seek is always fast, regardless of table size.
یک نکتهٔ ظریف: اگر کلید اصلی را از نوع GUID انتخاب کنید، بهخاطر تصادفی بودنِ مقادیر، درجِ جدید میتواند باعث Page Split (شکافتنِ صفحه) شود و کارایی را کاهش دهد. بههمین دلیل است که توصیه میشود برای کلید اصلی از اعداد ترتیبی (مانند int یا bigint با IDENTITY) یا GUIDهای ترتیبی (مانند NEWSEQUENTIALID()) استفاده کنید.
A subtle point: if you choose a GUID as your Primary Key, random values can cause Page Splits and hurt performance. That's why it's recommended to use sequential keys like int or bigint with IDENTITY, or sequential GUIDs like NEWSEQUENTIALID().
LSM در برابر درخت B — دو معیارِ کلیدیLSM vs B-Tree — two key metrics
دو مفهوم، بیشترِ بحثهای انتخابِ موتور را شکل میدهند: Write Amplification (هزینهی نوشتن) و Read Amplification (هزینهی خواندن). نوارهای زیر نشان میدهند که هر موتور در این دو معیار، چطور عمل میکند. Two concepts shape most engine-selection debates: Write Amplification (write cost) and Read Amplification (read cost). The bars below show how each engine performs on these two metrics.
Write Amplification — هر نوشتنِ شما چقدر روی دیسک اثر میگذارد؟Write amplification — how much disk work per write?
Read Amplification — هر خواندنِ شما چند بررسی فیزیکی میشود؟Read amplification — how many physical checks per read?
| LSM-Tree | B-Tree | |
|---|---|---|
| الگوی دیسکDisk pattern | نوشتن ترتیبی، توانِ عالی در فشارِ بالاSequential writes, great throughput under load | نوشتن تصادفی، صفهای دیسک در فشارِ بالاRandom writes, disk queues under load |
| کوئریهای RangeRange queries | ممکن، اما کندتر — چند فایل باید ادغام شوندPossible but slower — several files must be merged | بومی و سریع — بهبرکت زنجیرهٔ برگهاNative and fast — thanks to the leaf chain |
| کاربران معروفFamous users | Cassandra, HBase, ScyllaDB, RocksDB | SQL Server, PostgreSQL, MySQL, Oracle |
LSM را انتخاب کنید وقتی…Choose LSM when…
نوشتن غالب است: لاگها، فیدهای IoT، سریزمانی، سبدهای جمعهسیاه. میپذیرید که خواندن کمی کندتر و کمقابلپیشبینیتر باشد. Writes dominate: logs, IoT, time-series, Black-Friday carts. You accept slightly slower, less predictable reads.
درخت B را انتخاب کنید وقتی…Choose B-Tree when…
خواندن و نوشتن متعادل است، تراکنشها مهماند، کوئریهای Range زیاد دارید، و به تأخیرِ قابلپیشبینی نیاز دارید — درست مثل SQL Server خودتان. Reads and writes are balanced, transactions matter, range queries are common, and you need predictable latency — just like your SQL Server.
یک مثالِ عینی: یک رکورد ۱۰۰ بایتی را UPDATE میکنید.
A concrete example: you UPDATE a 100-byte record.
-- B-Tree
B-Tree: Changing 100 bytes -> rewrites an entire 8 KB page + a log entry
-> Write Amplification, visible right here
-- LSM
LSM: Changing 100 bytes -> appended to memtable in RAM (cheap)
... but this data will be rewritten 10–30 times later during compaction
The bill arrives in installments over time.
قاعدهی طلایی برای شما: SQL Server شما همان درخت B است — همین. وقتی صحبت از LSM میشود، یعنی برای آن کارِ خاص، باید سراغ یک دیتابیسِ دیگر بروید. مثلاً Cassandra برای جذب کردنِ حجمِ عظیمِ نوشتن، یا کشهای مبتنی بر RocksDB برای سرعتِ محلی. یک برنامه، چند موتور — هرکدام برای وظیفهی خودش. The golden rule for you: your SQL Server is B-Tree — that's it. When we talk about LSM, it means for that specific job, you should pick a different database. Like Cassandra for ingesting massive writes, or RocksDB-based caches for local speed. One app, multiple engines — each for its own task.
خوشهای در برابر غیرخوشهای — زمینِ خانگیِ شماClustered vs non-clustered — your home turf
ایندکس خوشهای (Clustered)Clustered index
خودِ جدول است. صفحههای داده، همان سطح برگِ درخت B هستند و دادهها بهطور فیزیکی بر اساس کلید خوشهبندی مرتب شدهاند. چون داده فقط یکجور میتواند مرتب باشد، هر جدول دقیقاً یک ایندکس خوشهای دارد. در SQL Server، کلید اصلی (Primary Key) بهصورت پیشفرض، همان ایندکس خوشهای میشود. It IS the table. Data pages are the B-tree leaf level, physically sorted by the clustering key. Data can only be sorted one way, so each table has exactly one clustered index. In SQL Server, the Primary Key becomes the clustered index by default.
ایندکس غیرخوشهای (Non-Clustered)Non-clustered index
یک درخت B جداگانه و کوچکتر. برگهایش کلید ایندکس را دارند، بههمراه یک اشارهگر به سطرِ متناظر در جدول اصلی. این اشارهگر، یا مقدارِ کلید خوشهبندی است (اگر جدول ایندکس خوشهای داشته باشد)، یا شناسهٔ فیزیکیِ سطر (اگر جدول Heap باشد — یعنی بدون ایندکس خوشهای). A separate, smaller B-tree. Its leaves hold the index key plus a pointer to the corresponding row in the main table. This pointer is either the clustering key (if the table has a clustered index) or a physical row ID (if the table is a Heap — no clustered index).
حالا یک هزینهٔ پنهان اینجا وجود دارد: وقتی یک ایندکس غیرخوشهای را میخوانید، برگِ آن به شما میگوید «این کلید را پیدا کردم، حالا برو به جدول اصلی تا بقیهی دادهها را بیاوری». به این پرشِ اضافی، Key Lookup (یا در کتابهای قدیمیتر، Bookmark Lookup) میگویند. برای پیدا کردنِ چند سطر، این پرش ارزان است؛ اما اگر کوئری شما صدها یا هزاران سطر برگرداند، این پرشها جمع میشوند و هزینهی سنگینی ایجاد میکنند. Now there's a hidden cost: when you read a non-clustered index, its leaf tells you "I found this key — now go to the main table to get the rest of the data." This extra hop is called a Key Lookup (or Bookmark Lookup in older books). For a few rows, this hop is cheap; but if your query returns hundreds or thousands of rows, these lookups add up and become expensive.
تشبیه با دفتر تلفن:
ایندکس خوشهای، خودِ دفتر تلفن است که بر اساسِ اسم مرتب شده — خودِ داده، بهترتیبِ الفبا.
ایندکس غیرخوشهای، یک فهرستِ جداگانه است، مثلاً «شمارهی تلفن ← اسم». شما شماره را پیدا میکنید، بعد باید بروید توی دفتر تلفن (جدول اصلی) و بقیهٔ اطلاعات را از همان سطر پیدا کنید. این رفتوبرگشت، همان Key Lookup است.
اما اگر همان فهرستِ جداگانه، از قبل همهٔ ستونهایی را که نیاز دارید داشته باشد، دیگر نیازی به برگشت به دفتر تلفن نیست. به این میگویند ایندکس پوششی (Covering Index). در SQL Server، ستونهای اضافی را با INCLUDE به ایندکس اضافه میکنید تا پوششی شود. در این حالت، موتور اصلاً به جدول اصلی دست نمیزند — جواب یکراست از همان درختِ کوچکِ غیرخوشهای میآید.
Phone book analogy:
The clustered index is the phone book itself — sorted by name. The data is physically in that order.
A non-clustered index is a separate list, like "phone number → name". You find the number, then you have to go back into the phone book (the main table) to get the rest of the info from that row. That round-trip is the Key Lookup.
But if that separate list already has all the columns you need, there's no need to go back to the phone book. That's a Covering Index. In SQL Server, you add extra columns with INCLUDE to make it covering. In this case, the engine never touches the main table — the answer comes straight from the smaller non-clustered tree.
سناریو: کوئریِ SELECT CustomerId, Total FROM Orders WHERE CustomerId = 1042. مشتری ۱۰۴۲، ۲۷ سفارش در جدول دارد. ایندکسی روی CustomerId ساختهایم، اما ستون Total را ندارد. پس موتور باید برای هر کدام از این ۲۷ سفارش، به جدول اصلی برگردد و Total را بخواند — این همان Key Lookup است.
Scenario: the query SELECT CustomerId, Total FROM Orders WHERE CustomerId = 1042. Customer 1042 has 27 orders in the table. We have an index on CustomerId, but it does not include Total. So the engine must go back to the main table for each of these 27 orders to read Total — that's the Key Lookup.
IX_Orders_CustomerId+ INCLUDE (Total)
۲۷ کلید CustomerId = 1042 در ایندکس غیرخوشهای
27 keys CustomerId = 1042 in the non-clustered index
هر کلید، یک اشارهگر به جدول اصلی دارد Each key has a pointer to the main table
جدول اصلی (خوشهای) — Orders · ۵٬۰۰۰٬۰۰۰ سطرMain table (clustered) — Orders · 5,000,000 rows
۲۷ بار Key Lookup: موتور باید برای هر کدام از ۲۷ سفارش، به جدول اصلی برگردد و Total را بخواند.
27 Key Lookups: the engine must go back to the main table for each of the 27 orders to read Total.
سوییچ را بزنید و تغییر را ببینید:
وقتی INCLUDE (Total) را فعال میکنید، ستون Total بههمراه کلیدهای ایندکس ذخیره میشود. حالا موتور بدون برگشت به جدول اصلی، همهی دادهها را از همان ایندکسِ کوچک میخواند. Logical Reads تقریباً نصف میشود و تعداد Key Lookup به صفر میرسد.
Flip the switch and watch the change:
When you enable INCLUDE (Total), the Total column is stored alongside the index keys. Now the engine reads all the data from the small index itself — no trip back to the main table. Logical Reads roughly halve, and Key Lookups drop to zero.
b.Entity<Order>(o =>
{
// PK → clustered by default
o.HasKey(x => x.Id);
o.HasIndex(x => x.CustomerId)
.Include(x => new { x.Total }); // covering
});CREATE INDEX IX_Orders_CustomerId
ON dbo.Orders(CustomerId)
INCLUDE (Total);
-- need a different sort for the table?
CREATE CLUSTERED INDEX CX_Orders
ON dbo.Orders(OrderDate);بدون INCLUDE (Total)Without INCLUDE (Total)
ایندکس، ۲۷ کلیدِ CustomerId = 1042 را پیدا میکند. اما چون Total را ندارد، موتور برای هر کدام از این ۲۷ سطر، یک بار به جدول اصلی (درختِ بزرگِ خوشهای) برمیگردد و Total را میخواند. ۲۷ بار Key Lookup = ۲۷ رفتوبرگشتِ اضافی.
The index finds 27 keys for CustomerId = 1042. But since it doesn't have Total, the engine goes back to the main table (the big clustered tree) for each of these 27 rows to read Total. 27 Key Lookups = 27 extra round-trips.
با INCLUDE (Total)With INCLUDE (Total)
حالا ستون Total هم در همان ایندکسِ کوچکِ غیرخوشهای ذخیره شده است. موتور ۲۷ کلید را پیدا میکند و بدون حتی یک برگشت به جدول اصلی، همهی دادهها را از همان جا میخواند. Key Lookup به صفر رسید؛ Logical Reads تقریباً نصف شد.
Now Total is stored inside the small non-clustered index. The engine finds the 27 keys and reads all the data from there — without a single trip back to the main table. Key Lookups dropped to zero; Logical Reads roughly halved.
این بخش، سه معمای روزمرهی شما را یکجا حل میکند:
۱. چرا کلید اصلی (PK) پیشفرض خوشهای است؟
در دیتابیسِ SQL Server، بهصورت پیشفرض، کلید اصلی (Primary Key) همان ایندکس خوشهای (Clustered) میشود. در EF Core، متد HasKey() دقیقاً همین کار را میکند. اگر بخواهید این رفتار را تغییر دهید، از .IsClustered(false) استفاده کنید — اما معمولاً نیازی به این کار نیست.
۲. چرا کلید اصلی از نوع GUID ضرر دارد؟
چون GUIDها تصادفی هستند، هر سطرِ جدید در جایی تصادفی از جدول درج میشود و باعث Page Split (شکافتنِ صفحه) و بههمریختگیِ فیزیکیِ دیسک میشود. بهترین راهحل، استفاده از اعداد ترتیبی مانند int یا bigint با IDENTITY است. اگر به GUID نیاز دارید، از نوعِ ترتیبیِ آن یعنی NEWSEQUENTIALID() در SQL Server استفاده کنید.
۳. چرا پلن اجرا هشدار Key Lookup میدهد و چطور رفعش کنم؟
این هشدار زمانی رخ میدهد که ایندکسِ غیرخوشهای، همهی ستونهای موردنیازِ کوئری را ندارد و موتور مجبور میشود برای گرفتنِ بقیهٔ ستونها به جدول اصلی برگردد. راهحل: در SQL Server با دستور INCLUDE، ستونهای جاافتاده را به ایندکس اضافه کنید تا «پوششی» (Covering) شود. در EF Core، این کار را با متد .Include(x => new { x.Total }) در تعریفِ ایندکس انجام دهید.
This section solves three everyday mysteries for you:
1. Why is the PK clustered by default?
In SQL Server, the Primary Key becomes the clustered index by default. In EF Core, HasKey() does exactly this. To change it, use .IsClustered(false) — but you rarely need to.
2. Why is a GUID PK a bad idea?
Because GUIDs are random, each new row lands in a random spot, causing Page Splits and physical fragmentation. Use sequential keys like int or bigint with IDENTITY. If you must use GUID, use the sequential version, NEWSEQUENTIALID() in SQL Server.
3. Why does the plan warn about Key Lookup and how do I fix it?
This happens when a non-clustered index is missing columns from your query. The fix: use INCLUDE in SQL Server to add the missing columns and make it Covering. In EF Core, use .Include(x => new { x.Total }) in your index definition.
ایندکسهای ترکیبی و انتخابگری — هوشمندانه انتخاب کنیدComposite indexes & selectivity — choosing wisely
یک ایندکس ترکیبی، بیش از یک ستون را بهعنوان کلید دارد — مثلاً (Status, OrderDate). اما ترتیبِ این ستونها بسیار مهم است، بهخاطر قاعدهٔ پیشوند چپترین: ایندکس ابتدا بر اساسِ ستونِ اول مرتب میشود، سپس درونِ هر گروه، بر اساسِ ستونِ دوم، و بههمین ترتیب. اگر ستونِ اول را در کوئریتان رد کنید، ترتیبِ بقیهی ستونها برای موتور بیفایده خواهد بود.
A composite index uses more than one column as the key — e.g. (Status, OrderDate). But the order of these columns matters enormously, because of the leftmost-prefix rule: the index is sorted by the first column, then within each group by the second, and so on. If you skip the first column in your query, the rest of the sort becomes useless to the engine.
تشبیه با دفتر تلفن: فرض کنید دفتر تلفن بر اساسِ نامخانوادگی مرتب شده، و درونِ هر نامخانوادگی، بر اساسِ نام. اگر بخواهید همهی «علی»ها را پیدا کنید، بدون اینکه نامخانوادگی را مشخص کنید، باید کلِ دفتر را ورق بزنید — چون ترتیبِ نام، درونِ هر نامخانوادگی معنا دارد، نه در کلِ دفتر. قاعدهٔ پیشوند چپترین دقیقاً همین است. Phone book analogy: imagine a phone book sorted by last name, then within each last name, sorted by first name. If you want to find all "John"s without specifying a last name, you'd have to flip through the entire book — because the first-name order only makes sense within each last-name group. That's exactly the leftmost-prefix rule.
انتخابگری (Selectivity) یعنی: «یک ستون، چقدر جستوجو را محدود میکند؟» فرمولش ساده است: تعدادِ مقادیرِ متمایز ÷ تعدادِ کلِ سطرها. هرچه این عدد به ۱ نزدیکتر باشد، ستون انتخابگریِ بالاتری دارد (یعنی تقریباً یکتا است و خوب فیلتر میکند). هرچه به ۰ نزدیکتر باشد، انتخابگریِ پایینتری دارد (یعنی خیلی ضعیف فیلتر میکند). Selectivity measures: "how much does this column narrow down the search?" The formula is simple: distinct values ÷ total rows. The closer to 1, the higher the selectivity (nearly unique, great for filtering). The closer to 0, the lower the selectivity (barely filters, weak alone).
انتخابگری روی جدول Orders با ۱۰٬۰۰۰٬۰۰۰ سطر (نمایش تقریبی)Selectivity on a 10,000,000-row Orders table (illustrative)
مثال: WHERE Status = 'Paid' AND CustomerId = 1042
Example: WHERE Status = 'Paid' AND CustomerId = 1042
۱۰,۰۰۰,۰۰۰ ÷ ۴ ÷ ۲۰۰,۰۰۰ ≈ ۱۲ سطر
(بعد از اعمال هر دو فیلتر، فقط ۱۲ سطر باقی میمانند)
(after applying both filters, only about 12 rows remain)
با کلیک روی هر دکمه، ترتیبِ ستونهای ایندکس عوض میشود. ببینید که هر ترتیب، کدام کوئریها را HIT (میخورد) و کدام را MISS (از دست میدهد). Click each button to change the index column order. Watch which queries HIT and which MISS with each order.
نکتهٔ مهم: «HIT» و «MISS» در این دمو به معنی سرعتِ اجرا هستند، نه دقتِ نتایج. کوئری همیشه دادههای درست را برمیگرداند. تفاوت فقط در این است که موتور میتواند از ایندکس برای سریعتر پیدا کردنِ آنها استفاده کند یا نه. Important: "HIT" and "MISS" in this demo refer to execution speed, not result correctness. The query always returns the right data. The only difference is whether the engine can use the index to find it faster or not.
ترتیب فعلیCurrent order
Status1 OrderDate2نتایج کوئریQuery results
- WHERE Status = 'Paid' AND OrderDate >= '2026-08-01'HIT ✓
- WHERE Status = 'Paid'HIT ✓
- WHERE OrderDate >= '2026-08-01'MISS ✗
CREATE INDEX IX_Orders_Status_Date
ON dbo.Orders(Status, OrderDate) INCLUDE (Total);
-- HIT: equality on leftmost, then range
WHERE Status = 'Paid' AND OrderDate >= '2026-08-01'
-- HIT: leftmost alone is fine
WHERE Status = 'Paid'
-- MISS: leftmost column skipped
WHERE OrderDate >= '2026-08-01'o.HasIndex(x => new { x.Status, x.OrderDate });
// first property = leftmost key column.
// Swap them, and you built a different index.
نکتهی تکمیلی: نسخههای جدید SQL Server گاهی میتوانند با Skip-Scan از روی ستونِ اولِ ردشده بپرند — اما این کار فقط وقتی ممکن است که تعدادِ مقادیرِ متمایزِ آن ستون کم باشد. برنامههایتان را روی این قابلیت بنا نکنید؛ آن را یک پاداشِ احتمالی در نظر بگیرید، نه یک تضمین.
Bonus note: newer SQL Server versions can sometimes Skip-Scan past a skipped first column — but only when that column has few distinct values. Don't build your plans around it; treat it as a possible bonus, not a guarantee.
دستور پختِ ایندکسسازیThe index recipe
۱. اول، ستونهای برابری (Equality) را قرار دهید.
۲. بعد، ستونهای محدوده (Range) یا مرتبسازی (ORDER BY) را بیاورید.
۳. بقیهی ستونهایی که در SELECT نیاز دارید را با INCLUDE اضافه کنید تا ایندکس پوششی (Covering) شود.
۴. بهخاطر داشته باشید: چند ایندکسِ پهن (با ستونهای زیاد)، بهتر از ایندکسهای باریک و زیاد است.
1. Start with equality columns first.
2. Then add the range or ORDER BY column.
3. INCLUDE the rest of the columns from your SELECT to make it covering.
4. Remember: fewer wide indexes are better than many narrow ones.
کجا دنبال کوئریها بگردیم؟Where to find the queries
ابزار EF Core لاگهایی تولید میکند که دستورات SQL واقعی اجرا شده توسط برنامه را نشان میدهند.
در این لاگها دقیقاً ببینید که کوئریها از چه ستونهایی در بخش WHERE و ORDER BY استفاده کردهاند و همان ستونها را ایندکس کنید.
در دیتابیس SQL Server، ابزاری وجود دارد که به آن Query Store میگویند.
این ابزار، کوئریهای پرمصرف و ایندکسهای گمشده را بر اساس دادههای واقعی به شما نشان میدهد، نه بر اساس حدس و گمان.
EF Core logs show you the real SQL your app runs.
Look at them carefully to see which columns are used in WHERE and ORDER BY, and index those.
In SQL Server, there's a tool called Query Store that shows you the top queries and missing indexes based on actual data — not guesses.
یک قانون ساده: قبل از اینکه هر ایندکسی را به جدول اضافه کنید، از خودتان بپرسید: «این ایندکس، کدام کوئریِ واقعی را سریعتر میکند؟» ایندکسی که هیچ کوئریای از آن استفاده نکند، فقط نوشتنها را کند میکند و فضای دیسک را اشغال میکند. ایندکسهای استفادهنشده را حذف کنید — sys.dm_db_index_usage_stats در SQL Server دقیقاً به شما نشان میدهد کدامها بلااستفادهاند.
A simple rule: before adding any index, ask yourself: "Which real query does this make faster?" An index that no query uses only slows down writes and wastes disk space. Drop unused indexes — sys.dm_db_index_usage_stats in SQL Server shows you exactly which ones are unused.
خودآزمایی — میتوانید اینها را بلند جواب بدهید؟Self-check — can you answer these out loud?
صفحه را ببندید و با کلمات خودتان جواب بدهید، بعد کلیک کنید و مقایسه کنید. اگر سوالی شما را گیر انداخت، به ایستگاه همان سوال بپرید. Close the page, answer in your own words, then click to compare. If one stumps you, hop back to its stop.
INSERT، UPDATE یا DELETE میکنید، موتور باید همهٔ ایندکسهای آن جدول را هم بهروز کند. هر ایندکس، یک درخت B جداگانه است که باید کلید جدید در آن درج شود، یا کلید قدیمی حذف شود. پس ایندکس بیشتر یعنی کارِ بیشتر برای هر نوشتن، بهعلاوهٔ فضای دیسکِ بیشتر برای نگهداریِ همهٔ آن درختها.
Every time you INSERT, UPDATE, or DELETE a row, the engine must also update every index on that table. Each index is a separate B-tree that needs its keys inserted or removed. More indexes mean more work per write, plus more disk space to store all those trees.
INCLUDE اضافه شدهاند هم در برگ ذخیره میشوند.Key Lookup یعنی وقتی موتور از ایندکس غیرخوشهای استفاده میکند و به ستونی نیاز دارد که در برگ نیست، باید یک پرشِ اضافی به جدول اصلی بزند و آن ستون را از آنجا بخواند. این پرش برای چند سطر، ارزان است؛ اما اگر کوئری شما صدها یا هزاران سطر برگرداند، این رفتوبرگشتها جمع میشوند و هزینهی سنگینی ایجاد میکنند. ایندکس پوششی (Covering) با
INCLUDE این پرش را حذف میکند.
A non-clustered index leaf contains the index key itself, plus a pointer to the corresponding row in the main table. This pointer is either the clustering key (if the table has a clustered index) or a physical row ID (if the table is a Heap). Also, any columns added with INCLUDE are stored at the leaf level.A Key Lookup is when the engine uses a non-clustered index but needs a column that isn't in the leaf — so it makes an extra jump to the main table to read it. This is cheap for a few rows, but if your query returns hundreds or thousands, these round-trips add up and become expensive. A covering index (with
INCLUDE) removes this extra hop.
(Status, OrderDate) برنده است. دلیلش قاعدهٔ پیشوند چپترین است: ستونهای برابری (=) باید جلوتر از ستونهای محدودهای (مانند «بزرگتر یا مساوی» یا «کوچکتر یا مساوی») قرار بگیرند. در این کوئری، Status یک مقدار مشخص دارد، پس باید اولین کلید ایندکس باشد تا موتور بتواند سریعاً به آن بخش از درخت برود. سپس OrderDate بهعنوان محدوده میآید. اگر ترتیب برعکس باشد (اول OrderDate، بعد Status)، چون درخت بر اساس OrderDate مرتب شده، موتور نمیتواند از Status برای محدود کردن جستوجو استفاده کند و مجبور میشود محدودهی بسیار بزرگتری را اسکن کند.
The index (Status, OrderDate) wins. The reason is the leftmost-prefix rule: equality columns (=) must come before range columns (>=, <=). In this query, Status has an exact value, so it should be the first key column, allowing the engine to quickly navigate to the right part of the tree. Then OrderDate comes next as the range. If the order is reversed — OrderDate first, then Status — the tree is sorted by OrderDate, and the engine cannot use Status to narrow the search, so it ends up scanning a much wider range.