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

join — สี่​วิธี​จับ​คู่​แถว และ​ความ​ชัน​ของ​แต่ละ​วิธี

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

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

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

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

file ของ​บท​นี้​คือ qe/joins.py ซึ่ง import ของ​จาก​สาม​บท​ก่อนหน้า คือ fixture ของ​บท 1, heapfile ของ​บท 2 และ bptree ของ​บท 3 ทั้ง​สาม file นั้น​ต้อง​อยู่​ใน qe/ ครบ​ก่อน

รัน​จาก folder ทำงาน​ด้าน​นอก​เหมือน​ทุก​บท คือ python3 qe/joins.py โมดูล​จะ​สร้าง​ตาราง​กับ index ของ​ตัวเอง​ใหม่​ทุก​ครั้ง​ลง​ใน .build/ การ​ลบ folder นั้น​ทั้ง​อัน​จึง​ปลอดภัย

code ใน​บท​นี้​ยก​มา​เป็น​ช่วง​จาก qe/joins.py ไม่ใช่​ทั้ง file เพราะ​โมดูล​นี้​ยาว​เกิน​กว่า​จะ​แปะ​ทั้งหมด​โดย​ยัง​อ่าน​รู้เรื่อง

โจทย์​เดียว​ที่​ทั้ง​บท​ใช้ และ​หน่วย​ที่​ใช้​วัด​มัน

หัวข้อ​ที่​มีชื่อ​ว่า “โจทย์​เดียว​ที่​ทั้ง​บท​ใช้ และ​หน่วย​ที่​ใช้​วัด​มัน”

ตาราง​ที่​ทั้ง​บท​ใช้ ซึ่ง​เขียน​ลง​หน้า 4096 byte จริง index ที่​เตรียม​ไว้​ล่วงหน้า เฉลย​ที่​ใช้​ตรวจ​ทั้ง​สี่​วิธี และ​เหตุผล​ที่​หน่วย​เดียว​ไม่​พอ​กับ​บท​นี้

หน่วย​ของ​บท​นี้​มี​สาม​หน่วย และ​ไม่มี​หน่วย​เวลา​สัก​หน่วย​เดียว

  • อ่าน​หนึ่ง​หน้า 4096 byte เท่ากับ 1 ครั้ง หน่วย​เดียว​กับ​ที่​บท 2 ตกลง​ไว้
  • เทียบ​คีย์​หนึ่ง​คู่ เท่ากับ 1 ครั้ง หยิบ​คีย์​สอง​ตัว​มา​เทียบ​กัน​หนึ่ง​ครั้ง​นับ​หนึ่ง
  • จำนวน​แถว​ที่​ต้อง​ถือ​ไว้​พร้อม​กัน ราคา​ที่​ไม่​ได้​จ่าย​เป็น I/O แต่​จ่าย​เป็น​หน่วย​ความ​จำ

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

หน่วย​ของ​ต้นทุน​คือ​ค่า​อ่าน​หน้า​แบบ​เรียง​หนึ่ง​หน้า = 1.0 ตาม​ที่​บท 5 ตั้ง​ไว้ ส่วน​บท​นี้​รายงาน​จำนวน​หน้าที่​อ่าน​ดิบๆ ไม่​ได้​แปลง​เป็นต้นทุน​ของ cost model

ตาราง​ที่​บท​นี้​ใช้​ถูก​เขียน​ลง​หน้า slotted-page จริง​แบบ​บท 2 ไม่ใช่​รายการ​ที่​ลอย​อยู่​ใน​หน่วย​ความ​จำ

ตารางแถวหน้าแถว​ต่อ​หน้าบทบาท
orders200,0003,15363.4ตาราง​หลัก​ของ​คอร์ส
customers2,00016125.0ตาราง​ที่​สอง สร้าง​จาก seed เดียวกัน
orders_day1,0171759.8ออเดอร์​ของ​วัน​ที่ 90 materialise เป็น​ตาราง​จริง

ยัง​มี​ตาราง​ที่​สี่​คือ​ร้าน 10 ร้าน ซึ่ง​โผล่​เฉพาะ​หัวข้อ​สุดท้าย​เรื่อง​ผลลัพธ์​กลาง​ทาง จึง​ไม่​อยู่​ใน​ตาราง​ข้าง​บน

index สาม​อัน​ถูก bulk load เตรียม​ไว้​ให้ index nested loop ใช้

indexหน้าความ​สูงรายการ
customers(id)72 ชั้น2,000
orders_day(customer_id)52 ชั้น1,017
orders(customer_id)4932 ชั้น200,000

โจทย์​หลัก​คือ orders_day ⋈ customers บน​เงื่อนไข orders.customer_id = customers.id

เฉลย​คือ 1,017 คู่ ซึ่ง​คำนวณ​จาก​รายการ​ใน​หน่วย​ความ​จำ​โดย​ไม่​ผ่าน​วิธี​ใด​เลย ทั้ง​สี่​วิธี​ต้อง​ได้​ตัวเลข​นี้​ตรง​กัน ไม่​งั้น​การ​เอา​ราคา​มา​เทียบ​กัน​ก็​ไม่มี​ความหมาย

ลูกค้า​ทุก id ใน​ช่วง 1 ถึง 2,000 มี​อยู่​จริง ออเดอร์​หนึ่ง​แถว​จึง​จับ​คู่​ได้ 1 คู่​พอดี จำนวน​แถว​ของ​ผลลัพธ์​จึง​เท่ากับ​จำนวน​แถว​ของ orders_day เสมอ ไม่​ว่า​จะ​ให้​ตาราง​ไหน​เป็น​ตัว​นอก

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

ตาราง customers ไม่​ได้​มา​จาก fixture.py แต่​เป็น​ข้อมูล​ของ​โมดูล​นี้​เอง

qe/joins.py
# ── ตารางที่สอง: ลูกค้า 2,000 คน (id เดินตั้งแต่ 1 ถึง N ตรงกับ customer_id ของ orders) ──
CUST_SEED = fixture.SEED + 1 # แยก stream จาก orders แต่ยังล็อกกับ SEED เดิม
CITIES = ["กรุงเทพ", "นนทบุรี", "ปทุมธานี", "สมุทรปราการ",
"เชียงใหม่", "ขอนแก่น", "ภูเก็ต", "ชลบุรี"]
TIERS = ["bronze", "silver", "gold"]
TIER_W = [60, 30, 10]
FIRST_NAMES = ["สมชาย", "สมหญิง", "อนันต์", "ปรีชา", "วิภา", "ณัฐพล",
"กมล", "ธนา", "รัตนา", "สุดา", "ชัยวัฒน์", "พิมล"]
CUST_FIXED = struct.Struct("<IBBH") # id, city, tier, len(name) = 8 B
# ── ตารางที่สาม: ร้าน 10 ร้าน (ใช้แค่ในหัวข้อ [6] เรื่องผลลัพธ์กลางทาง) ──
AREAS = ["ในเมือง", "ชานเมือง", "ริมทางด่วน"]
def customers(n=fixture.N_CUSTOMERS, seed=CUST_SEED):
"""แถวของตารางลูกค้า — deterministic เหมือน fixture.rows()"""
rng = random.Random(seed)
out = []
for cid in range(1, n + 1):
name = f"{rng.choice(FIRST_NAMES)} {cid:04d}"
out.append((cid, name, rng.randrange(len(CITIES)),
rng.choices(range(len(TIERS)), weights=TIER_W)[0]))
return out
def encode_customer(cid, name, city, tier):
b = name.encode()
return CUST_FIXED.pack(cid, city, tier, len(b)) + b

