Animated Architecture Patterns

Covering Index

ตอบ query จาก index อย่างเดียว: ใส่ column ที่ query select ไว้ใน index ด้วย จะได้ไม่ต้องอ่าน table เลย และ scan ก็เป็นแบบ index-only

หรือที่เรียกกันว่า index-only scan, INCLUDE columns

Acme app: recent orders PostgreSQL 18.6 SELECT id, status, total_thb SELECT id, status, total_thb FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20 EXPLAIN (ANALYZE, BUFFERS) Index Scan Backward Index Cond: (customer_id = 42) Buffers: shared hit=23 3 index pages20 heap pagesAll 20 entries sit on one leafpage, but each points to its rowon a different heap page: 20random reads when not cached. EXPLAIN (ANALYZE, BUFFERS) Index Only Scan Backward Index Cond: (customer_id = 42) Heap Fetches: 0Buffers: shared hit=4 3 index pages1 visibility map page id is included too: indexentries don't carry the primarykey. Without it the plan stayedan Index Scan: 23 pages. UPDATE ordersSET total_thb = total_thb - 500WHERE id IN (495179, 493709); Index Only Scan Backward Heap Fetches: 4Buffers: shared hit=8 VACUUM orders; Heap Fetches: 0Buffers: shared hit=4Heap Fetches counts the entriesit had to check in the heap. WHAT IT COSTS 990 status updates, pending → paidHOT updates80 → 0status sits in an index now 10,000 new orders, after a checkpointWAL written15 MB → 26 MB Between vacuums, changed pagescost Heap Fetches again.Autovacuum waits for 50 + 20%of the rows: 100,050 dead rowsin orders, by default. index orders_customer_id_created_at_idx1,927 pages · 15 MBON orders (customer_id, created_at) index orders_customer_covering_idx4,121 pages · 32 MB(customer_id, created_at) INCLUDE (id, status, total_thb) internal 3 root 210leaf 58leaf page 58 · 261 entries of 24 byteskey (customer_id, created_at) → heap tidBackward scan: newest entry first; an entry is key + tid root 209leaf 122leaf page 122 · 122 entries of 56 byteskeyidstatustotal_thbheap tidVM Each leaf entry now carries id, status and total_thb New entries (purple); the scan marked the old ones dead times: 2026, UTC⋮ 10 older entries of customer 42 4208-19 21:19465759delivered16730.67(2554,10) 4208-26 22:33471274delivered21369.53(4976,2) 4208-27 16:44471860delivered9372.75(1097,61) 4208-29 20:44473551delivered46395.20(820,82) 4209-01 14:17475685delivered14759.24(2189,75) 4209-01 14:40475698delivered22279.37(1695,29) 4209-08 14:44481174delivered15876.87(3826,75) 4209-14 04:00485504delivered12002.32(4384,29) 4209-24 15:54493709delivered21046.51(4806,99) 4209-26 12:58495179delivered51179.03(2392,2) 432024-12-31(32,24) 4209-24 15:54493709delivered20546.51(5057,91)4209-26 12:58495179delivered50679.03(5057,92) SIZE ON DISK pg_relation_size orders heap 5,058 pages · 40 MB orders_pkey (id) 1,374 pages · 11 MB orders_customer_id_created_at_idx 1,927 pages · 15 MB orders_customer_covering_idx 4,121 pages · 32 MB one leaf entry: 24 bytes → 56 bytes; per leaf page: 261 → 122INCLUDE columns are not in the root or internal pagesThe 3 columns as key columns also give 32 MB here.INCLUDE keeps a UNIQUE index unique on its key aloneand accepts types with no B-tree operator class. heap orders: 5,058 pages of 8 kB 40 MB visibility map, all-visible bits not read here heap page 2392 · 8 kB · 98 rowslpidcustomerstatustotal_thb121621074864delivered23546.07249517942delivered51179.03317351640972delivered18915.56417804361985cancelled35700.63⋮ 94 more rows, other customersOne row needed, one whole page read.The 20 rows sit on 20 pages spreadacross the whole table. Heap Fetches: 0 · not readThe scan reads 1 visibility map pageinstead: every page bit is set, so eachentry is answered from the index alone.VACUUM sets a bit when all rows on thepage are visible to everyone; any changeto the page clears it.INCLUDE columns are payload only• not searchable: WHERE status = …is a Filter, not an Index Cond• not sortable: ORDER BY total_thbadds a Sort• kept in leaf entries only• B-tree since PG 11, GiST 12, SP-GiST 14 heap page 5057, the last · 92 rowslpidcustomerstatustotal_thb8948658264140delivered43175.579021062992788delivered12651.189149370942delivered20546.519249517942delivered50679.03old versions on 2392 and 4806: deadVACUUM set page 5057 all-visible again.With only 2 dead rows it skipped theindex pass: 2392 and 4806 keep a deadstub until a later vacuum, and the scanskips their entries, marked dead. OTHER ENGINES MySQL 8.4 · InnoDBa secondary index entry4209-26 12:58495179← PKEvery entry carries the primary key, soSELECT id is covered. There is noINCLUDE: add status, total_thb as keycolumns. EXPLAIN shows Using index. The clustered index is the table, so alookup by id lands on the row itself. SQL ServerCREATE INDEX … (customer_id, created_at) INCLUDE (status, total_thb)Included columns sit at the leaf levelonly; the clustering key rides along.A column the index lacks costs oneKey Lookup per row.

