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

capstone — planner ของ​คุณ เทียบ​กับ sqlite3

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

เป้าหมาย​ไม่ใช่​ทำให้​สอง​ฝั่ง​ตอบ​ตรง​กัน เรา​สร้าง​สิ่ง​ที่​อธิบาย​ได้​ว่า​ทำไม​แผน​ของ SQLite ถึง​หน้าตา​แบบ​นั้น ไม่ใช่​สิ่ง​ที่​ดี​กว่า SQLite

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

และ​วิธี​แยก​ว่า​ความ​ต่าง​ข้อ​หนึ่ง​มา​จาก​อะไร คือ​การ​ทดลอง ไม่ใช่​การ​อธิบาย

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

file ของ​บท​นี้​คือ qe/compare.py ซึ่ง import ของ​ที่​บท 2 ถึง​บท 7 เขียน​ไว้​แล้ว​หก​ตัว คือ heapfile, bptree, stats, cost, paths และ joins บวก fixture ของ​บท 1 และ sqlite3 ที่​ติด​มา​กับ Python อยู่​แล้ว ยัง​ไม่​ต้อง​ติดตั้ง​อะไร​เพิ่ม​เหมือน​เดิม

file นี้​ยาว​กว่า​ทุก​บท​ที่​ผ่าน​มา​มาก บทเรียน​จึง​ยก​มา​เฉพาะ​ช่วง​ที่​อธิบาย และ​นี่​เป็น​บท​เดียว​ใน​คอร์ส​ที่​ไม่​ได้​ยก​มา​ทั้ง file ตัวเลข​ที่​ตาม​มา​จึง​ตรวจ​ซ้ำ​ได้​ครบ​ก็​ต่อ​เมื่อ​รัน qe/compare.py ทั้ง file ไม่ใช่​จาก​ท่อน​ที่​ยก​มา​ใน​หน้า​นี้

ฐาน​ข้อมูล​ที่​ใช้​เทียบ​คือ .build/orders.db ถ้า​ยัง​ไม่มี qe/compare.py จะ​สร้าง​มัน​จาก fixture ชุด​เดียว​กับ​บท 1 แล้ว​สั่ง ANALYZE ถ้า​ยัง​ไม่มี​สถิติ ส่วน​การ​ทดลอง​ทุก​อัน​ที่​ต้อง​แก้​อะไร​สัก​อย่าง ทำ​บน​สำเนา​เสมอ ตัว​จริง​ห้าม​แตะ

สนาม​แข่ง — ของ​สอง​ฝั่ง​วาง​อยู่​บน​ข้อมูล​ชุด​เดียวกัน​จริง​ไหม

หัวข้อ​ที่​มีชื่อ​ว่า “สนาม​แข่ง — ของ​สอง​ฝั่ง​วาง​อยู่​บน​ข้อมูล​ชุด​เดียวกัน​จริง​ไหม”

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

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

  • แถว​ใน SQLite เทียบ​กับ​ใน fixture ได้ 200,000 ต่อ 200,000 แถว ตรง​กัน
  • page_size ของ SQLite คือ 4096 B เท่ากับ PAGE_SIZE ที่​บท 2 ตรึง​ไว้​พอดี หน่วย “หน้า” ของ​สอง​ฝั่ง​จึง​วาง​ทาบ​กันได้ตรงๆ
  • ทั้ง file ของ SQLite กิน​ไป 11,009 หน้า

ตาราง dbstat ของ SQLite บอก​ได้​ว่า 11,009 หน้า​นั้น​ไป​อยู่​ที่​อะไร​บ้าง และ​เทียบ​กับ​ของ​ที่​เรา​สร้าง​เอง​ใน​บท 2 กับ​บท 3 ได้ตรงๆ หก​แถว​ข้าง​ล่าง​รวม​กัน​ได้ 11,003 หน้า ส่วน​อีก 6 หน้าที่​เหลือ​เป็น​ของ sqlite_schema 1 หน้า sqlite_stat1 1 หน้า และ sqlite_stat4 4 หน้า

อะไรSQLite (หน้า)ของ​เรา (หน้า)SQLite/เรา
orders3,5523,1531.13
idx_status_rest2,9514935.99
idx_placed_at6314931.28
idx_restaurant2,467
idx_status872
idx_customer530

ขีด แปล​ว่า​ฝั่ง​เรา​ไม่​ได้​สร้าง​โครงสร้าง​นั้น​ขึ้น​มา​จริง ไม่ใช่​ว่า​วัด​ไม่​ได้ บท​นี้​สร้าง index ของ​ตัวเอง​แค่​สอง​ตัว คือ idx_status_rest กับ idx_placed_at ไว้​นับ​หน้า​จริง ส่วน​ราคา​ของ​ทุก​แผน index คิด​จาก​รูปร่าง​ต้นไม้​ที่​บท 5 ตรึง​ไว้ ไม่​ได้​คิด​จาก file index ที่​สร้าง

ตาราง orders ของ SQLite ใหญ่​กว่า heap ของ​เรา 1.13 เท่า เพราะ​ของ​เรา​เก็บ​หก column แบบ​ความ​ยาว​คงที่​บวก​ชื่อ​ร้าน ส่วน SQLite เก็บ record format ที่​มี type กำกับ​ราย column บวก rowid บวก​ที่​ว่าง​เผื่อ​การ​แก้ไข​ใน​แต่ละ​หน้า

ส่วน index ห่าง​กัน​มากกว่า​นั้น​มาก idx_status_rest ของ SQLite ใหญ่​กว่า​ของ​เรา 5.99 เท่า เพราะ​คีย์​ของ​มัน​เก็บ​ค่า​จริง​ซึ่ง​เป็น​ข้อความ ไม่ใช่​เลข 4 byte แบบ​คีย์​ของ​เรา

ตัวเลข 15.6 % ของ​บท 3 ที่​บอกว่า index ทั้ง file กิน​แค่​เศษ​เสี้ยว​ของ heap จึง​เป็น​ตัวเลข​ของ​โครงสร้าง​ชุด​ของ​เรา ไม่ใช่​ของ index จริง​ที่​เก็บ​ข้อความ

orders ฝั่ง SQLite มี index ห้า​ตัว เป็น​ชุด​เดียว​กับ​ที่​บท 4 ใช้​ทุก​ตัว​อักษร เพื่อ​ให้​สอง​บท​ใช้ file เดียวกัน​ได้

qe/compare.py
# index ที่คอร์สสอน — ชุดเดียวกับบท 4 เป๊ะ ๆ เพื่อให้ .build/orders.db ใช้ร่วมกันได้
INDEXES = ("CREATE INDEX idx_status ON orders(status)",
"CREATE INDEX idx_restaurant ON orders(restaurant)",
"CREATE INDEX idx_status_rest ON orders(status, restaurant)",
"CREATE INDEX idx_placed_at ON orders(placed_at)",
"CREATE INDEX idx_customer ON orders(customer_id)")

schema ฝั่ง SQLite ตรง​กับ​หก column ของ fixture ที​ละ​ตัว แล้ว​เท​แถว​ทั้ง​สอง​แสน​เข้าไป​รวด​เดียว

qe/compare.py
con = sqlite3.connect(DB_PATH)
con.execute("CREATE TABLE orders(id INTEGER PRIMARY KEY, customer_id INTEGER,"
" status TEXT, restaurant TEXT, placed_at INTEGER,"
" total_satang INTEGER)")
con.executemany("INSERT INTO orders VALUES (?,?,?,?,?,?)", fixture.rows())
for sql in INDEXES:
con.execute(sql)
con.commit()
con.close()

ทุก connection ที่​บท​นี้​ใช้​อ่าน​แผน​ถูก​ตั้ง cached_statements=0 ไว้​ล่วงหน้า เพื่อ​กัน​ไม่​ให้​ได้​แผนที่​คอมไพล์​ไว้​ตั้งแต่​ก่อน​แก้​สถิติ​กลับ​มา​โดย​ไม่รู้ตัว

qe/compare.py
def connect(path):
"""cached_statements=0 สำคัญมาก: ถ้าไม่ปิด SQLite จะเอาคำสั่งที่คอมไพล์ไว้แล้วมาใช้ซ้ำ
เราจึงอาจได้แผน 'ก่อนแก้สถิติ' กลับมาโดยไม่รู้ตัว ซึ่งทำให้หัวข้อ [6] อ่านผลผิดทั้งหัวข้อ"""
return sqlite3.connect(path, cached_statements=0)

