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

cost model — ตัวเลข​ที่​ไม่มี​หน่วย​เวลา

บท 4 จบ​ลง​ที่​ค่า​ประมาณ​จำนวน​แถว ซึ่ง​ยัง​ไม่ใช่​คำ​ตอบ​ว่า​จะ​เลือก​แผน​ไหน planner ต้อง​แปลง​จำนวน​แถว​ให้​เป็น​ราคา​เสีย​ก่อน จึง​จะ​เอา​สอง​แผน​มา​วาง​เทียบ​กัน​ได้

บท​นี้​สร้าง​ตัว​แปลง​นั้น แผน​หนึ่ง​แผน​ถูก​ย่อ​เหลือ​เลข​สาม​ตัว แล้ว​ราคา​คือ​ผล​รวม​ถ่วง​น้ำหนัก​ของ​สาม​ตัว​นั้น

ผล​ที่​ตาม​มา​คือ​เส้น​สอง​เส้น ราคา​ของ​การ​สแกน​ทั้ง​ตาราง​ไม่​ขึ้น​กับ​ว่า​คำถาม​จะ​ได้​กี่​แถว ส่วน​ราคา​ของ​แผน index ไต่​ขึ้น​ตาม​จำนวน​แถว​ที่​ตรง​เงื่อนไข เส้น​สอง​เส้น​นี้​จึง​ตัด​กัน และ​จุด​ตัด​คือ​เส้น​ที่​ใช้​อ่าน​ผล​ของ planner ตัว​นี้ ไม่ใช่​ตัวเลข​ที่​มัน​คำนวณ

📦 code ของ​บท​นี้

บท​นี้​มี​สอง file qe/cost.py สร้าง model ราคา ส่วน qe/planner.py เอา​ราคา​นั้น​ไป​เลือก​แผน​จริง​กับ​คำถาม 10 ข้อ ครึ่ง​แรก​ของ​บท​เป็น​ของ file แรก ครึ่ง​หลัง​เป็น​ของ file ที่​สอง

ทั้ง​คู่ import ของ​ที่​บท​ก่อนหน้า​เขียน​ไว้​แล้ว cost.py ใช้ fixture กับ heapfile ส่วน planner.py ใช้ cost, fixture, bptree และ heapfile เพิ่ม​มา​อีก​สอง​ตัว ถ้า​สาม​บท​แรก​ยัง​ไม่​ครบ บท​นี้​จะ​รัน​ไม่​ผ่าน​ตั้งแต่​บรรทัด import

คำ​สั่ง​รัน​เรียก​จาก folder ทำงาน​ด้าน​นอก​เหมือน​เดิม คือ python3 qe/cost.py แล้ว​ตาม​ด้วย python3 qe/planner.py

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

cost modelcost modelสูตร​ที่​แปลง​จำนวน​หน้าที่​ต้อง​อ่าน​และ​จำนวน​แถว​ที่​ต้อง​หยิบ ให้​กลาย​เป็น​ตัวเลข​ตัว​เดียว​ที่​เอา​ไป​เทียบ​ระหว่าง​แผน​ได้ ทั้ง​ราคา​และ​จำนวน​แถว​ที่​ป้อน​เข้าไป​เป็น​ค่า​ประมาณ​ทั้ง​คู่ ของ​คอร์ส​นี้​ไม่​ได้​พยายาม​ทำนาย​ว่า​แผน​จะ​ใช้​เวลา​เท่าไร มัน​ทำ​อย่าง​เดียว​คือ​แปลง​แผน​ให้​เป็น​เลข​ตัว​เดียว​ที่​เอา​ไป​เทียบ​กับ​แผน​อื่น​ได้

ข้อ​ตกลง​ที่​ทำให้​เลข​ตัว​นั้น​มี​ความหมาย​มี​บรรทัด​เดียว หน่วย​ของ​ต้นทุน​คือ​ค่า​อ่าน​เพจ​แบบ​เรียงตัว​หนึ่ง​เพจ = 1.0 ทุก​อย่าง​ที่​เหลือ​ถูก​ตี​ราคา​เทียบ​กับ​ค่า​นี้

นี่​คือ unitless costunitless costข้อ​ตกลง​ว่า​ต้นทุน​ไม่มี​หน่วย​เวลา หน่วย​ของ​ต้นทุน​คือ​ค่า​อ่าน​เพจ​แบบ​เรียงตัว​หนึ่ง​เพจ = 1.0 จึง​ใช้​เทียบ​แผน​กัน​ได้ แต่​แปลง​เป็น​วินาที​ไม่​ได้ และ​ราคา​ที่​มัน​จ่าย​คือ​แปลง​กลับ​เป็น​วินาที​ไม่​ได้​เลย สิ่ง​ที่​ได้​กลับ​มา​คือ​ตัวเลข​ที่​ผู้​อ่าน​รัน​เอง​แล้ว​ได้​เท่า​กัน​ทุก​หลัก

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

qe/cost.py — แผน​หนึ่ง​แผน กับ​ราคา​ของ​มัน
Cost = collections.namedtuple("Cost", "seq rand rows")
"""แผนหนึ่งแผน = (หน้าเรียง, หน้าสุ่ม, แถวที่แตะ) — ราคาเป็นแค่ผลรวมถ่วงน้ำหนักของสามตัวนี้"""
def total(c, rand_page=RAND_PAGE, fetch=FETCH):
"""ราคารวมแบบไร้หน่วย · แยก parameter ออกมาให้ทำ sensitivity ได้โดยไม่ต้องแก้ค่าคงที่"""
return c.seq * SEQ_PAGE + c.rand * rand_page + c.rows * fetch
def touched_pages(c):
"""จำนวนหน้าที่แตะ ไม่คิดราคา — ใช้เทียบกับ Pager.reads ที่วัดได้จริง"""
return c.seq + c.rand

สูตร​ราคา​ทั้ง​สูตร​อยู่​ใน​บรรทัด​เดียว คือ seq * SEQ_PAGE + rand * RAND_PAGE + rows * FETCH

จุด​ที่​ตั้งใจ​ออกแบบ​ไว้​คือ rand_page กับ fetch เป็น​อาร์กิวเมนต์​ที่​มี​ค่า​ตั้งต้น ไม่ใช่​ค่า​คงที่​ที่​ฝัง​อยู่​ใน​ตัว​สูตร ราคา​ของ​ทาง​เลือก​นี้​คือ​ทุก​จุด​ที่​เรียก​ใช้​ต้อง​ยอมรับ​ว่า​ราคา​เปลี่ยน​ได้ แต่​สิ่ง​ที่​ได้​คือ​หัวข้อ​ท้าย​บท​ที่​เปลี่ยน​ค่า​คงที่​แล้ว​วัดผล​ได้​ทันที​โดย​ไม่​ต้อง​แก้​อะไร​ใน​สูตร

touched_pages ไม่​คิด​ราคา​เลย มัน​บวก​หน้า​สอง​ชนิด​เข้า​ด้วย​กัน​เฉยๆ เพราะ​บท​นี้​ต้อง​เอา​ไป​เทียบ​กับ​ตัว​นับ Pager.reads ของ​บท 2 ซึ่ง​นับ​เป็น​หน้า ไม่ใช่​เป็น​ราคา

ที่มา​ของ SEQ_PAGE, RAND_PAGE และ FETCH พร้อม​ตัวเลข​ที่​บท 2 กับ​บท 3 วัด​ไว้​แล้ว​และ​ถูก​หยิบ​มา​ใช้​ตรง​นี้

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