ขั้นที่ 1 · heap หนึ่ง page ต่อหนึ่ง row ถ้ามี index บน (customer_id, created_at) ตัว PostgreSQL 18.6 จะเดินผ่าน index สาม page (root, internal, leaf) ไปถึง entry ล่าสุดของลูกค้า 42 ที่อยู่บน leaf page 58 กันหมด ตัว entry เก็บแค่ key กับที่อยู่ของ row (tid ของมัน) และ row ทั้ง 20 ตัวอยู่บน 20 page ที่ต่างกันใน heap ขนาด 40 MB: EXPLAIN (ANALYZE, BUFFERS) รายงาน shared hit=23 คือ index 3 page กับ heap 20 page เพื่อให้ได้ 20 row ถ้า cache ยัง cold อยู่ ก็เท่ากับการอ่านแบบ random 20 ครั้ง

ขั้นที่ 2 · INCLUDE: ได้ index-only scan covering index (customer_id, created_at) INCLUDE (id, status, total_thb) เก็บ column ที่ select ไว้ในทุก leaf entry (56 byte แทน 24) แต่ไม่ได้เก็บใน root และ internal page ตัว plan กลายเป็น Index Only Scan ที่มี Heap Fetches: 0 และ shared hit=4: index 3 page กับ visibility map 1 page ส่วน column ที่ include ไว้ใช้นำทางการค้นหรือการ sort ไม่ได้ และต้อง include id ด้วย เพราะ index entry ของ PostgreSQL ไม่ได้พก primary key ไว้

ขั้นที่ 3 · visibility map เป็นตัวตัดสิน index entry ไม่ได้เก็บข้อมูล visibility ไว้ ตัว scan เลยต้องเช็ก all-visible bit ของ heap page ของแต่ละ entry และจะเข้า heap ก็ต่อเมื่อ bit นั้นไม่ได้ถูกตั้งไว้ หลัง UPDATE order 2 รายการของลูกค้า 42 แล้ว row version ใหม่บน page 5057 กับ version ที่ตายแล้วบน page 2392 และ 4806 ทำให้ต้องเสีย Heap Fetches: 4 (shared hit=8) และ scan ก็ mark entry ที่ตายแล้ว 2 ตัวไว้ระหว่างที่ผ่าน จากนั้น VACUUM ก็ตั้ง page 5057 ให้เป็น all-visible อีกครั้ง แล้วตัวเลขนี้ก็ลดลงเหลือ 0