CUST_SEED = fixture.SEED + 1 แยก​ลำดับ​ตัว​สุ่ม​ออก​จาก orders แต่​ยัง​ล็อก​ไว้​กับ seed เดิม ตาราง​ลูกค้า​จึง​ออก​มา​เหมือน​กัน​ทุก​เครื่อง​ด้วย​เหตุผล​เดียว​กับ​บท 1

บท​อื่น​ที่​ต้อง​ใช้​ตาราง​ลูกค้า​ต้อง import customers ตัว​นี้ ไม่ใช่​สร้าง​ขึ้น​มา​ใหม่​เอง ไม่​งั้น​ตัวเลข​จะ​ไม่​ตรง​กัน​ข้าม​บท

ขอบเขต​ของ​ตัวเลข​ทุก​ตัว​ใน​บท​นี้

โจทย์​ทั้ง​บท​เป็น join แบบ 1:N ที่​ออเดอร์​ทุก​แถว​จับ​คู่​ได้​ครบ ส่วน​ฝั่ง​ลูกค้า​มี​แถว​ที่​ไม่มี​คู่​อยู่​ด้วย ซึ่ง​ไม่​โผล่​ใน​ผล​เพราะ​ไม่มี outer join และ​ไม่มี many-to-many ตัวเลข​ที่​วัด​ได้​จึง​เป็น​ตัวเลข​ของ​รูปแบบ​นี้​รูปแบบ​เดียว

hash join ที่​นี่​สมมุติ​ว่า​ฝั่ง build ใส่​หน่วย​ความ​จำ​ได้​หมด ไม่มี​การ​แบ่ง partition ของ​จริง​ที่​ฝั่ง build ใหญ่​เกิน​หน่วย​ความ​จำ​ต้อง​แบ่ง partition แล้ว​จ่าย​ค่า​เขียน​กับ​อ่าน​กลับ​เพิ่ม​อีก

จำนวน bucket ที่​ตั้ง​ไว้ 1,024 เป็น​ค่าที่​เรา​เลือก​เอง มัน​เปลี่ยน​จำนวน​ครั้ง​ที่​เทียบ​คีย์ แต่​ไม่​เปลี่ยน​หน้าที่​อ่าน​เลย​สัก​หน้า

และ​เหมือน​ทุก​บท​ที่​ผ่าน​มา ยัง​ไม่มี buffer pool หนึ่ง rid เท่ากับ​อ่าน​หนึ่ง​หน้า​เสมอ ตัวเลข​หน้าที่​อ่าน​ทุก​ตัว​จึง​เป็น​กรณี​ที่ cache อะไร​ไม่​ได้​เลย

ผล​ของ​ทั้ง​สี่​วิธี​ใน​สาม​หน่วย ที่มา​ของ​ทุก​ตัวเลข​หน้าที่​อ่าน และ code ที่​ผลิต​ตัวเลข​เหล่า​นั้น

ตาราง​นอก​คือ orders_day ตาราง​ใน​คือ customers และ​ทั้ง​สี่​วิธี​ได้ 1,017 คู่​เท่า​กัน​หมด

วิธีหน้าที่​อ่านเทียบ​คีย์ถือ​ไว้ (แถว)คู่​ที่​ได้
nested loop (ที​ละ​แถว)16,2892,034,00011,017
block nested loop (B = 8 หน้า)652,034,0005071,017
index nested loop3,07013,37411,017
hash join332,3562,0001,017

ตัวเลข​หน้าที่​อ่าน​ทุก​ตัว​ใน​ตาราง​นั้น​ตาม​ที่มา​ได้ที​ละ​บรรทัด

  • nested loop เท่ากับ 17 (สแกน​ตาราง​นอก) + 1,017 แถว × 16 หน้า = 16,289
  • block nested loop เท่ากับ 17 + 3 block × 16 หน้า = 65
  • index nested loop เท่ากับ 17 + 1,017 แถว × (2 หน้า index + 1 หน้า heap) = 3,068 ส่วน​ที่​วัด​ได้​จริง​คือ 3,070
  • hash join เท่ากับ 16 (build จาก customers) + 17 (probe ด้วย orders_day) = 33

ส่วน​ต่าง 2 หน้า​ของ index nested loop คือ​คีย์​ที่นั่ง​อยู่​ท้าย​ใบ​พอดี ต้อง​เปิด​ใบ​ถัด​ไป​ดู​ว่า​คีย์​ยัง​ซ้ำ​อยู่​หรือ​หมด​แล้ว

nested-loop joinnested-loop joinการ​จับ​คู่​แถว​ด้วย​ลูป​ซ้อน​สอง​ชั้น​ที​ละ​แถว ตรง​ไป​ตรง​มา​ที่สุด​และ​แพง​ที่สุด เพราะ​ตาราง​ใน​ถูก​อ่าน​ใหม่​ทุก​แถว​ของ​ตาราง​นอก คือ​ลูป​ซ้อน​สอง​ชั้น​ที่​ตรง​ไป​ตรง​มา​ที่สุด หนึ่ง​แถว​ของ​ตาราง​นอก​เท่ากับ​สแกน​ตาราง​ใน​ทั้ง​ตาราง​หนึ่ง​รอบ

qe/joins.py
def nested_loop(outer, inner):
"""หนึ่งแถวของตารางนอก = สแกนตารางในทั้งตารางหนึ่งรอบ
ไม่มีอะไรค้างในหน่วยความจำเลย (ถือไว้ทีละแถว) และนั่นคือเหตุผลที่มันแพง:
ตารางในถูกอ่านซ้ำเท่ากับจำนวนแถวของตารางนอก
"""
_reset(outer, inner)
cmps = nres = so = si = 0
for ob in outer.scan():
ok, oid = outer.key(ob), outer.rowid(ob)
for ib in inner.scan():
cmps += 1
if inner.key(ib) == ok:
nres += 1
so += oid
si += inner.rowid(ib)
return Res(_reads(outer, inner), cmps, 1, nres, so, si)

ไม่มี​อะไร​ค้าง​ใน​หน่วย​ความ​จำเลย ถือ​ไว้​ที​ละ​แถว และ​นั่น​คือ​ทั้ง​ข้อดี​ข้อ​เดียว​และ​เหตุผล​ที่​มัน​แพง ตาราง​ใน​ถูก​อ่าน​ซ้ำ 1,017 รอบ เท่ากับ​จำนวน​แถว​ของ​ตาราง​นอก​พอดี

block nested loopblock nested loopnested-loop join ที่​วน​ลูปนอก​ที​ละ​บล็อก​ของ​หน้า​แทน​ที​ละ​แถว ลด​จำนวน​หน้าที่​อ่าน​ลง​มาก โดย​จำนวน​ครั้ง​ที่​เทียบ​คีย์​เท่า​เดิม​ทุก​ประการ เปลี่ยน​ข้อ​เดียว คือ​ลูปนอกวน​ที​ละ block ของ​หน้า ไม่ใช่​ที​ละ​แถว

