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

สถิติ — เดา​จำนวน​แถว​โดย​ไม่​รัน query

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

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

สอง​คำ​ที่​บท​นี้​ใช้​ตลอด​คือ selectivityselectivityสัดส่วน​ของ​แถว​ที่​ผ่าน​เงื่อนไข​หนึ่ง มี​ค่า​ระหว่าง 0 ถึง 1 และ​เป็น​ตัวเลข​ที่ planner ต้อง​เดา​ให้​ได้​ก่อน จึง​จะ​คิด​ราคา​ของ​แผน​ได้ กับ cardinalitycardinalityจำนวน​ค่าที่​ไม่​ซ้ำ​กัน​ของ​คอลัมน์ หรือ​จำนวน​แถว​ของ​ผลลัพธ์​ชุด​หนึ่ง เป็น​ตัวตั้ง​ของ​การ​ประมาณ​แบบ n/distinct และ​เป็น​ตัว​ที่​ทำให้​ต้นทุน​ของ​แผนที่​ต้อง​ไล่​ค้น​ที​ละ​ค่า​ระเบิด ตัว​แรก​คือ​สัดส่วน​ของ​แถว​ที่​ผ่าน​เงื่อนไข ตัว​หลัง​คือ​จำนวน​ค่าที่​ไม่​ซ้ำ​กัน​ของ column หนึ่ง และ​ทั้ง​คู่​คือ​ตัวตั้ง​ของ​ทุก​การ​เดา​ใน​บท​นี้

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

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

บท​นี้​เพิ่ม file เดียว​คือ qe/stats.py ซึ่ง import fixture ของ​บท 1 มาใช้ตรงๆ ไม่​ต้อง​มี heap file หรือ B+tree ของ​บท 2 กับ​บท 3 ก็​รัน​ได้

code ทุก​ช่วง​ใน​บท​นี้​ตัด​มา​จาก file เดียวกัน​นั้น ยก​มา​เฉพาะ​ช่วง​ที่​บท​นี้​อธิบาย เรียง​ตาม​ลำดับ​ที่​มัน​อยู่​จริง​ใน file จึง​ต้อง​ต่อ​ให้​ครบ​ทั้ง file ก่อน​ถึง​จะ​รัน​ได้

ส่วน​ที่​ไม่​ได้​ยก​มา​มี​ฟังก์ชัน​ช่วย​พิมพ์​ตาราง ตัว​ช่วย​เล็กๆ อย่าง​มัธยฐาน​กับ​การ​แปลง placed_at เป็น​เลข​วัน ตัว​ถอด record format ของ SQLite equi_depth_split ซึ่ง​เป็น equi-depth อีก​แบบ​ที่​ยอม​ผ่า​ค่า และ​ส่วน __main__ ที่​ประกอบ​ทุก​อย่าง​เข้า​ด้วย​กัน

โมดูล​นี้​เรียก sqlite3 ของ stdlib สร้าง .build/orders.db ขึ้น​ใหม่​ทุก​ครั้ง​ที่​รัน แล้ว​สั่ง ANALYZE เอง จึง​ลบ folder .build/ ทิ้ง​ได้​ตลอด​โดย​ไม่​เสีย​อะไร

รัน​จาก folder ทำงาน​ด้าน​นอก​เหมือน​บท​ก่อนหน้า ด้วย python3 qe/stats.py

หน่วย​ของ​บท​นี้​คือ​ความ​ผิด​ของ​ค่า​ประมาณ ไม่ใช่​ราคา​ของ​แผน

หัวข้อ​ที่​มีชื่อ​ว่า “หน่วย​ของ​บท​นี้​คือ​ความ​ผิด​ของ​ค่า​ประมาณ ไม่ใช่​ราคา​ของ​แผน”

นิยาม​ของ q-error code ที่​คำนวณ​มัน และ​กรณี​เดียว​ที่​มัน​วัด​ไม่​ได้

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

หน่วย​นั้น​คือ q-errorq-errorค่า​ความคลาด​ของ​การ​ประมาณ คิด​เป็น max(ค่า​ประมาณ/ของ​จริง, ของ​จริง/ค่า​ประมาณ) ไร้​หน่วย 1.00 คือ​แม่น​เป๊ะ และ​มัน​วัด​กรณี​ที่​ของ​จริง​เป็น​ศูนย์​ไม่​ได้ นิยาม​ของ​มัน​คือ max(ค่า​ประมาณ/ของ​จริง, ของ​จริง/ค่า​ประมาณ) ไร้​หน่วย และ 1.00 คือ​แม่น​เป๊ะ

รูปแบบ​นี้​ไม่​สนใจ​ทิศทาง ประมาณ​เกิน​จริง​กับ​ประมาณ​ต่ำ​กว่า​จริง​ใน​อัตราส่วน​เท่า​กัน ได้​ค่า​เท่า​กัน ซึ่ง​ตรง​กับ​สิ่ง​ที่​เรา​สนใจ คือ​ขนาด​ของ​ความ​ผิด ไม่ใช่​ว่า​ผิด​ไป​ทาง​ไหน

qe/stats.py
def qerror(est, act):
"""q-error = max(est/act, act/est) · ยกพื้นทั้งสองฝั่งที่ 1 แถว
เพราะ optimizer จริงก็ไม่ยอมประมาณต่ำกว่า 1 แถว และของจริง 0 แถวหารไม่ได้"""
e = max(float(est), 1.0)
a = max(float(act), 1.0)
return max(e / a, a / e)

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

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

กรณี​นั้น​ไม่ใช่​กรณี​สมมติ หัวข้อ independence จะ​เจอ​มัน​เข้า​เต็มๆ

ก่อน​จะ​มี​สถิติ ก็​ยัง​ต้อง​มี​คน​ตั้ง​ค่าที่​เดา​ไว้​ล่วงหน้า

หัวข้อ​ที่​มีชื่อ​ว่า “ก่อน​จะ​มี​สถิติ ก็​ยัง​ต้อง​มี​คน​ตั้ง​ค่าที่​เดา​ไว้​ล่วงหน้า”

ตัวเลข​คงที่​ที่​เปเปอร์​ปี 1979 ตั้ง​ไว้​ใช้​ตอน​ไม่มี​สถิติ และ​เงื่อนไข​ที่​ติด​มา​กับ​ตัวเลข​พวก​นั้น

เปเปอร์​ที่​วาง​ราก​ของ​แนวคิด “ประมาณ​ราคา​ของ​ทุก​แผน​แล้ว​เลือก​แผนที่​ถูก​ที่สุด” คือ Selinger et al. 1979 ซึ่ง​บท 1 อ้าง​ถึง​ไว้​แล้ว​โดย​ไม่​ยก​ตัวเลข — ตัวเลข​อยู่​ตรง​นี้

