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

B+tree — จาก 3,153 หน้า​เหลือ 3 หน้าที่​แตะ

บท 2 ปิด​ด้วย​ข้อ​จำกัด​ข้อ​เดียว​ของ heap file คือ​มัน​ไม่มี​ทาง​รู้​ล่วงหน้า​ว่า​หน้า​ไหน​ไม่​ต้อง​อ่าน ทุก​คำถาม​จึง​จ่าย​เท่า​กัน​หมด​คือ 3,153 หน้า

บท​นี้​ประกอบ​โครงสร้าง​ที่​บอก​ได้​ก่อน​อ่าน​ว่า​หน้า​ไหน​ต้อง​อ่าน แล้ว​วัด​ว่า​มัน​ลด​จำนวน​หน้าที่​แตะ​ลง​เหลือ​เท่าไร

คำ​ตอบ​คือ 3 หน้า แต่​ตัวเลข​ที่​ให้​อะไร​มากกว่า​นั้น​คือ​เหตุผล​ว่า​ทำไม​ถึง​เป็น 3 และ​ทำไม​มัน​แทบ​ไม่​โต​ตาม​ขนาด​ข้อมูล

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

บท​นี้​เพิ่ม file เดียว​คือ qe/bptree.py วาง​ไว้​ข้าง qe/fixture.py ของ​บท 1 กับ qe/heapfile.py ของ​บท 2 มัน​เรียก​ใช้​ทั้ง​สอง file นั้นตรงๆ จึง​ต้อง​มี​ครบ​ก่อน​ถึง​จะ​รัน​ได้

รัน​จาก folder ทำงาน​ด้าน​นอก​เหมือน​สอง​บท​ก่อน ด้วย python3 qe/bptree.py ตัวเลข​ผล​วัด​ใน​บท​นี้​คือ stdout ของ​คำ​สั่ง​เดียว​นั้น ส่วน​ตัวเลข​ที่มา​จาก​สูตร​จะ​ติด​ป้าย​กำกับ​ไว้​ทุก​จุด

จุด​ที่ heap ตัน และ​เงื่อนไข​ของ​สิ่ง​ที่​จะ​มา​แทน

หัวข้อ​ที่​มีชื่อ​ว่า “จุด​ที่ heap ตัน และ​เงื่อนไข​ของ​สิ่ง​ที่​จะ​มา​แทน”

สิ่ง​ที่ heap file ทำ​ไม่​ได้ และ​เงื่อนไข​สาม​ข้อ​ที่​โครงสร้าง​ใหม่​ต้อง​ผ่าน​ก่อน​จะ​เอา​มา​ใช้ได้

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

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

โครงสร้าง​ที่​บท​นี้​จะ​สร้าง​จึง​ต้อง​ผ่าน​สาม​ข้อ

  • อยู่​บน​หน้า 4096 byte แบบ​เดียว​กับ heap เพื่อ​ให้​หน่วย​ที่​คอร์ส​นี้​นับ คือ​จำนวน​หน้าที่​แตะ ใช้​กับ​ทั้ง​สองฝั่งได้ตรงๆ
  • ตอบ​ได้​ทั้ง​ค่า​เดียว​และ​ช่วง เพราะ​คำถาม​จริง​ส่วน​ใหญ่​เป็น​ช่วง ไม่ใช่​การ​ถาม​ค่า​เป๊ะๆ ค่า​เดียว
  • ชี้​กลับ​ไป​ที่​แถว​จริง​ด้วย record id ไม่​เก็บ​ตัว​แถว​ซ้ำ​อีก​ชุด

B+treeB+treeต้นไม้​ค้นหา​ที่​ทุกโหนด​เป็น​หนึ่ง​หน้า เก็บ​คีย์​กับ​ตัว​ชี้​ของ​ข้อมูล​จริง​ไว้​ที่​ใบ​ทั้งหมด และ​ร้อย​ใบ​ต่อ​กัน​เป็น​โซ่ จึง​ตอบ​ได้​ทั้ง​การ​ค้น​ค่า​เดียว​และ​การ​ค้น​เป็น​ช่วง ผ่าน​ทั้ง​สาม​ข้อ ราคา​ที่​จ่าย​มี​สอง​ข้อ คือ​กิน​ที่​บน disk เพิ่ม ซึ่ง​บท​นี้​วัด​ให้​ดู กับ​ต้อง​เขียน​ตาม​ทุก​ครั้ง​ที่​ตาราง​เปลี่ยน ซึ่ง​บท​นี้​ไม่​ได้​จ่าย​และ​ไม่​ได้​วัด