qe/joins.py
def block_nested_loop(outer, inner, block_pages):
"""อ่านตารางนอกทีละ block_pages หน้า เก็บไว้ในแรม แล้วสแกนตารางในหนึ่งรอบต่อบล็อก
เปลี่ยนแค่ "ใครถูกอ่านซ้ำ" — ตารางในถูกอ่านซ้ำเท่ากับจำนวนบล็อก ไม่ใช่จำนวนแถว
จำนวนครั้งที่เทียบคีย์ยังเท่าเดิมทุกประการ: |นอก| x |ใน|
"""
_reset(outer, inner)
cmps = nres = so = si = 0
mem = 0
for start in range(0, outer.npages, block_pages):
block = []
for pno in range(start, min(start + block_pages, outer.npages)):
page = outer.pager.read_page(pno)
for s in range(nslots(page)):
b = read_slot(page, s)
block.append((outer.key(b), outer.rowid(b)))
mem = max(mem, len(block))
for ib in inner.scan():
ik, iid = inner.key(ib), inner.rowid(ib)
for ok, oid in block:
cmps += 1
if ok == ik:
nres += 1
so += oid
si += iid
return Res(_reads(outer, inner), cmps, mem, nres, so, si)

ตาราง​ใน​ถูก​อ่าน​ซ้ำ​เท่ากับ​จำนวน block คือ 3 รอบ ไม่ใช่ 1,017 รอบ หน้าที่​อ่าน​จึง​ลด​จาก 16,289 เหลือ 65 หรือ​ต่าง​กัน 250.6 เท่า

จำนวน​ครั้ง​ที่​เทียบ​คีย์​ไม่​ขยับ​เลย​สัก​ครั้ง อยู่​ที่ 2,034,000 ครั้ง​เท่า​กัน​ทั้ง​คู่ ซึ่ง​เท่ากับ 1,017 × 2,000 พอดี block แก้​ที่ I/O ล้วน ไม่​แตะ CPU

ราคา​ที่​จ่าย​แทน​คือ 507 แถว​ที่​ต้อง​ถือ​ไว้​พร้อม​กัน เทียบ​กับ 1 แถว​ของ​วิธี​แรก

index nested loopindex nested loopnested-loop join ที่​ใช้ index ของ​ตาราง​ใน​ค้น​คู่​ของ​แต่ละ​แถว​จาก​ตาราง​นอก แทน​การ​อ่าน​ตาราง​ใน​ทั้ง​ตาราง คุ้ม​เมื่อ​ตาราง​นอก​เล็ก ไม่​สแกน​ตาราง​ใน​อีก​ต่อ​ไป แต่​ถาม index ของ​ตาราง​ใน​ที​ละ​แถว​นอก โดย​ต้อง​มี index อยู่​ก่อน​บน column ที่​ใช้ join

qe/joins.py
def index_probe(rd, key):
"""ลงจากรากถึงใบแล้วเก็บ rid ทุกตัวที่คีย์ตรง — คืน (rids, จำนวนครั้งที่เทียบคีย์)
เขียนซ้ำแทนที่จะเรียก rd.range() ของบท 3 เพราะบทนี้ต้องนับ "ครั้งที่เทียบคีย์"
ด้วย ไม่ใช่แค่หน้าที่อ่าน (การค้นทวิภาคในโหนดก็คือการเทียบคีย์เหมือนกัน)
"""
pno, cmps = rd.root, 0
while True:
page = rd.pager.read_page(pno)
is_leaf, n, nxt = header(page)
if is_leaf:
break
lo, hi = 0, n
while lo < hi:
mid = (lo + hi) // 2
cmps += 1
if inner_key(page, mid) < key:
lo = mid + 1
else:
hi = mid
pno = inner_child(page, lo)
lo, hi = 0, n
while lo < hi:
mid = (lo + hi) // 2
cmps += 1
if leaf_key(page, mid) < key:
lo = mid + 1
else:
hi = mid
out, i = [], lo
while True:
while i < n:
cmps += 1
if leaf_key(page, i) != key:
return out, cmps
out.append(leaf_rid(page, i))
i += 1
if nxt == NO_NEXT:
return out, cmps
page = rd.pager.read_page(nxt)
_, n, nxt = header(page)
i = 0

index_probe เขียน​ขึ้น​ใหม่​แทน​การ​เรียก​ฟังก์ชัน​ของ​บท 3 เพราะ​บท​นี้​ต้อง​นับ​ครั้ง​ที่​เทียบ​คีย์​ด้วย ไม่ใช่​แค่​หน้าที่​อ่าน การ​ค้น​แบบ binary search ภายใน​หนึ่ง​หน้า​ก็​คือ​การ​เทียบ​คีย์​เหมือน​กัน

การ​วางตัว​นับ​ไว้​ใน joins.py แทน​การ​ไป​เพิ่ม​ใน bptree.py ทำให้​ของ​ที่​ถูกวัด​ไม่​เปลี่ยน​รูป ตัวเลข​หน้าที่​อ่าน​ของ​บท 3 จึง​ยัง​เทียบ​กับ​บท​นี้ได้ตรงๆ

ผล​คือ​เทียบ​คีย์ 13,374 ครั้ง หรือ 13.2 ครั้ง​ต่อ​หนึ่ง​แถว​นอก น้อย​กว่า nested loop 152 เท่า เพราะ​มัน​ไม่​ต้อง​ไล่​เทียบ​แถว​นอก​หนึ่ง​แถว​กับ​แถว​ใน​ทั้ง 2,000 แถว

13.2 ครั้ง​ที่​เหลือ​คือ​การ​ค้น​แบบ binary search ใน node ที่​เดิน​ผ่าน ซึ่ง​ส่วน​ใหญ่​ยัง​เป็นการ​เทียบ​กับ​คีย์​ที่​ไม่​ตรง ราคา​ของ​มัน​โต​แบบ log ตาม​จำนวน​แถว​ใน ไม่ใช่​แบบ​เชิง​เส้น

ราคา​ที่​จ่าย​อยู่​ที่​หน้าที่​อ่าน 3,070 หน้า เพราะ​ทุก​แถว​นอก​ต้อง​เดิน​ลง​จากราก​ใหม่​ทุก​ครั้ง

hash joinhash joinการ join ด้วย​การ​สร้าง​ตาราง​แฮช​จาก​ตาราง​ฝั่ง​หนึ่ง​ไว้​ใน​หน่วย​ความ​จำ​ก่อน แล้ว​เอา​แถว​ของ​อีก​ฝั่ง​มา​ค้น อ่าน​แต่ละ​ตาราง​รอบ​เดียว แลก​กับ​แรม​ที่​ต้อง​ถือ​ฝั่ง​ที่​สร้าง​ไว้ สร้าง​ตาราง hash จาก​ตาราง​ใน​ไว้​ใน​หน่วย​ความ​จำ​ก่อน แล้ว​เอา​แถว​ของ​ตาราง​นอก​มา​ค้น