สถิติ​ที่ ANALYZE เขียน​ไว้​อยู่​ใน​ตาราง sqlite_stat1 รูปแบบ N k1 k2 ... โดย​ตัวเลข​หลัง N คือ​จำนวน​แถว​เฉลี่ย​ที่​มี​ค่า​คีย์​เท่า​กัน

indexsqlite_stat1
idx_customer200000 100
idx_placed_at200000 1
idx_restaurant200000 20000
idx_status200000 28572
idx_status_rest200000 28572 2899

นอกจาก​นั้น build ที่​เรา​วัด​ยัง​มี sqlite_stat4 อยู่ 97 ตัวอย่าง ซึ่ง​เป็น​ชั้น​ที่​สอง​ของ​สถิติ​ที่​บท 4 แนะนำ​ไว้​แล้ว และ​เป็น​ชั้น​ที่​หัว​ข้อถัดๆ ไป​จะ​ลบ​ทิ้ง​บน​สำเนา เพื่อ​ดู​ว่า​มัน​เปลี่ยน​แผน​ข้อ​ไหน​บ้าง

บท​นี้​เป็น​บท​เดียว​ใน​คอร์ส​ที่​ผล​ผูก​กับ build ของ SQLite

ตัวเลข​แผน​ทั้งหมด​ใน​บท​นี้​วัด​จาก runtime SQLite 3.53.4 ที่มา​กับ Python ของ​เครื่อง​ที่​ใช้​วัด และ build นั้น​เปิด SQLITE_ENABLE_STAT4 กับ SQLITE_ENABLE_DBSTAT_VTAB ไว้

build ที่​ไม่​ได้​เปิด SQLITE_ENABLE_STAT4 จะ​ให้​แผน​คนละ​แบบ​ตั้งแต่​หัวข้อ​แรก​ที่​แตะ​สถิติ และ build ที่​ไม่มี dbstat จะ​เทียบ​จำนวน​หน้า​ของ​สอง​ฝั่ง​ไม่​ได้​เลย

ส่วน​ข้อ​อ้าง​เรื่อง ซอร์ส code ของ planner ที่​บท 5 ยกมา ปัก​หมุด​ไว้​ที่ SQLite 3.51.0 ซึ่ง​เป็น​คนละ​ตัว​กับ runtime ที่​วัด​แผน​ใน​บท​นี้ อย่า​เขียน​รวม​เป็น​เลข​เดียว

ชุด query ที่​ป้อน​ให้​สอง​ฝั่ง วิธี​ที่​แต่ละ​ฝั่ง​เลือก​แผน และ​ตาราง​ผล​ว่า​ตรง​กัน​กี่​ข้อ

คำถาม​ที่​ป้อน​ให้​สอง​ฝั่ง​มี 22 ข้อ แบ่ง​เป็น​สาม​กลุ่ม​ตาม​บท​ที่​มัน​มา​จาก

  • กลุ่ม ก — 11 ข้อ ต้นทุน​ล้วน​ของ​บท 3 ถึง​บท 5 คือ point lookup, ช่วง​ค่า และ​เงื่อนไข​สอง column
  • กลุ่ม ข — 6 ข้อ ทาง​เข้า​ของ​บท 6 คือ covering, ลำดับ column และ​การ​เรียง
  • กลุ่ม ค — 5 ข้อ join ของ​บท 7 ซึ่ง​มี​หัวข้อ​ของ​ตัวเอง​อยู่​ท้าย​บท

ฝั่ง​เรา​เลือก​แผน​จาก​ค่า​ประมาณ​ของ​บท 4 เท่านั้น ไม่​เคย​เห็น​จำนวน​แถว​จริง​สัก​ข้อ ส่วน​ฝั่ง SQLite อ่าน​แผน​ด้วย EXPLAIN QUERY PLANEXPLAIN QUERY PLANคำ​สั่ง​ของ SQLite ที่​รายงาน​ว่า planner เลือก​แผน​ไหน​ให้ query หนึ่ง — สแกน​ตาราง หรือ​ใช้ index ตัว​ไหน — โดย​ไม่​ต้อง​รัน query เพื่อ​เอา​ผลลัพธ์​จริง บน .build/orders.db ที่ ANALYZE แล้ว

qe/compare.py
def explain_plan(path, sql, params=()):
"""แผนของ SQLite แบบอ่านออก — บทนี้เท่านั้นที่ใช้ EXPLAIN QUERY PLAN ได้ (บท 1-7 ห้าม)"""
con = connect(path)
out = [r[3] for r in con.execute("EXPLAIN QUERY PLAN " + sql, params)]
con.close()
return out

มี self-check คุม​อยู่​หนึ่ง​ชั้น คือ​จำนวน​แถว​ที่ SQLite คืน​ต้อง​ตรง​กับ​ที่​นับ​จาก fixture ทุก​ข้อ และ​มัน​ตรง​ทุก​ข้อ​จริง

ก่อน​ดู​ตาราง ต้อง​ประกาศ​นิยาม​ของ​คำ​ว่า “ตรง” ให้​ชัด​ก่อน เพราะ​สัดส่วน​ที่​ได้​เป็น​ผล​ของ​นิยาม ไม่ใช่​ของ planner อย่าง​เดียว

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

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

คำถามแถว​จริงฝั่ง​เราฝั่ง SQLiteผล
A1id = 123451rowid(PK)rowid(PK)ตรง
A2customer_id = 99597idx_customeridx_customerตรง
A3status = 'Placed'199สแกนidx_statusต่าง
A4status = 'Delivered'163,733สแกนสแกนตรง
A5restaurant = 'ครัวคุณแม่'11,934สแกนidx_restaurantต่าง
A6Rejected + ครัว​สุขใจ (คู่​ที่​ไม่มี​จริง)0สแกนidx_status_restต่าง
A7Rejected + ครัว​คุณ​แม่ (คู่​ที่​มี​จริง)8,126สแกนidx_status_restต่าง
A8placed_at วัน​ที่ 90 (วัน​ธรรมดา)1,017idx_placed_atidx_placed_atตรง
A9placed_at วัน​ที่ 140 (วัน​โปรโมชัน)17,603idx_placed_atidx_placed_atตรง
A10placed_at 60 วัน​แรก20,657idx_placed_atidx_placed_atตรง
A11placed_at ทั้ง 180 วัน200,000สแกนสแกนตรง
B1COUNT(*) ของ status='Delivered'163,733cover idx_statuscover idx_statusตรง
B2GROUP BY status ทั้ง​ตาราง200,000สแกน cover idx_statusสแกน cover idx_statusตรง
B3GROUP BY status ของ​ร้าน​ครัว​คุณ​แม่11,934cover idx_status_rest x7cover skip idx_status_restตรง
B4ORDER BY placed_at LIMIT 2020idx_placed_at (เรียง​มา​แล้ว)สแกน idx_placed_atตรง
B5ORDER BY total_satang LIMIT 20200,000สแกน​แล้ว​เรียงสแกน แล้ว​เรียง​ชั่วคราวตรง
B6restaurant='ครัวคุณแม่' ORDER BY status11,934สแกนskip idx_status_restต่าง

ผล​รวม​คือ​ตรง​กัน 12 จาก 17 ข้อ ต่าง​กัน 5 ข้อ คือ A3, A5, A6, A7 และ B6 แยก​เป็นกลุ่ม ก ตรง 7 จาก 11 ข้อ และ​กลุ่ม ข ตรง 5 จาก 6 ข้อ

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

คู่​ที่​น่า​ดู​ที่สุด​ใน​ตาราง​คือ A4 กับ B1 เงื่อนไข​เดียวกันเป๊ะ ตรง 163,733 แถว​เท่า​กัน แต่ A4 ที่​ขอ​ทุก column ทำให้​ทั้ง​สอง​ฝั่ง​สแกน ส่วน B1 ที่​ขอ​แค่ COUNT(*) ทำให้​ทั้ง​สอง​ฝั่ง​ย้าย​ไป covering index