TABLE 1 หน้า 26 ของ​เปเปอร์ กำหนด​ค่า selectivity ไว้​ตาม​รูป​ของ​เงื่อนไข

  • column = value ได้ 1/ICARD เมื่อ​มี index อยู่​บน column นั้น โดย ICARD คือ​จำนวน​ค่าที่​ไม่​ซ้ำ​กัน​ใน index ตัว​นั้น มิ​ฉะนั้น​จึง​ตก​ไป​ใช้ 1/10
  • column > value ได้​ค่า​จาก​การ​เทียบ​สัดส่วน​ของ​ช่วง เมื่อ column เป็น​ชนิด​เลขคณิต​และ​รู้​ค่าที่​เอา​มา​เทียบ​ตั้งแต่​ตอน​เลือก access path มิ​ฉะนั้น​จึง​ตก​ไป​ใช้ 1/3
  • column BETWEEN value1 AND value2 ได้ 1/4

ค่า 1/10 กับ 1/3 ไม่ใช่​ค่าที่​ใช้​เสมอ ทั้ง​คู่​เป็น​ค่าที่​ตก​ไป​ใช้​เมื่อ​เงื่อนไข​ข้าง​ต้น​ไม่​ครบ และ​เปเปอร์​เอง​ก็​ไม่​ได้​อ้าง​ว่า​มัน​มี​ที่มา

เปเปอร์​เขียน​กำกับ​ค่า 1/10 ไว้​เอง​ว่า “There is no significance to this number” (ตัวเลข​นี้​ไม่มี​นัย​สำคัญ​อะไร​เป็น​พิเศษ) ส่วน​ค่า 1/3 มา​จาก​สมมุติฐาน​ที่​เขียน​ไว้​ว่า “We hypothesize that few queries use predicates that are satisfied by more than half the tuples.” (เรา​ตั้ง​สมมุติฐาน​ว่า​มี query ไม่​กี่​ข้อ​ที่​ใช้​เงื่อนไข​ซึ่ง​แถว​เกิน​ครึ่ง​ตาราง​ผ่าน​ได้)

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

uniformity — ตัว​ประมาณ​ทั้ง​ตัว​คือ n หาร​ด้วย​จำนวน​ค่าที่​ไม่​ซ้ำ

หัวข้อ​ที่​มีชื่อ​ว่า “uniformity — ตัว​ประมาณ​ทั้ง​ตัว​คือ n หาร​ด้วย​จำนวน​ค่าที่​ไม่​ซ้ำ”

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

uniformity assumptionuniformity assumptionข้อ​สมมติ​ว่า​ทุก​ค่า​ของ​คอลัมน์​มี​จำนวน​แถว​เท่า​กัน จึง​ประมาณ​ด้วย n/distinct — แม่น​บน​คอลัมน์​ที่​กระจาย​เท่า​กัน และ​พลาด​หนัก​ตรง​ค่าที่​หา​ยาก​ของ​คอลัมน์​ที่​เบ้ บอกว่า​ทุก​ค่าที่​ต่าง​กัน​ของ column หนึ่ง​มี​จำนวน​แถว​เท่า​กัน ค่า​ประมาณ​ของ​ทุก​ค่า​จึง​เป็น​ตัวเลข​ตัว​เดียวกัน​หมด คือ n หาร​ด้วย​จำนวน​ค่าที่​ไม่​ซ้ำ

สถิติ​ที่​ต้อง​เก็บ​มี​แค่​สอง​ตัว คือ​จำนวน​แถว​ทั้ง​ตาราง​กับ cardinality ของ​แต่ละ column นี่​คือ​เหตุผล​ที่​มัน​เป็น​ตัว​ประมาณ​ที่​ทุก​เอนจินมี มัน​แทบ​ไม่มี​ราคา

qe/stats.py
def uniformity(rows, col, name, show=None):
"""ประมาณ count(col = v) ด้วย n/distinct ทุกค่าเท่ากันหมด แล้วเทียบของจริง"""
n = len(rows)
cnt = collections.Counter(r[col] for r in rows)
est = n / len(cnt)
qs = {v: qerror(est, a) for v, a in cnt.items()}
worst = min(qs, key=lambda v: (-qs[v], str(v))) # tie-break ด้วยชื่อ เพื่อให้ deterministic

ตัว​ประมาณ​ทั้ง​ตัว​อยู่​ที่ est = n / len(cnt) บรรทัด​เดียว ที่​เหลือ​คือ​การ​นับ​ของ​จริง​มา​เทียบ​เพื่อ​รายงาน​ผล

status มี​ค่าที่​ไม่​ซ้ำ​กัน 7 ค่า ค่า​ประมาณ​จึง​เป็น 28,571.43 แถว​เท่า​กัน​ทุก​ค่า ส่วน​ของ​จริง​ห่าง​กัน​หลาย​ร้อย​เท่า

ค่าของ​จริงq-error
Delivered163,7335.73
Cancelled21,1311.35
Rejected13,1652.17
PickedUp80635.45
Preparing58349.01
Confirmed38374.60
Placed199143.58

แย่​สุด​คือ 143.58 ที่ Placed ซึ่ง​มี​ของ​จริง 199 แถว มัธยฐาน​อยู่​ที่ 35.45 และ​มี 6 ค่า​จาก 7 ค่าที่​พลาด​เกิน 2 เท่า

ทิศทาง​ของ​ความ​พลาด​อ่าน​ออก​จาก​ตารางได้ตรงๆ ว่า​มัน​พัง​ตรง​ค่าที่​หา​ยาก ไม่ใช่​ค่าที่​เยอะ Delivered ที่​กิน​ตาราง 81.87 % พลาด​ไป 5.73 เท่า ส่วน Placed ที่​มี 199 แถว​พลาด​ไป 143.58 เท่า

ค่าที่​แม่น​ที่สุด​คือ Cancelled ที่ 1.35 เพราะ​จำนวน​แถว​จริง​ของ​มัน​บังเอิญ​ใกล้​ค่า​เฉลี่ย​ที่สุด ไม่ใช่​เพราะ​ตัว​ประมาณ​รู้​อะไร​เกี่ยว​กับ​มัน​เป็น​พิเศษ

restaurant มี 10 ค่า ค่า​ประมาณ​เท่า​กัน​หมด​ที่ 20,000.00 แถว แย่​สุด​คือ 1.68 ที่ ครัวคุณแม่ ซึ่ง​มี​ของ​จริง 11,934 แถว มัธยฐาน 1.26 และ​ไม่มี​ค่า​ไหน​พลาด​เกิน 2 เท่า​เลย​สัก​ค่า