qe/cost.py — ค่า​คงที่​ของ model และ​รูปร่าง​ของ index ที่​บท 3 วัด​ไว้
SEQ_PAGE = 1.0
"""นิยาม ไม่ได้ fit: อ่านเพจแบบเรียงหนึ่งเพจ = 1.0 ทุกอย่างวัดเทียบกับตัวนี้"""
READAHEAD = 16
"""สมมุติฐานข้อเดียวของโมเดล: หนึ่งคำขออ่านได้หน้าที่ติดกัน 16 หน้า (64 KB)
เราวัดเวลาไม่ได้ (กฎข้อ 3) จึงต้องมีสมมุติฐานสักข้อเพื่อให้ 'สุ่มแพงกว่าเรียง' มีตัวเลข"""
RAND_PAGE = 16.0
"""ราคาอ่านเพจแบบสุ่มหนึ่งเพจ
fit อย่างไร: การอ่านแบบเรียง 3,153 หน้าใช้ ceil(3153/16) = 198 คำขอ = 0.0628 คำขอต่อหน้า
ส่วนการหยิบแถวด้วย rid แบบกระจัดกระจายใช้ราว 1 คำขอต่อหน้า (วัดโครงสร้าง run จริง
ในบล็อก __main__ ข้อ [3]) อัตราส่วน = 1 / 0.0628 = READAHEAD พอดีเมื่อไม่มีหน้าติดกันเลย
เราจึงตั้งค่าไว้ที่ขีดจำกัดนั้น = 16.0 ซึ่งเป็นย่านที่จุดตัดอยู่จริง (จุดตัด ~0.2 % ของตาราง)
ข้อ [3] ยังวัดให้เห็นด้วยว่าถ้าหยิบแถวเยอะขึ้น อัตราส่วนนี้ตกลงเรื่อย ๆ จนถึง 1.0
แปลว่า RAND_PAGE ไม่ใช่ค่าคงที่จริง ๆ — มันเป็นค่าคงที่ที่เราเลือกใช้ในย่านที่สนใจ
ข้อ [8] จึงพิมพ์ความไวของจุดตัดต่อค่านี้ไว้ให้ครบ ตั้งแต่ 1.0 ถึง 64.0"""
FETCH = 0.0158
"""ราคาต่อ 'หนึ่งแถวที่ถูกแตะ' — ตามตัวชี้ rid + ถอดรหัสไบต์ของแถว (ไม่รวมค่าอ่านหน้า)
fit อย่างไร: หนึ่งหน้าราคา 1.0 และให้แถวกลับมา 63.43 แถว (วัดได้ในบท 2)
เราจึงตีราคาแถวหนึ่งแถวเท่ากับส่วนแบ่งของมันในหน้าเดียว = 1 / 63.43 = 0.01577 -> ปัดเป็น 0.0158
ค่านี้ทำให้สแกนทั้งตารางแพงกว่าจำนวนหน้าประมาณเท่าตัว (3,153 หน้า + 200,000 แถว)
ซึ่งตรงกับความจริงที่ว่าหน้าที่อ่านมาแล้วยังต้องถอดรหัสแถวข้างในทีละแถว"""
# ── รูปร่างของ index ที่ "วัดได้" ในบท 3 (ไม่ได้เดา) ──────────────────────
LEAF_CAP = 408 # รายการต่อใบ = 4089 // 10
HEIGHT = 2 # ความสูงของต้นไม้ที่ 200,000 คีย์ แบบแตกหน้าตัดท้าย

ตัวเลข​ที่​เอา​มา fit ทุก​ตัว​มา​จาก​บท​ก่อนหน้า ส่วน​ของ​ที่​เพิ่ม​ใหม่​ใน​บท​นี้​มี​ข้อ​เดียว คือ​สมมุติฐาน​ว่า​หนึ่ง​คำขอ​อ่าน​หน้าที่​ติด​กัน​ได้ 16 หน้า

ของ​ที่​เอา​มา fitค่า
แถว​ทั้งหมด200,000
heap ใช้​หน้า3,153
แถว​ต่อ​หน้า63.43
รายการ​ต่อ​ใบ​ของ index408
ความ​สูง​ของ index2 ชั้น

SEQ_PAGE = 1.0 ไม่​ได้ fit มา​จาก​อะไร มัน​คือ​นิยาม​ของ​หน่วย ค่า​อื่น​ถูกวัด​เทียบ​กับ​มัน

RAND_PAGE = 16.0 มา​จาก​สมมุติฐาน​ข้อ​เดียว​ของ​ทั้ง​บท คือ​หนึ่ง​คำขอ​อ่าน​ได้​หน้าที่​ติด​กัน 16 หน้า อ่าน​ทั้ง file 3,153 หน้า​แบบ​เรียง​จึง​ใช้ ceil(3153/16) = 198 คำขอ คิด​เป็น 0.06280 คำขอ​ต่อ​หน้า ส่วน​การ​หยิบ​แถว​ที่​กระจาย​จน​ไม่มีหน้า​ติด​กัน​เลย​ใช้ 1 คำขอ​ต่อ​หน้า ขีด​จำกัด​ตาม​นิยาม​ของ readahead จึง​เป็น 16.00 เท่า ส่วน​ที่​วัด​ได้​จริง​ตรง​ขีด​จำกัด​คือ 15.92 เท่า เพราะ ceil ปัด 198 คำขอ​ขึ้น

FETCH = 0.0158 เป็นการ​ตัดสิน​ใจ​เชิง​ออกแบบ ไม่ใช่​การ​วัด หนึ่ง​หน้า​ราคา 1.0 และ​ให้​แถว​กลับ​มา 63.43 แถว เรา​จึง​ตี​ราคา​แถว​หนึ่ง​แถว​เท่ากับ​ส่วนแบ่ง​ของ​มัน​ใน​หน้า คือ 1 / 63.43 = 0.01577 แล้ว​ปัด​เป็น 0.0158

ผล​ของ​การ​ตัดสิน​ใจ​ข้อ​นี้​ไม่​เล็ก​เลย ราคา​สแกน​ทั้ง​ตาราง​กลาย​เป็น 3,153 หน้า × 1.0 บวก​กับ 200,000 แถว × 0.0158 = 6,313.0

แปล​ว่า​ราว​ครึ่ง​หนึ่ง​ของ​ราคา​สแกน​ไม่ใช่​การ​อ่าน disk มัน​คือ​ค่า​งาน​ต่อ​แถว 3,160 ที่​จ่าย​ให้การ​ถอดรหัส​แถว​ข้าง​ใน​หน้าที่​อ่าน​มา​แล้ว และ​เส้น​แนว​นอน​ที่​สูง​ขึ้น​เท่าตัว​ก็​ดัน​จุด​ตัด​ขึ้น​ตาม​ไป​ด้วย

ราคา 6,313.0 นี้​คือ เส้น​แนว​นอน ของ​ทั้ง​บท มัน​เท่า​เดิม​ไม่​ว่า​คำถาม​จะ​ได้ 1 แถว​หรือ 200,000 แถว เพราะ​การ​สแกน​อ่าน​ทุก​หน้า​และ​ถอดรหัส​ทุก​แถว​อยู่​แล้ว​ก่อน​จะ​รู้​ว่า​แถว​ไหน​ตรง​เงื่อนไข

`RAND_PAGE` กับ `FETCH` ไม่ใช่​ตัวเลข​ที่​อยู่​ใน SQLite

RAND_PAGE กับ FETCH เป็น parameter ที่ fit กับ model ของ​เรา​เอง จาก​ของ​ที่​วัด​ได้​ใน​บท 2 กับ​บท 3 เท่านั้น ไม่ใช่​ค่า​ของ​ฐาน​ข้อมูล​ตัว​ใด​ใน​โลก​จริง

บท 8 จะ​วัด​ไว้​ว่า​ถ้า​อยาก​ได้​จุด​ตัด​ใกล้​จุด​ที่ SQLite เปลี่ยนใจ​จริง ต้อง​ตั้ง RAND_PAGE ราว 1.35 พร้อม​เปิด​สมมุติฐาน​ว่า buffer pool พัก​ได้​ทั้ง file ซึ่ง​ห่าง​จาก 16.0 อยู่​มาก

สิ่ง​ที่​ยก​ไป​ใช้​ต่อ​ได้​คือ​รูป​ของ​สูตร​กับ​วิธี​อ่าน​ผล ไม่ใช่​ตัวเลข​สาม​ตัว​นี้

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

ค่า​คงที่​ตัว​เดียว​สำหรับ “สุ่ม​แพง​กว่า​เรียง​กี่​เท่า” เป็น​เรื่อง​ที่​ไม่​จริง และ​วัด​ได้​ว่า​มัน​ไม่​จริง​แค่​ไหน

cost.py หยิบ​แถว​ด้วย rid แบบ​สุ่ม​หลาย​ขนาด แล้ว​ดู​ว่า​หน้าที่​ต้อง​แตะ​รวม​กัน​เป็น​ช่วง​ติด​กัน​กี่​ช่วง ยิ่ง​ช่วง​ยาว readahead ยิ่ง​ช่วย​ได้​มาก