เพราะ B+tree ของ​คอร์ส​นี้​ตัด​เรื่อง delete ออก​ทั้งหมด ต่อ​จาก​เส้น​ขอบเขต​ของ heap ที่​บท 1 ประกาศ​ไว้​ว่า​เขียน​ต่อ​ท้าย​อย่าง​เดียว มัน​สร้าง​ครั้ง​เดียว​จาก​ข้อมูล​ที่​มี​อยู่​แล้ว แล้ว​อ่าน​อย่าง​เดียว

fanout ไม่ใช่​ค่าที่​เลือก​เอง มัน​ตกลง​มา​จาก​เลขคณิต​ของ​หน้า​เดียว

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

การ​เดิน​เลขคณิต​จาก​ขนาด​หน้า 4096 byte ลง​ไป​ถึง​จำนวน​กิ่ง​ที่ node หนึ่ง node แตก​ได้

ค่า​คงที่​ทั้ง​ชุด​ของ B+tree นี้​ไม่มี​ตัว​ไหน​ถูก​พิมพ์​ทับ​ด้วย​เลข​ที่​คิด​มา​ก่อน ทุก​ตัว​คำนวณ​จาก​ขนาด​หน้า

qe/bptree.py — ค่า​คงที่​ของ node
import os
import struct
from heapfile import PAGE_SIZE, Pager, build_heap, decode, read_slot
import fixture
# ── รูปแบบไบต์ของโหนด (ทุก format ขึ้นต้นด้วย '<' ให้ชัดว่า little-endian) ──
NODE_HDR = struct.Struct("<BHI") # (is_leaf, nkeys, next_leaf) = 7 B
KEY = struct.Struct("<I") # placed_at (unix time) = 4 B
CHILD = struct.Struct("<I") # เลขหน้าของลูก = 4 B
RID = struct.Struct("<IH") # (pageno, slot) ของ heap = 6 B
META = struct.Struct("<III") # (root, height, nentries) อยู่หน้าที่ 0
HDR_SIZE = NODE_HDR.size # 7
BODY = PAGE_SIZE - HDR_SIZE # 4089 — ที่ว่างจริงในหนึ่งหน้า
LEAF_ENTRY = KEY.size + RID.size # 10 B ต่อหนึ่งรายการในใบ
LEAF_MAX = BODY // LEAF_ENTRY # รายการสูงสุดต่อใบ
# โหนดใน: n คีย์ ต้องมีตัวชี้ n+1 ตัว → KEY*n + CHILD*(n+1) <= BODY
INNER_MAX_KEYS = (BODY - CHILD.size) // (KEY.size + CHILD.size)
FANOUT = INNER_MAX_KEYS + 1 # จำนวนทางที่แตกได้ต่อโหนดใน
KEYS_OFF = HDR_SIZE # คีย์ของโหนดในเริ่มตรงนี้
CHILDREN_OFF = KEYS_OFF + INNER_MAX_KEYS * KEY.size # ตามด้วยตัวชี้ลูก
NO_NEXT = 0xFFFFFFFF # ใบขวาสุดไม่มีใบถัดไป

หัว​ของ​ทุก node ใช้ format <BHI> เก็บ​สาม​ค่า​คือ​ธง​ว่า​เป็น​ใบ​หรือ​ไม่ จำนวน​คีย์​ใน​หน้า​นั้น และ​เลข​หน้า​ของ​ใบ​ถัด​ไป รวม​เป็น 7 byte เท่า​กัน​ทั้ง​ใบ​และ node ที่​ไม่ใช่​ใบ

ช่อง​ใน​หนึ่ง​หน้าขนาด
หนึ่ง​หน้า4096 B
หัว node <BHI>7 B
เหลือ​ให้​ข้อมูล4089 B

ที่​เหลือ 4089 byte คือ​ทั้งหมด​ที่​เอา​ไป​ใส่​ของ​ได้ และ​ของ​ที่​ใส่​มี​สอง​แบบ​ตาม​ชนิด​ของ node

ใบ​เก็บ​คู่​ของ​คีย์​กับ record id คีย์​คือ placed_at เก็บ​ด้วย <I> กิน 4 byte ส่วน record id คือ​คู่​ของ​เลข​หน้า​กับ​หมายเลข slot เก็บ​ด้วย <IH> กิน 6 byte รวม​หนึ่ง​รายการ 10 byte พอดี