qe/joins.py
HASH_C = 2654435761
"""ค่าคงที่ทองของ Knuth (ราว 2^32 หารด้วยอัตราส่วนทอง)
ใช้สูตรตายตัวแทน hash() ของ Python เพราะ hash() ของสตริงขึ้นกับ PYTHONHASHSEED
และคอร์สนี้ต้องรันซ้ำได้ stdout เท่าเดิมเป๊ะ
"""
def hbucket(k, nbuckets):
"""คีย์ -> หมายเลขถัง (เลื่อนบิตทิ้งก่อนเพื่อให้บิตสูงมามีส่วนในการกระจาย)"""
return ((k * HASH_C) >> 11) % nbuckets
def hash_join(outer, inner, nbuckets):
"""สร้างตารางแฮชจากตารางใน (build) แล้วยิงตารางนอกเข้าไปหา (probe)
ทั้งสองตารางถูกอ่านคนละหนึ่งรอบพอดี ไม่มีการอ่านซ้ำ — ราคาของ I/O จึงต่ำสุด
ราคาที่จ่ายแทนคือหน่วยความจำ: ทุกแถวของฝั่ง build ต้องอยู่ในแรมพร้อมกัน
"""
_reset(outer, inner)
buckets = [[] for _ in range(nbuckets)]
for ib in inner.scan():
k = inner.key(ib)
buckets[hbucket(k, nbuckets)].append((k, inner.rowid(ib)))
cmps = nres = so = si = 0
for ob in outer.scan():
ok, oid = outer.key(ob), outer.rowid(ob)
for bk, bid in buckets[hbucket(ok, nbuckets)]:
cmps += 1
if bk == ok:
nres += 1
so += oid
si += bid
return Res(_reads(outer, inner), cmps, inner.nrows, nres, so, si)

HASH_C ไม่​ได้​เลือก​มาลอยๆ การ​ใช้ hash() ของ Python กับ​สตริง​ให้​ผล​ไม่​เท่า​กัน​ข้าม​การ​รัน​เพราะ​ขึ้น​กับ PYTHONHASHSEED ซึ่ง​ขัด​กับ​สัญญา​เรื่อง​การ​รัน​ซ้ำ​แล้ว​ได้​เลข​เดิม​ของ​ทั้ง​คอร์ส

ตาราง hash 1,024 bucket กับ​คีย์ 2,000 ตัว ใช้​จริง 963 bucket ยาว​ที่สุด 3 รายการ และ​เฉลี่ย 1.95 รายการ​ต่อ bucket เมื่อ​หาร​ด้วย bucket ทั้ง 1,024 ใบ ไม่ใช่​หาร​ด้วย​เฉพาะ​ใบ​ที่​ถูก​ใช้

จำนวน​ครั้ง​ที่​เทียบ​คีย์​จึง​เป็น 2,356 ครั้ง หรือ 2.32 ครั้ง​ต่อ​หนึ่ง probe ไม่ใช่ 1.00 พอดี เพราะ bucket หนึ่ง​ใบ​มี​คีย์​ที่​ไม่​ตรง​ปน​อยู่​ด้วย นี่​คือ​ราคา​ของ​การ​ชน​กัน

hash join อ่าน​น้อย​ที่สุด​คือ 33 หน้า แลก​กับ​การ​ถือ 2,000 แถว​ไว้​ใน​หน่วย​ความ​จำ​พร้อม​กัน ส่วน​อีก​สาม​วิธี​ถือ​ไว้​ระหว่าง 1 ถึง 507 แถว

flowchart LR
    Q["โจทย์เดียวกัน<br/>ตารางนอก 1,017 แถว 17 หน้า<br/>ตารางใน 2,000 แถว 16 หน้า"]
    Q --> A1["nested loop ทีละแถว<br/>วนตารางในใหม่ทุกแถวนอก รวม 1,017 รอบ"]
    A1 --> A2["อ่าน 16,289 หน้า<br/>เทียบคีย์ 2,034,000 ครั้ง<br/>ถือไว้ 1 แถว"]
    Q --> B1["block nested loop<br/>วนตารางในใหม่ทุก block รวม 3 รอบ"]
    B1 --> B2["อ่าน 65 หน้า<br/>เทียบคีย์ 2,034,000 ครั้ง<br/>ถือไว้ 507 แถว"]
    Q --> C1["index nested loop<br/>ถาม index ของตารางในทีละแถวนอก"]
    C1 --> C2["อ่าน 3,070 หน้า<br/>เทียบคีย์ 13,374 ครั้ง<br/>ถือไว้ 1 แถว"]
    Q --> D1["hash join<br/>อ่านสองตารางคนละรอบเดียว"]
    D1 --> D2["อ่าน 33 หน้า<br/>เทียบคีย์ 2,356 ครั้ง<br/>ถือไว้ 2,000 แถว"]

คำ​บรรยาย​ภาพ: โจทย์​เดียวกัน​และ​คำ​ตอบ​ชุด​เดียวกัน ต่าง​กัน​แค่​รูปแบบ​กา​รวน​ลูป · เส้น​บน​สุด​วน​ตาราง​ใน​ใหม่​ทุก​แถว​นอก เส้น​ถัด​มา​วน​ใหม่​ทุก block เส้น​ที่​สาม​แทน​การ​สแกน​ด้วย​การ​ถาม index และ​เส้น​ล่าง​สุด​อ่าน​แต่ละ​ตาราง​รอบ​เดียว​จบ · ตัวเลข​สาม​บรรทัด​ใน​กล่อง​ขวา​คือ​สาม​หน่วย​ของ​บท​นี้ ซึ่ง​ไม่​ได้​เรียง​ลำดับ​เหมือน​กัน​ทั้ง​สาม​หน่วย

ขนาด​ของ block และ​คู่​ที่​พิสูจน์​ว่า​หน่วย​เดียว​ไม่​พอ

หัวข้อ​ที่​มีชื่อ​ว่า “ขนาด​ของ block และ​คู่​ที่​พิสูจน์​ว่า​หน่วย​เดียว​ไม่​พอ”

ผล​ของ​การ​ขยาย block จาก 1 หน้า​ถึง 32 หน้า สูตร​ที่​อธิบาย​ทั้ง​แถว จุด​ที่​ผล​ของ block ตัน และ​คู่​ที่​อ่าน​หน้า​เท่า​กัน​เป๊ะ​แต่​เทียบ​คีย์​ต่าง​กัน​หลาย​ร้อย​เท่า

ตาราง​นอก​มี 17 หน้า ตาราง​ใน​มี 16 หน้า เมื่อ​ขยับ block ที​ละ​ขั้น ตัวเลข​ทั้ง​สาม​หน่วย​ขยับ​คนละ​ทิศ

block (หน้า)จำนวน blockหน้าที่​อ่านเทียบ​คีย์ถือ​ไว้ (แถว)
1172892,034,00067
291612,034,000129
45972,034,000256
83652,034,000507
162492,034,0001,006
321332,034,0001,017

แถว​แรก​คือ​แถว​ที่​ได้​เยอะ​ที่สุด แค่​เปลี่ยน​จากวน​ที​ละ​แถว​เป็น​วน​ที​ละ​หน้า หน้าที่​อ่าน​ก็​ลง​จาก 16,289 เหลือ 289 หรือ​ถูก​ลง 56 เท่า ทั้ง​ที่​เทียบ​คีย์​เท่า​เดิม​ทุก​ครั้ง

ส่วน​การ​ขยาย block ต่อ​จาก​นั้น​ทั้งหมด จาก 1 หน้า​ไป​ถึง 32 หน้า พา 289 หน้า​ลง​มา​ที่ 33 หน้า หรือ​ถูก​ลง​อีก 8.8 เท่า น้อย​กว่า​ก้าว​แรก​ก้าว​เดียว​ที่​ถูก​ลง 56 เท่า