หยิบ​กี่​แถวหน้า​ไม่​ซ้ำช่วง​ติด​กันหน้า​ต่อ​ช่วงคำขอ​ต่อ​หน้าสุ่ม/เรียง
1010101.001.000015.92
10099941.050.949515.12
4003763221.170.856413.64
1,0008686211.400.715411.39
10,0003,05110130.210.08101.29
100,0003,15313153.000.06281.00

ที่ 10 แถว อัตราส่วน​อยู่​ที่ 15.92 เท่า เกือบ​ชน​ขีด​จำกัด 16.00 พอดี เพราะ​สิบ​หน้า​นั้น​ไม่มีหน้า​ไหน​ติด​กัน​เลย

ที่ 100,000 แถว อัตราส่วน​เหลือ 1.00 เท่า เพราะ​หน้าที่​ต้อง​หยิบ​กิน​ทั้ง file ไป​แล้ว 3,153 หน้า และ​รวม​กัน​เป็น​ช่วง​ติด​กัน​ช่วง​เดียว การ​หยิบ​แบบ​สุ่ม​กลาย​เป็นการ​อ่าน​แบบ​เรียง​ไป​โดย​ปริยาย

แถว​ที่​ต้อง​อ่าน​ออก​เสียงดังๆ คือ​แถว 400 ซึ่ง​เป็น​ย่าน​ที่​จุด​ตัด​อยู่​จริง ตรง​นั้น​วัด​ได้ 13.64 เท่า ไม่ใช่ 16.0 ค่าที่​เรา​ใช้​จึง​สูงกว่าที่​วัด​ได้ 17 %

เรา​รู้​ว่า​ค่า​นี้​เอียง​ไป​ทาง​ไหน และ​รู้​ขนาด​ของ​ความ​เอียง หัวข้อความ​ไว​ท้าย​ครึ่ง​แรก​จะ​บอกว่า​มัน​แปลง​เป็น​ความคลาด​ของ​จุด​ตัด​เท่าไร

ผล​ของ​การ​ไล่​หา​ค่า​คงที่​ต้นทุน​ใน source ของ SQLite และ​รูปแบบ​ตัวเลข​ที่​มัน​ใช้​แทน

สิ่ง​ที่​คาด​ว่า​จะ​เจอ​คือ​ค่า​คงที่​ที่​ตั้ง​ชื่อ​ไว้​หัว file แบบ​เดียว​กับ SEQ_PAGE ของ​เรา สิ่ง​ที่​เจอ​จริง​คือ​ไม่มี​เลย

การ grep หา #define ทั้ง src/where.c ซึ่ง​ยาว 7,721 บรรทัด​ใน SQLite 3.51.0 ไม่​เจอ​ค่า​คงที่​ราคา seek หรือ scan สัก​ตัว ตัวเลข​ทั้งหมด​เป็น​เลข​ดิบ​ฝัง​อยู่​กลาง code โดย​มี​คอมเมนต์ /* TUNING: ... */ กำกับ​ไว้​เท่านั้น

ตัวอย่าง​ที่​ชัด​ที่สุด​อยู่​ที่ src/where.c:6241-6242 ซึ่ง​เขียน​ว่า “TUNING: Cost of a rowid lookup is 10” แปล​ว่า​ราคา​ของ​การ​ค้น​ด้วย rowid ตั้ง​ไว้​ที่ 10 แล้ว​บรรทัด​ถัด​มา​เขียน pLoop->rRun = 33;

เลข 33 ไม่​ได้​ขัด​กับ​เลข 10 เพราะ​ราคา​ใน code ส่วน​นี้​เก็บ​เป็น LogEst ซึ่ง src/sqliteInt.h:875-884 นิยาม​ไว้​ว่า “For quantity X, the value stored is 10*log2(X)” แปล​ว่า​ค่าที่​เก็บ​คือ​สิบ​เท่า​ของ log ฐาน​สอง​ของ​ปริมาณ​จริง และ src/sqliteInt.h:898 ประกาศ​ชนิด​ของ​มัน​ไว้​เป็น typedef INT16_TYPE LogEst;

ราคา​ที่​บีบ​ลง​เหลือ​จำนวนเต็ม 16 bit แบบ​นี้​มี​ราคา​ของ​มัน​เอง คอมเมนต์​เดียวกัน​บอกไว้ตรงๆ ว่า “quantities 16 and 17 are both represented by a LogEst of 40” คือ 16 กับ 17 แยก​กัน​ไม่​ออก​อีก​ต่อ​ไป

ฟิลด์ที่ planner ของ SQLite เอา​มา​เทียบ​กัน​จริง​มี​สาม​ตัว​ที่ src/whereInt.h:137-139 คือ rSetup, rRun และ nOut แนวคิด​เหมือน​ของ​เรา​ตรง​ที่​แผน​หนึ่ง​แผน​ถูก​ย่อ​เหลือ​จำนวน​ไม่​กี่​ตัว ส่วน​ความหมาย​ของ​แต่ละ​ตัว​คนละ​เรื่อง​กัน

model ราคา​ของ​มัน​เขียน​ไว้​ใน​คอมเมนต์​ที่ src/where.c:3882-3887 ว่า “cost = nRow * 3.0 // full-table scan” และ “cost = nRow * K // scan of covering index” โดย “where K is a value between 1.1 and 3.0” ส่วน​ฝั่ง​ที่​ต้อง seek อยู่​ที่ src/where.c:3893-3894 เขียน​ว่า “cost = nSeek * (log(nRow) + K * nVisit)”

เลข 3.0 ที่​คูณ​กับ​การ​สแกน​ทั้ง​ตาราง​ไม่​ได้​มา​จาก​การ​วัด​ฮาร์ดแวร์ src/where.c:4059-4062 อธิบาย​เหตุผล​ไว้​เอง​ว่า​มัน​คือ “an extra cost designed to discourage the use of full table scans, since index lookups have better worst-case performance if our stat guesses are wrong”

แปล​ว่า​มัน​คือ​ราคา​ส่วนเกิน​ที่​ตั้งใจ​ใส่​ไว้​กัน​ไม่​ให้​เลือก​สแกน​ทั้ง​ตาราง เพราะ​การ​ค้น​ด้วย index มี​ผลลัพธ์​กรณี​แย่​ที่สุด​ดี​กว่า​เมื่อ​ค่า​ประมาณ​จาก​สถิติ​ผิด

ราคา​ส่วนเกิน​นั้น​ถูก​เขียน​ออก​มา​ที่ src/where.c:4074 เป็น pNew->rRun = rSize + 16; ซึ่ง​เลข 16 คือ 10·log2(3) = 15.85 ปัด​ขึ้น

สอง​ข้อ​ที่​ต้อง​ติด​ไว้​กับ​ตัวเลข​ชุด​นี้

เลข 2.75 ที่​คอมเมนต์​เดียวกัน​เอ่ย​ถึง​ใช้​เฉพาะ​ตอน​คอมไพล์​ด้วย SQLITE_ENABLE_STAT4 ไม่ใช่​ค่าที่​ทุก build เห็น

ตัวเลข TUNING ทั้งหมด​เป็น​ค่า​ปรับ​จูน​ตาม​ประสบการณ์​ของ​ผู้​พัฒนา ไม่ใช่​ค่าที่​วัด​จาก​ฮาร์ดแวร์​ตัว​ใด​ตัว​หนึ่ง ซึ่ง​เป็น​สถานะ​เดียว​กับ RAND_PAGE ของ​เรา ต่าง​กัน​แค่​ว่า​ของ​เรา​เขียน​วิธี fit กำกับ​ไว้ ส่วน​ของ SQLite เขียน​เหตุผล​ของ​ทิศทาง​ไว้

ทุก​บรรทัด​ที่​อ้าง​ใน​หัวข้อ​นี้​ตรวจ​กับ source ของ SQLite 3.51.0

เอา​สูตร​ไป​ทำนาย​เลข​ที่​บท 2 กับ​บท 3 วัด​ไว้​แล้ว

หัวข้อ​ที่​มีชื่อ​ว่า “เอา​สูตร​ไป​ทำนาย​เลข​ที่​บท 2 กับ​บท 3 วัด​ไว้​แล้ว”