4089 หาร​ด้วย 10 ปัด​ลง​ได้ 408 นี่​คือ​จำนวน​รายการ​สูงสุด​ที่​ใบ​หนึ่ง​ใบรับ​ได้

node ที่​ไม่ใช่​ใบ​เก็บ​คีย์​กับ​ตัว​ชี้​ลูก และ​มัน​ไม่​สมมาตร เพราะ​คีย์ n ตัว​แบ่ง​ช่วง​ออก​เป็น n+1 ช่วง จึง​ต้อง​มี​ตัว​ชี้ n+1 ตัว​เสมอ เงื่อนไข​จึง​เป็น 4n + 4(n+1) ≤ 4089

แก้​สมการ​ได้ n สูงสุด​เท่ากับ (4089 − 4) หาร​ด้วย (4 + 4) ปัด​ลง​เหลือ 510 คีย์ และ​ตัว​ชี้ 511 ตัว

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

คีย์ 510 ตัว​ใน node ที่​ไม่ใช่​ใบ​ไม่ใช่​ข้อมูล มัน​คือ separator keyseparator keyคีย์ในโหนด​ใน​ที่​ทำ​หน้าที่​บอก​ทาง​ลง​เท่านั้น ไม่ใช่​ข้อมูล​จริง มัน​แบ่ง​ว่า​คีย์​ที่​กำลัง​ค้น​ควร​ลง​ไป​ทาง​กิ่ง​ไหน ที่​มีหน้าที่​เดียว​คือ​บอกว่า​คีย์​ที่​กำลัง​ค้น​ควร​ลง​ไป​ทาง​กิ่ง​ไหน ค่า​จริง​กับ record id อยู่​ที่​ใบ​ทั้งหมด

ทั้ง​ใบ​และ node ที่​ไม่ใช่​ใบ​ใช้​การ​ค้น​แบบ binary search ใน​หน้า​เดียวกัน​หมด และ​มัน​มี parameter ตัว​หนึ่ง​ที่​ตัดสิน​เรื่อง​คีย์​ซ้ำ

qe/bptree.py — leaf_search
def leaf_search(page, n, key, right):
"""ค้นแบบทวิภาคในใบ อ่านไบต์ตรง ๆ ไม่สร้าง list (นี่คือทางร้อนตอน insert)
right=False → ตำแหน่งซ้ายสุดที่คีย์ >= key (ใช้ตอนค้นหา)
right=True → ตำแหน่งขวาสุดที่คีย์ <= key (ใช้ตอนแทรก)
"""
lo, hi = 0, n
while lo < hi:
mid = (lo + hi) // 2
k = KEY.unpack_from(page, HDR_SIZE + mid * LEAF_ENTRY)[0]
if (k <= key) if right else (k < key):
lo = mid + 1
else:
hi = mid
return lo

placed_at มี​ค่า​ซ้ำ​อยู่​จริง​ใน​ข้อมูล​ชุด​นี้ ถ้า​ค้น​ด้วย right=True การ​ค้น​จะ​ข้าม​ไป​หลัง​ตัว​ซ้ำ​ตัว​สุดท้าย ซึ่ง​ถูก​ตอน​แทรก​เพราะ​ทำให้​ของ​ใหม่​ไป​ต่อ​ท้าย​พวก​เดียวกัน แต่​ผิด​ตอน​ค้น​เพราะ​จะ​ข้าม​คำ​ตอบ​ตัว​แรก​ไป

การ​ค้น​จึง​ใช้ right=False เสมอ ส่วน​การ​แทรก​ใช้ right=True เสมอ นี่​คือ​ทั้งหมด​ของ​วิธี​จัดการ​คีย์​ซ้ำ​ใน​โครงสร้าง​นี้

นโยบาย​แตก​หน้า​เปลี่ยน​ความ​สูง​ของ​ต้นไม้​ได้ ทั้ง​ที่​ข้อมูล​ชุด​เดียวกันเป๊ะ

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

ผล​วัด​ของ​การ​ใส่​คีย์​ครบ​สอง​แสน​ตัว​ด้วย​สอง​นโยบาย และ​ผล​ที่​ตาม​มา​ต่อ​ความ​สูง​กับ​ขนาด​ของ​ต้นไม้

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