customer_id มี 2,000 ค่า ค่า​ประมาณ​เท่า​กัน​หมด​ที่ 100.00 แถว แย่​สุด​คือ 1.37 มัธยฐาน 1.07 และ​พลาด​เกิน 2 เท่า 0 ค่า​จาก 2,000 ค่า

สาม column นี้​อยู่​ใน​ตาราง​เดียวกัน ผ่าน​ตัว​ประมาณ​ตัว​เดียวกัน แล้ว​ได้​ผล​คนละ​ขนาด แย่​สุด 143.58 บน status เทียบ​กับ​แย่​สุด 1.37 บน customer_id

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

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

independence — คูณ​สัดส่วน​สอง​ตัว​เข้า​ด้วย​กัน​แล้ว​เชื่อ​ผลลัพธ์

หัวข้อ​ที่​มีชื่อ​ว่า “independence — คูณ​สัดส่วน​สอง​ตัว​เข้า​ด้วย​กัน​แล้ว​เชื่อ​ผลลัพธ์”

ข้อ​สมมติ​ที่​สอง ผล​ของ​มัน​บน​คู่ status กับ restaurant ทั้ง 70 คู่ และ​คู่​ที่​มัน​สร้าง​แถว​ขึ้น​มา​เอง

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

independence assumptionindependence assumptionข้อ​สมมติ​ว่า​เงื่อนไข​สอง​ข้อ​ไม่​เกี่ยวข้อง​กัน จึง​ประมาณ​ด้วย sel(X) × sel(Y) × n — พัง​หนัก​ที่สุด​กับ​คู่​ค่าที่​ไม่มี​อยู่​จริง เพราะ​มัน​ยืนยัน​ว่า​มี​แถว​ทั้ง​ที่​ของ​จริง​ไม่มี​สัก​แถว อุด​ช่อง​นั้น​ด้วย​การ​บอกว่า​รู้​ค่า​ของ column หนึ่ง​แล้ว​ไม่​ได้​บอก​อะไร​เกี่ยว​กับ​อีก column ค่า​ประมาณ​ของ​สอง​เงื่อนไข​จึง​เป็น sel(X) คูณ sel(Y) คูณ n

qe/stats.py
def independence(rows, st_cnt, rs_cnt):
"""ประมาณ status=X AND restaurant=Y ด้วย sel(X)*sel(Y) แล้วเทียบของจริง"""
n = len(rows)
joint = collections.Counter((r[2], r[3]) for r in rows)
recs = []
for s, r in itertools.product(sorted(st_cnt), sorted(rs_cnt)):
act = joint.get((s, r), 0)
est = n * (st_cnt[s] / n) * (rs_cnt[r] / n)
recs.append((qerror(est, act), s, r, act, est))
recs.sort(key=lambda t: (-t[0], t[1], t[2]))
return joint, recs

บรรทัด​ที่​ทำงาน​จริง​คือ est = n * (st_cnt[s] / n) * (rs_cnt[r] / n) ข้อ​สมมติ​ที่​ดัง​ที่สุด​ใน​วงการ​เขียน​ได้​ใน​บรรทัด​เดียว

status 7 ค่า คูณ restaurant 10 ร้าน ได้ 70 คู่​ที่​เป็น​ไป​ได้ ใน​ข้อมูล​จริง​มี​อยู่ 69 คู่ อีก 1 คู่​ไม่มี​แถว​เลย​สัก​แถว

ภาพ​รวม​ดู​ดี มัธยฐาน q-error ของ​ทั้ง 70 คู่​อยู่​ที่ 1.09 และ​มี 11 คู่​ที่​พลาด​เกิน 2 เท่า ปัญหา​ของ independence ไม่​ได้​อยู่​ที่​ค่า​กลาง มัน​อยู่​ที่​หาง

statusrestaurantของ​จริงประมาณq-error
Rejectedครัว​สุขใจ01,565.61565.58
Rejectedครัว​คุณ​แม่8,126785.610.34
Deliveredครัว​คุณ​แม่2,4269,769.94.03

อีก 7 คู่​ที่​เหลือ​ใน​สิบ​อันดับ​แรก​เป็น Rejected คู่​กับ​ร้าน​อื่น q-error อยู่​ใน​ช่วง 2.03 ถึง 2.29 ทั้ง​เจ็ด​คู่ และ​ทุก​คู่​ประมาณ​เกิน​จริง

สอง​คู่​ที่​พลาด​หนัก​รอง​ลง​มา​เป็น​ร้าน​เดียวกัน คือ ครัวคุณแม่ ที่​บท 1 ตั้ง​น้ำหนัก Rejected ไว้​สูง​กว่า​ร้าน​อื่น​มาก​ใน MIX ร้าน​นี้​ปฏิเสธ​ออเดอร์​บ่อย​กว่า​ค่า​เฉลี่ย​ของ​ตาราง​มาก

ผล​คือ independence ประมาณ Rejected ของ​ร้าน​นี้​ต่ำ​ไป 10.34 เท่า และ​ประมาณ Delivered ของ​ร้าน​เดียวกัน​เกิน​ไป 4.03 เท่า ความ​ผิด​สอง​ข้อ​นี้​เป็น​ความ​ผิด​ข้อ​เดียวกัน​มอง​จาก​คนละ​ด้าน

คู่​ที่​ไม่มี​อยู่​จริง — ความ​ผิด​ที่ q-error บอก​ไม่​ครบ

คู่ Rejected × ครัวสุขใจ มี​ของ​จริง 0 แถว แต่ independence ยืนยัน​ว่า​มี 1,565.6 แถว

ทั้ง​สถานะ​นั้น​และ​ร้าน​นั้น​ต่าง​ก็​มี​อยู่​จริง​ใน​ตาราง เพียง​แต่​ไม่​เคย​มา​อยู่​ด้วย​กัน — บท 1 ตั้ง Rejected=0 ให้​ร้าน​นี้​ไว้​ใน MIX ตั้งแต่​ตอน​สร้าง​ข้อมูล

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

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

🔁 ที่มา​ของ​คู่​นี้​อยู่​ใน บท​ที่ 1 — สี่​จุด​ใน qe/fixture.py ที่​ต้อง​อ่าน​ให้​ออก

histogram — เก็บ​รูปร่าง​ของ column ไว้ แทน​ค่า​เฉลี่ย​ตัว​เดียว

หัวข้อ​ที่​มีชื่อ​ว่า “histogram — เก็บ​รูปร่าง​ของ column ไว้ แทน​ค่า​เฉลี่ย​ตัว​เดียว”