แต่​ปลายทาง​ของ​ก้าว​หลัง​ไม่ใช่​ของ​แถม มัน​คือ​จุด​ที่​หน้าที่​อ่าน​ของ block nested loop ไป​ชน​กับ hash join พอดี ซึ่ง​เป็น​คู่​ที่ Callout ท้าย​หัวข้อ​นี้​ยก​มา​เทียบ

สูตร​ที่​ตัวเลข​ทั้ง​แถว​เชื่อฟัง​คือ หน้าที่​อ่าน = P_นอก + ⌈P_นอก / B⌉ × P_ใน ผล​ของ B จึง​ตัน​ที่ B = P_นอก = 17 หน้า เพิ่ม block ต่อ​จาก​นั้น​ไม่​ได้​อะไร​อีก

สูตร​นี้​ใช้ B ตรงๆ ไม่ใช่ B−2 เพราะ Pager ของ​คอร์ส​นี้​ยัง​ไม่มี buffer pool จึง​ไม่​ต้อง​กัน​หน้า​ไว้​ให้​ตาราง​ใน​กับ output ตัวเลข​ที่​ได้​จึง​ต่าง​จาก​ตัวอย่าง​ของ CMU ที่​อ้าง​ไว้​ท้าย​บท​ด้วย​เหตุ​นี้

ราคา​ของ block ที่​ใหญ่​ขึ้น​อ่าน​ได้​จาก column ขวา​สุด แถว​ที่​ต้อง​ถือ​ไว้​พร้อม​กัน​โต​จาก 67 เป็น 1,017 แถว ซึ่ง​ก็​คือ​ทั้ง​ตาราง​นอก

อ่าน​เท่า​กัน​เป๊ะ 33 หน้า แต่​เทียบ​คีย์​ต่าง​กัน 863 เท่า

เมื่อ block ใหญ่​พอ​จะ​ใส่​ตาราง​นอก​ได้​ทั้ง​ตาราง block nested loop อ่าน 33 หน้า เท่ากับ hash join พอดี

แต่​เทียบ​คีย์​ต่าง​กัน 863 เท่า คือ 2,034,000 ครั้ง เทียบ​กับ 2,356 ครั้ง เพราะ block nested loop ยัง​เทียบ​ทุก​แถว​นอก​กับ​ทุก​แถว​ใน ส่วน hash join เทียบ​เฉพาะ​แถว​ที่​ตกลง​มา​ใน bucket เดียวกัน

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

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

join orderjoin orderลำดับ​ที่​แผน​หนึ่ง​เอา​ตาราง​มา​จับ​คู่​กัน ที่​สอง​ตาราง​แทบ​ไม่​ต่าง​กัน แต่​กลาย​เป็น​เรื่อง​ใหญ่​เมื่อ​จำนวน join เพิ่ม เพราะ​ราคา​ของ​มัน​อยู่​ที่​ขนาด​ของ​ผลลัพธ์​กลาง​ทาง ที่​สอง​ตาราง​มี​ให้​เลือก​แค่​สอง​ทาง คือ​ให้ orders_day เป็น​ตัว​นอก หรือ​ให้ customers เป็น​ตัว​นอก ทั้ง​สอง​ทาง​ให้​คำ​ตอบ​ตรง​กัน​ทุก​ครั้ง

หน่วย​แรก หน้าที่​อ่าน

วิธีนอก = orders_dayนอก = customersต่าง​กัน
nested loop (ที​ละ​แถว)16,28934,0162.09 เท่า
block nested loop (B = 8 หน้า)65501.30 เท่า
index nested loop3,0705,0371.64 เท่า
hash join33331.00 เท่า

หน่วย​ที่​สอง ครั้ง​ที่​เทียบ​คีย์

วิธีนอก = orders_dayนอก = customersต่าง​กัน
nested loop (ที​ละ​แถว)2,034,0002,034,0001.00 เท่า
block nested loop (B = 8 หน้า)2,034,0002,034,0001.00 เท่า
index nested loop13,37423,7311.77 เท่า
hash join2,3562,3561.00 เท่า

หน่วย​ที่​สาม แถว​ที่​ต้อง​ถือ​ไว้​พร้อม​กัน

วิธีนอก = orders_dayนอก = customersต่าง​กัน
nested loop (ที​ละ​แถว)111.00 เท่า
block nested loop (B = 8 หน้า)5071,0362.04 เท่า
index nested loop111.00 เท่า
hash join2,0001,0171.97 เท่า

nested loop กับ block nested loop เทียบ​คีย์​เท่า​กัน​เป๊ะ​ทั้ง​สอง​ทาง เพราะ 1,017 คูณ 2,000 กับ 2,000 คูณ 1,017 ให้​ผล​เท่า​กัน ที่​ต่าง​กัน​คือ​หน้าที่​อ่าน และ​ต่าง​แค่ 2.09 เท่ากับ 1.30 เท่า

hash join อ่าน 33 หน้า​เท่า​กัน​เป๊ะ​ทั้ง​สอง​ทาง เพราะ​ยังไง​ก็​อ่าน​แต่ละ​ตาราง​รอบ​เดียว ลำดับ​ของ​มัน​จึง​ไม่​ได้​ไป​โผล่​ที่ I/O

สิ่ง​ที่​ลำดับ​ของ hash join เปลี่ยน​คือ​หน่วย​ความ​จำ 2,000 แถว เทียบ​กับ 1,017 แถว หรือ 1.97 เท่า คำถาม​จริง​ของ​มัน​คือ​ฝั่ง build ใส่​หน่วย​ความ​จำ​ได้​ไหม ไม่ใช่​ว่า​อ่าน​กี่​หน้า

ที่​สอง​ตาราง​นี้​ส่วน​ต่าง 1.97 เท่า​ยัง​ไม่​เจ็บ แต่​ที่​โจทย์​เต็มตาราง​ใน​หัวข้อ​ถัด​ไป สอง​ทาง​ต่าง​กัน 100 เท่า คือ 2,000 แถว​ถ้า build จาก customers และ 200,000 แถว​ถ้า build จาก orders

index nested loop สลับ​ข้าง​ไม่​ได้​ฟรี เพราะ​ต้อง​มี index อีก​อัน​อยู่​ก่อน​บน​อีก​ฝั่ง ทาง​ที่ index อยู่​ฝั่ง customers อ่าน 3,070 หน้า ส่วน​ทาง​กลับ​อ่าน 5,037 หน้า

ทาง​กลับ​แพง​กว่า​เพราะ​ต้อง probe 2,000 ครั้ง​ตาม​จำนวน​แถว​นอก ทั้ง​ที่​ได้​คู่​กลับ​มา​เท่า​เดิม 1,017 คู่

อัตราส่วน​สูงสุด​ต่อ​ต่ำ​สุด​ของ​ทั้ง​สอง​ปัจจัย​บน​โจทย์​เดียวกัน และ​ข้อ​จำกัด​ของ​ตัวเลข​ที่​ได้

ตัวเลข​สอง​ชุด​จาก​สอง​หัวข้อ​ที่​แล้ว วาง​เทียบ​กันตรงๆ ได้​แบบ​นี้