ขั้นที่ 4 · ต้นทุน และ engine อื่น covering index ใช้พื้นที่ 32 MB แทน 15 MB ทุก insert ต้องเขียน entry ที่กว้างขึ้น และการ update status ก็เป็น HOT ไม่ได้อีกแล้ว: ก่อนหน้านี้ 80 จาก 990 ครั้งเป็น HOT แต่หลังจากนี้ไม่มีเลย ส่วน page ที่เปลี่ยนไประหว่างรอบ vacuum ก็ทำให้ต้องเสีย heap fetch อีก ใน InnoDB ทุก entry ของ secondary index พก primary key ไว้ และ clustered index เป็นที่เก็บ row ส่วน SQL Server ก็มี INCLUDE เหมือนกัน โดยเก็บ column ไว้ที่ระดับ leaf เท่านั้น

ปัญหา

แอปมือถือของ Acme เปิดมาก็เจอ order ล่าสุดของลูกค้า และ list นี้แสดงแค่สาม column:

SELECT id, status, total_thb FROM orders
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;

ถ้ามี index บน (customer_id, created_at) ตัว PostgreSQL ก็หา entry 20 ตัวเจอได้เร็ว มันอ่าน root, internal page หนึ่ง page และ leaf page หนึ่ง page ที่ entry ล่าสุดของลูกค้า 42 อยู่เรียงติดกัน (ที่นี่คือ leaf page 58) แล้วเดินถอยหลังไปตาม leaf นั้น จากใหม่สุดก่อน แต่ entry ของ B-tree เก็บแค่ key กับที่อยู่ของ row คือ tid ของมัน (heap page และหมายเลข item) ถ้าจะคืน id, status และ total_thb ได้ ตัว scan ต้องอ่าน row แต่ละตัวจาก table และ order ของลูกค้า 42 ก็กระจายอยู่ใน table ปนกับของคนอื่น ๆ: row ทั้ง 20 ตัวอยู่บน 20 page ที่ต่างกันของ heap ขนาด 5,058 page หรือ 40 MB

บน PostgreSQL 18.6 คำสั่ง EXPLAIN (ANALYZE, BUFFERS) รายงาน Buffers: shared hit=23: index 3 page กับ heap 20 page คืออ่าน page ขนาด 8 kB เต็ม ๆ หนึ่ง page ต่อทุก row ตอนที่ทุกอย่างอยู่ใน cache ก็ยังรันเสร็จในเวลาต่ำกว่า 0.1 ms ไปมาก (รันครั้งหนึ่งบน laptop) แต่พอ page พวกนั้นไม่อยู่ใน memory แล้ว การเปิดหน้าจอนี้อาจต้องอ่านแบบ random 20 ครั้ง ส่วน query เดียวกันนี้ตอนที่ไม่มี index บน customer_id สักตัว อ่านครบทั้ง 5,058 page

ทำงานยัง⁠ไง

covering index เก็บทุก column ที่ query ใช้ ทำให้ PostgreSQL ตอบ query นั้นได้จาก index อย่างเดียวด้วย index-only scan ตั้งแต่ PostgreSQL 11 เป็นต้นมา clause INCLUDE เพิ่ม column เข้าไปใน B-tree index ในฐานะ payload: column พวกนี้ถูกเก็บในทุก entry แต่ไม่ได้เป็นส่วนหนึ่งของ key