ฟังก์ชัน​สอง​ตัว​ที่​แปลง​จำนวน​แถว​เป็น​จำนวน​หน้า และ​ผล​เทียบ​กับ​หน้าที่​บท​ก่อนหน้า​นับ​ได้​จริง

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

qe/cost.py — จาก​จำนวน​แถว​ที่​ตรง​เงื่อนไข ไป​เป็น​จำนวน​หน้า
def heap_pages(t, matched, clustered, cache=False):
"""กี่หน้าของ heap ที่ต้องแตะ เมื่อ index ชี้แถวที่ตรงมาให้ matched แถว
clustered=True หน้าที่ต้องแตะเรียงต่อกัน (heap เรียงตามคีย์อยู่แล้ว)
ต้องการที่พักแค่หน้าเดียวก็พอ เพราะแถวถัดไปอยู่หน้าเดิม
clustered=False rid กระโดดไปทั่ว file
cache=False (ค่าตั้งต้น) = ไม่มี buffer pool เลย เหมือน Pager ของบท 2
หนึ่งแถว = หนึ่งการอ่าน แม้จะเป็นหน้าเดิมที่เพิ่งอ่านไป
cache=True = สมมุติว่าพักได้ทั้ง file จึงนับเฉพาะหน้าที่ไม่ซ้ำ
ใช้สูตร n(1-(1-1/n)^k) ซึ่งข้อ [5] วัดเทียบของจริงแล้ว
"""
if matched <= 0:
return 0.0
if clustered:
return float(min(t.npages, math.ceil(matched / t.rows_per_page)))
if cache:
return t.npages * (1.0 - (1.0 - 1.0 / t.npages) ** matched)
return float(matched)
def index_scan(t, matched, clustered=False, cache=False,
height=HEIGHT, leaf_cap=LEAF_CAP):
"""ไล่ index แล้วตามตัวชี้กลับไปหยิบแถวจาก heap
หน้าที่ลงจากรากถึงชั้นเหนือใบ = สุ่ม (กระโดดไปคนละที่ใน file)
หน้าใบที่เดินตามโซ่ = เรียง (ใบถูกจัดสรรต่อกันตอน bulk load)
ส่วน heap แล้วแต่ clustering ตามฟังก์ชันข้างบน"""
descent = float(height - 1)
leaves = float(max(1, math.ceil(matched / leaf_cap)))
hp = heap_pages(t, matched, clustered, cache)
if clustered:
return Cost(seq=leaves + hp, rand=descent, rows=float(matched))
return Cost(seq=leaves, rand=descent + hp, rows=float(matched))

heap_pages มี​สาม​ทางออก และ​แต่ละ​ทาง​ตอบ​คำถาม​คนละ​ข้อ

  • เรียง​ตาม​คีย์​อยู่​แล้ว แถว​ที่​ตรง​กอง​อยู่​ใน​หน้าที่​ติด​กัน จำนวน​หน้า​จึง​เป็น ceil(matched / rows_per_page) โดย​มี​เพดาน​ที่ npages ไว้​กัน​กรณี​ที่ matched ใหญ่​กว่า​จำนวน​แถว​ทั้ง​ตาราง
  • ไม่​เรียง และ​ไม่มี buffer pool ตัว​ชี้​แถว​หนึ่ง​ตัว​เท่ากับ​หนึ่ง​การ​อ่าน​เสมอ แม้​จะ​เป็น​หน้า​เดิม​ที่​เพิ่ง​อ่าน​ไป ผล​คือ matched หน้า​พอดี ซึ่ง​เป็น​เงื่อนไข​เดียว​กับ​ตัว​อ่าน​หน้า​ของ​บท 2
  • ไม่​เรียง แต่​พัก​ได้​ทั้ง file ใช้​สูตร n(1-(1-1/n)^k) ซึ่ง​ตอบ​คำถาม​ว่า​โยน​ของ k ครั้ง​ลง​ถัง n ใบ​แล้ว​จะ​โดน​กี่​ใบ นี่​คือ​ค่า​คาดหมาย​ของ​จำนวน​หน้าที่​ไม่​ซ้ำ ใน​กรณี​ที่พัก​ได้​ทั้ง file

index_scan แยก​หน้าที่​แตะ​ออก​เป็น​สอง​ชนิด​ตาม​ที่​บท 3 วัด​ไว้ หน้าที่​ลง​จากราก​ถึง​ชั้น​เหนือ​ใบ​นับ​เป็นการ​อ่าน​แบบ​สุ่ม เพราะ​กระโดด​ไป​คนละ​ที่​ใน file ส่วน​หน้า​ใบ​ที่​เดิน​ตาม​โซ่​นับ​เป็นการ​อ่าน​แบบ​เรียง เพราะ​ใบ​ถูก​จัดสรร​ต่อ​กัน​ตอน​สร้าง

เอา​สอง​ฟังก์ชัน​นี้​ไป​ทำนายหน้า heap ที่​ไม่​ซ้ำ แล้ว​เทียบ​กับ​เลข​ที่​บท 2 กับ​บท 3 นับ​ได้​จริง

กรณีแถววัด​ได้สูตร​ทำนายq-error
วัน 90 (คีย์​ตรง​ลำดับ​หน้า)1,0171717.01.00
วัน 140 วัน​โปรโมชัน17,603279278.01.00
customer_id = 995979395.51.03
rid สุ่ม 1,000 แถว1,000868857.11.01

q-error สูงสุด​ของ​ชุด​นี้​คือ 1.03 โดย​สอง​แถว​ล่าง​เป็น​ผล​ของ​กิ่ง cache=True ซึ่ง​สมมุติ​ว่า​พัก​ได้​ทั้ง file

สอง​แถว​บน​ใช้​ทางออก​แรก​ของ heap_pages และ​ได้​ตัวเลข​สวย​เพราะ heap ของ​คอร์ส​นี้​เรียง​ตาม placed_at อยู่​แล้ว​ตั้งแต่ stamps.sort() ในบท 1 การ​ถาม​ด้วย​ช่วง​เวลา​จึง​ตกลง​บน​หน้าที่​ติด​กัน​พอดี

แถว customer_id = 995 คือ​ด้าน​ตรง​ข้าม​บน​ตาราง​เดียวกัน 97 แถว​กระจาย​อยู่​บน 93 หน้า คือ​เกือบ​หนึ่ง​หน้า​ต่อ​หนึ่ง​แถว ทุก​ครั้ง​ที่​บท​นี้​ยก​ตัวเลข​ฝั่ง​ที่​เรียง​อยู่​แล้ว ให้​อ่าน​คู่​กับ​แถว​นี้​เสมอ

ส่วน​กิ่ง cache=False ที่​บท​นี้​ใช้​จริง​กับ​ตัวเลข​ที่​เหลือ​ทุก​ตัว​ตอบ​คนละ​อย่าง มัน​ตอบ 97 หน้า​กับ 1,000 หน้า เทียบ​ของ​จริง 93 กับ 868 หน้า คือ q-error 1.04 กับ 1.15 ซึ่ง​เป็น​ขอบ​บน​ตาม​ที่​บท 1 ประกาศ​ไว้​ว่า​ตัวเลข​ของ​คอร์ส​นี้​เป็น​ขอบ​บน

ตาราง​ราคา​ของ​ทั้ง​สาม​แผนที่ selectivity ต่างๆ แถว​ที่​ราคา​ของ​สแกน​กับ index ที่​ไม่ cluster เท่า​กัน​พอดี และ​ความ​ไว​ของ​แถว​นั้น​ต่อ​ค่า​คงที่​ที่​เรา fit เอง

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

selectivityแถวสแกนindex ไม่ clusterindex clusterไม่ cluster ÷ สแกนถูก​ที่สุด
0.010 %206,313.0337.318.30.05index cluster
0.050 %1006,313.01,618.620.60.26index cluster
0.100 %2006,313.03,220.224.20.51index cluster
0.200 %4006,313.06,423.330.31.02index cluster
0.500 %1,0006,313.016,034.850.82.54index cluster
1.000 %2,0006,313.032,052.684.65.08index cluster
2.000 %4,0006,313.064,089.2153.210.15index cluster
5.000 %10,0006,313.0160,199.0357.025.38index cluster
10.000 %20,0006,313.0320,382.0698.050.75index cluster
25.000 %50,0006,313.0800,929.01,718.0126.87index cluster
50.000 %100,0006,313.01,601,842.03,419.0253.74index cluster
100.000 %200,0006,313.03,203,667.06,820.0507.47สแกน