ปัจจัยหน่วยอัตราส่วน สูงสุด/ต่ำ​สุด
วิธี ทั้ง​สี่​ตัวหน้าที่​อ่าน493.6 เท่า (16,289 / 33)
วิธี เฉพาะ​สาม​ตัว​ที่​ใช้​จริงหน้าที่​อ่าน93.0 เท่า (3,070 / 33)
วิธี ทั้ง​สี่​ตัวเทียบ​คีย์863 เท่า (2,034,000 / 2,356)
วิธี เฉพาะ​สาม​ตัว​ที่​ใช้​จริงเทียบ​คีย์863 เท่า เท่า​กัน เพราะ​ตัว​ที่​แพง​ที่สุด​คือ block nested loop ซึ่ง​อยู่​ใน​ทั้ง​สอง​ชุด
วิธี ทั้ง​สี่​ตัวแถว​ที่​ถือ​ไว้2,000 เท่า (2,000 / 1)
ลำดับหน้าที่​อ่าน1.00 ถึง 2.09 เท่า
ลำดับเทียบ​คีย์1.00 ถึง 1.77 เท่า
ลำดับแถว​ที่​ถือ​ไว้1.00 ถึง 2.04 เท่า

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

ข้อ​สรุป​ที่​วัด​ได้​คือ ที่ 2 ตาราง วิธี​วน​ลูปมี​ผล​มากกว่า​ลำดับ 45 เท่า เมื่อ​นับ​จาก​หน้าที่​อ่าน​และ​ตัด nested loop ที​ละ​แถว​ออก ถ้า​นับ​มัน​ด้วย ตัว​คูณ​จะ​เป็น 236 เท่า

ตัวเลข​นี้​มี​ข้อ​จำกัด​สอง​ข้อ​ที่​ต้อง​พูด​พร้อม​กัน มัน​วัด​ที่ 2 ตาราง​เท่านั้น และ​มัน​วัด​ใน model ที่​คิด​เงิน​เฉพาะ​หน้าที่​อ่าน​กับ​ครั้ง​ที่​เทียบ​คีย์

ราคา​ตัว​จริง​ของ​ลำดับ​อยู่​ที่​ขนาด​ของ​ผลลัพธ์​กลาง​ทาง ซึ่ง model นี้​ไม่​คิด​เงิน​ให้​เลย และ​มัน​โต​ตาม​จำนวน​ตาราง​ที่ join หัวข้อ​สุดท้าย​ของ​บท​วัด​ของ​ชิ้น​นั้น​แยก​ต่างหาก

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

โจทย์​เดิม​แต่​เปลี่ยน​ตาราง​นอก​เป็น orders ทั้ง 200,000 แถว join กับ customers 2,000 แถว

วิธีหน้าที่​อ่านเทียบ​คีย์ถือ​ไว้ (แถว)สถานะ
hash join3,169461,1212,000วัด​จริง
index nested loop603,9562,626,3921วัด​จริง
block nested loop (B = 8)9,473400,000,000507จาก​สูตร ไม่​ได้​รัน
nested loop (ที​ละ​แถว)3,203,153400,000,0001จาก​สูตร ไม่​ได้​รัน

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

เหตุผล​ที่​ฐาน​ข้อมูล​ไม่​เลือก​สอง​แบบ​นี้​เมื่อ​ตาราง​ใหญ่​ไม่ใช่​เหตุผล​เดียวกัน nested loop ที​ละ​แถว​แพง​ทั้ง​สอง​หน่วย ส่วน block nested loop อ่าน​แค่ 9,473 หน้า ถูก​กว่า index nested loop ด้วย​ซ้ำ แต่​ยัง​เทียบ​คีย์ 400,000,000 ครั้ง ซึ่ง​เป็น​หน่วย​ที่ hash join ชนะ​มัน

hash join ถูก​กว่า index nested loop 191 เท่า คือ 3,169 หน้า เทียบ​กับ 603,956 หน้า

ที่มา​ของ 603,956 คือ index nested loop ถาม index customers(id) แล้ว​จ่าย​ค่า​เดิน​ลง​จากราก​ใหม่​ทุก​แถว​นอก 200,000 ครั้ง × (2 + 1) หน้า บวก​สแกน orders อีก 3,153 หน้า รวม​เป็น 603,153 ส่วน​ที่​วัด​ได้​เกิน​มา 803 หน้า​คือ​คีย์​ที่นั่ง​ท้าย​ใบ ด้วย​เหตุผล​เดียว​กับ​หัวข้อ​แรก

ส่วน hash join อ่าน​สอง​ตาราง​คนละ​รอบ​เดียว​จบ จำนวน​แถว​นอก​จึง​ไม่​ทำให้ I/O โต​เลย

ตัวเลข 191 เท่า​เป็น​ขอบ​บน ไม่ใช่​ค่า​กลาง

Pager ของ​คอร์ส​นี้​ยัง​ไม่มี buffer pool ทุก​ครั้ง​ที่​ต้องการ​หน้า​หนึ่ง​คือ​เรียก​อ่าน​ใหม่ แม้​เพิ่ง​อ่าน​หน้า​เดียวกัน​ไป​เมื่อ​ครู่

index customers(id) ทั้ง​อัน​มี​แค่ 7 หน้า ของ​จริง​ที่​มี buffer pool มัน​จะ​ค้าง​อยู่​ใน cache ทั้ง​อัน​ตั้งแต่​แถว​นอก​แถว​แรก ค่า​เดิน​ลง​จากราก​ของ​แถว​นอก​ที่​เหลือ​ทั้งหมด​จึง​แทบ​ไม่​ต้อง​แตะ disk เลย

603,956 หน้า​จึง​เป็น​กรณี​แย่​ที่สุด​ที่ cache อะไร​ไม่​ได้​เลย และ 191 เท่า​เป็น​ขอบ​บน​ของ​ช่องว่าง ไม่ใช่​ตัวเลข​ที่​จะ​เห็น​บน​ฐาน​ข้อมูล​จริง

ปลาย​อีก​ด้าน​ของ​โจทย์​ให้​ผลกลับ​ทาง คือ​ถามหา​ออเดอร์​ทั้งหมด​ของ​ลูกค้า id 1 ถึง 10 ซึ่ง​ได้​คำ​ตอบ 1,021 คู่

  • index nested loop คราว​นี้​ถาม index orders(customer_id) แทน อ่าน 1,059 หน้า แยก​เป็น 16 หน้า​สแกน customers บวก index 22 หน้า บวก heap 1,021 หน้า และ​เทียบ​คีย์ 1,209 ครั้ง
  • hash join อ่าน 3,169 หน้า แยก​เป็น build 16 หน้า บวก probe 3,153 หน้า ตัว​กรอง​ไม่​ช่วย​เลย​เพราะ​ยัง​ต้อง​สแกน orders ทั้ง​ตาราง​มา probe อยู่ดี

index nested loop จึง​ถูก​กว่า 3.0 เท่า​ที่​โจทย์​นี้ ทั้ง​ที่​แพ้ 191 เท่า​ที่​โจทย์​เต็มตาราง

สอง​โจทย์​นี้​เปลี่ยน​พร้อม​กัน​สอง​อย่าง คือ​ฝั่ง​ที่​เป็น​ตาราง​นอก​สลับ​ข้าง จาก orders 200,000 แถว มา​เป็น customers ที่​กรอง​เหลือ 10 แถว และ index ที่​ถูก probe ก็​สลับ​ตาม จาก customers(id) 7 หน้า มา​เป็น orders(customer_id) 493 หน้า