สิ่ง​ที่​พลิก​แผน​ของ A4 กับ B1 จึง​ไม่ใช่​เงื่อนไข แต่​เป็น column ที่ query ขอ ซึ่ง​เป็น​ประเด็น​ทั้งหมด​ของ​บท 6

ทำไม​ข้อ `placed_at` ถึง​ตรง​กัน​หมด

A8, A9 และ A10 ตรง​กัน​ทั้ง​สาม​ข้อ และ​ตัวเลข​หน้าที่​แตะ​ของ​ฝั่ง​เรา​ก็​สวย​ผิด​ปกติ เพราะ heap ของ​คอร์ส​นี้​เรียง​ตาม placed_at อยู่​แล้ว​ตั้งแต่ fixture แถว​ที่​ตรง​ช่วง​เวลา​เดียวกัน​จึง​กอง​อยู่​หน้า​เดียวกัน

เทียบ​กับ A2 ที่​สอง​ฝั่ง​ก็​ตรง​กัน​เหมือน​กัน แต่​ตรง​แค่ 97 แถว​และ​ถาม​ด้วย customer_id แถว​เหล่า​นั้น​กระจาย​อยู่​คนละ​หน้า​กัน​แทบ​ทุก​แถว ซึ่ง​บท 3 วัด​ไว้​ให้​ดู​แล้ว

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

แยก​ต้นเหตุ​ด้วย​การ​ทดลอง ไม่ใช่​ด้วย​การ​อธิบาย

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

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

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

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

  • เปลี่ยน​ไป​ตรง​กับ SQLite แปล​ว่า​ราคา​ของ​เรา​ถูก​อยู่​แล้ว สิ่ง​ที่​ผิด​คือ​ตัว​ประมาณ​จำนวน​แถว​ของ​บท 4
  • ยัง​ตอบ​เหมือน​เดิม แปล​ว่า​ต่อ​ให้​รู้ความ​จริง​เรา​ก็​ยัง​เลือก​แบบ​เดิม สิ่ง​ที่​ผิด​คือ cost model ของ​บท 5 กับ​บท 6
ค่า​ประมาณ​ของ​เราจริงq-errorเรา​เลือกถ้า​รู้ความ​จริง​เรา​จะ​เลือกต้นเหตุ
A328,571.4199143.58สแกนidx_statusตัว​ประมาณ (บท 4)
A520,000.011,9341.68สแกนสแกนcost model (บท 5-6)
A61,565.601565.58สแกนidx_status_restตัว​ประมาณ (บท 4)
A7785.68,12610.34สแกนสแกนcost model (บท 5-6)
B620,000.011,9341.68สแกนสแกนcost model (บท 5-6)

แถว A6 มี​ของ​จริง 0 แถว q-error ของ​มัน​จึง​เป็น​ผล​ของ​การ​ยกพื้น​ของ​จริง​ขึ้น​เป็น 1 แถว​ก่อน​หาร ไม่ใช่​ค่า​ความคลาด​ที่​วัด​ได้

ผล​คือ​ตัว​ประมาณ 2 ข้อ คือ A3 กับ A6 และ cost model 3 ข้อ คือ A5, A7 และ B6

สิ่ง​ที่​ตัดสิน​ไม่ใช่​ขนาด​ของ q-error แต่​คือ​ค่า​ประมาณ​กับ​ความ​จริง​ตก​คนละ​ฝั่ง​ของ​จุด​ตัด 394 แถว​หรือ​เปล่า A3 กับ A6 ตก​คนละ​ฝั่ง คำ​ตอบ​จึง​พลิก ส่วน A5, A7 และ B6 อยู่​เหนือ​จุด​ตัด​ทั้ง​ค่า​ประมาณ​และ​ความ​จริง จึง​ตอบ​เหมือน​เดิม​ทั้ง​สาม​ข้อ แม้ A7 จะ​พลาด​ไป 10.34 เท่า​ก็ตาม

flowchart TD
    QQ["คำถามหนึ่งข้อ<br/>สองฝั่งเลือกทางเข้าคนละทาง"] --> TT["ป้อนจำนวนแถวจริง<br/>เข้า cost model เดิมของเรา"]
    TT --> DEC{"คำตอบของเราเปลี่ยนไหม"}
    DEC -->|"เปลี่ยนไปตรงกับ SQLite"| EST["ต้นเหตุคือตัวประมาณจำนวนแถว<br/>บท 4 — A3 กับ A6"]
    DEC -->|"ยังตอบเหมือนเดิม"| CM["ต้นเหตุคือ cost model<br/>บท 5-6 — A5 A7 และ B6"]
    QQ --> THIRD["ต้นเหตุแบบที่สาม<br/>ที่โผล่มาตอนทำหัวข้อ join"]
    THIRD --> REW["SQLite เขียนเงื่อนไขใหม่ก่อนคิดราคา<br/>ไม่ใช่ตัวประมาณ ไม่ใช่ราคา"]
    EST --> S4["ชั้นสถิติที่ SQLite มี แต่เราไม่มี"]
    CM --> XO["จุดตัดของสองฝั่งอยู่คนละที่"]

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

ค่า​ประมาณ​ของ​สอง​ฝั่ง​ที่ A3 สถิติ​ชั้น​ที่​สอง​ที่ SQLite มี​แต่​เรา​ไม่มี และ​ผล​ของ​การ​ทดลอง​เอา​สถิติ​ชั้น​นั้น​ออก ซึ่ง​ไม่​ได้​ออก​มา​อย่าง​ที่​ตั้ง​เกณฑ์​ไว้

ตัว​ประมาณ​ของ​เรา​สำหรับ status คือ n / distinct ตัว​เดียว ได้ 200,000 หาร​ด้วย 7 เท่ากับ 28,571.4 แถว เท่า​กัน​หมด​ทุก​ค่า

ที่​น่า​สนใจ​คือ sqlite_stat1 ของ idx_status เขียน​ไว้​ว่า 200000 28572 ซึ่ง​เป็น​ตัวเลข​เดียวกันเป๊ะ สอง​ฝั่ง​เก็บ​สถิติ​ชั้น​แรก​เหมือน​กัน​ทุก​ประการ

ความ​ต่าง​อยู่​ที่ SQLite ยัง​มี sqlite_stat4 ซึ่ง​เก็บ​จำนวน​แถว​จริง​ของ​ค่าที่​ถูก​เก็บ​ตัวอย่าง​ไว้

statusของ​จริงstat4 บอกตัว​ประมาณ​ของ​เรา
Placed19919928,571.4
Delivered163,733163,73328,571.4
Rejected13,16513,16528,571.4

ที่ Placed SQLite เห็น​ตัวเลข 199 แถว​ซึ่ง​น้อย​มาก จึง​ใช้ index ส่วน​เรา​เห็น 28,571.4 แถว ซึ่ง​อยู่​เหนือ​จุด​ตัด 394 แถว​ของ​บท 5 ไป​ไกล จึง​สแกน

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

ถ้า​ความ​ต่าง​มา​จาก​สถิติ​ชั้น​ที่​สอง​จริง การ​เอา​มัน​ออก​ต้อง​ทำให้ SQLite เดา​เหมือน​เรา แล้ว​ต้อง​ทำให้​มัน​เลือก​เหมือน​เรา​ด้วย การ​ทดลอง​นี้​ทำ​บน​สำเนา แล้ว​ลบ​ตาราง sqlite_stat4 ทิ้ง​ทั้ง​ตาราง

คำถามSQLite ปกติSQLite ไม่มี stat4ฝั่ง​เรา
A3 status = 'Placed'idx_statusidx_statusสแกน
A4 status = 'Delivered'สแกนidx_statusสแกน
A5 restaurant = 'ครัวคุณแม่'idx_restaurantidx_restaurantสแกน
A6 Rejected + ครัว​สุขใจidx_status_restidx_status_restสแกน

พอ​ไม่มี stat4 SQLite ก็​เชื่อ​ค่า​เฉลี่ย 28,571 แถว​เหมือน​เรา แล้ว​เลือก index ให้​ทุก​ค่า​ของ status รวม​ถึง Delivered ที่​ตรง 163,733 แถว ซึ่ง​เป็น​แผนที่​แย่ชัดๆ

นี่​คือ​ผล​ที่​ควร​จด​ไว้​ให้​ดี ค่า​เฉลี่ย​ตัว​เดียวกันเป๊ะ​ทำให้​เรา​สแกน​ทุก​อย่าง แต่​ทำให้ SQLite ใช้ index ทุก​อย่าง