column สแกน​เป็น 6,313.0 ทุก​แถว นั่น​คือ​ความหมาย​ของ​คำ​ว่า​เส้น​แนว​นอน

column index ที่​ไม่ cluster ไต่​จาก 337.3 ไป​ถึง 3,203,667.0 คือ​ชัน​มาก เพราะ​ทุก​แถว​ที่​เพิ่ม​เข้า​มา​แปล​ว่า​อีก​หนึ่ง​หน้าที่​ต้อง​กระโดด​ไป​อ่าน

แถว​ที่​ต้อง​ดู​คือ 0.200 % ซึ่ง​เป็น 400 แถว ตรง​นั้น​สแกน​ราคา 6,313.0 ส่วน index ราคา 6,423.3 อัตราส่วน​เป็น 1.02 คือ​เพิ่ง​ข้าม​เส้น​มา​นิดเดียว

crossover selectivitycrossover selectivityจุด​ที่​ราคา​ของ​แผน index เท่ากับ​ราคา​ของ​การ​สแกน​ทั้ง​ตาราง เลย​จุด​นี้​ไป​การ​สแกน​ถูก​กว่า และ​มัน​เป็น​ค่า​ของ index ตัว​นั้น​กับ​ข้อมูล​ชุด​นั้น ไม่ใช่​ค่า​ประจำ​ตาราง คือ​จำนวน​แถว​ที่​ราคา​ของ​สอง​แผน​เท่า​กัน​พอดี ต่ำ​กว่า​นั้น index ถูก​กว่า สูง​กว่า​นั้น​สแกน​ถูก​กว่า และ​นี่​คือ​เส้น​ที่​ใช้​อธิบาย​ทุก​การ​ตัดสิน​ใจ​ใน​ครึ่ง​หลัง​ของ​บท

การ​หา​จุด​ตัด​ไม่​ต้อง​ไล่​ที​ละ​แถว เพราะ​ราคา​ของ index เพิ่ม​ตาม​จำนวน​แถว​เสมอ จึง​ค้น​แบบ​ทวิภาค​ได้

qe/cost.py — หา​จำนวน​แถว​ที่ index เริ่ม​แพง​กว่า​สแกน
def crossover(t, clustered=False, cache=False, rand_page=RAND_PAGE, fetch=FETCH):
"""จำนวนแถวน้อยที่สุดที่ทำให้ index แพงกว่าสแกน — คืน None ถ้าไม่มีจุดตัด
ราคาของ index เพิ่มตามจำนวนแถวเสมอ จึงค้นแบบทวิภาคได้ (ไม่ต้องไล่ทีละแถว)"""
scan = total(seq_scan(t), rand_page, fetch)
def idx(r):
return total(index_scan(t, r, clustered, cache), rand_page, fetch)
if idx(t.nrows) <= scan:
return None
lo, hi = 1, t.nrows
while lo < hi:
mid = (lo + hi) // 2
if idx(mid) > scan:
hi = mid
else:
lo = mid + 1
return lo

บรรทัด​ที่​ต้อง​อ่าน​คือ if idx(t.nrows) <= scan: return None ซึ่ง​บอกว่า​จุด​ตัด​อาจ​ไม่มี​อยู่​เลย ถ้า​แผน index ยัง​ถูก​กว่า​ตอน​ที่​ตรง​เงื่อนไข​ทั้ง​ตาราง ก็​ไม่มี​จำนวน​แถว​ไหน​ที่​ทำให้​สแกน​ชนะ

flowchart LR
    R["จำนวนแถวที่ตรงเงื่อนไข"] --> S["แผนสแกน<br/>อ่านทุกหน้าแบบเรียง แล้วถอดรหัสทุกแถว"]
    R --> I["แผน index ที่ไม่ cluster<br/>เดินต้นไม้ แล้วตามตัวชี้กลับไปอ่าน heap"]
    S --> SC["ราคา 6,313.0 เท่าเดิมเสมอ<br/>ไม่ขึ้นกับจำนวนแถวที่ตรง"]
    I --> IC["ราคาไต่ขึ้นตามจำนวนแถวที่ตรง<br/>ทุกแถวคืออีกหนึ่งหน้าที่ต้องกระโดดไปอ่าน"]
    SC --> X{"เส้นสองเส้นตัดกันที่ 394 แถว<br/>= 0.197 % ของตาราง"}
    IC --> X
    X -->|"ตรงเงื่อนไขน้อยกว่า 394 แถว"| A["index ถูกกว่า"]
    X -->|"ตรงเงื่อนไขมากกว่า 394 แถว"| B["สแกนถูกกว่า"]

คำ​บรรยาย​ภาพ: ราคา​ของ​แผน​สแกน​ไม่​ขึ้น​กับ​จำนวน​แถว​ที่​ตรง​เงื่อนไข​เลย จึง​เป็น​เส้น​แนว​นอน​ที่ 6,313.0 · ราคา​ของ​แผน index ที่​ไม่ cluster ไต่​ขึ้น​ตาม​จำนวน​แถว เพราะ​ไม่มี buffer pool ตัว​ชี้​แถว​หนึ่ง​ตัว​จึง​เท่ากับ​หนึ่ง​หน้า​เสมอ · เส้น​สอง​เส้น​ตัด​กัน​ที่ 394 แถว และ​ตัวเลข​นี้​เป็น​ของ index ที่​ไม่ cluster เท่านั้น ฝั่ง cluster อยู่​คนละ​ที่​กัน​มาก

จุด​ตัด​เป็น​ตัวเลข​ของ index ตัว​นั้น ไม่ใช่​ของ​ตาราง

หัวข้อ​ที่​มีชื่อ​ว่า “จุด​ตัด​เป็น​ตัวเลข​ของ index ตัว​นั้น ไม่ใช่​ของ​ตาราง”

จุด​ตัด​สาม​ค่า​ข้าง​ล่าง​นี้​มา​จาก​ตาราง​เดียวกัน ข้อมูล​ชุด​เดียวกัน และ​จำนวน​แถว​เท่า​กัน​ทุก​ประการ

แผนจุด​ตัดคิด​เป็น​สัดส่วน​ของ​ตาราง
index ไม่ cluster ไม่มี buffer pool394 แถว0.197 %
index ไม่ cluster มี buffer pool420 แถว0.210 %
index cluster185,094 แถว92.547 %

สอง​ค่าหัว​กับ​ท้าย​ห่าง​กัน 470 เท่า ประโยค​ว่า “จุด​ตัด​ของ index อยู่​ราวๆ กี่​เปอร์เซ็นต์” จึง​ไม่มี​ความหมาย ถ้า​ไม่​บอกว่า​พูด​ถึง index ที่ cluster หรือ​ไม่

clustering เปลี่ยน​สอง​อย่าง​พร้อม​กัน คือ​แตะ​หน้า​น้อย​ลง และ​หน้าที่​แตะ​ราคา​ถูก​ลง​เพราะ​กลาย​เป็นการ​อ่าน​แบบ​เรียง ผล​จึง​ทบ​กัน​แทนที่​จะ​บวก​กัน

สิ่ง​ที่ buffer pool ทำ​กลับ​เล็ก​จน​น่า​ประหลาด​ใจ มัน​ขยับ​จุด​ตัด​จาก 394 เป็น 420 แถว​เท่านั้น ประโยชน์​ของ buffer pool มี​จริง แต่​มัน​อยู่​คนละ​ที่​กับ​การ​ตัดสิน​ใจ​เลือก​แผน

กรณี cluster ใน​คอร์ส​นี้​เป็น​อุบัติเหตุ​ของ fixture ไม่ใช่​คุณสมบัติ​ของ index มัน​เกิด​เพราะ heap เรียง​ตาม placed_at อยู่​แล้ว index บน column อื่น​ใน​ตาราง​เดียวกัน​ไม่​ได้​ของ​แถม​นี้