ราคา​ของ index nested loop โต​ตาม​จำนวน​แถว​ของ​ฝั่ง​ที่​เป็น​ตัว​นอก คำถาม​ว่า​วิธี​ไหน​ดี​ที่สุด​จึง​ตอบ​ไม่​ได้​ถ้า​ไม่​บอก​ขนาด​ของ​ทั้ง​สอง​ข้าง​มา​ด้วย

ทำไม​ลำดับ​ถึง​กลาย​เป็น​เรื่อง​ใหญ่​เมื่อ​จำนวน​ตาราง​เพิ่ม

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

ผลลัพธ์​กลาง​ทาง​ของ join สามตาราง ผล​ของ​การกรอง​ก่อน ราคา​ที่ model นี้​มอง​ไม่​เห็น และ​จำนวน​ลำดับ​ที่​ต้อง​เลือก​เมื่อ​ตาราง​เพิ่ม

คำถาม​ใหม่​ใช้​สามตาราง คือ​ออเดอร์​ของ​วัน​ที่ 90 ที่​ลูกค้า​ระดับ gold สั่ง​จากร้าน​ครัว​คุณ​แม่ โดย​ขอ​ชื่อ​ลูกค้า​กับ​ทำเล​ของ​ร้าน​มา​ด้วย

เงื่อนไข join มี​สอง​ข้อ คือ orders.customer_id = customers.id กับ orders.restaurant = restaurants.name

ข้อ​ที่​ต้อง​จำ​คือ customers กับ restaurants ไม่มี​เงื่อนไข​ต่อ​กันเอง​เลย ลำดับ​ที่​เอา​สอง​ตาราง​นี้​มา​ชน​กัน​ก่อน​จึง​สร้าง cross product ขึ้น​มา​เอง​โดย​ไม่มี​ใคร​สั่ง

flowchart TD
    S["ตารางฐานก่อนกรอง<br/>ออเดอร์ของวัน 1,017 แถว · ลูกค้า 2,000 แถว · ร้าน 10 แถว"]
    S --> L1["ลำดับ ออเดอร์ ต่อ ลูกค้า ต่อ ร้าน"]
    S --> R1["ลำดับ ลูกค้า ต่อ ร้าน ต่อ ออเดอร์"]
    L1 --> L2["ขั้นที่ 1 สองตารางมีเงื่อนไขต่อกัน<br/>ได้ของกลางทาง 1,017 แถว"]
    L2 --> L3["ขั้นที่ 2 ได้ 1,017 แถว<br/>ของกลางทางใหญ่สุด 1,017 แถว"]
    R1 --> R2["ขั้นที่ 1 ลูกค้ากับร้านไม่มีเงื่อนไขต่อกัน<br/>ได้ cross product 20,000 แถว"]
    R2 --> R3["ขั้นที่ 2 ได้ 1,017 แถว<br/>ของกลางทางใหญ่สุด 20,000 แถว"]
    L3 --> E["ผลสุดท้ายเป็นชุดเดียวกันเป๊ะทั้งสองลำดับ<br/>ต่างกันแค่ราคา 19.7 เท่า"]
    R3 --> E

คำ​บรรยาย​ภาพ: สอง​ลำดับ​ของ join สามตาราง​ที่​ให้​คำ​ตอบ​ชุด​เดียวกันเป๊ะ · เส้น​ซ้าย​เริ่ม​จาก​คู่​ที่​มี​เงื่อนไข​ต่อ​กัน ของกลาง​ทาง​จึง​ไม่​โต​เกิน 1,017 แถว · เส้น​ขวา​เริ่ม​จาก​ลูกค้า​กับ​ร้าน​ซึ่ง​ไม่มี​เงื่อนไข​ต่อ​กัน​เลย ผล​ของ​ขั้น​แรก​จึง​เป็น cross product 2,000 × 10 = 20,000 แถว · ลำดับ​ไม่​เปลี่ยน​คำ​ตอบ มัน​เปลี่ยน​แค่​ราคา

หก​ลำดับ​ที่​เป็น​ไป​ได้​ถูกวัด​สอง​ครั้ง ครั้ง​แรก​กรอง​ที่​ตาราง​ฐาน​ก่อน​แล้ว​ค่อย join ครั้ง​ที่​สอง join ให้​ครบ​ก่อน​แล้ว​ค่อย​กรอง​ตอน​ท้าย

หลัง​กรอง​ที่​ตาราง​ฐาน เหลือ​ออเดอร์​ของ​วัน 1,017 แถว ลูกค้า​ระดับ gold 209 คน และ​ร้าน​เป้าหมาย 1 ร้าน ตัวเลข​ใน​ตาราง​ข้าง​ล่าง​สืบ​กลับ​มา​ที่​สาม​จำนวน​นี้​ได้​ทุก​ตัว

แบบ​ที่​วัดลำดับ​ที่​ดี​ที่สุดลำดับ​ที่​แย่​ที่สุดต่าง​กัน
กรอง​ก่อน (predicate pushdown)ออเดอร์ ต่อ ร้าน ต่อ ลูกค้า สร้าง​ของกลาง​ทาง 62 แถวลูกค้า ต่อ ร้าน ต่อ ออเดอร์ สร้าง 209 แถว3.4 เท่า
ไม่​กรอง​ก่อนออเดอร์ ต่อ ลูกค้า ต่อ ร้าน สร้าง​ของกลาง​ทาง 1,017 แถวลูกค้า ต่อ ร้าน ต่อ ออเดอร์ สร้าง 20,000 แถว19.7 เท่า

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

การกรอง​ที่​ตาราง​ฐาน​ก่อน​ยุบ​โทษ​ของ​ลำดับ​ที่​แย่​ลง​จาก 19.7 เท่า​เหลือ 3.4 เท่า นี่​คือ​เหตุผล​ที่ predicate pushdown เป็นการ​แปลง query ที่​ทุก​ฐาน​ข้อมูล​ทำ​ก่อน​จะ​เริ่ม​เลือก​ลำดับ​ด้วย​ซ้ำ

qe/joins.py
def rel_join(a, b):
"""ต่อสองผลลัพธ์กลางทางเข้าด้วยกัน คืนแถวที่ได้
ถ้าสองข้างไม่มีเงื่อนไขร่วม (เช่น customers กับ restaurants) ผลคือ cross product
ซึ่งเป็นสิ่งที่ลำดับที่แย่ทำให้เกิดขึ้นเอง โดยไม่มีใครสั่ง
"""
if not a or not b:
return []
sa, sb = _present(a[0]), _present(b[0])
if ("o" in sa) == ("o" in sb):
return [_merge(x, y) for x in a for y in b]
oside, other = (a, b) if "o" in sa else (b, a)
tags = sorted(_present(other[0]) & {"c", "r"})
if not tags:
return [_merge(x, y) for x in a for y in b]
idx = collections.defaultdict(list)
for y in other:
idx[tuple(y[1][0] if t == "c" else y[2][1] for t in tags)].append(y)
out = []
for x in oside:
for y in idx.get(tuple(x[0][OCOL[t]] for t in tags), ()):
out.append(_merge(x, y))
return out
def orderings(n):
"""จำนวนลำดับที่เป็นไปได้: แบบ left-deep = n! · ทุกทรงรวม bushy = (2(n-1))!/(n-1)!"""
return math.factorial(n), math.factorial(2 * (n - 1)) // math.factorial(n - 1)
ราคา​ที่ model ของ​บท​นี้​มอง​ไม่​เห็น