qe/bptree.py — _split_leaf
def _split_leaf(self, pno, page, n, nxt, pos, key, rid):
entries = leaf_entries(page)
entries.insert(pos, (key, rid[0], rid[1]))
if self.append_split and nxt == NO_NEXT and pos == n:
cut = n # ต่อท้ายใบขวาสุด → ปล่อยให้ใบซ้ายเต็ม
else:
cut = (n + 1) // 2 # กรณีทั่วไป — หั่นครึ่ง
rpage = new_node(True)
rpno = self._alloc(rpage)
leaf_pack(page, entries[:cut])
leaf_pack(rpage, entries[cut:])
set_header(page, 1, cut, rpno) # ร้อยโซ่ใบ: ซ้าย -> ใหม่ -> เดิม
set_header(rpage, 1, len(entries) - cut, nxt)
return entries[cut][0], rpno

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

ผล​ของ​สอง​นโยบาย​บน​ข้อมูล​ชุด​เดียวกันเป๊ะ ต่าง​กัน​แบบ​นี้

แตก​แบบ​ตัด​ท้ายแตก​แบบ​หั่น​ครึ่ง
ความ​สูง2 ชั้น3 ชั้น
ราก1 node · 490 คีย์1 node · 2 คีย์
ชั้น​กลางไม่มี3 node · 977 คีย์
ใบ491 node980 node
ทั้ง​ต้น492 หน้า (15.6 % ของ heap)984 หน้า (31.2 % ของ heap)
ใบ​เต็ม​เฉลี่ย99.8 %50.0 %
flowchart LR
    D["คีย์ชุดเดียวกัน 200,000 ตัว"]
    D --> A["แตกแบบตัดท้าย<br/>ใบเต็มเฉลี่ย 99.8 %"]
    D --> B["แตกแบบหั่นครึ่ง<br/>ใบเต็มเฉลี่ย 50.0 %"]
    A --> A2["ใบ 491 node"]
    B --> B2["ใบ 980 node"]
    A2 --> A3["สูง 2 ชั้น<br/>ทั้งต้น 492 หน้า"]
    B2 --> B3["สูง 3 ชั้น<br/>ทั้งต้น 984 หน้า"]

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

ความ​สูง​ของ B+tree จึง​ไม่​ได้​เป็น​ฟังก์ชัน​ของ​จำนวน​แถว​อย่าง​เดียว มัน​เป็น​ฟังก์ชัน​ของ fill factor ใน​ใบ​ด้วย ใบ​ที่​เต็ม​ครึ่ง​เดียว​แปล​ว่า​ต้อง​ใช้ใบ​เป็น​สอง​เท่า และ​สอง​เท่านั้น​ดัน​ชั้น​บน​ขึ้น​มา​อีก​ชั้น

ราคา​ของ​การ​ตัด​ท้าย​คือ​มัน​ได้​ผล​เฉพาะ​ตอน​ที่​คีย์​เข้า​มา​เรียง​แล้ว ซึ่ง​เป็น​จริง​ตรง​นี้​เพราะ​แถว​ถูก​เขียน​เรียง​ตาม placed_at มา​ตั้งแต่​บท 1 ถ้า​คีย์​เข้า​มาสลับ​ไป​มา เงื่อนไข pos == n แทบ​ไม่​เป็น​จริง​เลย และ​ผลลัพธ์​จะ​ไหล​กลับ​ไป​หา​แบบ​หั่น​ครึ่ง

ต้นไม้​แบบ​ตัด​ท้าย​ถูก​เขียน​ลง file จริง​ได้ 493 หน้า เท่ากับ 2,019,328 byte ที่มากกว่า 492 อยู่​หนึ่ง​หน้า​คือ​หน้าที่ 0 ซึ่ง​เก็บ meta สาม​ค่า​คือ​เลข​หน้า​ของ​ราก ความ​สูง และ​จำนวน​รายการ

ตัวเลข 15.6 % ใน​ตาราง​ข้าง​บน​คือ 492 หน้า​ของ node ทั้ง​ต้น​เทียบ​กับ heap 3,153 หน้า และ​หน้า meta ที่​เพิ่ม​มา​อีก​หนึ่ง​หน้า​ก็​ไม่​ทำให้​สัดส่วน​นั้น​ขยับ ที่ index กิน​น้อย​ขนาด​นี้​ทั้ง​ที่​ทำให้​ครบ​ทุก​แถว เพราะ​ใบ​เก็บ​แค่​คีย์ 4 byte กับ record id 6 byte ไม่​ได้​เก็บ​ตัว​แถว