ตัว​ประมาณ​ตัว​ที่​สาม สอง​วิธี​แบ่ง bucket และ​การ​ทดสอบ​สาม​แบบ​บน​ข้อมูล​ชุด​เดียวกัน กับ​คุณสมบัติ​ที่​ตำรา​ท่อง​กัน​ซึ่ง​จริง​แค่​ครึ่ง​เดียว

histogramhistogramการ​สรุป​การกระจาย​ของ​คอลัมน์​เป็น bucket แล้ว​เก็บ​จำนวน​แถว​ของ​แต่ละ bucket ไว้ ทำให้​ประมาณ​จำนวน​แถว​ได้​โดย​ไม่​ต้อง​สมมติ​ว่า​ทุก​ค่า​มี​แถว​เท่า​กัน (equi-width แบ่ง​ช่วง​เท่า​กัน equi-depth แบ่ง​ให้​จำนวน​แถว​ใกล้​เคียง​กัน) แก้​ปัญหา​ของ uniformity ด้วย​การ​เลิก​ใช้​ค่า​เฉลี่ย​ตัว​เดียว​ทั้ง column แล้ว​หั่น​ช่วง​ของ​ค่า​ออก​เป็น bucket เก็บ​จำนวน​แถว​ของ​แต่ละ bucket ไว้​แทน

บท​นี้​ทำ histogram บน placed_at แต่​แปลง​เป็น​เลข​วัน​ก่อน จึง​เหลือ​ค่าที่​ต่าง​กัน 180 ค่า ไม่ใช่ 196,891 ค่า​ระดับ​วินาที​ที่​บท 1 นับ​ไว้

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

ค่าที่​เป็น​ไป​ได้​คือ​วัน 0 ถึง 179 รวม 180 วัน​ที่​มี​ข้อมูล ตั้ง B = 15 bucket ได้​เป้าหมาย n/B = 13,333.33 แถว​ต่อ bucket

วัน​ที่​หนัก​ที่สุด​คือ​วัน 140 ซึ่ง​มี 17,603 แถว คิด​เป็น 8.80 % ของ​ทั้ง​ตาราง นี่​คือ​วัน​โปรโมชัน​ที่​บท 1 ฝัง​ไว้​ใน​ข้อมูล