CREATE INDEX orders_customer_covering_idx ON orders (customer_id, created_at)
  INCLUDE (id, status, total_thb);
  • key ยังเป็นตัวทำงานหลัก customer_id กับ created_at เป็นตัวเรียง entry และใช้กับการค้นและการ sort ส่วน column ที่ include ไว้ก็ติดไปด้วยใน leaf entry ที่ในตัวอย่างนี้ขยายจาก 24 เป็น 56 byte ตัว root กับ internal page เก็บแค่ค่า key เพราะ suffix truncation ของ B-tree ตัด column ที่ไม่ใช่ key ออกจากชั้นบน ๆ เสมอ
  • ทุก column ต้องอยู่ครบ รวมถึง id ตัว planner จะพิจารณา index-only scan ก็ต่อเมื่อทุก column ที่ query อ้างถึงอยู่ใน index ครบ ตัว index entry ของ PostgreSQL ไม่ได้พก primary key ไว้ (ของ InnoDB พก) ถ้ามีแค่ INCLUDE (status, total_thb) ตัว plan เลยยังเป็น Index Scan ที่ใช้ 23 buffer พอ include id เข้าไปด้วยก็กลายเป็น Index Only Scan Backward ที่มี Heap Fetches: 0 และ Buffers: shared hit=4: index 3 page กับ visibility map 1 page
  • payload ไม่ใช่ key column ที่ include ไว้ใช้เป็นเงื่อนไขค้นใน index ไม่ได้: พอเพิ่ม AND status = 'cancelled' ก็ได้ Filter ที่เช็กกับ entry ทั้ง 179 ตัวของลูกค้า 42 (ตัดออก 175 ตัว โดยยังไม่ต้องแตะ heap) ไม่ใช่ Index Cond และ column พวกนี้ก็ไม่ได้ให้ลำดับด้วย: ORDER BY total_thb ทำให้มี Sort ครอบ row 179 ตัว ส่วนใน UNIQUE index พวกมันไม่มีส่วนในการเช็ก uniqueness
  • visibility map ตัดสินว่าจะข้าม heap ได้ไหม index entry ไม่มีข้อมูล visibility: row version ไหน visible กับ snapshot ไหน ถูกบันทึกไว้ใน heap เท่านั้น PostgreSQL เลยเก็บ visibility map ไว้ให้ทุก table ใช้สอง bit ต่อ heap page โดย bit แรกบอกว่าทุก row ใน page นั้น visible กับทุก transaction ตัว index-only scan จะเปิดดู bit ของ heap page ที่ entry แต่ละตัวชี้ไป ถ้า bit ถูกตั้งไว้ ค่าก็มาจาก index ได้ตรง ๆ ถ้าไม่ได้ตั้ง PostgreSQL จะดึง row จาก heap มาเช็ก และ EXPLAIN ANALYZE จะนับการเข้าแบบนี้ไว้ใน Heap Fetches
  • VACUUM ตั้ง bit ส่วนการเขียนล้าง bit การ insert, update หรือ delete ใด ๆ บน page จะล้าง bit ของ page นั้น และมีแค่ VACUUM (หรือ autovacuum) ที่ตั้งมันกลับได้ ในการรันนี้ UPDATE ที่ลด 500 บาทจาก order 2 รายการของลูกค้า 42 เขียน row version ใหม่ลง page 5057 และทิ้ง version เก่าที่ตายแล้วไว้บน page 2392 และ 4806 ทำให้ bit ถูกล้าง 3 ตัว: Heap Fetches: 4 และ shared hit=8 คือเข้า heap สองครั้งเพื่อคืน version ใหม่ และอีกสองครั้งเพื่อพบว่า version เก่าตายแล้ว ระหว่างทาง scan ยัง mark entry ที่ตายแล้ว 2 ตัวนั้นไว้ใน index ทำให้ scan รอบหลัง ๆ ข้ามมันไปได้ หลัง VACUUM ตัว page 5057 ก็เป็น all-visible อีกครั้ง และตัวเลขลดลงเหลือ 0 พอมี dead row แค่ 2 row ตัว VACUUM ก็ข้ามรอบที่ไล่ index ไป (มันทำแบบนี้ตอนที่ row ตายน้อยมาก) ทำให้ page 2392 และ 4806 ยังมี dead line pointer ค้างอยู่ และยังไม่ถูกตั้ง bit ใน map จนกว่าจะ vacuum รอบหลัง ส่วน live entry ก็ไม่มีตัวไหนชี้ไปที่นั่นแล้ว