การ​เปิด​ต้นไม้​กลับ​จาก file จริง​ผ่าน​ตัว​นับ​หน้า แล้ว​วัด​ว่าการ​ค้น​ค่า​เดียว​แตะ​กี่​หน้า

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

ตัว​สร้าง​ของ BPlusTreeReader อ่าน​หน้าที่ 0 เพื่อ​เอา meta ออก​มา แล้ว​รีเซ็ต​ตัว​นับ​กลับ​เป็น​ศูนย์​ทันที เพื่อ​ไม่​ให้​หน้า meta ปน​เข้าไป​ใน​ตัวเลข​ที่​บท​นี้​รายงาน

qe/bptree.py — BPlusTreeReader
def _descend(self, key):
"""ลงจากรากถึงใบซ้ายสุดที่อาจมีคีย์นี้ (right=False เพื่อไม่ข้ามตัวซ้ำ)"""
pno = self.root
while True:
page = self.pager.read_page(pno)
is_leaf, n, _ = header(page)
if is_leaf:
return pno, page
pno = inner_child(page, inner_search(page, n, key, right=False))
def range(self, lo, hi):
"""คืน [(key, (pageno, slot))] ของคีย์ในช่วง [lo, hi] เดินตามโซ่ใบ"""
pno, page = self._descend(lo)
out = []
i = leaf_search(page, header(page)[1], lo, right=False)
while True:
_, n, nxt = header(page)
while i < n:
k = leaf_key(page, i)
if k > hi:
return out
out.append((k, leaf_rid(page, i)))
i += 1
if nxt == NO_NEXT:
return out
pno, i = nxt, 0
page = self.pager.read_page(pno)
def lookup(self, key):
return [rid for _, rid in self.range(key, key)]

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

ทุก​ครั้ง​ที่ code เรียก self.pager.read_page ตัว​นับ​เพิ่ม​หนึ่ง นี่​คือ​หน่วย​เดียว​กับ​ที่​บท 1 ตกลง​ไว้ และ​เป็น​ตัวเลข​ที่​ผู้​อ่าน​รัน​เอง​แล้ว​ต้อง​ได้​เท่า​กัน

ค้น​คีย์ placed_at = 1747119566 ซึ่ง​มี​อยู่ 1 แถว ได้​ผล​แบบ​นี้

ขั้นหน้าที่​แตะ
เดิน​จากราก​ลง​ถึง​ใบ2 หน้า
ตาม​ไป​หยิบ​แถว​จาก heap1 หน้า
รวม3 หน้า
ถ้า​ไม่มี index ต้อง​อ่าน​ทั้ง heap3,153 หน้า

3 เทียบ 3,153 คือ​แตะ​หน้า​น้อย​กว่า 1,051 เท่า และ​แถว​ที่​ได้​ตรง​กับ​การ​อ่าน​ทั้ง​ตาราง​แล้วกรอง​เอง​ทุก​ประการ

เลข 2 ฝั่ง index ไม่ใช่​ค่าที่​ตั้ง​ไว้ มัน​คือ​ความ​สูง​ของ​ต้นไม้​พอดี เพราะ _descend อ่าน​หน้า​ละ​ครั้ง​ลง​ไป​ที​ละ​ชั้น​จนถึง​ใบ

flowchart TD
    R["ชั้น 0 · ราก 1 node · 490 separator key"]
    R --> L1["ใบที่ 1"]
    R --> L2["ใบที่ 2 · ใบที่คีย์ตกลงมา"]
    R --> L3["ใบที่ 3"]
    R --> LN["ใบที่ 491"]
    L1 -. leaf chain .-> L2
    L2 -. leaf chain .-> L3
    L3 -. leaf chain .-> LN
    L2 --> H["หน้าใน heap ที่ record id ชี้ไป"]
    classDef hit stroke-width:3px
    class R,L2,H hit

คำ​บรรยาย​ภาพ: เส้น​ทึบ​คือ​ทาง​ที่ point lookup เดิน คือ​ราก 1 หน้า แล้ว​ลง​ใบ​อีก 1 หน้า รวม​ฝั่ง index 2 หน้า แล้ว​ตาม​ตัว​ชี้​ไป​หยิบ​แถว​จาก heap อีก 1 หน้า รวม 3 หน้า · เส้น​ประ​คือ leaf chain ที่​ร้อย​ใบ​ทั้ง 491 ใบ​ต่อ​กัน​ตาม​ลำดับ​คีย์ ซึ่ง point lookup ไม่​ได้​ใช้ แต่​หัวข้อ​ถัด​ไป​ใช้

