query ตัวเดียวกัน ราคาต่างกันสองพันเท่า เพราะค่าเดินมาถึงเอนจินคนละทาง
หน้ารายการออเดอร์ของคุณใช้ Skip().Take() แล้วมันช้าลงเรื่อย ๆ เมื่อผู้ใช้กดหน้าถัดไป
คุณอ่านเจอว่าทางแก้คือ keyset pagination — เลิกนับหน้า แล้วพกคีย์ของแถวสุดท้ายไปแทน
คุณเขียนมันเป็น WHERE placed_at > x OR (placed_at = x AND id > y) เปิด SQL console
วางค่าจริงลงไป กด run แล้วมันเร็วอย่างที่ควรจะเป็น
พอขึ้น production มันช้ากว่าเดิม
ข้อความ SQL ไม่ได้ต่างกันเลยสักตัวอักษร สิ่งที่ต่างคือค่าเดินมาถึงเอนจินคนละทาง ตอนคุณทดสอบ ค่าอยู่ในข้อความ SQL ตอนแอปยิง ค่ามาเป็น parameter
หน้านี้นับ opcode ที่ SQLite 3.53.4 เดินจริงในทั้งสองกรณี บนตาราง 200,000 แถว ที่ความลึก 100,000 แถว ผลคือ 600,284 opcode เมื่อค่าเป็น parameter และ 298 opcode เมื่อค่าอยู่ในข้อความ SQL ต่างกัน 2,014 เท่า
วิธีวัด ตารางเดียวกับบทความที่ 14 และไม่ได้เพิ่ม index ใหม่เลย
หัวข้อที่มีชื่อว่า “วิธีวัด ตารางเดียวกับบทความที่ 14 และไม่ได้เพิ่ม index ใหม่เลย”สี่ท่าที่คืนแถวชุดเดียวกันเป๊ะ ต่างกันแค่วิธีเขียน
ตัววัดใช้ตารางใบเดียวกับ ทำไม index ที่คุณเพิ่งเพิ่มถึงไม่ถูกใช้ คือ orders 200,000 แถว ที่ seed คงที่ — ไม่ได้คัดลอกตัวสร้างมา แต่ import ตัวเดิมมาใช้ ถ้าคัดลอก ข้ออ้างว่า “ตารางใบเดียวกัน” จะจริงในวันแรกแล้วค่อย ๆ ผิดเงียบ ๆ
และไม่ต้องสร้าง index ใหม่เลยแม้แต่ตัวเดียว เพราะ ix_orders_placed_at ที่ประกาศไว้ว่า
(placed_at) มี id ต่อท้ายให้อยู่แล้ว — id เป็น INTEGER PRIMARY KEY ซึ่งคือ rowid
ของ SQLite ทุก index จึงเก็บ rowid ไว้เป็น column สุดท้ายโดยอัตโนมัติ
สี่ท่าที่วัด ทุกท่าคืน 20 แถวเดียวกัน:
| ชื่อในตาราง | ท่า | ค่ามาถึงเอนจินอย่างไร |
|---|---|---|
offset | LIMIT 20 OFFSET ? | ความลึกเป็น parameter |
rowvalue | WHERE (placed_at, id) > (?, ?) | เป็น parameter |
orform_param | WHERE placed_at > ? OR (placed_at = ? AND id > ?) | เป็น parameter |
orform_literal | ประโยคเดียวกับบรรทัดบน | ฝังอยู่ในข้อความ SQL |
สองแถวล่างคือ ประโยคที่มีรูปเดียวกันทุกตัวอักษร ต่างกันแค่ตำแหน่งที่ค่าปรากฏ
keyset pagination พก คีย์ของแถวสุดท้ายของหน้าก่อน ไปด้วย ไม่ใช่แถวแรกของหน้านี้
ถ้าหยิบผิดแถวเดียว ท่า keyset จะคืนชุดที่เลื่อนไปหนึ่งแถวจากท่า OFFSET
แล้วราคาที่เอามาเทียบกันก็เป็นราคาของงานคนละงาน
ตัววัดจึงดึงผลของทั้งสี่ท่าออกมาเทียบกันทุกความลึก และ โยนทิ้งทั้งการวัด ถ้าไม่ตรง · นี่คือด่านที่ทำให้ตารางข้างล่างมีความหมาย ไม่ใช่คำสัญญาในเชิงอรรถ
ราคาต่อหน้า วัดเป็น opcode ที่เอนจินเดินจริง
หัวข้อที่มีชื่อว่า “ราคาต่อหน้า วัดเป็น opcode ที่เอนจินเดินจริง”หน่วยคือจำนวนนับ ไม่ใช่วินาที เวลาเปลี่ยนตามเครื่อง จำนวนนับไม่เปลี่ยน
| ความลึกของหน้า | OFFSET | row-value | OR + parameter | OR + literal |
|---|---|---|---|---|
| 0 | 231 | 305 | 305 | 286 |
| 1,000 | 3,231 | 309 | 6,280 | 294 |
| 10,000 | 30,231 | 309 | 60,280 | 294 |
| 100,000 | 300,231 | 312 | 600,284 | 298 |
| 199,980 | 600,171 | 309 | 537 | 294 |
อ่านสามอย่างจากตารางนี้
OFFSET คิดเงินตามแถวที่มันข้าม 3 opcode ต่อแถวพอดี ไม่ใช่ตัวเลขโดยประมาณ
· นี่คือส่วนที่คนส่วนใหญ่รู้อยู่แล้ว และเป็นเหตุผลที่ทุกคนแนะนำให้เลิกใช้
ท่า OR ที่ค่าเป็น parameter คิดเงิน 6 opcode ต่อแถว คือ สองเท่าพอดีของท่าที่มันมาแก้ ทางแก้ที่แพงกว่าปัญหา
row-value ราคาแบน 309 ที่ความลึกพัน และ 312 ที่ความลึกแสน · ต่างกันไม่ถึงสิบ opcode ทั้งที่ข้ามแถวเพิ่มอีกเก้าหมื่นเก้าพัน
ประโยคเดียวกัน สองราคา ต่างกันตรงที่ค่าเดินมาทางไหน
หัวข้อที่มีชื่อว่า “ประโยคเดียวกัน สองราคา ต่างกันตรงที่ค่าเดินมาทางไหน”column ที่สามกับที่สี่ของตารางข้างบน คือ SQL ประโยคเดียวกัน
ที่ความลึก 100,000 ประโยคเดียวกันนี้ราคา 600,284 opcode เมื่อค่าเป็น parameter และ 298 opcode เมื่อค่าฝังอยู่ในข้อความ
แผนของสองกรณีนี้ต่างกันคำเดียว
ค่าเป็น parameter SCAN orders USING INDEX ix_orders_placed_atค่าอยู่ในข้อความ SEARCH orders USING INDEX ix_orders_placed_at (placed_at>?)SEARCH คือกระโดดเข้าไปที่ช่วงที่ต้องการ · SCAN คือเดินจากต้น index แล้วทิ้งแถวที่ไม่ผ่าน
เงื่อนไขไปเรื่อย ๆ · ระหว่างสองคำนี้คือแถวเก้าหมื่นเก้าพันที่ถูกอ่านขึ้นมาแล้วโยนทิ้ง
เหตุผลอยู่ที่ว่า planner ต้องรู้อะไรก่อนถึงจะพับสองกิ่งของ OR เป็นช่วงเดียวได้
กิ่งที่หนึ่งคือ placed_at > x กิ่งที่สองคือ placed_at = x AND id > y สองกิ่งนี้ต่อกัน
เป็นช่วงเดียวได้ก็ต่อเมื่อรู้ว่า x ของทั้งสองกิ่งเป็นค่าเดียวกัน ซึ่งตอนวางแผน
ยังไม่มีใครส่งค่ามาให้ · row-value ไม่ต้องรู้ค่าเลย เพราะช่วงอยู่ในรูปประโยคอยู่แล้ว
ข้อโต้แย้งแรกที่ทุกคนยกคือสถิติ · ตัววัดจึงลบ sqlite_stat1 กับ sqlite_stat4 ทิ้งทั้งคู่
แล้ววัดช่องเดิมซ้ำบน connection ใหม่
| ท่า | มีสถิติ | ลบสถิติทิ้ง |
|---|---|---|
OFFSET | 300,231 | 300,231 |
| row-value | 312 | 312 |
| OR + parameter | 600,284 | 600,283 |
| OR + literal | 298 | 293 |
สถิติไม่ได้ขยับอะไรเลย ตัวแปรคือค่าเดินมาทางไหน ไม่ใช่ planner รู้จักข้อมูลดีแค่ไหน
หน้าสุดท้ายเร็วอีกครั้ง และนั่นคือเหตุผลที่การทดสอบด้วยมือมองไม่เห็น
หัวข้อที่มีชื่อว่า “หน้าสุดท้ายเร็วอีกครั้ง และนั่นคือเหตุผลที่การทดสอบด้วยมือมองไม่เห็น”ดูแถวล่างสุดของตารางราคาอีกครั้ง ที่ความลึก 199,980 ท่า OR + parameter กลับมาอยู่ที่ 537 opcode — ถูกกว่าแถวเหนือมันพันเท่า
ที่ความลึกนั้น SQLite เปลี่ยนไปใช้แผน MULTI-INDEX OR คือแยกสองกิ่งไปค้นทีละกิ่งแล้วรวมผล
ซึ่งคุ้มเมื่อทั้งสองกิ่งคืนแถวน้อย และแถวสุดท้ายของตารางก็เป็นแบบนั้นพอดี
ผลคือเส้นราคาของท่านี้เป็น โหนก ไม่ใช่เส้นตรง ถูกที่หน้าแรก แพงที่สุดตรงกลาง แล้วถูกอีกครั้งที่หน้าสุดท้าย
และคนทดสอบมักหยิบสองปลายนั้นพอดี หน้าแรกเพราะมันเปิดขึ้นมาก่อน หน้าสุดท้ายเพราะ กดปุ่มไปท้ายตารางง่ายกว่าไล่ไปทีละหน้า · ทั้งสองจุดที่หยิบง่ายที่สุด คือสองจุดที่ปัญหาไม่โผล่
วัดซ้ำบน PostgreSQL ด้วยหน่วยของมันเอง
หัวข้อที่มีชื่อว่า “วัดซ้ำบน PostgreSQL ด้วยหน่วยของมันเอง”ข้อโต้แย้งที่แรงที่สุดของหน้านี้คือ “มันเป็นนิสัยของ planner ของ SQLite ตัวเดียว”
ตัววัดอีกตัวรันหน้าเดียวกันบน PostgreSQL 18.6 ที่ความลึก 100,000 โดยส่งค่าเป็น parameter
จริงผ่าน PREPARE / EXECUTE แล้ววัดด้วยหน่วยของ PostgreSQL เอง คือจำนวน buffer ที่แตะ
— ไม่ได้เอา opcode ของ SQLite ข้ามมา
| ท่า | buffers | แถวที่อ่านขึ้นมาแล้วทิ้ง |
|---|---|---|
OFFSET | 1,231 | 0 |
| row-value | 4 | 0 |
| OR + parameter | 1,231 | 100,000 |
PostgreSQL พิมพ์หลักฐานออกมาตรง ๆ ในสิ่งที่ SQLite เก็บเงียบไว้ บรรทัด
Rows Removed by Filter: 100,000 บอกว่า query
ที่อ้างว่า ข้าม แถวเหล่านั้น อ่านมันขึ้นมาครบทุกแถวแล้วโยนทิ้ง · ส่วนท่า row-value ได้บรรทัด
Index Cond: (ROW(placed_at, id) > ROW(...)) และไม่ทิ้งแถวเลยสักแถว
แต่สองเอนจินไม่ได้ผิดที่เดียวกัน และหน้านี้จะไม่เหมารวม บน SQLite ท่า OR กลายเป็น
SEARCH ได้ถ้าค่าอยู่ในข้อความ · บน PostgreSQL ไม่มีทางนั้น ตัววัดบังคับทั้งสองโหมด
ของ plan cache แล้ว ทั้ง force_custom_plan ที่ planner เห็นค่าจริงตอนวางแผน และ
force_generic_plan ที่ไม่เห็น — ได้ 1,231 กับ
1,231 buffer เท่ากันทั้งคู่
ข้อสรุปที่ข้ามเอนจินได้จึงไม่ใช่ตัวเลข แต่เป็นข้อนี้: ท่า OR ไม่ได้กลายเป็นการกระโดด เข้าช่วงบนเอนจินไหนเลยในเงื่อนไขที่แอปทำงานอยู่จริง ส่วน row-value กลายเป็นทั้งสองตัว
แล้ว ORM ของคุณเขียนท่าไหนให้
หัวข้อที่มีชื่อว่า “แล้ว ORM ของคุณเขียนท่าไหนให้”ถามด้วยการรัน ไม่ใช่ด้วยการอ่านเอกสาร
ตัววัดตัวที่สามถาม EF Core 10.0.10.0 ตรง ๆ ด้วย ToQueryString() ว่า LINQ สามท่า
ออกมาเป็น SQL อะไร
ท่าที่หนึ่ง Skip(100_000).Take(20) ได้ LIMIT @p1 OFFSET @p — ท่าที่คิดเงินตามแถวที่ข้าม
ท่าที่สอง เขียน keyset ใน LINQ ได้แบบเดียวคือเทียบสองกิ่งด้วย ||
db.Orders .Where(o => string.Compare(o.PlacedAt, lastPlacedAt) > 0 || (o.PlacedAt == lastPlacedAt && o.Id > lastId)) .OrderBy(o => o.PlacedAt).ThenBy(o => o.Id) .Take(20)ได้ SQL ออกมาแบบนี้ — คือช่องที่แพงที่สุดในตารางแรกพอดี ทั้งรูปประโยคและวิธีที่ค่ามาถึง
WHERE "o"."PlacedAt" > @lastPlacedAt OR ("o"."PlacedAt" = @lastPlacedAt AND "o"."Id" > @lastId)ท่าที่สาม เขียน row-value ด้วย tuple แล้วให้ EF แปล — ผลคือ
InvalidOperationException แปลไม่ได้
นั่นแปลว่า ท่าที่ราคาแบน เขียนใน LINQ ไม่ได้เลย คนที่ย้ายจาก Skip/Take มาเป็น keyset
ใน EF Core จึงเดินจากช่องที่คิด 3 opcode ต่อแถว ไปยังช่องที่คิด
6 opcode ต่อแถว โดยเชื่อว่ากำลังแก้ปัญหา
FromSql กับ interpolated string เขียน row-value ได้ และ ยังส่งค่าเป็น parameter อยู่
ข้อหลังสำคัญกว่าข้อแรก เพราะทางออกที่ให้คุณต่อสตริงเองคือทางออกที่เปิดช่อง injection
db.Orders.FromSql( $"SELECT * FROM Orders WHERE (PlacedAt, Id) > ({lastPlacedAt}, {lastId}) ORDER BY PlacedAt, Id LIMIT 20")ToQueryString() ของบรรทัดนี้ยืนยันว่าค่าออกมาเป็น @p0 กับ @p1 ไม่ได้ถูกฝังลงข้อความ
· และการวัดในตารางแรกบอกแล้วว่า row-value ที่ค่าเป็น parameter ยังราคาแบน
ถ้าคีย์ของคุณเป็น column เดียวที่ไม่ซ้ำอยู่แล้ว ปัญหานี้ไม่มีตั้งแต่ต้น WHERE id > ?
เป็นช่วงเดียวโดยธรรมชาติ ไม่ต้องมี OR และ LINQ ก็เขียนได้ตรง ๆ · ท่าสอง column จำเป็น
เฉพาะตอนที่ column ที่เรียงไม่ unique เท่านั้น
เอาไปใช้ได้ทันที
หัวข้อที่มีชื่อว่า “เอาไปใช้ได้ทันที”- อย่าเชื่อผลจากการวางค่าลง console ค่าที่พิมพ์ลงไปเองกับค่าที่แอปส่ง ให้แผนคนละแผนได้
· ทดสอบด้วย
PREPAREหรือด้วย parameter จริงเสมอ - ถ้าเขียน keyset ด้วย
ORให้ถือว่ายังไม่ได้แก้อะไร จนกว่าจะเห็นคำว่าSEARCH(SQLite) หรือIndex Cond(PostgreSQL) ในแผนที่รันด้วย parameter - บน PostgreSQL ให้ดูบรรทัด
Rows Removed by Filterมันบอกจำนวนแถวที่ query อ่านขึ้นมา ทั้งที่บอกว่าข้าม เป็นตัวเลขที่ฟ้องท่าที่ผิดได้เร็วที่สุด - ทดสอบหน้ากลาง ๆ ไม่ใช่หน้าแรกกับหน้าสุดท้าย สองปลายคือจุดที่ราคาของท่า OR ต่ำที่สุด
- ทำไม index ที่คุณเพิ่งเพิ่มถึงไม่ถูกใช้ — ตารางใบเดียวกัน
และเป็นหน้าที่อธิบายว่า
SEARCHกับSCANต่างกันตรงไหน - สร้าง query engine เอง — บทที่ 8 ใช้หน่วย opcode หน่วยเดียวกับหน้านี้ และอธิบายว่า VDBE นับอะไรอยู่
สิ่งที่หน้านี้ยืนยันไม่ได้
หัวข้อที่มีชื่อว่า “สิ่งที่หน้านี้ยืนยันไม่ได้”ขอบเขตของการวัดแคบกว่าที่หัวข้ออาจทำให้เข้าใจ
- จำนวน opcode ขึ้นกับบิลด์ของ SQLite ตัวเลขทั้งหมดมาจาก SQLite 3.53.4 บิลด์อื่น ให้ตัวเลขไม่เท่ากันได้ · ตัวตรวจจึงตรวจ รูปของเส้น ไม่ได้ตรึงตัวเลขทีละตัว
- หน้านี้ไม่ได้วัดเวลา ไม่มีวินาที ไม่มี throughput · opcode กับ buffer เป็นจำนวนนับ ที่ทำซ้ำได้ ส่วนเวลาขึ้นกับเครื่อง cache และงานอื่นที่วิ่งอยู่พร้อมกัน
- ตัวเลขฝั่ง SQLite กับฝั่ง PostgreSQL เทียบกันตรง ๆ ไม่ได้ คนละหน่วย และหน่วยหนึ่ง นับคำสั่ง อีกหน่วยนับหน้า · สิ่งที่เทียบข้ามเอนจินได้คือ อัตราส่วนภายในเอนจินเดียวกัน และข้อสรุปเชิงพฤติกรรม ไม่ใช่ตัวเลขดิบ
- ไม่ได้วัดว่าตารางที่เขียนหนักตลอดเวลาจะให้ผลแบบเดียวกัน ตารางที่วัดนิ่ง ไม่มีใครเขียน แทรกระหว่างการวัด
- ท่า
MULTI-INDEX ORที่โผล่มาที่หน้าสุดท้าย ยังไม่ได้ถูกไล่ว่าเริ่มคุ้มตรงไหนพอดี หน้านี้รายงานว่ามันเกิดขึ้นจริงที่ความลึกไหน ไม่ได้อ้างว่ารู้เส้นแบ่ง
ตัวเลขทุกตัวในหน้านี้มาจากสามคำสั่งใน repo ของไซต์นี้
# ฝั่ง SQLite — stdlib ล้วน ไม่ต้องมีอะไรเพิ่ม อยู่ในเกต unit ของ CI ด้วยpython3 scripts/pagination/probe.py --check
# ฝั่ง EF Core — ต้องมี .NET SDKnpm run gen:pagination-ef
# ฝั่ง PostgreSQL — ต้องมี Docker จึงอยู่นอก npm testscripts/pg-transaction-spike/up.shpython3 scripts/pagination/pg.py --checkdocker rm -f kaen-pg-spike--check ไม่ได้เทียบกับตัวเลขที่ commit ไว้เฉย ๆ มันวัดใหม่ทั้งชุดแล้วตรวจว่าข้ออ้าง
ของหน้านี้ยังเป็นจริงไหม — เส้นของ OFFSET ยังชันสามต่อแถว เส้นของ row-value ยังแบน
ท่า OR ที่ค่าเป็น parameter ยังชันสองเท่าของ OFFSET และช่องว่างระหว่าง parameter
กับ literal ยังเกินร้อยเท่า
- SQLite — Row Values — หัวข้อ 2.1 เขียนไว้ตรงตัวว่า row value ที่ประกอบเป็น prefix ของ index ใช้เป็นขอบของช่วงได้ (ตรวจแล้ว 2026-08-21)
- SQLite — The OR Optimization — ที่มาของแผน
MULTI-INDEX OR(ตรวจแล้ว 2026-08-21) - PostgreSQL 18 — EXPLAIN — ความหมายของ
BUFFERSและRows Removed by Filter(ตรวจแล้ว 2026-08-21) - PostgreSQL 18 — PREPARE — custom plan กับ generic plan และเกณฑ์ห้าครั้งแรก (ตรวจแล้ว 2026-08-21)