แถว A3 จึง​ผ่าน​เกณฑ์​แค่​ครึ่ง​เดียว พอ​ไม่มี stat4 SQLite เดา​เท่า​ค่า​เฉลี่ย​ของ​เรา​ก็​จริง แต่​มัน​ยัง​เลือก idx_status อยู่​เหมือน​เดิม ความ​ต่าง​ของ A3 ยัง​อยู่​ครบ การ​ทดลอง​ย้อน​กลับ​นี้​จึง​ไม่​ได้​ยืนยัน​ว่า stat4 คือ​ต้นเหตุ​ของ A3 มัน​บอก​อย่าง​อื่น​แทน

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

การกวาด​หา​จุด​ที่ SQLite เปลี่ยนใจ ระยะ​ห่าง​ระหว่าง​จุด​ตัด​ของ​สอง​ฝั่ง และ​สิ่ง​ที่​ต้อง​แก้​ใน​ค่า​คงที่​ของ​เรา​เพื่อ​ให้​เข้า​ใกล้

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

จุด​ตัด​ของ​เรา​สำหรับ index ที่​ไม่ cluster คือ 394 แถว เท่ากับ 0.197 % ของ​ตาราง เลย​จุด​นั้น​ไป​การ​สแกน​ถูก​กว่า

จุด​ตัด​ของ SQLite ไม่​ได้​ประกาศ​ไว้​ที่ไหน แต่​วัด​ได้ วิธี​วัด​คือ​กวาด​ด้วย customer_id <= k แล้ว​ดู​ว่า​มัน​เปลี่ยนใจ​ที่ k ไหน เลือก customer_id เพราะ​มัน​กระจาย​ทั่ว​ทั้ง file จึง​เป็น​เคส​ไม่ cluster แบบ​เดียว​กับ​ที่​ราคา​ของ​เรา​คิด

k (ลูกค้า 1..k)แถว​ที่​ตรง% ของ​ตารางSQLiteSQLite ไม่มี stat4ฝั่ง​เรา
101,0210.51indexindexสแกน
1009,9915.00indexindexสแกน
40040,21620.11indexindexสแกน
90090,28645.14indexindexสแกน
1,600160,06480.03สแกนindexสแกน
2,000200,000100.00สแกนindexสแกน

ค้น​ทวิภาค​ต่อ​ลง​ไป​ได้​จุด​ที่​แน่นอน SQLite เปลี่ยนใจ​ที่ k = 1,111 ซึ่ง​เท่ากับ 111,261 แถว หรือ 55.63 % ของ​ตาราง ส่วน​เรา​เปลี่ยนใจ​ที่ 394 แถว ห่าง​กัน 282 เท่า

column SQLite ไม่มี stat4 ยัง​บอก​อีก​อย่าง พอ​ไม่มี stat4 SQLite ไม่​เปลี่ยนใจ​เลย​แม้​ช่วง​จะ​กิน​ทั้ง​ตาราง เพราะ​ค่า​เดา​สำเร็จรูป​ของ​ช่วง​ค่า​ไม่​ได้​ขึ้น​กับ k มัน​เดา​เป็น​สัดส่วน​คงที่

สถิติ​หาย​ไป​ที​ละ​ชั้น แผน​ก็​เพี้ยน​คนละ​แบบ ไม่ใช่​เพี้ยน​หนัก​ขึ้น​แบบ​เดียวกัน

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

RAND_PAGEสมมุติฐาน buffer poolจุด​ตัด​ของ​เรา (แถว)% ของ​ตาราง
16.00ไม่มี​เลย3940.20
16.00มี (ทั้ง file)4200.21
1.00มี (ทั้ง file)173,03886.52
1.35มี (ทั้ง file)112,60256.30

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

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

บท 5 ตั้ง RAND_PAGE = 16.0 จาก readahead 16 หน้า โดย​ไม่มี buffer pool เลย

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

มัน​เป็น parameter ที่​เรา fit กับ cost model ของ​เรา​เอง​ใน​บท 5 และ​หัวข้อ​นี้​คือ​หลักฐาน​ตรง​กัน​ข้าม​กับ​ความ​เข้าใจ​ว่า​มัน​คือ​ค่า​ของ SQLite

แถว​ที่​เข้า​ใกล้​จุด​ตัด​ของ SQLite ที่สุด​เปลี่ยน​ทั้ง​ค่า​คงที่​และ​สมมุติฐาน​เรื่อง buffer pool พร้อม​กัน แต่​น้ำหนัก​ของ​สอง​อย่าง​นี้​ไม่​เท่า​กัน​เลย

ลำพัง buffer pool ขยับ​จุด​ตัด​จาก 394 เป็น 420 แถว ส่วน​การ​ลด RAND_PAGE เหลือ 1.35 บน​สมมุติฐาน​เดียวกัน​นั้น​พา​ไป​ถึง 112,602 แถว ความ​ต่าง​เกือบ​ทั้งหมด​จึง​อยู่​ที่​ราคา​ที่​แต่ละ​ฝั่ง​ตั้ง​ให้การ​อ่าน​แบบ​สุ่ม ซึ่ง​เป็น​ตัวเลข​ที่​ทั้ง​สอง​ฝั่ง​สมมุติ​ขึ้น​ทั้ง​คู่

A6 — คู่​ที่​ไม่มี​อยู่​จริง สองฝั่งพลาดพอๆ กัน แต่​ตัดสิน​คนละ​ทาง

หัวข้อ​ที่​มีชื่อ​ว่า “A6 — คู่​ที่​ไม่มี​อยู่​จริง สองฝั่งพลาดพอๆ กัน แต่​ตัดสิน​คนละ​ทาง”

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

A6 ถามหา​ออเดอร์​สถานะ Rejected ของ​ร้าน​ครัว​สุขใจ ซึ่ง​เป็น​คู่​ที่ fixture ตั้งใจ​ทำให้​ไม่มี​อยู่​จริง​ตั้งแต่​บท 1 ของ​จริง​คือ 0 แถว

ค่า​ประมาณ​ของ​เรา​จาก independence assumption ได้ 1,565.6 แถว ส่วน​ค่าที่ SQLite มี​ให้​ใช้​เมื่อ​คู่​นี้​ไม่​ติดตัวอย่าง​ใน stat4 คือ k2 ของ idx_status_rest ซึ่ง​เท่ากับ 2,899 แถว

สองฝั่งพลาดพอๆ กัน คือ​ประมาณ​ว่า​มี​พัน​กว่า​แถว​ทั้ง​ที่​ของ​จริง​ไม่มี​สัก​แถว แต่​ตัดสิน​คนละ​ทาง

เหตุผล​ที่​ตัวเลข​ใกล้​กัน​แล้ว​ยัง​ตัดสิน​คนละ​ทาง อยู่​ที่​จุด​ตัด​คนละ​เส้น พัน​กว่า​แถว​อยู่ เหนือ จุด​ตัด​ของ​เรา​ที่ 394 แถว แต่​อยู่ ต่ำ​กว่า จุด​ตัด​ของ SQLite ที่ 111,261 แถว​ไป​มาก

ผล​คือ SQLite แตะ index ไม่​กี่​หน้า​แล้ว​ตอบ​ว่า​ไม่มี​แถว​เลย ส่วน​เรา​อ่าน​ครบ​ทั้ง 3,153 หน้า​เพื่อ​ได้​คำ​ตอบ​เดียวกัน โดยที่ Pager ของ​เรา​ยัง​ไม่มี buffer pool ตัวเลข​นั้น​จึง​เป็น​จำนวน​ครั้ง​ที่​เรียก​อ่าน​หน้า​เต็มๆ

ส่วน​เหตุผล​ที่​ตาราง​ต้นเหตุ​จัด A6 ไว้​ที่​ตัว​ประมาณ เป็น​คำถาม​คนละ​ข้อ พอ​ป้อน 0 แถว​จริง​เข้าไป ราคา​เดิม​ของ​เรา​ย้าย​ไป idx_status_rest ทันที ค่า​ประมาณ​จึง​เป็น​สิ่ง​ที่​แก้​แล้ว​ความ​ต่าง​ข้อ​นี้​หาย​ไป ส่วน​จุด​ตัด​คือ​สิ่ง​ที่​อธิบาย​ว่า​ทำไม​สอง​ฝั่ง​พลาด​พอกัน​แล้ว​ยัง​ไป​คนละ​ทาง