ตัวเลข​ทั้งหมด​นี้​นับ​ภาย​ใต้​เงื่อนไข​เดียว​กับ​สอง​บท​ก่อน คือ​ยัง​ไม่มี buffer pool ตัว​อ่าน​หน้า​จึง​เรียก​อ่าน​ใหม่​ทุก​ครั้ง record id หนึ่ง​ตัว​จึง​เท่ากับ​หนึ่ง​หน้า​เสมอ ซึ่ง​เป็น​ขอบ​บน

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

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

ทุก​ใบ​เก็บ​เลข​หน้า​ของ​ใบ​ถัด​ไป​ไว้​ใน​หัว​ของ​ตัวเอง ตัว​ชี้​ชุด​นี้​คือ leaf chainleaf chainตัว​ชี้​ที่​ร้อย​ใบ​ของ B+tree ต่อ​กัน​ตาม​ลำดับ​คีย์ ทำให้การ​อ่าน​เป็น​ช่วง​เดิน​จาก​ใบ​หนึ่ง​ไป​ใบ​ถัด​ไป​ได้​เลย โดย​ไม่​ต้อง​ย้อน​กลับ​ขึ้น​ไป​ที่​ราก และ​มัน​ทำให้การ​อ่าน​เป็น​ช่วง​เดิน​จาก​ใบ​หนึ่ง​ไป​ใบ​ถัด​ไป​ได้​เลย โดย​ไม่​ต้อง​ย้อน​ขึ้น​ไป​ที่​ราก​ทุก​ครั้ง

ใน range จะ​เห็น​ว่า​หลัง​เดิน​ลง​ถึง​ใบ​แรก​แล้ว ลูป​ที่​เหลือ​ไม่​แตะ​ราก​อีก​เลย มัน​วน​อ่าน​ใบ​ตาม nxt จนกว่า​จะ​เจอ​คีย์​ที่​เกิน​ขอบ​บน หรือ​จนกว่า​จะ​หมด​โซ่

ถาม​ช่วง​หนึ่ง​วัน​สอง​ครั้ง บน​วัน​ที่​ปริมาณ​ต่าง​กัน​มาก ได้​ผล​แบบ​นี้

ช่วง​ที่​ถามแถว​ที่​คืน​มาindex แตะheap แตะ (หน้าที่​ไม่​ซ้ำ)รวม
วัน​ที่ 90 (วัน​ธรรมดา)1,0175 หน้า (ลง​จากราก 2 + เดิน​ตาม leaf chain 3)17 หน้า22 หน้า
วัน​ที่ 140 (วัน​โปรโมชัน)17,60345 หน้า (ลง​จากราก 2 + เดิน​ตาม leaf chain 43)279 หน้า324 หน้า

ทั้ง​สอง​แถว​เทียบ​กับ​การ​อ่าน​ทั้ง heap 3,153 หน้า และ​ทั้ง​สอง​แถว​ให้​คำ​ตอบ​ตรง​กับ​การ​อ่าน​ทั้ง​ตาราง​แล้วกรอง​เอง​ทุก​ประการ

เลข 17 กับ 279 คือ​หน้าที่​ไม่​ซ้ำ​กัน ซึ่ง​เป็น​ขอบ​ล่าง​ตาม​กติกา​คู่​ที่​ประกาศ​ไว้​ข้าง​บน ถ้า​ไล่​ตาม record id ที​ละ​ตัว​โดย​ไม่มี buffer pool เลย วัน​ที่ 90 จะ​ขอ​อ่าน 1,017 ครั้ง​และ​วัน​ที่ 140 จะ​ขอ​อ่าน 17,603 ครั้ง ของ​จริง​อยู่​ระหว่าง​สอง​ค่า​นั้น

จุด​ที่​น่า​ดู​อยู่​ใน​แถว​ล่าง ฝั่ง index จ่าย​ไป 45 หน้า ส่วน​ฝั่ง heap จ่าย​ไป 279 หน้า​ไม่​ซ้ำ นั่น​คือ​ราว 86 % ของ​งาน​ทั้งหมด​ไม่​ได้​อยู่​ที่​โครงสร้าง​ที่​เรา​เพิ่ง​สร้าง แต่​อยู่​ที่​การ​ไล่​ตาม​ตัว​ชี้​กลับ​ไป​อ่าน​แถว​จริง

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

