ข้าม​ไป​ยัง​เนื้อหา

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 แถว​เดียวกัน:

ชื่อ​ใน​ตารางท่าค่า​มา​ถึง​เอน​จิ​นอ​ย่าง​ไร
offsetLIMIT 20 OFFSET ?ความ​ลึก​เป็น parameter
rowvalueWHERE (placed_at, id) > (?, ?)เป็น parameter
orform_paramWHERE placed_at > ? OR (placed_at = ? AND id > ?)เป็น parameter
orform_literalประโยค​เดียว​กับ​บรรทัด​บนฝัง​อยู่​ใน​ข้อความ SQL

สอง​แถว​ล่าง​คือ ประโยค​ที่​มี​รูป​เดียวกัน​ทุก​ตัว​อักษร ต่าง​กัน​แค่​ตำแหน่ง​ที่​ค่า​ปรากฏ

ทำไม​ต้อง​ยืนยัน​ว่า​สี่​ท่า​คืน​แถว​เดียวกัน

keyset pagination พก คีย์​ของ​แถว​สุดท้าย​ของ​หน้า​ก่อน ไป​ด้วย ไม่ใช่​แถว​แรก​ของ​หน้า​นี้ ถ้า​หยิบ​ผิด​แถว​เดียว ท่า keyset จะ​คืน​ชุด​ที่​เลื่อน​ไป​หนึ่ง​แถว​จาก​ท่า OFFSET แล้ว​ราคา​ที่​เอา​มา​เทียบ​กัน​ก็​เป็น​ราคา​ของ​งาน​คนละ​งาน

ตัว​วัด​จึง​ดึง​ผล​ของ​ทั้ง​สี่​ท่า​ออก​มา​เทียบ​กัน​ทุก​ความ​ลึก และ โยน​ทิ้ง​ทั้ง​การ​วัด ถ้า​ไม่​ตรง · นี่​คือ​ด่าน​ที่​ทำให้​ตาราง​ข้าง​ล่าง​มี​ความ​หมาย ไม่ใช่​คำ​สัญญา​ใน​เชิงอรรถ

หน่วย​คือ​จำนวนนับ ไม่ใช่​วินาที เวลา​เปลี่ยน​ตาม​เครื่อง จำนวนนับ​ไม่​เปลี่ยน

ความ​ลึก​ของ​หน้าOFFSETrow-valueOR + parameterOR + literal
0231305305286
1,0003,2313096,280294
10,00030,23130960,280294
100,000300,231312600,284298
199,980600,171309537294

อ่าน​สาม​อย่าง​จาก​ตาราง​นี้

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 ไม่​ต้อง​รู้​ค่า​เลย เพราะ​ช่วง​อยู่​ใน​รูป​ประโยค​อยู่​แล้ว

ตัด​ข้อ​แก้ตัว​ที่​ทุก​คน​ยก​ก่อน — ไม่ใช่​เพราะ​ยัง​ไม่​ได้ ANALYZE

ข้อ​โต้​แย้ง​แรก​ที่​ทุก​คน​ยก​คือ​สถิติ · ตัว​วัด​จึง​ลบ sqlite_stat1 กับ sqlite_stat4 ทิ้ง​ทั้ง​คู่ แล้ว​วัด​ช่อง​เดิม​ซ้ำ​บน connection ใหม่

ท่ามี​สถิติลบ​สถิติ​ทิ้ง
OFFSET300,231300,231
row-value312312
OR + parameter600,284600,283
OR + literal298293

สถิติ​ไม่​ได้​ขยับ​อะไร​เลย ตัวแปร​คือ​ค่า​เดิน​มา​ทาง​ไหน ไม่ใช่ planner รู้จัก​ข้อมูล​ดี​แค่​ไหน

หน้า​สุดท้าย​เร็ว​อีก​ครั้ง และ​นั่น​คือ​เหตุผล​ที่​การ​ทดสอบ​ด้วย​มือ​มอง​ไม่​เห็น

หัวข้อ​ที่​มีชื่อ​ว่า “หน้า​สุดท้าย​เร็ว​อีก​ครั้ง และ​นั่น​คือ​เหตุผล​ที่​การ​ทดสอบ​ด้วย​มือ​มอง​ไม่​เห็น”

ดู​แถว​ล่าง​สุด​ของ​ตาราง​ราคา​อีก​ครั้ง ที่​ความ​ลึก 199,980 ท่า OR + parameter กลับ​มา​อยู่​ที่ 537 opcode — ถูก​กว่า​แถว​เหนือ​มัน​พัน​เท่า

ที่​ความ​ลึก​นั้น SQLite เปลี่ยน​ไป​ใช้​แผน MULTI-INDEX OR คือ​แยก​สอง​กิ่ง​ไป​ค้น​ที​ละ​กิ่ง​แล้ว​รวม​ผล ซึ่ง​คุ้ม​เมื่อ​ทั้ง​สอง​กิ่ง​คืน​แถว​น้อย และ​แถว​สุดท้าย​ของ​ตาราง​ก็​เป็น​แบบ​นั้น​พอดี

ผล​คือ​เส้น​ราคา​ของ​ท่า​นี้​เป็น โหนก ไม่ใช่​เส้น​ตรง ถูก​ที่​หน้า​แรก แพง​ที่สุด​ตรง​กลาง แล้ว​ถูก​อีก​ครั้ง​ที่​หน้า​สุดท้าย