q-error 1565.58 เป็น​สิ่ง​ประดิษฐ์ ไม่ใช่​การ​วัด

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

ตัวเลข​ที่​มี​ความหมาย​จริง​ใน​ข้อ​นี้​คือ 1,565.6 เทียบ​กับ 0 ไม่ใช่​ตัว​คูณ

และ​นี่​เป็น​ความ​พลาด​ชนิด​ที่​การ​เก็บ​ตัวอย่าง​ช่วย​ไม่​ได้ คู่​ที่​ไม่มี​แถว​เลย​ย่อม​ไม่มี entry ใน index ให้​สุ่ม​ติดตัวอย่าง​มา​ตั้งแต่​ต้น

ราคา​สาม​แผน​ของ​ฝั่ง​เรา​ใน​ข้อ​ที่ SQLite เลือก​ต่าง​ออก​ไป และ​หน่วย​ที่​อธิบาย​ความ​ต่าง​นั้น​ซึ่ง​ไม่​ได้​อยู่​ใน​ราคา

B6 ถามออเดอร์​ของ​ร้าน​ครัว​คุณ​แม่​แล้ว​เรียง​ตาม status ของ​จริง 11,934 แถว เรา​เลือก​สแกน ส่วน SQLite เลือก skip idx_status_rest

หน่วย​ของ​ต้นทุน​คือ​ค่า​อ่าน​เพจ​แบบ​เรียงตัว​หนึ่ง​เพจ = 1.0 ราคา​สาม​แผน​ของ​ฝั่ง​เรา​เมื่อ​ป้อน​จำนวน​แถว​จริง​จึง​ออก​มา​แบบ​นี้

แผน​ของ​ฝั่ง​เราราคา​รวมหน้า​เรียงหน้า​สุ่ม
สแกน6,313.03,1530
idx_status_rest x7191,274.63011,941
idx_restaurant แล้ว​เรียง191,178.63011,935

ต่อ​ให้​ตัด​แผน​สแกน​ทิ้ง​แล้ว​ให้​เลือก​ระหว่าง​สอง index เท่านั้น เรา​ก็​ยัง​เลือก​ผิด​อยู่ดี เพราะ idx_restaurant แล้ว​เรียง ถูก​กว่า​นิดเดียว

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

  • ผ่าน idx_status_rest ทั้ง 7 ช่วง ผลออก​มา​เรียง​ตาม status อยู่​แล้ว ถือ​ไว้ 1 แถว
  • ผ่าน idx_restaurant ต้อง​กอง​ไว้​ให้​ครบ 11,934 แถว​แล้ว​ค่อย​เรียง

cost model สาม​ตัวเลข​ของ​บท 5 ไม่มี​ที่​ให้​ใส่​ราคา​ของ​การ​เรียง เรา​จึง​มอง​ไม่​เห็น​ข้อ​นี้ ส่วน SQLite คิด​ราคา​การ​เรียง​ด้วย จึง​ยอม​ลง​จากราก​หลาย​ครั้ง​เพื่อ​ไม่​ต้อง​เรียง​ทีหลัง

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

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

qe/compare.py
def plan_cost(t, matched, *, k_ranges=1, covering=False, clustered=False, cache=False,
height=cost.HEIGHT, leaf_cap=cost.LEAF_CAP):
"""สูตรเดียวกับบท 6: ต้นทุน = k x ความสูง (ลงจากราก k ครั้ง) + ใบที่เดินผ่าน + heap
covering=True คือคำตอบอยู่ในคีย์ครบแล้ว ไม่ต้องตามตัวชี้กลับไปเปิดแถว (heap = 0 หน้า)"""
matched = max(0, matched)
leaves = float(max(k_ranges, math.ceil(matched / leaf_cap))) if matched else float(k_ranges)
descent = float((height - 1) * k_ranges)
hp = 0.0 if covering else cost.heap_pages(t, matched, clustered, cache)
if clustered:
return cost.Cost(seq=leaves + hp, rand=descent, rows=float(matched))
return cost.Cost(seq=leaves, rand=descent + hp, rows=float(matched))

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

qe/compare.py
def choose(cands, matched):
"""ตรรกะทั้งหมดของ planner เรา: คิดราคาทุกแผนที่รู้จัก แล้วเอาแผนที่ถูกที่สุด"""
priced = [(cost.total(c.cost(matched)), c) for c in cands]
best = min(priced, key=lambda p: p[0])
return best[1], best[0]

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

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

ฝั่ง​เรา​นับ​หน้าที่​แตะ​ได้​เพราะ​เรา​เขียน Pager เอง ฝั่ง SQLite นับ​ไม่​ได้​แบบ​นั้น แต่​มี​หน่วย​ของ​ตัวเอง​ที่​วัด​ซ้ำ​ได้​เท่า​กัน​ทุก​ครั้ง ต่าง​จาก​เวลา

VDBE opcodeVDBE opcodeคำ​สั่ง​เดี่ยวๆ ใน​โปรแกรม​ที่ SQLite คอมไพล์ query ออก​มา​แล้ว​เดิน​บน​เครื่อง​เสมือน​ของ​มัน นับ​จำนวน​ที่​เดิน​จริง​ได้ และ​เป็น​คนละ​หน่วย​กับ​จำนวน​หน้าที่​แตะ วัด​ได้​สอง​แบบ และ​สอง​แบบ​นี้​ตอบ​คนละ​คำถาม

qe/compare.py
def program(path, sql, params=()):
"""โปรแกรม VDBE ที่คอมไพล์ออกมา (EXPLAIN เฉย ๆ) — คืนรายชื่อ opcode ตามลำดับ"""
con = connect(path)
out = [r[1] for r in con.execute("EXPLAIN " + sql, params)]
con.close()
return out
def vdbe_steps(path, sql, params=()):
"""จำนวน opcode ที่ 'ถูกเดินจริง' ตอนรัน — นับด้วย progress handler ทุก 1 opcode
หน่วยนี้วัดซ้ำได้เท่ากันเป๊ะทุกครั้ง (ต่างจากเวลา) แต่ต้องระวังว่ามันไม่ใช่เวลา
และไม่ใช่สิ่งที่ planner ของ SQLite พยายามทำให้น้อยที่สุด — หัวข้อ [5.3] วัดให้เห็น"""
con = connect(path)
hits = [0]
def tick():
hits[0] += 1
return 0
con.set_progress_handler(tick, 1)
nrows = len(con.execute(sql, params).fetchall())
con.set_progress_handler(None, 1)
con.close()
return hits[0], nrows

program คืน​ความ​ยาว​ของ​โปรแกรม​ที่​คอมไพล์​ออก​มา ส่วน vdbe_steps นับ​จำนวน​คำ​สั่ง​ที่​ถูก​เดิน​จริง​ตอน​รัน ผ่าน progress handler ที่​ตั้ง​ให้​เรียก​ทุก 1 opcode

ความ​ยาว​โปรแกรม​ของ​ทั้ง 17 ข้อ​อยู่​ใน​ช่วง 14 ถึง 45 opcode เท่านั้น ตั้งแต่ 14 opcode ที่ A1 กับ B1 เท่า​กัน ไป​จนถึง 45 opcode ที่ B3 ซึ่ง​ยาว​ที่สุด

ตัวเลข​นั้น​สั้น​เสมอ​เพราะ opcode เกือบ​ทั้งหมด​อยู่​ใน​ลูป ความ​ยาว​โปรแกรม​จึง​ไม่​ได้​บอกว่า​งาน​หนัก​แค่​ไหน มัน​บอก​แค่​ว่า​แผน​ซับซ้อน​แค่​ไหน

จำนวน​ที่​เดิน​จริง​เป็น​คนละ​เรื่อง วิธี​วัด​คือ​บังคับ​สอง​แผน​บน​คำถาม​เดียวกัน​ด้วย INDEXED BY กับ NOT INDEXED แล้ว​เทียบ

คำถามแถวindex (opcode)สแกน (opcode)index/สแกนSQLite เลือก
A3 status='Placed'1994,124603,5260.007index
A4 status='Delivered'163,7331,639,4651,748,2640.938สแกน
A5 restaurant='ครัวคุณแม่'11,934121,474685,6710.177index
A6 Rejected x ครัว​สุขใจ02,138628,4650.003index