ลองรันดู

ข้อมูลตัวอย่างมาจาก samples/acme-shop ที่เป็น script สร้าง template database acme บน PostgreSQL 18 ในเวลาราวหนึ่งนาที

ทำสำเนาใหม่ของข้อมูลตัวอย่างโดยสั่งจาก database อื่นบน server เดียวกัน แล้วรัน script ในสำเนานั้น ตัวเลข buffer หลัง CREATE INDEX แต่ละครั้งมาจากการรัน EXPLAIN รอบที่สอง เพราะรอบแรกยังต้องอ่าน index ใหม่เข้า buffer cache ด้วย ส่วนตัวเลขหลัง UPDATE มาจากการรันรอบแรก: รอบนั้น mark entry ที่ตายแล้ว 2 ตัวไว้ ทำให้รอบที่สองแสดง Heap Fetches: 2

-- from another database:  CREATE DATABASE covering_index TEMPLATE acme STRATEGY FILE_COPY;
-- then, connected to covering_index (PostgreSQL 18):

VACUUM (ANALYZE) orders;  -- a fresh copy has an empty visibility map

PREPARE recent AS
  SELECT id, status, total_thb FROM orders
  WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;

CREATE INDEX orders_customer_id_created_at_idx ON orders (customer_id, created_at);
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) EXECUTE recent;
-- Index Scan Backward using orders_customer_id_created_at_idx on orders
--   Index Cond: (customer_id = 42)
--   Buffers: shared hit=23               3 index pages + 20 heap pages

CREATE INDEX orders_customer_covering_idx ON orders (customer_id, created_at)
  INCLUDE (id, status, total_thb);
DROP INDEX orders_customer_id_created_at_idx;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) EXECUTE recent;
-- Index Only Scan Backward using orders_customer_covering_idx on orders
--   Heap Fetches: 0
--   Buffers: shared hit=4                3 index pages + 1 visibility map page

UPDATE orders SET total_thb = total_thb - 500 WHERE id IN (495179, 493709);
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) EXECUTE recent;
--   Heap Fetches: 4                      new versions on page 5057, dead ones on 2392 and 4806
--   Buffers: shared hit=8

VACUUM orders;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) EXECUTE recent;
--   Heap Fetches: 0
--   Buffers: shared hit=4

SELECT pg_size_pretty(pg_relation_size('orders_customer_covering_idx'));
-- 32 MB   (orders_customer_id_created_at_idx was 15 MB)

ใช้ตอนไหนดี

  • query ที่ hot และแคบ หน้าจอ, API endpoint หรือ lookup ที่รันเป็นพัน ๆ ครั้งต่อนาที และอ่าน column สั้น ๆ ไม่กี่ตัวผ่าน index ที่ selective: list order ล่าสุด, การเช็ก status, การดูราคา
  • page ที่ยัง all-visible อยู่ heap จะถูกข้ามก็เฉพาะตรงที่ visibility bit ถูกตั้งไว้ ประโยชน์เลยมากที่สุดกับ table ที่ส่วนใหญ่มีแต่การอ่านหรือการต่อท้าย และ autovacuum ตามทัน: ประวัติ, reference data, order ที่แทบไม่ถูกเปลี่ยนอีกหลัง delivered ส่วน table ที่ row เปลี่ยนตลอดเวลา ตัว scan ก็ต้องเข้า heap อยู่ดี และ payload ก็มีแต่ต้นทุน
  • unique key ที่ต้องการ column เพิ่มอีกสักหนึ่งหรือสองตัว ด้วย CREATE UNIQUE INDEX … ON customers (email) INCLUDE (id, name) การ lookup ด้วย email จะคืน id และ name ของลูกค้าจาก index ได้เลย และ uniqueness ก็ยังใช้กับ email อย่างเดียว
  • ไม่เหมาะกับ column ที่กว้างหรือมีหลายตัว การ include column เกือบทั้งหมดของ row ก็คือการก็อป table ไปไว้ใน index และการอ่าน heap ยังถูกกว่าการคอยดูแลสำเนาที่สองให้เป็นปัจจุบัน ถ้าเป็น read model ที่มีหลาย column หรือมี join ใช้ materialized view หรือ read store แยก แบบใน CQRS จะเหมาะกว่า