ถ้า​ค่า​คงที่​ที่​เรา fit เอง​ผิด จุด​ตัด​ฝั่ง​ไม่ cluster ผิด​ตาม​หนึ่ง​ต่อ​หนึ่ง ส่วน​ฝั่ง cluster แทบ​ไม่​ขยับ

หัวข้อ​ที่​มีชื่อ​ว่า “ถ้า​ค่า​คงที่​ที่​เรา fit เอง​ผิด จุด​ตัด​ฝั่ง​ไม่ cluster ผิด​ตาม​หนึ่ง​ต่อ​หนึ่ง ส่วน​ฝั่ง cluster แทบ​ไม่​ขยับ”

RAND_PAGE เป็น​ค่าที่​เรา​ตั้ง​เอง จึง​ต้อง​รู้​ว่า​ถ้า​ตั้ง​ผิด​แล้ว​ผล​เสีย​ใหญ่​แค่​ไหน วิธี​ตอบ​คือ​กวาด​ค่า​มัน​ตั้งแต่ 1.0 ถึง 64.0 แล้ว​ดู​จุด​ตัด

RAND_PAGEจุด​ตัด index ไม่ clusterจุด​ตัด index cluster
1.06,199 แถว (3.100 %)185,538 แถว (92.77 %)
2.03,127 แถว (1.563 %)185,507 แถว (92.75 %)
4.01,571 แถว (0.785 %)185,444 แถว (92.72 %)
8.0787 แถว (0.394 %)185,317 แถว (92.66 %)
16.0394 แถว (0.197 %)185,094 แถว (92.55 %)
32.0197 แถว (0.099 %)184,621 แถว (92.31 %)
64.098 แถว (0.049 %)183,671 แถว (91.84 %)

ฝั่ง​ไม่ cluster กวาด​ค่า​ไป 64 เท่า จุด​ตัด​ขยับ 63 เท่า คือ​ผกผัน​กันตรงๆ ไม่มี​การ​ขยาย​และ​ไม่มี​การ​ยุบ

ฝั่ง cluster กวาด​ค่า​เท่า​กัน จุด​ตัด​ขยับ​แค่ 1.01 เท่า เพราะ​แผน cluster จ่าย​ราคา​สุ่ม​แค่​ตอน​ลง​จากราก​ไม่​กี่​หน้า ที่​เหลือ​อ่าน​เรียง​หมด RAND_PAGE จึง​แทบ​ไม่มี​อะไร​ให้​คูณ

เอา​ค่าที่​วัด​ได้​จริง​ใน​ย่าน​จุด​ตัด​คือ 13.64 มา​แทน 16.0 จุด​ตัด​ขยับ​จาก 394 เป็น 462 แถว นี่​คือ​ขนาด​ของ​ความคลาด​ที่​การ​เลือก​ค่า​คงที่​ครั้ง​เดียว​สร้าง​ขึ้น

ตั้ง​ราคา​ให้​เท่ากับ​การ​นับ​หน้า แล้ว​สูตร​ต้อง​ได้​เลข​เดิม​ของ​บท 2

หัวข้อ​ที่​มีชื่อ​ว่า “ตั้ง​ราคา​ให้​เท่ากับ​การ​นับ​หน้า แล้ว​สูตร​ต้อง​ได้​เลข​เดิม​ของ​บท 2”

การ​ตรวจ​ย้อน​ที่​ถูก​ที่สุด​คือ​ตั้ง RAND_PAGE = 1.0 และ FETCH = 0.0 ซึ่ง​ทำให้​ราคา​กลาย​เป็นการ​นับ​หน้า​ล้วน​แบบ​หน่วย​ของ​บท 2 พอดี

จุด​ตัด​ที่​สูตร​ให้​คือ 3,145 แถว ส่วน​จุด​คุ้ม​ทุน​ที่​บท 2 วัด​ได้​คือ 3,153 แถว ต่าง​กัน 8 แถว

ส่วน​ต่าง​นั้น​อธิบาย​ได้​หมด มัน​คือ​ค่า​เดิน​ต้นไม้​ที่​บท 2 ไม่​ได้​นับ คือ​ลง​จากราก 1 หน้า บวก​ใบ​อีก​ไม่​กี่​หน้า

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

ครึ่ง​หลัง​ของ​บท​เปลี่ยน​มา​ที่ qe/planner.py ซึ่ง​เอา​ราคา​จาก cost.py ไป​เลือก​แผน​ให้​คำถาม​จริง

qe/planner.py — ทาง​เลือก​ทั้งหมด และ​การ​เลือก
def plans(t, matched, clustered):
"""ทางเลือกทั้งหมดที่ planner ของเรารู้จัก — มีแค่สองทาง ตั้งใจให้มีแค่สองทาง"""
return [("สแกน", cost.seq_scan(t)),
("index", cost.index_scan(t, max(1, matched), clustered=clustered))]
def choose(t, q):
"""เลือกแผนจาก 'ค่าประมาณ' แล้วคิดย้อนว่าถ้ารู้จำนวนแถวจริงจะเลือกอะไร"""
scan = cost.total(cost.seq_scan(t))
idx_est = cost.total(cost.index_scan(t, max(1, round(q.est)), clustered=q.clustered))
idx_act = cost.total(cost.index_scan(t, q.act, clustered=q.clustered)) # 0 แถวก็ยังต้องเดินต้นไม้
picked = "index" if idx_est < scan else "สแกน"
picked_cost = idx_act if picked == "index" else scan # จ่ายตามความจริง ไม่ใช่ตามที่เดา
best_name = "index" if idx_act < scan else "สแกน"
return Choice(q, scan, idx_est, idx_act, picked, picked_cost,
min(scan, idx_act), best_name)

ตรรกะ​การ​เลือก​อยู่​ใน​บรรทัด​เดียว​คือ picked = "index" if idx_est < scan else "สแกน" ที่​เหลือ​ใน​ฟังก์ชัน​เป็นการ​บันทึก​ว่า​ถ้า​รู้ความ​จริง​จะ​เลือก​อะไร

จุด​สำคัญ​คือ idx_est คิด​จาก​ค่า​ประมาณ​ของ​บท 4 ส่วน idx_act คิด​จาก​จำนวน​แถว​จริง planner เห็น​แค่​ตัว​แรก แต่​ราคา​ที่​ต้อง​จ่าย​คำนวณ​จาก​ตัว​หลัง เพราะ​เวลา​รัน​จริง​มัน​ไม่​ได้​จ่าย​ตาม​ที่​เดา​ไว้

plans บอก​กติกา​ของ​สนาม​นี้ตรงๆ ว่า​มี​สอง​ทาง​เลือก​และ​ตั้งใจ​ให้​มี​สอง​ทาง ไม่มี covering index ไม่มี​การ​รวม​ผล​จาก​หลาย index และ​ไม่มี join ตัวเลข​คุณภาพ​การ​ตัดสิน​ใจ​ที่​กำลัง​จะ​เห็น​จึง​เป็น​คุณภาพ​ใน​โลก​ที่​มี​สอง​ทาง​เลือก​เท่านั้น

สิ่ง​ที่ planner รู้​ก่อน​ตัดสิน​ใจ​มี​เท่า​นี้

  • ตาราง orders มี 200,000 แถว 3,153 หน้า และ 63.43 แถว​ต่อ​หน้า
  • ค่า​คงที่​ของ model คือ SEQ_PAGE 1.0, RAND_PAGE 16.0 และ FETCH 0.0158
  • ราคา​สแกน​ทั้ง​ตาราง​คือ 6,313.0 และ​ไม่​ขึ้น​กับ​คำถาม​เลย
  • จุด​ตัด​ของ index ที่​ไม่ cluster คือ 394 แถว ส่วน​ของ index ที่ cluster คือ 185,094 แถว
flowchart TD
    Q["คำถามหนึ่งข้อ"] --> E["ค่าประมาณจำนวนแถวจากบท 4"]
    E --> C{"ค่าประมาณตกอยู่ฝั่งไหนของจุดตัด"}
    C -->|"ต่ำกว่าจุดตัด"| PI["เลือกแผน index"]
    C -->|"สูงกว่าจุดตัด"| PS["เลือกแผนสแกน"]
    PI --> P["ตอนรันจริง จ่ายราคาตามจำนวนแถวจริง ไม่ใช่ตามที่เดา"]
    PS --> P
    P --> W{"จำนวนแถวจริงอยู่ฝั่งเดียวกับค่าประมาณไหม"}
    W -->|"ฝั่งเดียวกัน"| OK["เลือกถูก แม้ค่าประมาณจะพลาดหลายเท่า"]
    W -->|"คนละฝั่ง"| BAD["เลือกผิด และนี่คือนิยามของการเลือกผิดในบทนี้"]