ของกลาง​ทางใน​หัวข้อ​นี้​อยู่​ใน​หน่วย​ความ​จำ​ล้วน ราคา​ของ​มัน​จึง​เป็น 0 ใน​หน่วย​ที่​หัวข้อ​ก่อนหน้า​ใช้ ซึ่ง​เป็นการ​โกง​ที่​ต้อง​รู้ตัว

ถ้า​เอา​ของกลาง​ทาง​ไป materialise ลง disk จริง ที่ 59.8 แถว​ต่อ​หน้า ผล​จะ​เป็น​แบบ​นี้

  • 1,017 แถว เท่ากับ 17 หน้า เขียน​แล้ว​อ่าน​กลับ​อีกรอบ เท่ากับ 34 ครั้ง​ที่​แตะ disk
  • 20,000 แถว เท่ากับ 335 หน้า เขียน​แล้ว​อ่าน​กลับ​อีกรอบ เท่ากับ 670 ครั้ง​ที่​แตะ disk

59.8 แถว​ต่อ​หน้า​เป็น​ความ​หนาแน่น​ของ​แถวออเดอร์​ล้วน ส่วน​แถว​กลาง​ทาง​ต่อ​ออเดอร์​กับ​ลูกค้า​เข้า​ด้วย​กัน​แล้ว จึง​กว้าง​กว่า​และ​ลง​หน้า​ได้​น้อย​กว่า 17 หน้า​กับ 335 หน้า​จึง​เป็น​ขอบ​ล่าง​ของ​ราคา​จริง

หน่วย​หน้าที่​อ่าน​ของ​หัวข้อ​ก่อนหน้า​คิด​เงิน​ให้​ตาราง​ฐาน​เท่านั้น ตัว​คูณ​ของ​ลำดับ​ที่​วัด​ได้ 1.00 ถึง 2.09 เท่า​จึง​เป็น​ตัวเลข​ของ model ที่​มอง​ข้าม​ของ​ชิ้น​นี้​ไป

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

ตารางleft-deep (n!)ทุก​ทรง​รวม bushy
222
3612
424120
103,628,80017,643,225,600

ที่ 2 ตาราง​มี​ให้​เลือก​แค่ 2 ทาง และ​ใน​หน่วย​หน้าที่​อ่าน​เรา​วัด​แล้ว​ว่า​สอง​ทาง​นั้น​ต่าง​กัน​ไม่​เกิน 2.09 เท่า ตัว​คูณ​ของ​ลำดับ​จึง​เล็ก​เฉพาะ​ใน​หน่วย​ที่ model นี้​คิด​เงิน

ใน​หน่วย​แถว​ที่​ต้อง​ถือ​ไว้​พร้อม​กัน สอง​ทาง​เดียวกัน​นี้​ต่าง​กัน 1.97 เท่า​ที่​โจทย์​สอง​ตาราง​ของ​บท และ​ต่าง​กัน​ถึง 100 เท่า​ที่​โจทย์​เต็มตาราง เพราะ​ฝั่ง build ของ hash join สลับ​จาก 2,000 แถว​เป็น 200,000 แถว ซึ่ง​เป็น​หน่วย​ที่​ตัดสิน​ว่า​มัน​ทำได้​หรือ​ไม่​ได้​เลย

ที่ 10 ตาราง​มี 3,628,800 ทาง และ​ของกลาง​ทาง​คูณ​กัน​ไป​เรื่อยๆ ตาม​ความ​ยาว​ของ​ลำดับ ตัว​คูณ​ของ​ลำดับ​จึง​ไม่ใช่​ค่า​คงที่ มัน​โต​ตาม​จำนวน join

ส่วนตัว​คูณ​ของ​วิธี​วน​ลูปอยู่​ที่​รูปแบบ​กา​รวน​ลูป​ล้วนๆ และ​เห็น​ผล​ตั้งแต่ 2 ตาราง นี่​คือ​เหตุผล​ที่​บท​นี้​จัด​ลำดับ​ความ​สำคัญ​แบบ​ที่​จัด

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


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • CMU 15-445/645 Fall 2022, lecture #11 Join Algorithms (ตรวจ 2026-08-11) — ตัวอย่าง​ที่​คำนวณ​เสร็จ​แล้ว​บน​โจทย์​คนละ​ขนาด​กับ​ของ​เรา คือ R มี M = 1,000 หน้า (100,000 แถว) และ S มี N = 500 หน้า (40,000 แถว) นับ​เป็น​ครั้ง​ที่​ต้อง​อ่าน​หน้า​ตาม​หน่วย​ของ​คอร์ส​นี้​ได้​ตาม​นี้ (หน้า 22, 24, 49, 82 และ​ตาราง​สรุป​หน้า 85)
    • nested loop แบบ naive M + (m·N) = 50,001,000 ครั้ง
    • block nested loop แบบ​ยัง​ไม่​คิด buffer M + (M·N) = 501,000 ครั้ง
    • sort-merge = 7,500 ครั้ง ซึ่ง​เป็น​วิธี​ที่​ห้องเรียน​นั้น​สอน​แต่​บท​นี้​ไม่​ได้​สร้าง
    • hash 3·(M+N) = 4,500 ครั้ง
  • CMU 15-445/645 Fall 2025, lecture #12 (ตรวจ 2026-08-11) — ใช้​เฉพาะ​ตอน​อ้าง​สูตร​ของ block nested loop ที่​มี buffer เข้า​มา​เกี่ยว คือ M + (⌈M/(B−2)⌉ · N) ซึ่ง​ที่ B = 102 ให้ 6,000 ครั้ง​เมื่อ R เป็น​ตาราง​นอก และ 5,500 ครั้ง​เมื่อ​สลับ​ข้าง
  • ตาราง​สรุป​ของ​รุ่น Fall 2025 เขียน​สูตร​ไว้​แบบ​หนึ่ง​แต่​ใส่​ตัวอย่าง​เป็น​ค่า​ของ​กรณี​ที่​สลับ​ตาราง​นอก​แล้ว ตัวอย่าง​ที่​คำนวณ​เสร็จ​ของ​บท​นี้​จึง​หยิบ​มา​จากรุ่น Fall 2022 ซึ่ง​สอดคล้อง​กัน​ทุก​ช่อง แล้ว​ใช้​รุ่น Fall 2025 เฉพาะ​ตอน​อ้าง​สูตร เพราะ​สอง​รุ่น​ใช้​สูตร​ของ block nested loop คนละ​ตัว
  • Martin Kleppmann, Designing Data-Intensive Applications บท​ที่ 10 (ISBN 978-1449373320, 2017) — การ​อ่าน​ต่อ​สำหรับ join ฝั่ง​ประมวล​ผล​เป็น​ชุด ซึ่ง​อธิบาย​ว่า​รูปแบบ​กา​รวน​ลูป​เดียวกัน​นี้​ถูก​ยก​ไป​วาง​บน​หลาย​เครื่อง​อย่างไร

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

ข้อ 1 / 3

block nested loop ที่ block ใหญ่พอจะใส่ตารางนอกได้ทั้งตาราง อ่าน 33 หน้า เท่ากับ hash join พอดี ข้อสรุปที่ถูกต้องคืออะไร