และ​คน​ทดสอบ​มัก​หยิบ​สอง​ปลาย​นั้น​พอดี หน้า​แรก​เพราะ​มัน​เปิด​ขึ้น​มา​ก่อน หน้า​สุดท้าย​เพราะ กด​ปุ่ม​ไป​ท้าย​ตาราง​ง่าย​กว่า​ไล่​ไป​ที​ละ​หน้า · ทั้ง​สอง​จุด​ที่​หยิบ​ง่าย​ที่สุด คือ​สอง​จุด​ที่​ปัญหา​ไม่​โผล่

ข้อ​โต้​แย้ง​ที่​แรง​ที่สุด​ของ​หน้า​นี้​คือ “มัน​เป็น​นิสัย​ของ planner ของ SQLite ตัว​เดียว”

ตัว​วัด​อีก​ตัว​รัน​หน้า​เดียวกัน​บน PostgreSQL 18.6 ที่​ความ​ลึก 100,000 โดย​ส่ง​ค่า​เป็น parameter จริง​ผ่าน PREPARE / EXECUTE แล้ว​วัด​ด้วย​หน่วย​ของ PostgreSQL เอง คือ​จำนวน buffer ที่​แตะ — ไม่​ได้​เอา opcode ของ SQLite ข้าม​มา

ท่าbuffersแถว​ที่​อ่าน​ขึ้น​มา​แล้ว​ทิ้ง
OFFSET1,2310
row-value40
OR + parameter1,231100,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 กลาย​เป็น​ทั้ง​สอง​ตัว

ถาม​ด้วย​การ​รัน ไม่ใช่​ด้วย​การ​อ่าน​เอกสาร

ตัว​วัด​ตัว​ที่​สาม​ถาม 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 ต่อ​แถว โดย​เชื่อ​ว่า​กำลัง​แก้​ปัญหา

ทางออก​ที่​ยัง​ไม่​ต้อง​แลก​กับ SQL injection

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 ต่ำ​ที่สุด
🔁 อ่าน​คู่​กับ​สอง​หน้า​นี้

ขอบเขต​ของ​การ​วัด​แคบ​กว่า​ที่​หัวข้อ​อาจ​ทำให้​เข้าใจ

  • จำนวน opcode ขึ้น​กับ​บิ​ลด์​ของ SQLite ตัวเลข​ทั้งหมด​มา​จาก SQLite 3.53.4 บิ​ลด์​อื่น ให้​ตัวเลข​ไม่​เท่า​กัน​ได้ · ตัว​ตรวจ​จึง​ตรวจ รูป​ของ​เส้น ไม่​ได้​ตรึง​ตัวเลข​ที​ละ​ตัว
  • หน้า​นี้​ไม่​ได้​วัด​เวลา ไม่มี​วินาที ไม่มี throughput · opcode กับ buffer เป็น​จำนวนนับ ที่​ทำ​ซ้ำ​ได้ ส่วน​เวลา​ขึ้น​กับ​เครื่อง cache และ​งาน​อื่น​ที่​วิ่ง​อยู่​พร้อม​กัน
  • ตัวเลข​ฝั่ง SQLite กับ​ฝั่ง PostgreSQL เทียบ​กัน​ตรง ๆ ไม่​ได้ คนละ​หน่วย และ​หน่วย​หนึ่ง นับ​คำ​สั่ง อีก​หน่วย​นับ​หน้า · สิ่ง​ที่​เทียบ​ข้าม​เอน​จิน​ได้​คือ อัตราส่วน​ภายใน​เอน​จิ​น​เดียวกัน และ​ข้อ​สรุป​เชิง​พฤติกรรม ไม่ใช่​ตัวเลข​ดิบ
  • ไม่​ได้​วัด​ว่า​ตาราง​ที่​เขียน​หนัก​ตลอด​เวลา​จะ​ให้​ผล​แบบ​เดียวกัน ตาราง​ที่​วัด​นิ่ง ไม่มี​ใคร​เขียน แทรก​ระหว่าง​การ​วัด
  • ท่า MULTI-INDEX OR ที่​โผล่​มา​ที่​หน้า​สุดท้าย ยัง​ไม่​ได้​ถูก​ไล่​ว่า​เริ่ม​คุ้ม​ตรง​ไหน​พอดี หน้า​นี้​รายงาน​ว่า​มัน​เกิด​ขึ้น​จริง​ที่​ความ​ลึก​ไหน ไม่​ได้​อ้าง​ว่า​รู้​เส้น​แบ่ง

ตัวเลข​ทุก​ตัว​ใน​หน้า​นี้​มา​จาก​สาม​คำ​สั่ง​ใน repo ของ​ไซต์​นี้

Terminal window
# ฝั่ง SQLite — stdlib ล้วน ไม่ต้องมีอะไรเพิ่ม อยู่ในเกต unit ของ CI ด้วย
python3 scripts/pagination/probe.py --check
# ฝั่ง EF Core — ต้องมี .NET SDK
npm run gen:pagination-ef
# ฝั่ง PostgreSQL — ต้องมี Docker จึงอยู่นอก npm test
scripts/pg-transaction-spike/up.sh
python3 scripts/pagination/pg.py --check
docker 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)