ทุก​บรรทัด​วัด​ซ้ำ​ได้​ตัวเลข​เดิม​เป๊ะ เพราะ​จำนวน opcode ขึ้น​กับ​แผน​กับ​ข้อมูล ไม่​ขึ้น​กับ​เครื่อง และ​ทุก​บรรทัด​มี self-check ว่า​สอง​แผน​คืน​แถว​เท่า​กัน​จริง

แถว A4 คือ​แถว​ที่​ต้อง​อ่าน​ให้​ละเอียด แผน index เดิน 1,639,465 opcode ส่วน​แผน​สแกน​เดิน 1,748,264 แปล​ว่า​แผน index เดิน opcode น้อย​กว่า 6.2 % แต่ SQLite เลือก​สแกน

ประโยค​ที่​ซื่อสัตย์​เมื่อ​สอง​หน่วย​ชี้​คนละ​ทาง

จำนวน opcode วัด​ซ้ำ​ได้​และ​ชี้​ไป​ทาง index ส่วน planner ของ SQLite ชี้​ไป​ทาง​สแกน ทั้ง​สอง​อย่าง​จริง​พร้อม​กัน​ได้ เพราะ​มัน​วัด​คนละ​อย่าง

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

และ​ห้าม​เอา opcode ไป​หาร​กับ​หน้าที่​แตะ​ของ​เรา มัน​เป็น​คนละ​หน่วย และ​เรา​ไม่มี​ทาง​นับ​หน้าที่ SQLite อ่าน​จริง​จาก Python stdlib ล้วนๆ ได้

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

ใน​สี่​เคส​ที่​วัด เคส​ที่​คม​ที่สุด​คือ A6 แผน index เดิน opcode น้อย​กว่า​แผน​สแกน 294 เท่า และ​นั่น​คือ​ตัวเลข​ที่​ยก​ไป​อ้าง​ได้ ไม่ใช่​ตัวเลข​ของ A4

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

เรื่อง​ที่​เล่า​กัน​บ่อย​คือ​แก้​สถิติ​แล้ว​แผน​พลิก การ​ทดลอง​นี้​ทำ​จริง​สี่​ขั้น​บน​สำเนา .build/compare_fudge.db โดย​ตัว​จริง​ไม่​ถูก​แตะ คำถาม​ที่​ใช้​ตรวจ​คือ status = 'Delivered' ซึ่ง​ตรง 163,733 แถว

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

ขั้น 1 โกหก​ว่า idx_status มี 1 แถว​ต่อ​หนึ่ง​ค่า ด้วย​การ​ตั้ง stat เป็น 200000 1 แล้ว​ถาม​ซ้ำ​บน connection เดิม ทั้ง​ก่อน​แก้ หลัง​แก้ และ​หลัง​สั่ง ANALYZE sqlite_schema ได้​คำ​ตอบ​ว่า​สแกน​ทั้ง​สาม​ครั้ง

คำ​อธิบาย​ที่​มักได้ยิน​ตรง​นี้​คือ SQLite อ่าน​สถิติ​ตอน​โหลด schema แล้ว​เก็บ​ผล​ไว้​ใน cache ของ connection นั้น การ​แก้​ตาราง​สถิติ​เฉยๆ จึง​ไม่​ทำให้​แผน​ขยับ

แต่​ผล​ของ​ขั้น 1 ลำพัง​ตัว​มัน​เอง​ยัง​แยก​ไม่​ออกว่า​มา​จาก cache นั้น​หรือ​มา​จาก stat4 ที่​ยัง​อยู่​ครบ เพราะ​ขั้น 2 ที่​เปิด connection ใหม่​ทั้ง​อัน​ก็​ยัง​ได้​แผน​เดิม การ​ตั้ง cached_statements=0 ของ connect จึง​เป็นการ​ปิด​ความ​เสี่ยง​ไว้​ล่วงหน้า ไม่ใช่​สิ่ง​ที่​บท​นี้​วัด​ได้​ว่า​เปลี่ยน​แผน​ข้อ​ไหน

ขั้น 2 เปิด connection ใหม่​ทั้ง​อัน แผน​ยัง​ไม่​ขยับ ทั้ง​ที่​โกหก​ไป​แล้ว​ว่า status ทุก​ค่า​มี​แค่ 1 แถว

เหตุผล​คือ sqlite_stat4 ของ idx_status ยัง​อยู่​ครบ 14 ตัวอย่าง และ​ค่าที่​ถาม​เป็น​ค่า​คงที่​ใน​คำ​สั่ง SQLite จึง​หยิบ​จำนวน​แถว​จริง​ของ​ค่า​นั้น​จาก stat4 มา​ใช้ ไม่​ต้อง​พึ่ง​ค่า​เฉลี่ย​ใน stat1 เลย

ขั้น 3 ลบ sqlite_stat4 แล้ว​ถาม​ซ้ำ คราว​นี้​แผน​ขยับ SQLite ย้าย​ไป​ใช้ idx_status กับ​คำถาม​ที่​ตรง​ถึง 163,733 แถว ซึ่ง​เป็น​แผนที่​แย่กว่าสแกนชัดๆ

ขั้น​นี้​แยก​ไม่​ออกว่า​ผล​มา​จาก​คำ​โกหก​หรือ​มา​จาก​การ​ลบ stat4 เพราะ​หัวข้อ​ต้นเหตุ​ที่​หนึ่ง​วัด​ไว้​แล้ว​ว่า​ลำพัง​การ​ลบ stat4 โดยที่ stat1 ยัง​ซื่อสัตย์​อยู่ ก็พา Delivered ไป idx_status เหมือน​กัน

ลำดับ​ที่​การ​ทดลอง​ชุด​นี้​ยืนยันได้ตรงๆ จึง​มี​ข้อ​เดียว คือ​เมื่อ stat4 มี​ตัวอย่าง​ของ​ค่าที่​ถาม มัน​มา​ก่อน stat1 เสมอ ซึ่ง​เป็น​สิ่ง​ที่​ขั้น 2 พิสูจน์ ส่วน​อันดับ​ระหว่าง stat1 กับ​ค่า​เดา​สำเร็จรูป ไม่มี​ขั้น​ไหน​แยก​ให้​เห็น

ขั้น 4 โกหก​ว่า idx_restaurant แทบ​ไม่​กรอง​อะไร​เลย คือ 100,000 แถว​ต่อ​ค่า แล้ว​ถาม restaurant = 'ครัวคุณแม่' มัน​ไม่​ยอม​กลับ​ไป​สแกน​ตาราง แต่​ย้าย​ไป​ใช้ idx_status_rest แทน

รายการ​แผน​ของ​เรา​สำหรับ​คำถาม​นี้​มี​แค่​สแกน​กับ idx_restaurant เรา​จึง​ไม่มี​ทาง​ตอบ​แบบ​เดียวกัน แผน k ช่วง​ของ​บท 6 เรา​ใส่​ไว้​เฉพาะ​ข้อ​ที่​ถาม​ถึง status ด้วย คือ B3 กับ B6 และ​ที่ B6 เรา​ก็​ตี​ราคา​มัน​แพง​กว่า​สแกน​อยู่ดี

ผล​ของ​ทั้ง​สี่​ขั้น​ผูก​กับ build ที่​รัน​อยู่ build ที่​ไม่​ได้​เปิด SQLITE_ENABLE_STAT4 จะ​เห็น​แผน​พลิก​ตั้งแต่​ขั้น 2 เลย ผลออก​ทาง​ไหน​ก็​รายงาน​ทาง​นั้น ตัวเลข​ที่​แต่ง​ขึ้น​เผยแพร่​ไม่​ได้

join — สิ่ง​เดียว​ที่​เทียบ​กัน​ได้​คือ​ใคร​เป็น​ตาราง​นอก

หัวข้อ​ที่​มีชื่อ​ว่า “join — สิ่ง​เดียว​ที่​เทียบ​กัน​ได้​คือ​ใคร​เป็น​ตาราง​นอก”