ตัวเลข​ฝั่ง heap ทั้ง 17 และ 279 สวย​ผิด​ปกติ เพราะ​แถว​ถูก​เขียน​เรียง​ตาม placed_at มา​ตั้งแต่​ตอน​สร้าง​ข้อมูล แถว​ของ​วัน​เดียวกัน​จึง​กอง​ติด​กัน​อยู่​ไม่​กี่​หน้า

เปลี่ยน​ไป​ถาม​ด้วย​ค่าที่​ไม่​ได้​เรียง​ตาม​หน้า เช่น customer_id = 995 แล้ว​ภาพ​กลับ​ด้าน 97 แถว​ของ​ลูกค้า​ราย​นี้​กระจาย​อยู่​บน 93 หน้า คิด​เป็น 0.96 หน้า​ต่อ​แถว

วาง​สอง​อัตรา​ไว้​ข้าง​กัน วัน​ที่ 90 ได้ 1,017 แถว​จาก 17 หน้า ส่วน​ลูกค้า​ราย​นี้​ได้ 97 แถว​จาก 93 หน้า สอง​อัตรา​บน​ตาราง​เดียวกัน​นี้​ห่าง​กัน​เกือบ 60 เท่า

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

ลำดับ​ที่​ตรง​กัน​ระหว่าง index กับ heap ใน​บท​นี้​เป็น​เรื่อง​บังเอิญ​ของ​ข้อมูล​ชุด​นี้ ไม่ใช่​สมบัติ​ของ B+tree ชื่อ​เรียก​ของ​สมบัติ​ข้อ​นี้​กับ​ราคา​ของ​มัน​เป็น​เรื่อง​ของ​บท 5

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

คำถาม​ที่​ตาม​มา​ทันที​คือ​ตัวเลข 3 หน้า​อยู่​ได้​ถึง​ข้อมูล​ขนาด​ไหน ตอบ​ได้​ด้วยฟังก์ชันสั้นๆ ที่​ไล่​หาร​จำนวน​ใบ​ด้วย fanout จน​เหลือ node เดียว