คำ​บรรยาย​ภาพ: planner เห็น​แค่​ค่า​ประมาณ​จาก​บท 4 แล้ว​เทียบ​ว่า​มัน​ตก​อยู่​ฝั่ง​ไหน​ของ​จุด​ตัด · ราคา​ที่​จ่าย​จริง​คำนวณ​จาก​จำนวน​แถว​จริง ซึ่ง planner ไม่​เคย​เห็น · การ​เลือก​ผิด​จึง​ไม่​ได้​นิยาม​ด้วย​ขนาด​ของ​ความคลาด แต่​นิยาม​ด้วย​การ​ที่​ค่า​ประมาณ​กับ​ความ​จริง​อยู่​คนละ​ฝั่ง​ของ​จุด​ตัด

ผล​การ​เลือก​ของ planner ทั้ง​สิบ​ข้อ พร้อม​ราคา​ที่​ควร​จะ​เป็น​ถ้า​รู้​จำนวน​แถว​จริง

คำถาม​สิบ​ข้อ​ใช้​ตัว​ประมาณ​ชุด​เดียว​กับ​บท 4 เป๊ะๆ คือ n/distinct สำหรับ​เงื่อนไข​เท่ากับ sel(X) × sel(Y) × n สำหรับ​สอง​เงื่อนไข​พร้อม​กัน และ​การกระจาย​เท่า​กัน​ตลอด​ช่วง​ค่า​สำหรับ​ช่วง​วัน ราคา​สแกน​เป็น 6,313.0 ทุก​ข้อ

queryประมาณจริงq-errorindex ตาม​ค่า​ประมาณindex ตาม​ความ​จริงเลือกควร​เลือก
Q1 id = 123451.011.0033.033.0indexindex
Q2 customer_id = 995100.0971.031,618.61,570.5indexindex
Q3 status = 'Placed'28,571.4199143.58457,674.43,204.1สแกนindex
Q4 status = 'Delivered'28,571.4163,7335.73457,674.42,622,733.0สแกนสแกน
Q5 restaurant = 'ครัวคุณแม่'20,000.011,9341.68320,382.0191,178.6สแกนสแกน
Q6 status = 'Rejected' และ restaurant = 'ครัวสุขใจ'1,565.601565.5825,100.717.0สแกนindex
Q7 placed_at ใน​วัน​ที่ 901,111.11,0171.0954.652.1indexindex
Q8 placed_at ใน​วัน​ที่ 1401,111.117,60315.8454.6616.1indexindex
Q9 placed_at ใน 60 วัน​แรก66,666.720,6573.232,285.3719.4indexindex
Q10 placed_at ทั้ง 180 วัน200,000.0200,0001.006,820.06,820.0สแกนสแกน

สี่​ข้อ​ท้าย​ใช้ index บน placed_at ซึ่ง​เป็น​ตัว​ที่ cluster จุด​ตัด​ของ​มัน​จึง​เป็น 185,094 แถว ส่วน​หก​ข้อ​แรก​ใช้ index ที่​ไม่ cluster และ​วัด​กับ​จุด​ตัด 394 แถว

สรุป​คุณภาพ​การ​ตัดสิน​ใจ​ทั้ง​ชุด

ตัว​ชี้​วัดค่า
query ทั้งหมด10
เลือก​ผิด2 ข้อ (Q3, Q6)
ราคา​รวม​ที่​จ่าย​จริง34,556.1
ราคา​รวม​ถ้า​เลือก​ถูก​ทุก​ข้อ25,151.3
ส่วน​ที่​เสียเปล่า9,404.9

ส่วน​ที่​เสียเปล่า​คิด​เป็น 37.4 % ของ​ราคา​ที่​ควร​จ่าย และ​มัน​มา​จาก​คำถาม​แค่​สอง​ข้อ​จาก​สิบ​ข้อ

Q3 จ่าย 6,313.0 แทนที่​จะ​จ่าย 3,204.1 คือ​แพง​เกิน​ไป 2.0 เท่า ส่วน Q6 จ่าย 6,313.0 แทนที่​จะ​จ่าย 17.0 คือ​แพง​เกิน​ไป 371.4 เท่า

ค่า​ประมาณ​ที่​พลาด​หนัก​กว่า ไม่​ได้​แปล​ว่า​จ่าย​แพง​กว่า

หัวข้อ​ที่​มีชื่อ​ว่า “ค่า​ประมาณ​ที่​พลาด​หนัก​กว่า ไม่​ได้​แปล​ว่า​จ่าย​แพง​กว่า”

ความ​สัมพันธ์​ระหว่าง q-error กับ​ราคา​ที่​เสีย​ไป​จริง จาก​สาม​ข้อ​ที่​ผลออก​มา​ไม่​เรียง​ตาม​กัน

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

queryq-errorแพง​เกิน​ไป
Q3143.582.0 เท่า
Q61565.58371.4 เท่า
Q815.84ไม่​เสีย​อะไร​เลย

Q8 พลาด​ไป 15.84 เท่า ซึ่ง​เป็น​ความคลาด​ที่​ไม่​ได้​เล็ก แต่​ไม่​ทำให้​เลือก​ผิด​สัก​นิด เพราะ​มัน​อยู่​บน index ที่ cluster ซึ่ง​เส้น​ราคา​ชัน​น้อย​มาก ประมาณ 1,111.1 แถว​หรือ​ของ​จริง 17,603 แถว​ก็​ยัง​อยู่​ต่ำ​กว่า​จุด​ตัด 185,094 แถว​ทั้ง​คู่

Q3 พลาด​ไป 143.58 เท่า มากกว่า Q8 เกือบ​สิบ​เท่า แต่​เสีย​แค่ 2.0 เท่า เพราะ​แม้​จะ​เลือก​ฝั่ง​ผิด ราคา​ของ​ฝั่ง​ที่​ควร​เลือก​ก็​ไม่​ได้​ถูก​กว่า​กัน​มาก

Q6 คือ​ข้อ​ที่​เจ็บ​ที่สุด ของ​จริง​ไม่มี​สัก​แถว ราคา​ที่​ควร​จ่าย​จึง​เป็น​แค่​ค่า​เดิน​ต้นไม้ 17.0 แต่​ค่า​ประมาณ​บอกว่า​มี 1,565.6 แถว planner จึง​ไป​สแกน​ทั้ง​ตาราง

สิ่ง​ที่​กำหนด​ราคา​ไม่ใช่​ขนาด​ของ​ความคลาด แต่​คือ​ฝั่ง​ของ​จุด​ตัด

ค่า​ประมาณ​ที่​พลาด​สิบ​เท่า​แล้ว​ยัง​อยู่​ฝั่ง​เดิม​ของ​จุด​ตัด ไม่​ทำให้​เสีย​อะไร​เลย ส่วน​ค่า​ประมาณ​ที่​พลาด​เล็กน้อย​แต่​ข้าม​เส้น​ไป ทำให้​จ่าย​เพิ่ม​ทันที

นี่​คือ​สะพาน​ที่​เชื่อม​บท 4 กับ​บท​นี้ การ​ไล่​ทำให้​ค่า​ประมาณ​แม่น​ขึ้น​ทุก​จุด​จึง​ไม่ใช่​การ​ลงทุน​ที่​คุ้ม​เสมอ​ไป สิ่ง​ที่​คุ้ม​กว่า​คือ​ทำให้​มัน​แม่น​ตรง​ย่าน​ที่​คาบ​เส้น

ราคา​ของ​มุมมอง​นี้​คือ​มัน​ต้อง​รู้​จุด​ตัด​ก่อน และ​จุด​ตัด​เป็น​ของ index ตัว​นั้น​กับ​ข้อมูล​ชุด​นั้น ไม่ใช่​ค่าที่​ตั้ง​ไว้​ล่วงหน้า​ได้