ขอบเขต​ของ​การ​เทียบ join ผล​ว่า​ตาราง​นอก​ตรง​กัน​กี่​ข้อ และ​ต้นเหตุ​ความ​ต่าง​แบบ​ที่​สาม​ที่​เจอ​ใน​หัวข้อ​นี้

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

ข้อมูล​ฝั่ง customers คือ​ตาราง​ของ​บท 7 ทั้ง​ชุด มี 2,000 แถว กิน​ไป 16 หน้า​ใน heap ของ​เรา ใน​นั้น tier gold มี 209 คน และ​ออเดอร์​ของ​คน​กลุ่ม​นี้​รวม 20,912 แถว คิด​เป็น 10.46 % ของ orders

ราคา​ฝั่ง​เรา​ตั้ง​สมมุติฐาน​เพิ่ม​หนึ่ง​ข้อ คือ​ให้ customers ทั้ง 16 หน้า​อยู่​ใน buffer pool ได้​ทั้ง​ตาราง ไม่​งั้น cost model ของ​บท 5 จะ​คิด​ค่า​อ่าน​หนึ่ง​หน้า​ต่อ​หนึ่ง​แถว ซึ่ง​เกิน​จริง​มาก​กับ​ตาราง​เล็ก​ขนาด​นี้

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

คำถามแถว​จริงตาราง​นอก​ที่​เรา​เลือกตาราง​นอก​ของ SQLiteผลopcode ที่​เดิน​จริง
C1ออเดอร์​ของ​ลูกค้า 995 + เมือง​ของ​ลูกค้า97orderscustomersต่าง2,816
C2นับ​ออเดอร์​ของ​ลูกค้า tier gold20,912customerscustomersตรง65,904
C3จำนวนออเดอร์​ต่อ​เมือง (ไม่​กรอง​อะไร​เลย)200,000customerscustomersตรง2,608,441
C4tier gold + status='Placed'17ordersordersตรง3,746
C5tier gold + status='Delivered'17,163orderscustomersต่าง124,900

ตาราง​นอก​ตรง​กัน 3 จาก 5 ข้อ และ​สอง​ข้อ​ที่​ต่าง​กัน​มา​จาก​คนละ​เหตุผล​กัน​สนิท

C1 คือ​ต้นเหตุ​แบบ​ที่​สาม เงื่อนไข​ที่​เขียน​ไว้​คือ o.customer_id = 995 ซึ่ง​อยู่​ฝั่ง orders แต่ SQLite เริ่ม​จาก customers เพราะ​มัน​ส่ง​ต่อ​เงื่อนไข​ข้าม​เครื่องหมาย​เท่ากับ​ของ join ได้

c.id = o.customer_id คู่​กับ o.customer_id = 995 จึง​แปล​ว่า c.id = 995 ซึ่ง​เหลือ​แถว​เดียว planner ของ​เรา​ไม่​ได้​ส่ง​ต่อ​เงื่อนไข​แบบ​นั้น จึง​เห็น​ฝั่ง customers เป็น​ทั้ง​ตาราง

ต้นเหตุ​ข้อ​นี้​ไม่ใช่​ตัว​ประมาณ และ​ไม่ใช่​ราคา แต่​เป็นการ​เขียน​เงื่อนไข​ใหม่​ก่อน​คิด​ราคา ซึ่ง​เป็น​ขั้นตอน​ที่ planner ของ​เรา​ไม่มี​เลย

C5 เป็นต้นเหตุ​เดิม และ C4 คือ​ตัว​คุม​ของ​มัน คำถาม​หน้าตา​เหมือน​กัน​ทุก​อย่าง ต่าง​แค่​ค่า​ของ status แต่ SQLite สลับ​ตาราง​นอก​ระหว่าง​สอง​ข้อ เพราะ stat4 บอกว่า Placed หา​ยาก​กว่า​มาก ส่วน​เรา​ใช้​ค่า​เฉลี่ย​ตัว​เดียวกัน​กับ​ทั้ง​สอง​ข้อ จึง​ตอบ​เหมือน​กัน

แผน join ทั้ง​ห้า​ข้อ​ไม่มี​ข้อ​ไหน​เป็น hash join

แผน​ของ SQLite ทั้ง​ห้า​ข้อ​เป็น​กา​รวน​ลูป​ซ้อน​กับ index ทั้งหมด ซึ่ง​ตรง​กับ​ที่ SQLite ประกาศ​ไว้​ว่า​มัน​มี nested loop อย่าง​เดียว ข้อสังเกต​นี้​อ้าง​จาก​แผน​ห้า​ข้อ​ที่​วัด​ได้​ใน​หัวข้อ​นี้​เท่านั้น

บท 7 วัด​ไว้​แล้ว​ว่า hash join ชนะ index nested loop เรื่อง​หน้าที่​อ่าน​อย่าง​ขาดลอย​ใน​โจทย์​ของ​บท​นั้น แต่​เป็น​คนละ​โจทย์​กับ​ห้า​ข้อ​นี้ จึง​เอา​ตัวเลข​สอง​ชุด​มา​เทียบตรงๆ ไม่​ได้

สิ่ง​ที่​สรุป​ได้​คือ​แผนที่​ดี​ที่สุด​ที่ SQLite เลือก​ได้ กับ​แผนที่​ดี​ที่สุด​ที่​มี​อยู่​จริง ไม่​จำเป็น​ต้อง​เป็น​อัน​เดียวกัน

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

คอร์ส​นี้​ใช้ ANALYZE เพราะ​ต้องการ determinism คือ​รัน​ซ้ำ​ต้อง​ได้​แผน​เดิม​เป๊ะ​และ stdout เท่า​เดิม​ทุก​ครั้ง

ส่วน SQLite แนะนำ PRAGMA optimize สำหรับ​แอป​จริง หัวข้อ​นี้​จึง​รัน​ทั้ง​สอง​ตัว​บน​สำเนา​ที่​ลบ​สถิติ​ทิ้ง​หมด แล้ว​วัดผล​ต่าง

indexANALYZE เต็มๆPRAGMA optimize
idx_customer200000 100200000 101
idx_placed_at200000 1200000 1
idx_restaurant200000 20000200000 2001
idx_status200000 28572200000 1334
idx_status_rest200000 28572 2899200000 1334 308

PRAGMA optimize วิเคราะห์​แบบ​จำกัด​จำนวน​แถว​ที่​อ่าน มัน​จึง​ได้​ค่า​เฉลี่ย​ต่อ​คีย์​ที่​ต่ำ​กว่า​ความ​จริง​มาก และ​ไม่​ได้​เขียน sqlite_stat4 ไว้​เลย จาก 97 ตัวอย่าง​เหลือ 0

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

แต่​ข้อ​สรุป​ที่​ใช้ได้​จริง​ไม่​ได้​กลับ​ข้าง สอง​คำ​สั่ง​นี้​ตอบ​คนละ​โจทย์

  • ANALYZE อ่าน​ตาราง​แล้ว​เขียน​สถิติ​ใหม่​ทั้งหมด​ทุก​ครั้ง ผล​เหมือน​เดิม​ทุก​ครั้ง​ที่​รัน เหมาะ​กับ​บทเรียน​หรือ benchmark ที่​ต้อง​รัน​ซ้ำ​ได้
  • PRAGMA optimize ดู​ว่า​ตาราง​ไหน​น่า​จะ​ต้อง​วิเคราะห์​ใหม่​แล้ว​ค่อย​สั่ง​ให้​เอง แบบ​จำกัด​ปริมาณ​งาน เหมาะ​กับ​ของ​จริง​ที่​ข้อมูล​เปลี่ยน​ตลอด​และ​ไม่มี​ใคร​มา​นั่ง​สั่ง​เอง
  • ราคา​ของ​ตัว​หลัง คือ​ผล​ขึ้น​กับ​ว่า connection นั้น​เคย​รัน query อะไร​มา​บ้าง และ​ตัวเลข​ที่​ได้​เป็น​ค่า​ประมาณ ซึ่ง​เป็น​สอง​ข้อ​ที่​ทำให้​มัน​ไม่​เหมาะ​กับ​คอร์ส​นี้

ใน​แอป​จริง​จึง​ใช้ PRAGMA optimize และ​ถ้า​ต้องการ​สถิติ​ละเอียด​ค่อย​สั่ง ANALYZE เป็น​ครั้ง​คราว​ตอน​ข้อมูล​นิ่ง ส่วน​พฤติกรรม​ที่​วัด​ได้​ใน​หัวข้อ​นี้​ผูก​กับ build ที่​รัน​อยู่

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