ได้อะไร เสียอะไร

  • ขนาด covering index ใช้ 32 MB (4,121 page) เทียบกับ 15 MB (1,927 page) ของ index สอง column แบบธรรมดา: มี 122 entry ต่อ leaf page แทน 261 และต้องใช้ memory สองเท่าเพื่อเก็บมันไว้ใน cache
  • ทุกการเขียนต้องจ่าย ทุก insert ต้องเขียน entry ที่กว้างขึ้น: order ใหม่ 10,000 รายการเขียน WAL 26 MB แทน 15 MB ในช่วงทันทีหลัง checkpoint ตอนที่การเปลี่ยนครั้งแรกของแต่ละ page จะ log ทั้ง page ส่วนการ update column ที่ include ไว้ไม่มีทางเป็น HOT (heap-only) ได้ เพราะ HOT ต้องไม่มี column ที่มี index ถูกเปลี่ยน: จากการ update pending → paid 990 ครั้ง มี 80 ครั้งที่เป็น HOT ตอนใช้ index ธรรมดา และไม่มีเลยตอนใช้ covering index ทำให้ทุกครั้งต้องเพิ่ม entry ลงในทุก index ของ table
  • ประโยชน์อยู่ได้แค่เท่าที่ visibility map ยังช่วยได้ page ที่เปลี่ยนไประหว่างรอบ vacuum ทำให้ต้องเสีย heap fetch อีก ส่วนค่า default ของ autovacuum คือจะจัดการ table เมื่อมี row ที่ตายแล้ว 50 row บวก 20 % ของ table: สำหรับ orders คือ 100,050 row ถ้า table ไหนพึ่ง index-only scan อาจอยากตั้ง autovacuum_vacuum_scale_factor ให้ต่ำลง โดยตั้งเป็น storage parameter บน table นั้น
  • ข้อจำกัด entry ของ B-tree ใหญ่เกินราวหนึ่งในสามของ page ไม่ได้ ถ้า payload กว้างเกินไป insert ก็อาจ fail ได้ ส่วน column ที่ include จะเป็น expression ไม่ได้ และ B-tree deduplication (PostgreSQL 13 ขึ้นไป) ไม่ถูกใช้กับ index ที่มี INCLUDE เลย

