cost model — ตัวเลขที่ไม่มีหน่วยเวลา
บท 4 จบลงที่ค่าประมาณจำนวนแถว ซึ่งยังไม่ใช่คำตอบว่าจะเลือกแผนไหน planner ต้องแปลงจำนวนแถวให้เป็นราคาเสียก่อน จึงจะเอาสองแผนมาวางเทียบกันได้
บทนี้สร้างตัวแปลงนั้น แผนหนึ่งแผนถูกย่อเหลือเลขสามตัว แล้วราคาคือผลรวมถ่วงน้ำหนักของสามตัวนั้น
ผลที่ตามมาคือเส้นสองเส้น ราคาของการสแกนทั้งตารางไม่ขึ้นกับว่าคำถามจะได้กี่แถว ส่วนราคาของแผน index ไต่ขึ้นตามจำนวนแถวที่ตรงเงื่อนไข เส้นสองเส้นนี้จึงตัดกัน และจุดตัดคือเส้นที่ใช้อ่านผลของ planner ตัวนี้ ไม่ใช่ตัวเลขที่มันคำนวณ
บทนี้มีสอง 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 จึงใช้เทียบแผนกันได้ แต่แปลงเป็นวินาทีไม่ได้ และราคาที่มันจ่ายคือแปลงกลับเป็นวินาทีไม่ได้เลย สิ่งที่ได้กลับมาคือตัวเลขที่ผู้อ่านรันเองแล้วได้เท่ากันทุกหลัก
แผนหนึ่งแผนถูกย่อเหลือสามจำนวน คือหน้าที่อ่านแบบเรียง หน้าที่อ่านแบบสุ่ม และแถวที่ต้องหยิบขึ้นมาถอดรหัส
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 ซึ่งนับเป็นหน้า ไม่ใช่เป็นราคา
ค่าคงที่สามตัว และของที่เอามา fit มัน
หัวข้อที่มีชื่อว่า “ค่าคงที่สามตัว และของที่เอามา fit มัน”ที่มาของ
SEQ_PAGE,RAND_PAGEและFETCHพร้อมตัวเลขที่บท 2 กับบท 3 วัดไว้แล้วและถูกหยิบมาใช้ตรงนี้
ค่าคงที่ทั้งสามตัวเขียนคู่กับวิธี fit ของมันไว้ในที่เดียวกัน เพราะถ้าแยกกันเมื่อไร คนอ่านจะเข้าใจว่ามันเป็นค่าที่รู้มาก่อน
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 // 10HEIGHT = 2 # ความสูงของต้นไม้ที่ 200,000 คีย์ แบบแตกหน้าตัดท้ายตัวเลขที่เอามา fit ทุกตัวมาจากบทก่อนหน้า ส่วนของที่เพิ่มใหม่ในบทนี้มีข้อเดียว คือสมมุติฐานว่าหนึ่งคำขออ่านหน้าที่ติดกันได้ 16 หน้า
| ของที่เอามา fit | ค่า |
|---|---|
| แถวทั้งหมด | 200,000 |
| heap ใช้หน้า | 3,153 |
| แถวต่อหน้า | 63.43 |
| รายการต่อใบของ index | 408 |
| ความสูงของ index | 2 ชั้น |
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 เป็น parameter ที่ fit กับ model ของเราเอง จากของที่วัดได้ในบท 2 กับบท 3 เท่านั้น ไม่ใช่ค่าของฐานข้อมูลตัวใดในโลกจริง
บท 8 จะวัดไว้ว่าถ้าอยากได้จุดตัดใกล้จุดที่ SQLite เปลี่ยนใจจริง ต้องตั้ง RAND_PAGE ราว 1.35 พร้อมเปิดสมมุติฐานว่า buffer pool พักได้ทั้ง file ซึ่งห่างจาก 16.0 อยู่มาก
สิ่งที่ยกไปใช้ต่อได้คือรูปของสูตรกับวิธีอ่านผล ไม่ใช่ตัวเลขสามตัวนี้
RAND_PAGE คงที่เฉพาะในย่านที่เราสนใจ
หัวข้อที่มีชื่อว่า “RAND_PAGE คงที่เฉพาะในย่านที่เราสนใจ”ผลวัดที่แสดงว่าอัตราส่วนสุ่มต่อเรียงตกลงเรื่อยๆ ตามจำนวนแถวที่หยิบ และค่าที่ใช้จริงสูงกว่าที่วัดได้ตรงย่านจุดตัดเท่าไร
ค่าคงที่ตัวเดียวสำหรับ “สุ่มแพงกว่าเรียงกี่เท่า” เป็นเรื่องที่ไม่จริง และวัดได้ว่ามันไม่จริงแค่ไหน
cost.py หยิบแถวด้วย rid แบบสุ่มหลายขนาด แล้วดูว่าหน้าที่ต้องแตะรวมกันเป็นช่วงติดกันกี่ช่วง ยิ่งช่วงยาว readahead ยิ่งช่วยได้มาก
| หยิบกี่แถว | หน้าไม่ซ้ำ | ช่วงติดกัน | หน้าต่อช่วง | คำขอต่อหน้า | สุ่ม/เรียง |
|---|---|---|---|---|---|
| 10 | 10 | 10 | 1.00 | 1.0000 | 15.92 |
| 100 | 99 | 94 | 1.05 | 0.9495 | 15.12 |
| 400 | 376 | 322 | 1.17 | 0.8564 | 13.64 |
| 1,000 | 868 | 621 | 1.40 | 0.7154 | 11.39 |
| 10,000 | 3,051 | 101 | 30.21 | 0.0810 | 1.29 |
| 100,000 | 3,153 | 1 | 3153.00 | 0.0628 | 1.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 วัดไว้แล้ว”ฟังก์ชันสองตัวที่แปลงจำนวนแถวเป็นจำนวนหน้า และผลเทียบกับหน้าที่บทก่อนหน้านับได้จริง
สูตรราคาจะน่าเชื่อได้ก็ต่อเมื่อจำนวนหน้าที่มันทำนายตรงกับที่บทก่อนหน้านับได้จริง ตรงนี้จึงตั้งราคาให้เป็นกลางก่อน คือนับหน้าอย่างเดียว ไม่คิดราคา
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,017 | 17 | 17.0 | 1.00 |
| วัน 140 วันโปรโมชัน | 17,603 | 279 | 278.0 | 1.00 |
customer_id = 995 | 97 | 93 | 95.5 | 1.03 |
| rid สุ่ม 1,000 แถว | 1,000 | 868 | 857.1 | 1.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 ไม่ cluster | index cluster | ไม่ cluster ÷ สแกน | ถูกที่สุด |
|---|---|---|---|---|---|---|
| 0.010 % | 20 | 6,313.0 | 337.3 | 18.3 | 0.05 | index cluster |
| 0.050 % | 100 | 6,313.0 | 1,618.6 | 20.6 | 0.26 | index cluster |
| 0.100 % | 200 | 6,313.0 | 3,220.2 | 24.2 | 0.51 | index cluster |
| 0.200 % | 400 | 6,313.0 | 6,423.3 | 30.3 | 1.02 | index cluster |
| 0.500 % | 1,000 | 6,313.0 | 16,034.8 | 50.8 | 2.54 | index cluster |
| 1.000 % | 2,000 | 6,313.0 | 32,052.6 | 84.6 | 5.08 | index cluster |
| 2.000 % | 4,000 | 6,313.0 | 64,089.2 | 153.2 | 10.15 | index cluster |
| 5.000 % | 10,000 | 6,313.0 | 160,199.0 | 357.0 | 25.38 | index cluster |
| 10.000 % | 20,000 | 6,313.0 | 320,382.0 | 698.0 | 50.75 | index cluster |
| 25.000 % | 50,000 | 6,313.0 | 800,929.0 | 1,718.0 | 126.87 | index cluster |
| 50.000 % | 100,000 | 6,313.0 | 1,601,842.0 | 3,419.0 | 253.74 | index cluster |
| 100.000 % | 200,000 | 6,313.0 | 3,203,667.0 | 6,820.0 | 507.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 เพิ่มตามจำนวนแถวเสมอ จึงค้นแบบทวิภาคได้
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 pool | 394 แถว | 0.197 % |
| index ไม่ cluster มี buffer pool | 420 แถว | 0.210 % |
| index cluster | 185,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.0 | 6,199 แถว (3.100 %) | 185,538 แถว (92.77 %) |
| 2.0 | 3,127 แถว (1.563 %) | 185,507 แถว (92.75 %) |
| 4.0 | 1,571 แถว (0.785 %) | 185,444 แถว (92.72 %) |
| 8.0 | 787 แถว (0.394 %) | 185,317 แถว (92.66 %) |
| 16.0 | 394 แถว (0.197 %) | 185,094 แถว (92.55 %) |
| 32.0 | 197 แถว (0.099 %) | 184,621 แถว (92.31 %) |
| 64.0 | 98 แถว (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 ทั้งตัวคือการเลือกค่าที่น้อยกว่า”ตรรกะการเลือกแผนที่อยู่ในบรรทัดเดียว เหตุผลที่ราคาซึ่งจ่ายจริงคิดจากจำนวนแถวที่ planner ไม่เคยเห็น และรายการของที่ planner รู้ก่อนตัดสินใจ
ครึ่งหลังของบทเปลี่ยนมาที่ qe/planner.py ซึ่งเอาราคาจาก cost.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_PAGE1.0,RAND_PAGE16.0 และFETCH0.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-error | index ตามค่าประมาณ | index ตามความจริง | เลือก | ควรเลือก |
|---|---|---|---|---|---|---|---|
Q1 id = 12345 | 1.0 | 1 | 1.00 | 33.0 | 33.0 | index | index |
Q2 customer_id = 995 | 100.0 | 97 | 1.03 | 1,618.6 | 1,570.5 | index | index |
Q3 status = 'Placed' | 28,571.4 | 199 | 143.58 | 457,674.4 | 3,204.1 | สแกน | index |
Q4 status = 'Delivered' | 28,571.4 | 163,733 | 5.73 | 457,674.4 | 2,622,733.0 | สแกน | สแกน |
Q5 restaurant = 'ครัวคุณแม่' | 20,000.0 | 11,934 | 1.68 | 320,382.0 | 191,178.6 | สแกน | สแกน |
Q6 status = 'Rejected' และ restaurant = 'ครัวสุขใจ' | 1,565.6 | 0 | 1565.58 | 25,100.7 | 17.0 | สแกน | index |
Q7 placed_at ในวันที่ 90 | 1,111.1 | 1,017 | 1.09 | 54.6 | 52.1 | index | index |
Q8 placed_at ในวันที่ 140 | 1,111.1 | 17,603 | 15.84 | 54.6 | 616.1 | index | index |
Q9 placed_at ใน 60 วันแรก | 66,666.7 | 20,657 | 3.23 | 2,285.3 | 719.4 | index | index |
Q10 placed_at ทั้ง 180 วัน | 200,000.0 | 200,000 | 1.00 | 6,820.0 | 6,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 กับราคาที่เสียไปจริง จากสามข้อที่ผลออกมาไม่เรียงตามกัน
เอาสามข้อจากตารางข้างบนมาวางเรียงกัน แล้วจะเห็นว่าความคลาดของค่าประมาณกับราคาของการเลือกผิดไม่ได้ไปด้วยกัน
| query | q-error | แพงเกินไป |
|---|---|---|
| Q3 | 143.58 | 2.0 เท่า |
| Q6 | 1565.58 | 371.4 เท่า |
| Q8 | 15.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 ตัวนั้นกับข้อมูลชุดนั้น ไม่ใช่ค่าที่ตั้งไว้ล่วงหน้าได้
q-error วัดกรณีที่ของจริงเป็นศูนย์ไม่ได้ เพราะต้องหารด้วยศูนย์ สูตรจึงยกพื้นทั้งตัวตั้งและตัวหารขึ้นเป็นอย่างน้อย 1 แถวก่อนหาร
เลข 1565.58 จึงเป็นผลของการยกพื้นนั้น ไม่ใช่ค่าความคลาดจริง สิ่งที่จริงคือของจริงมี 0 แถว แต่ค่าประมาณยืนยันว่ามี 1,565.6 แถว
คู่ที่ไม่มีอยู่จริงคู่นี้มาจาก MIX["ครัวสุขใจ"] ที่ตั้ง Rejected=0 ในบท 1 และบท 8 จะกลับมาที่มันอีกครั้ง
เอาแผนไปรันจริงบน B+tree แล้วนับหน้า
หัวข้อที่มีชื่อว่า “เอาแผนไปรันจริงบน B+tree แล้วนับหน้า”ผลเทียบระหว่างหน้าที่สูตรทำนายกับหน้าที่ตัวนับจริงบน B+tree ของบท 3 นับได้ และแผนที่พลิกเมื่อเปลี่ยนค่าคงที่
ราคาที่คิดมาทั้งบทยังเป็นแค่ตัวเลขบนสูตร ขั้นสุดท้ายจึงสร้าง B+tree ของบท 3 ขึ้นมาจริง แล้วรันแผนเพื่อนับหน้าที่แตะจริง ตรงนี้ตั้งราคาเป็นกลางอีกครั้ง คือเทียบกันที่จำนวนหน้าล้วน
ชุดที่เอาไปรันเป็นแผนบน index placed_at ห้าแบบตายตัว ไม่ใช่แผนที่ planner เลือกให้คำถามสิบข้อ ทั้งห้าแบบจึงคิดราคาแบบ cluster เหมือนกันหมด
index ที่สร้างได้กินเนื้อที่ 493 หน้า สูง 2 ชั้น และใบละไม่เกิน 408 รายการ
| แผน | แถวที่ตรง | วัดจริง (หน้า) | สูตรทำนาย | q-error |
|---|---|---|---|---|
| point lookup หนึ่งคีย์ | 1 | 3 | 3 | 1.00 |
| range วันที่ 90 | 1,017 | 22 | 21 | 1.05 |
| range วันที่ 140 | 17,603 | 324 | 323 | 1.00 |
| range 60 วันแรก | 20,657 | 378 | 378 | 1.00 |
| สแกนทั้งตาราง | 200,000 | 3,153 | 3,153 | 1.00 |
q-error สูงสุดของชุดนี้คือ 1.05 และส่วนที่พลาดอธิบายได้ทั้งหมด มันคือค่าเดินโซ่ใบกับหน้าหัวท้ายของช่วง ซึ่งสูตรไม่ได้นับ
ถ้าเปลี่ยน RAND_PAGE แผนไหนพลิก
หัวข้อที่มีชื่อว่า “ถ้าเปลี่ยน RAND_PAGE แผนไหนพลิก”ค่าคงที่ที่เรา fit เองอาจผิด คำถามที่ตามมาคือมันทำให้เลือกแผนต่างออกไปกี่ข้อ วิธีตอบคือกวาด RAND_PAGE ตั้งแต่ 1.0 ถึง 64.0 แล้วดูว่าแผนไหนเปลี่ยน
| RAND_PAGE | Q2 | Q6 | อีกแปดข้อ |
|---|---|---|---|
| 1.0 | index | index | ไม่เปลี่ยนสักข้อ |
| 2.0 | index | index | ไม่เปลี่ยนสักข้อ |
| 4.0 | index | index | ไม่เปลี่ยนสักข้อ |
| 8.0 | index | สแกน | ไม่เปลี่ยนสักข้อ |
| 16.0 | index | สแกน | ไม่เปลี่ยนสักข้อ |
| 32.0 | index | สแกน | ไม่เปลี่ยนสักข้อ |
| 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 บรรทัด แล้วไม่เจอค่าคงที่ราคาที่ตั้งชื่อไว้เลย
- นิยาม LogEst ที่
- 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 บอกอะไร และเอาไปแปลงเป็นเวลาได้ไหม