หลัง​เทียบ​มา​ทั้ง​บท ช่องว่าง​ของ​สอง​ฝั่ง​เขียน​ออก​มา​เป็น​รายการได้ตรงๆ

SQLite มี แต่ planner ของ​เรา​ไม่มี

  • sqlite_stat4 คือ​จำนวน​แถว​จริง​ของ​ค่าที่​ถูก​เก็บ​ตัวอย่าง​ไว้ แทน​ค่า​เฉลี่ย​ทั้ง column
  • การ​หยิบ skip-scan ขึ้น​มา​พิจารณา​เอง​ทุก​คำถาม ส่วน​แผน k ช่วง​ของ​บท 6 ที่​เทียบเท่า​กัน เรา​ใส่​ไว้​ใน​รายการ​แค่ B3 กับ B6 ด้วย​มือ
  • การ​รู้​จาก​ตัว query เอง​ว่า​แผน​ไหน covering ได้ และ​รู้​ว่า COUNT(*) ไม่​ต้อง​เปิด​แถว​จริง ส่วน​ฝั่ง​เรา​ต้อง​ประกาศ​ไว้​เป็น​ราย​ข้อ
  • การ​คิด​ราคา​ของ​การ​เรียง จึง​ยอม​จ่าย​ค่า​เดิน​ต้นไม้​เพิ่ม​เพื่อ​ไม่​ต้อง​เรียง​ทีหลัง
  • การ​ส่ง​ต่อ​เงื่อนไข​ข้าม​เครื่องหมาย​เท่ากับ​ของ join ก่อน​คิด​ราคา ซึ่ง​เป็น​สิ่ง​ที่ C1 วัด​ให้​เห็น

เรา​มี แต่ SQLite ไม่​ได้​ให้

  • ราคา​ของ​ทุก​แผนที่​แพ้ ไม่ใช่​แค่​แผนที่​ชนะ EXPLAIN QUERY PLAN บอกว่า​เลือก​อะไร แต่​ไม่​บอกว่า​ทำไม​ถึง​ไม่​เลือก​อีก​แผน
  • หน่วย​เดียว​ตลอด​คอร์ส คือ​หน้าที่​แตะ โดย​มี 3,153 หน้า​ของ heap เป็น​ตัว​หาร​เดียวกัน​หมด
  • parameter ที่​เปลี่ยน​แล้ว​วัดผล​ได้​ทันที​ว่า​จุด​ตัด​ขยับ​ไป​ไหน

สิ่ง​ที่​เทียบ​กันไม่ได้ตรงๆ เลย​คือ​หน้าที่​แตะ​ของ​เรา​กับ opcode ของ SQLite เรา​ไม่มี​ทาง​นับ​หน้าที่ SQLite อ่าน​จริง​จาก Python ล้วนๆ ได้ จึง​ไม่​เทียบ​ข้าม​หน่วย

ทุก​จุด​ที่​ไม่​ตรง ชี้​กลับ​ไป​ที่​บท​ใด​บท​หนึ่ง​เสมอ

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

สิ่ง​ที่​ของเล่น​เจ็ด​บท​นี้​ทำได้​และ​ทำ​ไม่​ได้ และ​ที่​ที่​ผู้​อ่าน​ควร​ไป​ต่อ

ของ​ที่​เรา​สร้าง​มา​เจ็ด​บท​เป็น​ของเล่น​สำหรับ​อธิบาย​ว่า​ทำไม​แผน​ของ SQLite ถึง​หน้าตา​แบบ​นั้น​เท่านั้น มัน​ไม่​ได้​เร็ว​กว่า ไม่​ได้​ฉลาด​กว่า และ​ไม่​ได้​ทำซ้ำ​สิ่ง​ที่ SQLite ทำ

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

flowchart LR
    L2["บท 2<br/>หน้าและ heap file"] --> L3["บท 3<br/>B+tree"]
    L3 --> L4["บท 4<br/>สถิติ"]
    L4 --> L5["บท 5<br/>ราคา"]
    L5 --> L6["บท 6<br/>ทางเข้า"]
    L6 --> L7["บท 7<br/>join"]
    L7 --> L8["บท 8<br/>เทียบกับ SQLite"]
    L8 -.->|"A3 ค่าเฉลี่ยต่อ column · A6 independence"| L4
    L8 -.->|"C5 — ต้นเหตุเดียวกับ A3 และ A6"| L4
    L8 -.->|"A5 กับ A7 — จุดตัดคนละเส้น"| L5
    L8 -.->|"B6 — ราคาสามตัวเลขไม่มีช่องให้กรอกราคาการเรียง"| L5
    L8 -.->|"C1 — ส่งต่อเงื่อนไขข้ามเท่ากับก่อนคิดราคา"| L7
    L8 -.->|"3,153 หน้า เป็นตัวหารเดียวกันหมด"| L2

คำ​บรรยาย​ภาพ: เส้น​ทึบ​คือ​ลำดับ​ที่​คอร์ส​นี้​ประกอบ​ของ​ขึ้น​มา​ที​ละ​ชั้น ตั้งแต่​หน้า​และ heap file จนถึง​การ​เทียบ​กับ SQLite · เส้น​ประ​คือ​ความ​ต่าง​แต่ละ​ข้อ​ที่​บท​นี้​วัด​ได้ ชี้​ย้อน​กลับ​ไป​ที่​บท​ที่​อธิบาย​มัน​ได้ · ทุก​เส้น​ประ​มี​ปลายทาง​เสมอ นั่น​คือ​เงื่อนไข​ที่​บท​นี้​ตั้ง​ไว้​ตั้งแต่​ต้น

🔁 อ่าน​ต่อ — จาก​ผู้​สร้าง​แผน กลับ​ไป​หา​ผู้​วินิจฉัย​แผน

คอร์ส​นี้​สร้าง​สิ่ง​ที่​ผลิต​แผน​ขึ้น​มา ส่วน​การ​วินิจฉัย​อาการ​จาก​แผนที่​ได้​มา​แล้ว เป็น​คนละ​งาน

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

กลับ​ไป​อ่าน ทำไม index ที่​คุณ​เพิ่ง​เพิ่ม​ถึง​ไม่​ถูก​ใช้ อีกรอบ อาการ​ทุก​ข้อ​ใน​บทความ​นั้น​มีชื่อ​บท​ของ​คอร์ส​นี้​กำกับ​อยู่​แล้ว


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • EXPLAIN QUERY PLAN — เอกสาร​ทางการ​ของ SQLite (ตรวจ 2026-08-11) — รูปแบบ​ของ​แผนที่​คำ​สั่ง​นี้​คืน​ออก​มา และ​ข้อ​เตือน​ว่า​รูปแบบ​นั้น​เปลี่ยน​ได้​ระหว่าง version จึง​ไม่​ควร​ให้​โปรแกรม​อ่าน​ผล​ของ​มัน​โดยตรง
  • The Virtual Database Engine of SQLite — รายการ opcode ทั้งหมด (ตรวจ 2026-08-11) — ความหมาย​ของ​แต่ละ opcode ที่ EXPLAIN คืน​มา และ​คำ​อธิบาย​ว่า​เครื่อง​เสมือน​ตัว​นี้​เดิน​โปรแกรม​อย่างไร
  • PRAGMA optimize (ตรวจ 2026-08-11) — สิ่ง​ที่​คำ​สั่ง​นี้​ทำ​จริง และ​คำ​แนะนำ​ของ SQLite ว่า​แอป​จริง​ควร​เรียก​มัน​ตอน​ไหน
  • ANALYZE (ตรวจ 2026-08-11) — สถิติ​ที่​คำ​สั่ง​นี้​เขียน​ลง sqlite_stat1 และ​เงื่อนไข​ที่ sqlite_stat4 จะ​ถูก​เขียน​ด้วย

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

ข้อ 1 / 3

ที่ A4 (status='Delivered' ตรง 163,733 แถว) แผน index เดิน 1,639,465 opcode ส่วนแผนสแกนเดิน 1,748,264 opcode แต่ SQLite เลือกสแกน ข้อสรุปที่ซื่อสัตย์ที่สุดคืออะไร