เลข 1565.58 ของ Q6 เป็น​สิ่ง​ประดิษฐ์ ไม่ใช่​ผล​วัด

q-error วัด​กรณี​ที่​ของ​จริง​เป็น​ศูนย์​ไม่​ได้ เพราะ​ต้อง​หาร​ด้วย​ศูนย์ สูตร​จึง​ยกพื้น​ทั้ง​ตัวตั้ง​และ​ตัว​หาร​ขึ้น​เป็น​อย่าง​น้อย 1 แถว​ก่อน​หาร

เลข 1565.58 จึง​เป็น​ผล​ของ​การ​ยกพื้น​นั้น ไม่ใช่​ค่า​ความคลาด​จริง สิ่ง​ที่​จริง​คือ​ของ​จริง​มี 0 แถว แต่​ค่า​ประมาณ​ยืนยัน​ว่า​มี 1,565.6 แถว

คู่​ที่​ไม่มี​อยู่​จริง​คู่​นี้​มา​จาก MIX["ครัวสุขใจ"] ที่​ตั้ง Rejected=0 ในบท 1 และ​บท 8 จะ​กลับ​มา​ที่​มัน​อีก​ครั้ง

ผล​เทียบ​ระหว่าง​หน้าที่​สูตร​ทำนาย​กับ​หน้าที่​ตัว​นับ​จริง​บน B+tree ของ​บท 3 นับ​ได้ และ​แผนที่​พลิก​เมื่อ​เปลี่ยน​ค่า​คงที่

ราคา​ที่​คิด​มา​ทั้ง​บท​ยัง​เป็น​แค่​ตัวเลข​บน​สูตร ขั้น​สุดท้าย​จึง​สร้าง B+tree ของ​บท 3 ขึ้น​มา​จริง แล้ว​รัน​แผน​เพื่อ​นับ​หน้าที่​แตะ​จริง ตรง​นี้​ตั้ง​ราคา​เป็นกลาง​อีก​ครั้ง คือ​เทียบ​กัน​ที่​จำนวน​หน้า​ล้วน

ชุด​ที่​เอา​ไป​รัน​เป็น​แผน​บน index placed_at ห้า​แบบ​ตายตัว ไม่ใช่​แผนที่ planner เลือก​ให้​คำถาม​สิบ​ข้อ ทั้ง​ห้า​แบบ​จึง​คิด​ราคา​แบบ cluster เหมือน​กัน​หมด

index ที่​สร้าง​ได้​กิน​เนื้อที่ 493 หน้า สูง 2 ชั้น และ​ใบ​ละ​ไม่​เกิน 408 รายการ

แผนแถว​ที่​ตรงวัด​จริง (หน้า)สูตร​ทำนายq-error
point lookup หนึ่ง​คีย์1331.00
range วัน​ที่ 901,01722211.05
range วัน​ที่ 14017,6033243231.00
range 60 วัน​แรก20,6573783781.00
สแกน​ทั้ง​ตาราง200,0003,1533,1531.00

q-error สูงสุด​ของ​ชุด​นี้​คือ 1.05 และ​ส่วน​ที่​พลาด​อธิบาย​ได้​ทั้งหมด มัน​คือ​ค่า​เดิน​โซ่​ใบ​กับ​หน้า​หัว​ท้าย​ของ​ช่วง ซึ่ง​สูตร​ไม่​ได้​นับ

ค่า​คงที่​ที่​เรา fit เอง​อาจ​ผิด คำถาม​ที่​ตาม​มา​คือ​มัน​ทำให้​เลือก​แผน​ต่าง​ออก​ไป​กี่​ข้อ วิธี​ตอบ​คือ​กวาด RAND_PAGE ตั้งแต่ 1.0 ถึง 64.0 แล้ว​ดู​ว่า​แผน​ไหน​เปลี่ยน

RAND_PAGEQ2Q6อีก​แปด​ข้อ
1.0indexindexไม่​เปลี่ยน​สัก​ข้อ
2.0indexindexไม่​เปลี่ยน​สัก​ข้อ
4.0indexindexไม่​เปลี่ยน​สัก​ข้อ
8.0indexสแกนไม่​เปลี่ยน​สัก​ข้อ
16.0indexสแกนไม่​เปลี่ยน​สัก​ข้อ
32.0indexสแกนไม่​เปลี่ยน​สัก​ข้อ
64.0สแกนสแกนไม่​เปลี่ยน​สัก​ข้อ

พลิก​ไป 2 ข้อ​จาก 10 ข้อ และ​ทั้ง​สอง​ข้อ​คือ​ข้อ​ที่​คาบ​เส้น​อยู่​แล้ว Q2 ประมาณ​ไว้ 100 แถว คิด​เป็น 0.25 เท่า​ของ​จุด​ตัด ส่วน Q6 ประมาณ​ไว้ 1,566 แถว คิด​เป็น 3.97 เท่า​ของ​จุด​ตัด

อีก​แปด​ข้อ​ที่​เหลือ​อยู่​ห่าง​จาก​จุด​ตัด​มาก​พอ​จน​กวาด​ค่า​คงที่​ไป 64 เท่า​ก็​ไม่​สะเทือน

ผล​นี้​เปลี่ยน​ความหมาย​ของ​งาน fit ทั้งหมด​ที่​ทำ​มา​ครึ่ง​บท ค่า​คงที่​ที่​เรา​ตั้ง​เอง​มี​ผล​กับ​คำถาม​ที่​คาบ​เส้น​เท่านั้น ไม่​ได้​มี​ผล​กับ​ทุก​คำถาม ราคา​ของ​การ​ปรับ​จูนมัน​จึง​ต้อง​จ่าย​ไป​กับ query ที่​อยู่​ใกล้​จุด​ตัด ไม่ใช่​จ่าย​เท่า​กัน​หมด

บท 6 จะ​เปิด​กล่อง​คำ​ว่า “แผน index” ที่​บท​นี้​นับ​เป็น​ทาง​เลือก​เดียว แล้ว​พบ​ว่า​ข้าง​ใน​มี​ทาง​เข้า​หลาย​แบบ​ที่​ราคา​ต่าง​กัน​มาก ทั้ง​ที่​ใช้ column ชุด​เดียวกัน


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • SQLite 3.51.0 source (sha256 5330719b8b80bf563991ff7a373052943f5357aae76cd1f3367eab845d3a75b7 · ตรวจ​กับ source แล้ว 2026-08-11) — ที่มา​ของ​ทุก​ข้อ​อ้าง​เรื่อง​ราคา​ของ SQLite ใน​บท​นี้
    • นิยาม LogEst ที่ src/sqliteInt.h:875-884 และ​ชนิด​ของ​มัน​ที่ src/sqliteInt.h:898
    • ฟิลด์ที่ planner เอา​มา​เทียบ​กัน​จริง​ที่ src/whereInt.h:137-139
    • model ราคา​ใน​คอมเมนต์​ที่ src/where.c:3882-3887 และ src/where.c:3893-3894
    • เหตุผล​ของ​เลข 3.0 ที่ src/where.c:4059-4062 กับ src/where.c:4074
    • ตัวเลข TUNING ที่ src/where.c:6241-6242 และ src/where.c:6267-6268
    • ผล​การ​ไล่​หา #define ทั้ง src/where.c ซึ่ง​ยาว 7,721 บรรทัด แล้ว​ไม่​เจอ​ค่า​คงที่​ราคา​ที่​ตั้ง​ชื่อ​ไว้​เลย
  • Access Path Selection in a Relational Database Management System — Selinger et al., Proc. 1979 ACM SIGMOD, หน้า 23–34 (ตรวจ PDF แล้ว 2026-08-11) — ต้นตำรับ​ของ​แนวคิด​ที่​บท​นี้​ทำซ้ำ คือ​ประมาณ​ราคา​ของ​ทุก​แผนที่​เป็น​ไป​ได้​ก่อน แล้ว​เลือก​แผนที่​ราคา​ต่ำ​สุด

เช็กความเข้าใจ — บทที่ 5

ข้อ 1 / 3

ราคาสแกนทั้งตารางในบทนี้คือ 6,313.0 ทั้งที่ตารางมี 3,153 หน้า ตัวเลข 6,313.0 บอกอะไร และเอาไปแปลงเป็นเวลาได้ไหม