qe/bptree.py — height_for
def height_for(nrows, leaf_cap, fanout):
leaves = max(1, -(-nrows // leaf_cap))
h, c = 1, leaves
while c > 1:
c = -(-c // fanout)
h += 1
return h, leaves

ป้อน​ค่าที่​วัด​ได้​จริง​เข้าไป คือ​ใบรับ​ได้ 408 รายการ​และ fanout 511 ได้​ตาราง​นี้

จำนวน​แถวความ​สูงจำนวน​ใบpoint lookup ฝั่ง index
200,0002 ชั้น491 หน้า2 หน้า
1,000,0003 ชั้น2,451 หน้า3 หน้า
10,000,0003 ชั้น24,510 หน้า3 หน้า
100,000,0003 ชั้น245,099 หน้า3 หน้า

จาก​แถว​บน​สุด​ถึง​แถว​ล่าง​สุด ข้อมูล​โต​จาก 200,000 แถว​เป็น 100,000,000 แถว แต่​จำนวน​หน้าที่​แตะ​ฝั่ง index ขยับ​จาก 2 เป็น 3 เท่านั้น

เหตุผล​อยู่​ใน​การ​คูณ​สาม​ตัว ต้นไม้ 3 ชั้น​มี​ราก​ที่​แตก​ได้ 511 ทาง แต่ละ​ทาง​เป็น node ที่​แตก​ได้​อีก 511 ทาง และ​แต่ละ​ใบรับ​ได้ 408 รายการ เพดาน​จึง​เป็น 511 × 511 × 408 เท่ากับ 106,537,368 แถว

คำ​ว่า “ต้นไม้​เตี้ย” จึง​ไม่ใช่​คำ​เปรียบเปรย มัน​คือ​ผล​ของ​การ​คูณ​เลข​สาม​ตัว​นั้น

ตาราง​ข้าง​บน​เป็น​กรณี​ดี​ที่สุด​เพราะ​สมมติ​ว่า​ใบ​เต็ม 408 รายการ ตาราง​จริง​ที่​ผ่าน​การ​ลบ​และ​แทรก​สลับ​มา​แล้ว​จะ​ไม่​เป็น​แบบ​นั้น ลอง​ตั้ง​ใบ​เต็ม​ที่ 70 % คือ 285 รายการ และ fanout 357 แล้ว​ดู​อีกรอบ

ที่ 200,000 แถว​ความ​สูง​ขยับ​จาก 2 เป็น 3 ชั้น​ทันที และ​ที่ 100,000,000 แถว​ขยับ​เป็น 4 ชั้น กล่าว​คือ​ที่​สอง​ขนาด​นี้ fill factor ที่​ตกลง​มา​ซื้อ​ความ​สูง​เพิ่ม​หนึ่ง​ชั้น และ point lookup จ่าย​เพิ่ม​หนึ่ง​หน้า ส่วน​ที่ 1,000,000 กับ 10,000,000 แถว ความ​สูง​ยัง​เป็น 3 ชั้น​เท่า​เดิม​ทั้ง​สอง​สมมติฐาน

ตัวเลข 511 นี้​ผูก​กับ​คีย์​ที่​แคบ​ผิด​ปกติ

คีย์​ของ​คอร์ส​นี้​เป็น <I> ตัว​เดียว​กว้าง 4 byte ส่วน index ของ​ฐาน​ข้อมูล​จริง​มัก​เก็บ​คีย์​ประกอบ​เพื่อ​ให้​แถว​ที่​คีย์​ซ้ำ​กัน​ยัง​เรียง​กัน​ได้​แน่นอน

ถ้า​เปลี่ยน​ไป​เก็บ​คีย์​ประกอบ​กว้าง 8 byte รายการ​ใน​ใบ​จะ​โต​จาก 10 byte เป็น 14 byte และ fanout ตก​จาก 511 เหลือ 341 คือ 1.50 เท่า ไม่ใช่​ครึ่ง​หนึ่ง​อย่าง​ที่​คีย์​กว้าง​ขึ้น​เท่าตัว​ชวน​ให้​คิด เพราะ​ตัว​ชี้​ลูก 4 byte ในโหนด​ใน​ไม่​ได้​โต​ตาม​คีย์​ไป​ด้วย

สิ่ง​ที่​ยก​ไป​ใช้​กับ index ตัว​อื่น​ได้​คือ​วิธี​คำนวณ ไม่ใช่​ตัวเลข 408 กับ 511

คำถาม​ที่​ตัวเลข​ใน​บท​นี้​เปิด​ทิ้ง​ไว้ และ​หน้าที่​ของ​สอง​บท​ถัด​ไป

บท​นี้​วัด​สอง​กรณี​สุด​ขั้ว​ไว้​แล้ว​บน​ตาราง​เดียวกัน คือ 3 หน้า​สำหรับ​หนึ่ง​แถว​ที่​ตอบ​ด้วย​การ​ลง​จากราก​ครั้ง​เดียว กับ 93 หน้า​สำหรับ 97 แถว​ที่​กระจาย​อยู่​คนละ​หน้า​เพราะ​คีย์​ที่​ถาม​ไม่​ได้​เรียง​ตาม​หน้า

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

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

ส่วน​ที่​บท​นี้​ยืนยัน​ได้​แล้ว​มี​ข้อ​เดียว คือ​จำนวน​หน้าที่​แตะ​ฝั่ง index ของ​การ​ค้น​ค่า​เดียว​แทบ​ไม่​โต​ตาม​ขนาด​ข้อมูล ขณะ​ที่​ฝั่ง heap ยัง​โต​ตาม​จำนวน​แถว​ที่​คืน และ​จำนวน​หน้า​ของ​การ​อ่าน​ทั้ง​ตาราง​โต​ตามตรงๆ ทุก​แถว

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


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • SQLite Database File Format — หัวข้อ B-tree Pages (ตรวจ 2026-08-11) — โครง​ของ​หน้า B-tree ใน​ของ​จริง ทั้ง​การ​แยก interior page กับ leaf page และ​การ​วาง cell pointer array ใน​หน้า เป็น​ที่มา​ของ​การ​ที่​คอร์ส​นี้​ตรึง​หน้า​ไว้​ที่ 4096 byte เท่ากับ page_size ของ file SQLite ที่​บท 8 เอา​มา​เทียบ
  • Alex Petrov, Database Internals (O’Reilly, 2019 — ISBN 978-1-4920-4034-7) บท 2–4 — การ​อ่าน​ต่อ​สำหรับ B-tree bulk loading, นโยบาย​แตก​หน้า และ​การ​วาง​โครง​หน้า​บน disk ซึ่ง​เป็น​สาม​เรื่อง​ที่​บท​นี้​ทำ​แบบ​ย่อ​ที่สุด​เท่า​ที่​จะ​ย่อ​ได้

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

ข้อ 1 / 3

ทำไม fanout ของ B+tree ในบทนี้ถึงเป็น 511 ทาง