qe/stats.py
def equi_width(days, b):
"""แบ่งโดเมนวันเป็น b ช่วงกว้างเท่ากัน — ขอบง่าย แต่จำนวนแถวต่อ bucket คุมไม่ได้"""
width = DAYS // b
out = []
for i in range(b):
lo, hi = i * width, (i + 1) * width - 1
out.append([lo, hi, 0, hi - lo + 1])
for d in days:
out[min(int(d) // width, b - 1)][2] += 1
return [tuple(x) for x in out]
bucketช่วง​วันแถวส่วนแบ่ง
00-118380.42 %
112-232,5201.26 %
224-354,0732.04 %
336-475,7602.88 %
448-597,4663.73 %
560-719,0514.53 %
672-8310,6785.34 %
784-9512,4276.21 %
896-10713,8306.92 %
9108-11915,6617.83 %
10120-13117,3018.65 %
11132-14334,61817.31 %
12144-15520,40510.20 %
13156-16721,82710.91 %
14168-17923,54511.77 %

ขอบ​ของ​ทุก bucket คำนวณ​ได้​จาก​เลขวันตรงๆ ไม่​ต้อง​เก็บ​อะไร​เพิ่ม นั่น​คือ​ข้อดี​ทั้งหมด​ของ​วิธี​นี้

ราคา​ของ​มัน​อยู่​ที่ bucket 11 ซึ่ง​กิน 34,618 แถว หรือ 17.31 % ของ​ตาราง เพราะ​วัน​โปรโมชัน​ตก​อยู่​ใน​ช่วง 132-143 พอดี วิธี​นี้​คุม​ความ​กว้าง​ของ​ช่วง​ได้ แต่​คุม​จำนวน​แถว​ต่อ bucket ไม่​ได้​เลย

equi-depth — แบ่ง​ให้​จำนวน​แถว​ของ​แต่ละ bucket ใกล้​เคียง​กัน

หัวข้อ​ที่​มีชื่อ​ว่า “equi-depth — แบ่ง​ให้​จำนวน​แถว​ของ​แต่ละ bucket ใกล้​เคียง​กัน”
qe/stats.py
def equi_depth_atomic(sorted_days, b):
"""equi-depth แบบห้ามผ่าค่าเดียวกันข้าม bucket (กติกาที่ฐานข้อมูลจริงใช้
เพราะ bucket ต้องตอบคำถาม 'ค่า v มีกี่แถว' ได้)
ปิด bucket ที่จุดซึ่ง 'ใกล้โควตาที่สุด' ไม่ใช่จุดแรกที่เกินโควตา"""
n = len(sorted_days)
runs = sorted(collections.Counter(sorted_days).items())
out, i, left = [], 0, n
while i < len(runs):
left_buckets = b - len(out)
if left_buckets <= 1:
seg = runs[i:] # bucket สุดท้ายรับที่เหลือทั้งหมด
out.append((seg[0][0], seg[-1][0], sum(c for _, c in seg), len(seg)))
break
target = left / left_buckets
bound = max(i + 1, len(runs) - (left_buckets - 1)) # ต้องเหลือ run ให้ bucket ที่ยังไม่เปิด
cnt, nd, j = 0, 0, i
while j < bound:
c = runs[j][1]
if cnt and abs(cnt - target) <= abs(cnt + c - target):
break
cnt, nd, j = cnt + c, nd + 1, j + 1
out.append((runs[i][0], runs[j - 1][0], cnt, nd))
left -= cnt
i = j
return out

ต้อง​บอก​ไว้​ตรง​นี้​ว่า equi_depth_atomic เป็น greedy ที่​เขียน​ขึ้น​เอง​สำหรับ​คอร์ส​นี้ ไม่​ใช่​อัลกอริทึม​ของ PostgreSQL หรือ​ของ SQLite

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

bucketช่วง​วันแถวค่าที่​ต่าง​กันส่วนแบ่ง
00-4713,191486.60 %
148-6713,383206.69 %
268-8212,879156.44 %
383-9513,360136.68 %
496-10713,830126.92 %
5108-11712,959106.48 %
6118-12714,032107.02 %
7128-13613,44596.72 %
8137-1394,76732.38 %
9140-14017,60318.80 %
10141-14914,81197.41 %
11150-15713,86786.93 %
12158-16514,44587.22 %
13166-17213,44076.72 %
14173-17913,98876.99 %

ความ​ต่าง​ที่​เห็น​ชัด​ที่สุด​อยู่​ที่ bucket 9 ซึ่ง​มี​วัน 140 อยู่​วัน​เดียว และ bucket 0 ซึ่ง​กวาด​วัน 0 ถึง 47 รวม 48 วัน​เข้า​มา​อยู่​ด้วย​กัน

การ​อ่าน​ค่า​ออก​จาก histogram ทำ​แบบ​เดียวกัน​ทั้ง​สอง​แบบ

qe/stats.py
def est_from(hist, d):
"""ประมาณ count(day = d) จาก histogram: กระจายแถวใน bucket ให้ทุกค่าเท่ากัน"""
los = [h[0] for h in hist]
i = bisect.bisect_right(los, d) - 1
lo, hi, cnt, nd = hist[max(i, 0)]
return cnt / nd

หา​ว่า​ค่าที่​ถาม​อยู่ bucket ไหน แล้ว​หาร​จำนวน​แถว​ของ bucket นั้น​ด้วย​จำนวน​ค่าที่​ต่าง​กัน​ใน bucket นั้น นี่​คือ uniformity อีก​ครั้ง เพียง​แต่​ขอบเขต​ของ​ข้อ​สมมติ​แคบ​ลง​เหลือ​แค่​ภายใน bucket เดียว

flowchart TB
    subgraph W["equi-width — แบ่ง 180 วันเป็น 15 ช่วงกว้างเท่ากัน"]
        direction LR
        W10["bucket 10<br/>วัน 120-131<br/>17,301 แถว"] --> W11["bucket 11<br/>วัน 132-143<br/>34,618 แถว"] --> W12["bucket 12<br/>วัน 144-155<br/>20,405 แถว"]
    end
    subgraph D["equi-depth — ห้ามผ่าค่าเดียวกันข้าม bucket"]
        direction LR
        D8["bucket 8<br/>วัน 137-139<br/>4,767 แถว"] --> D9["bucket 9<br/>วัน 140 วันเดียว<br/>17,603 แถว"] --> D10["bucket 10<br/>วัน 141-149<br/>14,811 แถว"]
    end
    W --> NW["วันโปรโมชันจมรวมกับอีกสิบเอ็ดวัน<br/>ถามว่าวัน 140 มีกี่แถว ได้ q-error 6.10"]
    D --> ND["วันโปรโมชันได้ bucket ของตัวเอง<br/>ถามว่าวัน 140 มีกี่แถว ได้ q-error 1.00"]

คำ​บรรยาย​ภาพ: สาม bucket กลาง​ของ​แต่ละ​วิธี วาง​บน​ค่า​ชุด​เดียวกัน​คือ​เลข​วัน​ของ placed_at · equi-width บังคับ​ให้​ทุก​ช่วง​กว้าง​เท่า​กัน วัน​โปรโมชัน​จึง​ตก​ไป​รวม​กับ​วัน​อื่น​ใน bucket 11 · equi-depth เลือก​ขอบ​เอง​เพื่อ​คุม​จำนวน​แถว วัน 140 จึง​ได้ bucket ของ​ตัวเอง​ที่ bucket 9 · ตัวเลข q-error ใต้​ภาพ​คือ​ผล​ของ​การ​ถาม​ว่า​วัน 140 มี​กี่​แถว ด้วย histogram แต่ละ​แบบ

คุณสมบัติ​ที่​ตำรา​ท่อง​กัน จริง​แค่​ครึ่ง​เดียว

หัวข้อ​ที่​มีชื่อ​ว่า “คุณสมบัติ​ที่​ตำรา​ท่อง​กัน จริง​แค่​ครึ่ง​เดียว”

ประโยค​ที่​ได้ยิน​บ่อย​คือ equi-depth การันตี​ว่า bucket ที่​ใหญ่​ที่สุด​จะ​มี​แถว​เท่ากับ n/B ไม่​ว่า​ข้อมูล​จะ​รูปร่าง​ไหน โมดูล​นี้​ทดสอบ​มัน​บน​ข้อมูล​ชุด​เดียวกัน​สาม​แบบ

วิธี​แบ่งbucket ใหญ่​สุดเทียบ​กับ n/B
equi-depth ที่​ยอม​ผ่า​ค่า​เดียว​ข้าม bucket13,334 แถว1.0000 เท่า
equi-depth ที่​ห้าม​ผ่า​ค่า17,603 แถว1.3202 เท่า
equi-width34,618 แถว2.5963 เท่า

แบบ​ที่​ทำให้​คุณสมบัติ​นี้​เป็น​จริง​คือ​แบบ​ที่​ยอม​ให้​ค่า​เดียว​ถูก​ผ่า​ไป​อยู่​มากกว่า​หนึ่ง bucket ซึ่ง​อยู่​ใน qe/stats.py ชื่อ equi_depth_split และ​เป็น​ช่วง​ที่​บท​นี้​ไม่​ได้​ยก​มา ราคา​ของ​มัน​คือ 14 วัน​ที่​ถูก​ผ่า

histogram แบบ​นั้น​ตอบ​คำถาม “วัน d มี​กี่​แถว” ตรงๆ ไม่​ได้ ต้อง​รวม​ข้าม bucket ก่อน ซึ่ง​เป็น​เหตุผล​ที่​ฐาน​ข้อมูล​จริง​ไม่​ใช้​กติกา​แบบ​นั้น

พอ​บังคับ​กติกา​ที่​ฐาน​ข้อมูล​จริง​ใช้ คือ​ห้าม​ผ่า​ค่า ค่า​เดียว​ที่​หนัก​เกิน​เป้าหมาย​ก็​ทำลาย​การันตี​ทันที วัน 140 มี 17,603 แถว​อยู่​ใน​ค่า​เดียว ผ่า​ไม่​ได้ bucket ที่​รับ​มัน​จึง​ใหญ่​กว่า​เป้าหมาย 1.3202 เท่า

สิ่ง​ที่​พูด​ได้​แน่ๆ จึง​เป็น​ประโยค​ที่​อ่อน​กว่า​นั้น คือ ค่าที่​หนัก​ที่สุด​ใน​ข้อมูล​เป็น​พื้น​ของ bucket ที่​ใหญ่​ที่สุด​เสมอ ผ่า​ค่า​ไม่​ได้​ก็​ลง​ไป​ต่ำ​กว่า​ค่า​นั้น​ไม่​ได้ ที่ B = 15 พื้น​นี้​คือ 17,603 แถว และ bucket ใหญ่​สุด​ก็​ลง​มา​อยู่​ที่​พื้น​พอดี

เมื่อ​พื้น​สูง​กว่า​เป้าหมาย การันตี n/B ก็​เป็น​ไป​ไม่​ได้​ตั้งแต่​ต้น​ด้วย​เลขคณิต​ล้วนๆ แต่​การ​ดัน n/B ให้​สูง​กว่า​พื้น​ด้วย​การ​ลด B ก็​ไม่​ได้​แปล​ว่า​การันตี​จะ​กลับ​มา เพราะ​กติกา​ห้าม​ผ่า​ค่า​ยัง​บังคับ​ให้​วัน​ข้าง​เคียง​ถูก​ยุบ​เข้า bucket เดียวกัน​อยู่ดี

เอา histogram ทั้ง​สอง​แบบ​ไป​ประมาณ​จำนวน​แถว​ของ​ทั้ง 180 วัน แล้ว​วัด q-error ทุก​วัน ได้​ผล​แบบ​นี้

histogramq-error แย่​สุดมัธยฐานวัน​ที่​พลาด​เกิน 2 เท่า
equi-width13.971.054/180
equi-depth (ห้าม​ผ่า​ค่า)54.961.0413/180

ตรง​จุด​ที่​เป็น​โจทย์​ตั้งต้น equi-depth ชนะ​ขาด ถาม​ว่า​วัน 140 มี​กี่​แถว มัน​ได้ q-error 1.00 ส่วน equi-width ได้ 6.10

แต่​เมื่อ​ดู​ทั้ง 180 วัน มัน​แย่​กว่า​ทั้ง​ค่า​แย่​สุด และ​จำนวน​วัน​ที่​พลาด​เกิน 2 เท่า​ซึ่ง​มากกว่า​สาม​เท่าตัว

จุด​ที่​มัน​แพ้​คือ​หาง​ที่​ข้อมูล​บาง วัน 0 มี​ของ​จริง 5 แถว equi-width ประมาณ 69.8 แถว ส่วน equi-depth ประมาณ 274.8 แถว เพราะ​มัน​ยุบ​วัน​เงียบๆ หลาย​สิบ​วัน​เข้า​มา​อยู่ bucket เดียวกัน

equi-depth คุม​จำนวน​แถว​ต่อ bucket ได้ แต่​ไม่​ได้​คุม​ความ​คล้าย​กัน​ของ​ค่าที่​อยู่​ใน bucket เดียวกัน ความ​แม่น​ตรง spike จึง​ถูก​แลก​มา​ด้วย​ความ​แม่น​ตรง​หาง

ผล​ของ ANALYZE บน​ตาราง​ชุด​เดียวกัน รูปแบบ​ของ stat1 สิ่ง​ที่ index ร่วม​ให้​มา​แทน และ​ชั้น​ที่​สอง​ที่​ชื่อ stat4

ทุก​ตัว​ประมาณ​จนถึง​ตรง​นี้​เป็น​ของ​ที่​เรา​เขียน​ขึ้น​เอง หัวข้อ​นี้​เปิด​ของ​จริง​มา​เทียบ โดย​โหลด​ข้อมูล​ชุด​เดียวกันลง SQLite แล้ว​สั่ง​ให้​มัน​เก็บ​สถิติ​เอง

qe/stats.py
def build_db(rows):
"""สร้าง orders.db ใหม่ทุกครั้งจาก fixture แล้วสั่ง ANALYZE ให้ SQLite เก็บสถิติเอง"""
os.makedirs(BUILD, exist_ok=True)
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
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 (?,?,?,?,?,?)", rows)
for sql in ("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)"):
con.execute(sql)
con.commit()
con.execute("ANALYZE")
con.commit()
return con

ANALYZE เดิน index ทุก​ตัว​แล้ว​เขียน​ผล​ลง sqlite_stat1sqlite_stat1ตาราง​ที่​คำ​สั่ง `ANALYZE` ของ SQLite เขียน​สถิติ​ลง​ไป เก็บ​จำนวน​แถว​ของ index และ​จำนวน​แถว​เฉลี่ย​ที่​มี​ค่า​คีย์​เท่า​กัน — คือ uniformity assumption ที่​เขียน​ลง​ดิสก์ หนึ่ง​บรรทัด​ต่อ​หนึ่ง index

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

indexstat ที่ SQLite เขียนn/distinct ที่​เรา​คำนวณ​เอง
idx_customer200000 100100.00
idx_placed_at200000 11.02
idx_restaurant200000 2000020,000.00
idx_status200000 2857228,571.43
idx_status_rest200000 28572 28992,898.55

สอง​หลัก​นี้​เกาะ​กัน​ทุก​แถว ค่าที่ SQLite เขียน​คือ​จำนวนเต็ม​ที่​อยู่​ติด​กับ n หาร​ด้วย​จำนวน​ค่าที่​ไม่​ซ้ำ​ที่​เรา​คำนวณ​เอง​เสมอ แปล​ว่า stat1 คือ uniformity assumption ใน​หัวข้อ​ก่อนหน้า เขียน​ลง disk

แถว​ที่​ต้อง​อ่าน​แยก​คือ idx_placed_at ซึ่ง​ได้ 1 ทั้ง​ที่​ของ​จริง​คือ 1.02 ตัวเลข​นี้​ไม่​ได้​มา​จาก​การ​ปัด​ตาม​ปกติ แต่​มา​จาก​กฎ​พิเศษ​ที่ ANALYZE ใช้​บีบ​ค่า​ของ column ที่​เกือบ​ไม่​ซ้ำ​ลง​มา​เหลือ 1 column แบบ​นั้น​จึง​หน้าตา​เหมือน​ไม่​ซ้ำ​เลย​ใน​สายตา optimizer

index ร่วม​ให้​สถิติ​ร่วม แต่​ไม่​ได้​แปล​ว่า​เดา​ดี​ขึ้น

หัวข้อ​ที่​มีชื่อ​ว่า “index ร่วม​ให้​สถิติ​ร่วม แต่​ไม่​ได้​แปล​ว่า​เดา​ดี​ขึ้น”

index บน (status, restaurant) ทำให้​มี​ตัวเลข​ตัว​ที่​สาม​คือ 2,899 แถว​ต่อ​หนึ่ง​คู่ ซึ่ง​เป็น​สถิติ​ของ​คู่จริงๆ ไม่​ต้อง​คูณ selectivity อีก​แล้ว independence หาย​ไป​จาก​สมการ​ทั้ง​ข้อ

ค่าที่​เรา​คำนวณ​เอง​คือ 2,898.55 ซึ่ง​ก็​คือ n หาร​ด้วย​จำนวน​คู่​ที่​มี​อยู่​จริง​ใน​ข้อมูล

แต่​สิ่ง​ที่​เข้า​มา​แทน independence คือ uniformity บน​คู่ และ​มัน​พัง​คนละ​ที่

ตัว​ประมาณq-error แย่​สุดมัธยฐานคู่​ที่​พลาด​เกิน 2 เท่า
sel(X) คูณ sel(Y)1565.581.0911/70
ตัวเลข​ต่อ​คู่​จาก index ร่วม2899.0035.8160/70

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

คู่ของ​จริงsel คูณ selq-errorต่อ​คู่q-error
Rejected × ครัว​สุขใจ01,565.61565.582,8992899.00
Rejected × ครัว​คุณ​แม่8,126785.610.342,8992.80
Delivered × ครัว​คุณ​แม่2,4269,769.94.032,8991.19
Rejected × ส้มตำ​แซ่บนัว9262,122.62.292,8993.13
Rejected × ข้าว​มัน​ไก่​ประตู​น้ำ8341,856.82.232,8993.48

ผล​รวม​สวน​ความ​คาดหมาย การ​มี​สถิติ​ร่วม​ทำให้​ดี​ขึ้น 2 คู่ และ​แย่​ลง 68 คู่​จาก 70 คู่

ที่​ดี​ขึ้น​คือ​คู่​ที่​ผิด​ปก​ติจริงๆ Rejected × ครัวคุณแม่ ตก​จาก 10.34 เหลือ 2.80 เพราะ​ตัวเลข​ต่อ​คู่​ไม่​ได้​ตั้ง​อยู่​บน​สัดส่วน​ของ Rejected ทั้ง​ตาราง​ที่​ร้าน​นี้​ไม่​ทำ​ตาม

ที่​แย่​ลง​เพราะ​มัน​เป็น​เลข​ตัว​เดียว​ใช้​กับ​ทุก​คู่​เท่า​กัน​หมด มัน​ทิ้ง​ความ​ต่าง​ระหว่าง Delivered ที่​กิน​ตาราง 81.87 % กับ Placed ที่​มี 0.10 % ไป​ทั้งดุ้น ส่วน independence อย่าง​น้อย​ยัง​ใช้​สัดส่วน​ราย column อยู่

ประโยค​ที่​ถูก​จาก​ตาราง​นี้​คือ ตัว​ประมาณ​ที่​ใช้ stat1 อย่าง​เดียว​แย่​กว่า ไม่ใช่ “SQLite ประมาณ​แย่​กว่า” — ตัว​ประมาณ​ที่​เอา​ตัวเลข​ต่อ​คู่​ไป​ใช้​กับ​ทุก​คู่​เป็น​สิ่ง​ที่​เรา​ประกอบ​ขึ้น​เอง​จาก​สิ่ง​ที่ stat1 มี ไม่ใช่​วิธี​ที่ SQLite ใช้​จริง

และ​หัวข้อ​ถัด​ไป​คือ​เหตุผล​ว่า​ทำไม​มัน​ถึง​ไม่ใช่

sqlite_stat4 — ชั้น​ที่​สอง​ที่​เก็บ​จำนวน​แถว​จริง ไม่ใช่​ค่า​เฉลี่ย

หัวข้อ​ที่​มีชื่อ​ว่า “sqlite_stat4 — ชั้น​ที่​สอง​ที่​เก็บ​จำนวน​แถว​จริง ไม่ใช่​ค่า​เฉลี่ย”

build ของ SQLite ที่​ใช้​วัด​บท​นี้​คอมไพล์​ด้วย SQLITE_ENABLE_STAT4 จึง​มี​ตาราง sqlite_stat4 เพิ่ม​มา​อีก​ตาราง มัน​เก็บ nEq คือ​จำนวน​แถว​จริง​ของ​ค่าที่​ถูก​เก็บ​ไว้​เป็น​ตัวอย่าง

qe/stats.py
def stat4(con, idx, ncols):
"""คืน {ค่าที่ถูกเก็บเป็นตัวอย่าง: nEq} ของ index หนึ่ง
nEq คือจำนวนแถวจริงของค่านั้น ไม่ใช่ค่าเฉลี่ย — นี่คือ histogram ตัวจริงของ SQLite"""
out = {}
for neq, sample in con.execute(
"SELECT neq, sample FROM sqlite_stat4 WHERE idx = ? ORDER BY rowid", (idx,)):
out[tuple(sample_cols(sample, ncols))] = int(neq.split()[ncols - 1])
return out

ค่าตัวอย่าง​ถูก​เก็บ​เป็น blob ตาม​รูปแบบ record ของ SQLite การ​อ่าน​มัน​ออก​มา​จึง​ต้อง​ถอดรูปแบบ​นั้น​เอง ซึ่ง qe/stats.py ทำ​ด้วย stdlib ล้วน​ผ่าน​ฟังก์ชัน varint กับ sample_cols ที่​ไม่​ได้​ยก​มา​แสดง​ตรง​นี้​เพราะ​ยาว

ANALYZE เก็บ​ตัวอย่าง​ไว้ 97 แถว​สำหรับ 5 index รวม​กัน

indexจำนวน​ตัวอย่าง
idx_customer24
idx_placed_at24
idx_restaurant11
idx_status14
idx_status_rest24

ตัวอย่าง​นับ​เป็น​แถว​ใน sqlite_stat4 ไม่ใช่​จำนวน​ค่าที่​ต่าง​กัน ค่า​เดียวกัน​จึง​ติดตัวอย่าง​ได้​มากกว่า​หนึ่ง​ครั้ง ตัวอย่าง 14 แถว​ของ idx_status เป็น​ค่าที่​ต่าง​กัน​เพียง 7 ค่า

idx_status ติดตัวอย่าง​ครบ​ทั้ง 7 ค่า​จาก 7 ค่า และ nEq ตรง​กับ​ของ​จริง​ทุก​ค่า q-error จึง​เป็น 1.00 ทั้ง​เจ็ด​ค่า ความ​เบ้​ที่​ทำให้ uniformity พลาด​ไป 143.58 เท่า หาย​ไป​หมด​ตรง​นี้

idx_status_rest ติดตัวอย่าง 24 คู่​จาก 69 คู่​ที่​มี​จริง และ​ทั้ง 24 คู่ nEq ตรง​ของ​จริง​เป๊ะ คู่ Rejected × ครัวคุณแม่ ได้ nEq เท่ากับ 8,126 ซึ่ง​เท่ากับ​ของ​จริง

คู่​ที่​ไม่​ติดตัวอย่าง​ตก​กลับ​ไป​ใช้​ค่า​เฉลี่ย​ของ stat1 เหมือน​เดิม และ​คู่ Rejected × ครัวสุขใจ ไม่มี​ทาง​ติดตัวอย่าง​ได้​เลย เพราะ​ไม่มี entry อยู่​ใน index ให้​สุ่ม​เจอ มัน​จึง​ตก​ไป​ใช้​ตัวเลข 2,899 ต่อ​คู่

การ​เก็บ​ตัวอย่าง​แก้​ความ​เบ้​ได้ แต่​แก้​คู่​ที่​ไม่มี​อยู่​จริง​ไม่​ได้

stat4 ลบ​ความ​ผิดพลาด​ที่​เกิด​จาก​ความ​เบ้ออก​ได้​จริง​สำหรับ​ค่าที่​ติดตัวอย่าง จาก 143.58 เท่า​เหลือ 1.00 บน status ทั้ง 7 ค่า

ราคา​ของ​มัน​คือ​สถิติ​ที่​ต้อง​เก็บ​เพิ่ม​และ​งาน​ที่ ANALYZE ต้อง​ทำ​เพิ่ม ไม่ใช่​ของ​ฟรี และ build ที่​ไม่​ได้​เปิด SQLITE_ENABLE_STAT4 จะ​มี​แค่ stat1 ผล​ของ​หัวข้อ​นี้​ก็​จะ​ต่าง​ออก​ไป​ทั้ง​หัวข้อ

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

ค่า​ประมาณ​ที่​พลาด​เพราะ​ความ​เบ้​กับ​ค่า​ประมาณ​ที่​พลาด​เพราะ​คู่​ไม่มี​อยู่​จริง จึง​เป็น​ความ​ผิด​คนละ​ชนิด และ​แก้​ด้วย​เครื่องมือ​คนละ​อัน

ตัวเลข​ของ SQLite ใน​หัวข้อ​นี้​วัด​จาก 3.53.4 ซึ่ง​เป็น​ตัว​ที่มา​กับ Python ของ​เครื่อง​ที่​ใช้​วัด ส่วน​คำ​อธิบาย​รูปแบบ​ของ stat1 อ้าง​จาก​ซอร์ส 3.51.0

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

ตัว​ประมาณ​สาม​แบบ​ใน​บท​นี้​พัง​คนละ​แบบ และ​ไม่มี​แบบ​ไหน​พัง​ตลอด​เวลา

  • uniformity พลาด​หนัก​ตรง​ค่าที่​หา​ยาก​ของ column ที่​เบ้ และ​แม่น​มาก​บน column ที่​กระจาย​เท่า​กัน ทั้ง​สอง​อย่าง​เกิด​ใน​ตาราง​เดียวกัน
  • independence ให้​ค่า​กลาง​ที่​ดี แต่​พัง​หนัก​ที่สุด​กับ​คู่​ที่​ไม่มี​อยู่​จริง ซึ่ง​เป็น​ความ​ผิด​ที่​การ​เก็บ​ตัวอย่าง​ช่วย​ไม่​ได้
  • histogram แลก​ความ​แม่น​ตรง​จุด​ที่​ข้อมูล​หนา กับ​ความ​แม่น​ตรง​หาง​ที่​ข้อมูล​บาง และ​การ​เลือก​วิธี​แบ่ง bucket คือ​การ​เลือก​ว่า​จะ​ยอม​พลาด​ตรง​ไหน
flowchart TD
    Q["เงื่อนไขบน column เดียว"] --> U["uniformity<br/>n หารด้วยจำนวนค่าที่ไม่ซ้ำ"]
    Q --> H["histogram<br/>แถวใน bucket หารด้วยจำนวนค่าที่ต่างกันใน bucket"]
    P["เงื่อนไขบนสอง column พร้อมกัน"] --> I["independence<br/>sel ของ X คูณ sel ของ Y คูณ n"]
    U --> UQ["status แย่สุด 143.58<br/>customer_id แย่สุด 1.37"]
    H --> HQ["equi-width แย่สุด 13.97<br/>equi-depth แย่สุด 54.96"]
    I --> IQ["แย่สุด 1565.58 ที่คู่ซึ่งของจริง 0 แถว<br/>เลขจากการยกพื้น ไม่ใช่ผลวัด<br/>มัธยฐาน 1.09"]
    UQ --> R["จำนวนแถวที่ประมาณได้"]
    HQ --> R
    IQ --> R
    R --> C["ส่งต่อให้บทถัดไปเอาไปคิดราคาของแผน"]

คำ​บรรยาย​ภาพ: ทาง​เดิน​ของ​ค่า​ประมาณ​หนึ่ง​ค่า จาก​เงื่อนไข​ไป​จนถึง​จำนวน​แถว​ที่ planner เชื่อ · เงื่อนไข​บน column เดียว​เลือก​ได้​ระหว่าง uniformity กับ histogram ส่วน​เงื่อนไข​บน​สอง column พร้อม​กัน​ใช้​การ​คูณ selectivity · กล่อง​ที่​ติดตัวเลข​คือ q-error ที่​บท​นี้​วัด​ได้​ของ​แต่ละ​เส้นทาง · ทุก​เส้นทาง​ไป​จบ​ที่​จำนวน​แถว​ประมาณ​ตัว​เดียว ซึ่ง​บท​ถัด​ไป​เอา​ไป​คิด​ราคา

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

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

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


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Access Path Selection in a Relational Database Management System — Selinger et al., Proc. 1979 ACM SIGMOD, หน้า 23–34 (ตรวจ PDF แล้ว 2026-08-11) — TABLE 1 หน้า 26 คือ​ที่มา​ของ​ค่า 1/ICARD, 1/10, 1/3 และ 1/4 พร้อม​เงื่อนไข​ของ​แต่ละ​ค่า และ​เป็น​ที่มา​ของ​สอง​ประโยค​ที่​ยก​มา​ตรง​ตัว​ใน​หัวข้อ​ที่​สอง​ของ​บท​นี้
  • SQLite File Format — หัวข้อ The sqlite_stat1 table (ตรวจ 2026-08-11) — รูปแบบ​ของ stat ที่ ANALYZE เขียน ตรวจ​คู่​กับ​ซอร์ส src/analyze.c บรรทัด 20 และ 41–65 ของ SQLite 3.51.0 ส่วน​ผล​ที่​วัด​ได้​ใน​บท​นี้​มา​จาก SQLite 3.53.4 ที่มา​กับ Python ของ​เครื่อง​ที่​ใช้​วัด

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

ข้อ 1 / 3

uniformity ประมาณ status พลาดไป 143.58 เท่าที่ค่า Placed แต่ประมาณ customer_id พลาดแค่ 1.37 เท่าเป็นอย่างมาก ทั้งที่เป็นตารางเดียวกันและตัวประมาณตัวเดียวกัน อธิบายผลนี้อย่างไรจึงตรง