ข้อควรรู้ตอนลงมือทำ

  • การอ่าน EXPLAIN มองหา Index Only Scan แล้วดูที่ Heap Fetches ถ้าตัวเลขใกล้กับจำนวน row ที่คืน แปลว่า visibility map ไม่ได้ช่วย และ scan นั้นก็คือ index scan ที่ปลอมตัวมา ตัวเลข pg_class.relallvisible เทียบกับ relpages คือค่าประเมินของ planner ว่ามีสัดส่วน all-visible เท่าไร ส่วน extension pg_visibility อ่านตัว map ได้โดยตรง และตั้งแต่ PostgreSQL 18 เป็นต้นมา ตัว EXPLAIN ANALYZE รายงาน buffer ให้โดยไม่ต้องขอ เรื่องการอ่าน plan โดยรวมอยู่ใน Query Execution Plans
  • vacuum หลัง bulk load table ที่เพิ่ง load หรือ restore มาใหม่มี visibility map ว่าง: บนสำเนาใหม่ของข้อมูลตัวอย่าง plan บอกว่าเป็น Index Only Scan แล้วก็จริง แต่กลับแสดง Heap Fetches: 20 และ 23 buffer ไม่ได้ดีไปกว่า index ธรรมดาเลย จนกว่าจะรัน VACUUM ส่วนตั้งแต่ PostgreSQL 13 เป็นต้นมา การ insert ก็ trigger autovacuum ได้ด้วย ตัว table แบบ append-only ก็เลยได้ bit ตั้งไว้เหมือนกัน
  • INCLUDE หรือ key column ถ้าใส่ column เหล่านี้เป็น key column ต่อท้ายอย่าง (customer_id, created_at, id, status, total_thb) ก็ได้ 32 MB เท่ากันในตัวอย่างนี้ เพราะ suffix truncation ก็ตัด key column ท้าย ๆ ออกจาก page ชั้นบนด้วย ตอนที่แค่ column ข้างหน้าก็พอแล้ว ให้ใช้ INCLUDE เมื่อ column ที่เพิ่มต้องไม่เปลี่ยนสิ่งที่ unique, เมื่อ type ของมันไม่มี B-tree operator class หรือเมื่ออยากให้เห็นชัดว่าเป็น payload ส่วน key column ให้ใช้เมื่อ query filter หรือ sort ด้วย column พวกนั้นด้วย แบบใน Composite Index ตัว B-tree รองรับ INCLUDE ตั้งแต่ PostgreSQL 11, GiST ตั้งแต่ 12 และ SP-GiST ตั้งแต่ 14 ส่วน index type อื่น ๆ เปรียบเทียบไว้ใน Index Types และตัว B-tree เองอยู่ใน B-Tree Index
  • MySQL 8.4 (InnoDB) ตัว table เองคือ clustered index บน primary key และทุก entry ของ secondary index เก็บ column ของ primary key ไว้ให้ InnoDB ใช้หา row เพราะแบบนี้ index บน (customer_id, created_at) เลย cover SELECT id … อยู่แล้ว และ lookup ด้วย id ก็ไปถึง row ใน clustered index ได้โดยตรง MySQL ไม่มี INCLUDE: ให้เพิ่ม status และ total_thb เป็น key column ต่อท้าย แล้ว EXPLAIN จะแสดง Using index เมื่อ query ถูก cover ส่วนคำถามเรื่อง visibility ตอบกันที่ระดับ index page: ถ้า record ของ secondary index ถูก delete-mark หรือ page ของมันถูก update โดย transaction ที่ใหม่กว่า ตัว InnoDB จะไปหา row ใน clustered index แทนการตอบจาก index
  • SQL Server ตัว nonclustered index รับ INCLUDE (status, total_thb) ได้ ส่วน column ที่ include จะเก็บไว้ที่ระดับ leaf เท่านั้น และไม่นับรวมใน key limit (32 key column และ 1,700 byte ตั้งแต่ SQL Server 2016) บน table ที่มี clustered index ทุก entry ของ nonclustered index จะพก clustering key ไว้ คล้ายกับใน InnoDB ถ้า index ขาด column ที่ query ต้องใช้ ตัว plan จะแสดง Key Lookup (หรือ RID Lookup บน heap table) สำหรับทุก row
  • DynamoDB ตัว global secondary index เก็บ projection ของแต่ละ item เป็น KEYS_ONLY, INCLUDE หรือ ALL และมี key attribute ของ table อยู่ในนั้นเสมอ query บน global secondary index ดึง attribute ที่ไม่ได้ project ไว้ไม่ได้ ทำให้ projection ต้อง cover query นั้น ดูเพิ่มที่ Amazon DynamoDB
  • วิธีที่ UPDATE เขียน row version และวิธีที่ VACUUM ลบมันออก อธิบายไว้ในหน้า PostgreSQL