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

หน้า (page) — ทำไม "อ่าน​หนึ่ง​แถว" ถึง​ไม่มี​อยู่​จริง

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

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

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

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

บท​นี้​เขียน file ใหม่​หนึ่ง file คือ qe/heapfile.py วาง​ไว้​ข้างๆ qe/fixture.py ของ​บท​ที่​แล้ว ส่วน​โครงสร้าง​ที่​บท​นี้​ยก​มา​ต้อง import แค่ struct ซึ่ง​ติด​มา​กับ​ภาษา​อยู่​แล้ว

code block ทุก​อัน​ใน​บท​นี้​เรียง​ตาม​ลำดับ​ที่​พิมพ์​ลง file ได้ตรงๆ ตั้งแต่ block แรก​ถึง block สุดท้าย

ส่วน​ที่​พิมพ์​ตัวเลข​ทั้งหมด​ของ​บท​นี้​ออก​มา​คือ block __main__ ท้าย file เดียวกัน ซึ่ง​บท​นี้​ไม่​ได้​ยก​มา มัน​เพิ่ม import อีก​สี่​ตัว​คือ os, random, collections และ fixture ของ​บท​ที่​แล้ว ทั้งหมด​ยัง​เป็น stdlib เหมือน​เดิม

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

ขนาด​ของ​ก้อน​ที่​คอร์ส​นี้​ตรึง​ไว้ ค่า​คงที่​ทั้ง​ชุด​ของ​โมดูล และ​วิธี​แปลง​หนึ่ง​แถว​ให้​เป็น byte

หน่วย​ที่​บท​ที่​แล้ว​ตกลง​ไว้​คือ​อ่าน​หนึ่ง​หน้า​นับ​เป็น​หนึ่ง​ครั้ง และ​คอร์ส​นี้​ตรึง​ขนาด​หนึ่ง​หน้า​ไว้​ที่ 4096 byte ตลอด​ทั้ง​แปดบท

ค่า​คงที่​ทั้ง​ชุด​ของ​โมดูล​นี้​อยู่​รวม​กัน​ที่​หัว file และ​มัน​คือ​กติกา​ทั้งหมด​ของ​รูปแบบ​ข้อมูล​ที่​บท​นี้​ใช้

qe/heapfile.py
"""บท 2 — slotted page + heap file + pager ที่นับหน้าที่อ่าน. stdlib เท่านั้น"""
import struct
PAGE_SIZE = 4096
HEADER = struct.Struct("<HH") # (nslots, content_start)
SLOT = struct.Struct("<HH") # (offset, length) = 4 B
HEADER_SIZE = HEADER.size # 4
SLOT_SIZE = SLOT.size # 4
MAX_ROW = PAGE_SIZE - HEADER_SIZE - SLOT_SIZE
STATUS = ["Placed", "Confirmed", "Preparing", "PickedUp",
"Delivered", "Rejected", "Cancelled"]
FIXED = struct.Struct("<IIBIIH") # id, customer_id, status, placed_at, satang, len(name)

< ที่​ขึ้น​ต้น​ทุก format string อ่าน​ว่า little-endian แบบ​ไม่​ใส่ padding ระหว่าง field คอร์ส​นี้​บังคับ​ให้​ทุก struct ขึ้น​ต้น​ด้วย​มัน ไม่ใช่​เรื่อง​รสนิยม

ถ้า​ปล่อย​ให้​เป็น​ค่า​ปริยาย Python จะ​จัด field ให้​ตรง word boundary ตาม​เครื่อง​ที่​รัน ขนาด​ของ​แถว​เดียวกัน​จะ​ไม่​เท่า​กัน​ข้าม​เครื่อง และ file ที่​เขียน​บน​เครื่อง​หนึ่ง​จะ​อ่าน​บน​อีก​เครื่อง​ไม่​ได้

MAX_ROW ออก​มา​เป็น 4088 B จาก​การลบตรงๆ หน้า​เปล่า​มี 4096 B แต่​ต้อง​กัน 4 B ให้ header และ​อีก 4 B ให้ slot ของ​แถว​นั้น​เอง แถว​ที่​ใหญ่​กว่า​นี้​ลง​หน้า​เดียว​ไม่​ได้ และ​ของ​จริง​แก้​ด้วย overflow page ซึ่ง​คอร์ส​นี้​ไม่​ได้​สร้าง

หนึ่ง​แถว​ถูก​แปลง​เป็น byte ด้วย​สอง​ฟังก์ชัน​นี้

qe/heapfile.py
def encode(oid, cid, st, rest, ts, satang):
b = rest.encode()
return FIXED.pack(oid, cid, STATUS.index(st), ts, satang, len(b)) + b
def decode(buf):
oid, cid, st, ts, sat, n = FIXED.unpack_from(buf, 0)
return (oid, cid, STATUS[st], buf[FIXED.size:FIXED.size + n].decode(), ts, sat)

FIXED กิน​พอดี 19 B ทุก​แถว เพราะ < ตัด​สิทธิ์ padding ทิ้ง​ไป แถว​หนึ่ง​จึง​เป็น​ส่วน​คงที่ 19 B ต่อ​ด้วย​ชื่อ​ร้าน​เข้า​รหัส UTF-8 ที่​ยาว​ไม่​เท่า​กัน โดย​มี field ขนาด 2 B ตัว​สุดท้าย​ของ​ส่วน​คงที่​บอก​ไว้​ว่า​ชื่อ​ยาว​กี่ byte

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

โครง​ภายใน​หนึ่ง​หน้า เหตุผล​ที่​ต้อง​มี slot directory และ​เงื่อนไข​ที่​ตัดสิน​ว่า​แถว​ถัด​ไป​ลง​หน้า​นี้​ได้​หรือ​ไม่

slotted pageslotted pageโครง​ภายใน​หนึ่ง​หน้าที่​มี header กับ slot directory โต​จาก​ด้าน​หนึ่ง และ​ข้อมูล​แถว​โต​สวน​เข้า​มา​จาก​อีก​ด้าน ทำให้​ย้าย​แถว​ภายใน​หน้า​ได้​โดยที่ record id ไม่​เปลี่ยน แก้​โจทย์​ข้อ​เดียว คือ​ทำ​อย่างไร​ให้​เก็บ​แถว​ที่​ยาว​ไม่​เท่า​กันลง​ใน​ก้อน​ขนาด​คงที่ แล้ว​ยัง​ชี้​กลับ​มา​ที่​แถว​ใด​แถว​หนึ่ง​ได้

คำ​ตอบ​คือ​ให้​สอง​อย่าง​โต​เข้าหา​กัน​จาก​คนละ​ด้าน​ของ​หน้า

  • header 4 B ที่​ต้น​หน้า เก็บ​คู่ (nslots, content_start) คือ​จำนวน slot ที่​ใช้​ไป​แล้ว กับ​ตำแหน่ง​ที่​ข้อมูล​แถว​เริ่ม
  • slot directory ต่อ​จาก header ทันที แต่ละ slot กิน 4 B เก็บ​คู่ (offset, length) ของ​หนึ่ง​แถว และ​โต​จาก​ซ้าย​ไป​ขวา
  • ข้อมูล​แถว เริ่ม​จาก​ท้าย​หน้า​แล้ว​โต​สวน​กลับ​มา​ทาง​ซ้าย แถว​ใหม่​ไป​วาง​ไว้​ซ้าย​กว่า​แถว​ก่อนหน้า​เสมอ

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

qe/heapfile.py
def new_page():
p = bytearray(PAGE_SIZE)
HEADER.pack_into(p, 0, 0, PAGE_SIZE)
return p
def free_space(page):
n, cs = HEADER.unpack_from(page, 0)
return cs - (HEADER_SIZE + n * SLOT_SIZE)
def insert(page, row):
if len(row) > MAX_ROW:
raise ValueError(f"แถว {len(row)} B ใหญ่เกินหนึ่งหน้า — ของจริงใช้ overflow page")
if free_space(page) < len(row) + SLOT_SIZE:
return None
n, cs = HEADER.unpack_from(page, 0)
cs -= len(row)
page[cs:cs + len(row)] = row
SLOT.pack_into(page, HEADER_SIZE + n * SLOT_SIZE, cs, len(row))
HEADER.pack_into(page, 0, n + 1, cs)
return n

หน้า​เปล่า​ตั้ง content_start ไว้​ที่ 4096 ซึ่ง​เป็น​ตำแหน่ง​ถัด​จาก byte สุดท้าย​ของ​หน้า อ่าน​ว่า​ข้อมูล​แถว​ยัง​ไม่​เริ่ม

บรรทัด​ที่​ตัดสิน​ทุก​อย่าง​คือ free_space(page) < len(row) + SLOT_SIZE แถว​ใหม่​ต้อง​จ่าย​ค่า slot ของ​ตัวเอง​ด้วย ไม่ใช่​แค่​ที่​วางข้อมูล

ถ้า​ลืม + SLOT_SIZE ไป โปรแกรม​จะ​ไม่ error ทันที มัน​จะ​ยอมรับ​แถว​ที่​พอดี​เป๊ะ แล้ว slot ตัว​ใหม่​จะ​เขียน​ทับ byte ต้นๆ ของ​แถว​ที่​เพิ่ง​วาง​ไป กว่า​จะ​รู้ตัว​คือ​ตอน​อ่าน​กลับ​แล้ว​ได้​ค่า​เพี้ยน

insert คืน​หมายเลข slot กลับ​มา ไม่​ได้​คืน offset และ​นั่น​คือ​ทั้งหมด​ของ​เรื่อง record idrecord idคู่ (หมายเลข​หน้า, หมายเลข slot) ที่​ระบุ​ตำแหน่ง​ของ​หนึ่ง​แถว เป็น​สิ่ง​ที่ index เก็บ​ไว้​ชี้​กลับ​มา​ที่ heap แทน​การ​เก็บ​ตัว​แถว​เอง ซึ่ง​ใน​คอร์ส​นี้​คือ​คู่ (หมายเลขหน้า, หมายเลข slot)

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

flowchart LR
    H["header 4 byte<br/>nslots และ content_start"] --> S["slot directory<br/>slot ละ 4 byte<br/>โตจากซ้ายไปขวา"]
    S --> F["ที่ว่างตรงกลาง<br/>หดลงทุกครั้งที่แทรกแถว"]
    F --> D["ข้อมูลแถว<br/>โตจากขวามาซ้าย"]
    S -. "แต่ละ slot เก็บตำแหน่งและความยาวของหนึ่งแถว" .-> D
    R["record id คือคู่ หมายเลขหน้า กับ หมายเลข slot"] --> S

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

การ​อ่าน​กลับ​กับ​การ​ลบ​เป็น​ด้าน​ตรง​ข้าม​ของ slot ตัว​เดียวกัน

qe/heapfile.py
def read_slot(page, slot):
n, _ = HEADER.unpack_from(page, 0)
if not 0 <= slot < n:
raise IndexError(slot)
off, ln = SLOT.unpack_from(page, HEADER_SIZE + slot * SLOT_SIZE)
if ln == 0:
raise KeyError("slot ถูกลบไปแล้ว")
return bytes(page[off:off + ln])
def nslots(page):
return HEADER.unpack_from(page, 0)[0]
def delete(page, slot):
SLOT.pack_into(page, HEADER_SIZE + slot * SLOT_SIZE, 0, 0)

delete ไม่​ได้​ลบ byte ของ​แถว​ทิ้ง มัน​เขียน​ค่า (0, 0) ลง slot แล้ว​ปล่อย​ไว้​เท่านั้น

คู่ (0, 0) ปลอดภัย​ทั้ง​สอง​ช่อง offset 0 อยู่​ใน​เขต header เสมอ​จึง​ไม่มี​วัน​เป็น​ตำแหน่ง​จริง​ของ​แถว ส่วน​ความ​ยาว 0 ก็​เป็น​ไป​ไม่​ได้ เพราะ​ลำพัง​ส่วน​คงที่​ของ​แถว​ก็​กิน 19 B แล้ว

ตัว​ที่ read_slot ตรวจ​จริง​คือ field ความ​ยาว มัน​เทียบ ln == 0 แล้ว​โยน KeyError ออก​มา จึง​แยก slot ที่​ถูกลบ​ออก​จาก slot ที่​ยัง​ใช้​อยู่​ได้ โดย​ไม่​ต้อง​เพิ่ม field ใหม่​เข้าไป​ใน​หน้า

ราคา​ของ​ทาง​เลือก​นี้​คือ slot ของ​แถว​ที่​ถูกลบ​ยัง​กิน​ที่ 4 B ต่อ​ไป และ byte ของ​แถว​เก่า​ยัง​นอน​อยู่​ใน​หน้า​จนกว่า​จะ​มี​การ​จัด​ที่​ว่าง​ใหม่ heap ของ​คอร์ส​นี้​เขียน​ต่อ​ท้าย​อย่าง​เดียว​และ​ไม่​เคย​เรียก delete เลย​สัก​ครั้ง ฟังก์ชัน​นี้​จึง​มี​ไว้​ให้​เห็น​รูปร่าง​ของ​มัน​เท่านั้น

ตาราง​ขนาด​ของ​สิบ​ร้าน​ใน​ข้อมูล​ตั้งต้น พร้อม​ช่วง​และ​ค่า​เฉลี่ย​ของ​ขนาด​แถว​ทั้ง​ตาราง

ส่วน​คงที่ 19 B เท่า​กัน​ทุก​แถว ส่วน​ที่​ทำให้​แถว​ยาว​ไม่​เท่า​กัน​มี​อย่าง​เดียว​คือ​ชื่อ​ร้าน ซึ่ง​เป็น​ข้อความ​ไทย​เข้า​รหัส UTF-8

ชื่อ​ร้านขนาด​ชื่อขนาด​แถว
ครัว​สุขใจ27 B46 B
ครัว​คุณ​แม่30 B49 B
ชาบู​บ้านนา30 B49 B
ลาบ​เป็ด​อุดร33 B52 B
ส้มตำ​แซ่บนัว36 B55 B
พิซซ่า​ลุง​หมี36 B55 B
กาแฟดริป​ซอย​เก้า45 B64 B
ข้าว​หมูแดง​เจ๊​หมวย51 B70 B
ข้าว​มัน​ไก่​ประตู​น้ำ54 B73 B
ก๋วยเตี๋ยว​เรือ​รังสิต60 B79 B

อักษร​ไทย​หนึ่ง​ตัว​กิน 3 B ใน UTF-8 ชื่อ​ที่​คน​อ่าน​ว่า​ยาว​กว่า​กัน​ไม่​กี่​พยางค์​จึง​ต่าง​กัน​หลาย​สิบ byte

ทั้ง​ตาราง 200,000 แถว​ได้​แถว​เล็ก​สุด 46 B ใหญ่​สุด 79 B และ​เฉลี่ย 59.99 B

ถ้า​นับ​ค่า slot 4 B ที่​แต่ละ​แถว​ต้อง​จ่าย​เข้าไป​ด้วย ค่า​เฉลี่ย​ขยับ​เป็น 63.99 B ตัวเลข​ตัว​หลัง​นี่แหละ​ที่​กำหนด​ว่า​หนึ่ง​หน้า​ใส่​ได้​กี่​แถว

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

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

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

qe/heapfile.py
class Pager:
def __init__(self, path, mode="r+b"):
self.f = open(path, mode)
self.reads = 0
def read_page(self, pageno):
self.f.seek(pageno * PAGE_SIZE)
self.reads += 1
return bytearray(self.f.read(PAGE_SIZE))
def write_page(self, pageno, page):
self.f.seek(pageno * PAGE_SIZE)
self.f.write(page)
def page_count(self):
self.f.seek(0, 2)
return self.f.tell() // PAGE_SIZE

self.reads คือ​หน่วย​ที่​บท​ที่​แล้ว​ตกลง​ไว้ ใน​รูป​ของ​ตัวแปร​จริง​หนึ่ง​ตัว ทุก​ครั้ง​ที่​มี​ใคร​ขอ​หนึ่ง​หน้า ตัวเลข​นี้​เพิ่ม​หนึ่ง

Pager.reads ไม่ใช่​จำนวน I/O ที่​ลง​ไป​ถึง​ตัว disk

มัน​นับ​จำนวน​ครั้ง​ที่​มี​การ​เรียก​อ่าน​หนึ่ง​หน้า​เท่านั้น file ขนาด 12.9 MB นี้​อยู่​ใน page cache ของ OS แทบ​ทั้งหมด การ​อ่าน​ส่วน​ใหญ่​จึง​ไม่​ได้​ลง​ไป​ถึง​ตัว disk เลย

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

Pager ตัว​นี้​ยัง​ไม่มี buffer pool ด้วย ขอ​หน้า​เดิม​ซ้ำ​สิบ​ครั้ง​ก็​นับ​สิบ​ครั้ง ตัว​ชี้​แถว​หนึ่ง​ตัว​จึง​เท่ากับ​หนึ่ง​หน้า​เสมอ

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

qe/heapfile.py
def build_heap(path, rows):
"""คืน dict: oid -> rid=(pageno, slot)"""
pg = Pager(path, "w+b")
page, pno, rid = new_page(), 0, {}
for r in rows:
row = encode(*r)
s = insert(page, row)
if s is None:
pg.write_page(pno, page)
page, pno = new_page(), pno + 1
s = insert(page, row)
rid[r[0]] = (pno, s)
pg.write_page(pno, page)
pg.f.flush()
return pg, rid

if s is None เป็น​บรรทัด​ที่​ควร​หยุด​อ่าน​สัก​ครู่ เพราะ​เขียน​เป็น if not s ก็​ผ่าน​สายตา​คน​รีวิว​ได้​ง่าย​มาก

insert คืน​หมายเลข slot และ​แถว​แรก​ของ​หน้า​ได้​หมายเลข 0 เสมอ ซึ่ง Python ตีความ​ว่า​เป็น​เท็จ เงื่อนไข​ที่​เขียน​ผิด​จึง​เข้าใจ​ว่า​แถว​แรก​สุด​ของ​ทั้ง file ใส่​ไม่​ลง มัน​ปิด​หน้า​แรก​ทั้ง​ที่​มี​อยู่​แถว​เดียว แล้ว​เขียน​แถว​นั้น​ซ้ำ​ลง​หน้า​ถัด​ไป

สิ่ง​ที่​ทำให้​บั๊ก​นี้​เงียบ​คือ​มัน​เกิด​ครั้ง​เดียว​จบ s ตัว​ใหม่​ที่​ได้​หลัง​เปิด​หน้า​ไม่​ถูก​ทดสอบ​ซ้ำ แถว​ถัด​ไป​ได้​หมายเลข slot 1 ซึ่ง​เป็น​จริง เงื่อนไข​จึง​ไม่​เข้า​อีก​เลย​ทั้ง file ส่วน​ตอน​ที่​หน้า​เต็ม​จริง insert คืน None ซึ่ง​เป็น​เท็จ​อยู่​แล้ว พฤติกรรม​ตรง​นั้น​ยัง​ถูก

ผล​คือ heap ที่​เกิน​มา​หนึ่ง​หน้า และ​เดิน​อ่าน​กลับ​ได้​แถว​เกิน​มา​หนึ่ง​แถว ไม่มี exception ไม่มี stack trace มี​แต่​ตัว​นับ​ที่​เกิน​ไป​ที​ละ​หนึ่ง กับ​การ​ตรวจ histogram สถานะ​ท้าย​บท​ที่​จะ​ไม่​ตรง​กับ​ของ fixture.py อยู่​หนึ่ง​แถว

รัน​จาก folder ทำงาน​ด้าน​นอก​เหมือน​บท​ที่​แล้ว

Terminal window
python3 qe/heapfile.py

สอง​ค่าที่​ต้อง​ตรง​ก่อน​อย่าง​อื่น​คือ​จำนวน​หน้า 3,153 หน้า กับ​แถว​ที่​อ่าน​กลับ​มา​ได้ 200,000 แถว ถ้า​สอง​ค่า​นี้​ตรง ตัวเลข​ที่​เหลือ​ทั้ง​บท​จะ​ตรง​ตาม​ไป​ด้วย

เดิน​อ่าน​กลับ​ทั้ง file แล้ว​วัด​รูปร่าง​ของ​มัน​ได้​แบบ​นี้

ค่าผล​ที่​วัด​ได้
จำนวน​หน้า​ทั้งหมด3,153 หน้า
ขนาด file12,914,688 B
แถว​ที่​อ่าน​กลับ​มา​ได้200,000 แถว
แถว​ต่อ​หน้า เฉลี่ย63.43 แถว
แถว​ต่อ​หน้า น้อย​สุด / มาก​สุด (ไม่​นับ​หน้า​สุดท้าย)58 / 68 แถว
หน้า​สุดท้าย36 แถว
ที่​ว่าง​ที่​เหลือ​ใน​หน้าที่​เขียน​เต็ม​แล้วน้อย​สุด 0 B / มาก​สุด 82 B / เฉลี่ย 32.50 B

แถว​ต่อ​หน้า​แกว่ง​จาก 58 ถึง 68 ไม่​ได้​แกว่ง​เพราะอัลกอริทึม​การ​แพ็ก​ฉลาด​บ้าง​ไม่​ฉลาด​บ้าง อัลกอริทึม​นี้​ไม่มี​ทาง​เลือก​อะไร​เลย มัน​แทรก​ไป​เรื่อยๆ จน​ใส่​ไม่​ลง

มัน​แกว่ง​เพราะ​ชื่อ​ร้าน​ยาว 27 ถึง 60 byte ล้วนๆ หน้าที่​บังเอิญ​ได้​ร้าน​ชื่อ​สั้น​มา​เยอะ​ก็​ใส่​ได้ 68 แถว หน้าที่​ได้​ร้าน​ชื่อ​ยาว​ก็ได้ 58 แถว

flowchart TD
    A["แถวถัดไปที่ต้องเขียน"] --> B{"ที่ว่างในหน้าปัจจุบัน<br/>พอสำหรับแถวบวกอีก 4 byte ไหม"}
    B -- "พอ" --> C["แทรกลงหน้าปัจจุบัน<br/>เพิ่มจำนวน slot ขึ้นหนึ่ง<br/>เลื่อนจุดเริ่มข้อมูลไปทางซ้าย"]
    C --> A
    B -- "ไม่พอ" --> D["เขียนหน้าปัจจุบันลง heap file<br/>ที่ว่างที่เหลือกลายเป็นหางหน้า"]
    D --> E["เปิดหน้าใหม่ แล้วแทรกแถวเดิมลงไป"]
    E --> A

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

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

fill factorfill factorสัดส่วน​ของ​เนื้อที่​ใน​หน้าที่​ถูก​ใช้​จริง สูง​มาก​ตอน​ที่​ไฟล์​ยัง​เขียน​ต่อ​ท้าย​อย่าง​เดียว และ​ตกลง​เมื่อ​ตาราง​ถูกลบ​หรือ​แก้​จน​หน้า​แตก​เป็น​รู ของ heap นี้​คือ 12,810,514 B ที่​ถูก​ใช้​จริง จาก 12,914,688 B ทั้ง file คิด​เป็น 99.19 %

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

ส่วนสัดส่วน​ของ​ทั้ง file
ข้อมูล​แถว92.90 %
slot directory และ header6.29 %
หาง​หน้าที่​ใส่​แถว​ถัด​ไป​ไม่​ลง0.81 %

หาง​หน้า​คือ​สิ่ง​ที่​คน​คาด​ว่า​จะ​เสีย​มาก​ที่สุด เพราะ​มัน​เห็น​ภาพ​ง่าย ปิด​หน้า​ทิ้ง​ทั้ง​ที่​ยัง​เหลือ​ที่​ว่าง คูณ​ด้วย 3,153 หน้า

แต่​มัน​เป็น​ก้อน​ที่​เล็ก​ที่สุด​ใน​สาม​ส่วน แค่ 0.81 % เพราะ​แถว​เฉลี่ย 59.99 B เล็ก​เมื่อ​เทียบ​กับ​หน้า 4096 B หาง​ที่​เหลือ​ของ​หน้าที่​เขียน​เต็ม​แล้ว​จึง​เฉลี่ย​แค่ 32.50 B

สิ่ง​ที่​กิน​จริง​คือ slot directory ซึ่ง​คิด 4 B ต่อ​แถว รวม​กับ header อีก​หน้า​ละ 4 B ได้ 6.29 % ของ​ทั้ง file แพง​กว่า​หาง​หน้า​เกือบ 8 เท่า

overhead ของ heap เป็น​ค่า​ต่อ​แถว ไม่ใช่​ค่า​ต่อ​หน้า

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

ผล​วัด​บอก​ตรง​ข้าม 0.81 % อยู่​ที่​ขอบ​ของ​หน้า ส่วน 6.29 % อยู่​ที่ slot ที่​ทุก​แถว​ต้อง​จ่าย บวก header อีก​หน้า​ละ 4 B ซึ่ง​เป็น​ก้อน​ที่​เล็ก​กว่า​มาก

ผล​ตาม​มา​คือ​การ​ลด overhead ของ heap ต้อง​ไป​ลด​ที่​ขนาด​ของ slot หรือ​จำนวน​แถว ไม่ใช่​ที่​การ​จัด​หน้า​ให้​เต็ม​ขึ้น และ​มัน​จะ​กลับ​มา​อีก​ครั้ง​ตอน​บท 3 ตอน​คิด​ว่า​รายการ​หนึ่ง​รายการ​ใน​ใบ​ของ B+tree ควร​ใหญ่​แค่​ไหน

โครงสร้าง​ทั้งหมด​นี้​มี​ราคา​ที่​วัด​ได้ ถ้า​เท​แถว​ดิบ​ต่อ​กัน​โดย​ไม่​แบ่ง​หน้า​เลย 200,000 แถว​จะ​ใช้ 2,930 หน้า

heap ใช้ 3,153 หน้า มากกว่า​กัน 223 หน้า คิด​เป็น 7.61 % และ​สิ่ง​ที่​ได้​กลับ​มา​คือ​ความ​สามารถ​อย่าง​เดียว คือ​ระบุ​ตำแหน่ง​ของ​แถว​ใด​แถว​หนึ่ง​แล้วกระโดด​ไป​อ่าน​ได้​เลย

การ​เท​แถว​ดิบ​ต่อ​กัน​ไม่ใช่​ทาง​เลือก​จริง

ใน​กอง​ที่​เท​ต่อ​กัน​ไม่มี header ไม่มี slot และ​ไม่มี​ขอบเขต​ของ​หน้า การ​จะ​รู้​ว่า​แถว​ที่​ต้องการ​เริ่ม​ที่ byte ไหน​ต้อง​ไล่​อ่าน​จาก byte แรก​ของ file มา​เรื่อยๆ

แปล​ว่า​มัน​ตอบ​ได้​แค่​คำถาม​เดียว​คือ​อ่าน​ทั้ง​ตาราง และ​ตอบ​คำถาม “ขอ​แถว​นี้​แถว​เดียว” ไม่​ได้​เลย

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

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

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

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

การ​อ่าน​สอง​แบบ​ใช้ Pager ตัว​เดียวกัน​และ​นับ​ด้วย​ตัวแปร reads ตัว​เดียวกัน ต่าง​กัน​แค่​ลำดับ​ของ​หน้าที่​ขอ

การ​อ่านหน้าที่​แตะแถว​ที่​ได้หน้าที่​แตะ​ต่อ​หนึ่ง​แถว
เรียง​ทั้ง​ตาราง3,153200,0000.01577
หยิบ​สุ่ม​ด้วย record id1,0001,0001.00000

การ​อ่าน​เรียง​หยิบ​ได้​เฉลี่ย 63.43 แถว​ต่อ​หนึ่ง​หน้าที่​แตะ ส่วน​การ​หยิบ​ด้วย record id ยก​หน้า​มา​ทั้ง​หน้า​เพื่อ​แถว​เดียว

0.50 % ของ​แถว จ่าย​ไป 31.72 % ของ​ราคา​การ​อ่าน​ทั้ง​ตาราง

1,000 แถว​ที่​หยิบ​มา​คิด​เป็น 0.50 % ของ​ตาราง

1,000 หน้าที่​จ่าย​ไป​คิด​เป็น 31.72 % ของ​ราคา​การ​อ่าน​ทั้ง​ตาราง

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

ตัวเลข 1,000 หน้า​เป็น​ขอบ​บน​ของ​กรณี​ที่ cache อะไร​ไม่​ได้​เลย เพราะ Pager ยัง​ไม่มี buffer pool

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

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

อีก​อย่าง​ที่​ค่า 1.00000 ไม่​ได้​คิด​ราคา​ให้​คือ​การ​รู้​ว่า record id ของ​แถว​ที่​ต้องการ​คือ​อะไร บท​นี้​ได้​มัน​มา​ฟรี​จาก dict ที่ build_heap คืน​กลับ​มา ซึ่ง​ลอย​อยู่​ใน​หน่วย​ความ​จำ​ล้วนๆ และ​เป็น​ราคา​ที่​บท​หน้า​ต้อง​จ่าย​จริง

สอง​แบบ​นี้​ไม่​ได้​มี​แบบ​ไหน​ถูก​กว่า​เสมอ มัน​มี​จุด​ที่​ราคา​เท่า​กัน

ภาย​ใต้​ขอบ​บน​เดียวกัน​นี้ คือ​หนึ่ง​หน้า​ต่อ​หนึ่ง​แถว จุด​นั้น​อยู่​ที่ 3,153 แถว หรือ 1.58 % ของ​ตาราง หยิบ​น้อย​กว่า​นั้น​การ​ไล่​ตาม record id ถูก​กว่า มากกว่า​นั้น​การ​อ่าน​ทั้ง​ตาราง​ถูก​กว่า ถ้า​คิด cache เข้าไป​ด้วย จุด​นี้​จะ​ขยับ​สูง​ขึ้น

1.58 % ต่ำ​กว่า​ที่​คน​ส่วน​ใหญ่​เดา​มาก และ​เหตุผล​อยู่​ใน​ตาราง​ข้าง​บน​แล้ว คือ​หน้าที่​แตะ​ต่อ​หนึ่ง​แถว​ของ​การ​หยิบ​ด้วย record id คือ 1.00000 ขณะ​ที่​ของ​การ​อ่าน​เรียง​คือ 0.01577

คำถาม​ระดับ​ร้าน​ที่ heap ตอบ​ได้ ราคา​ที่​ต้อง​จ่าย​เพื่อ​ตอบ และ​จำนวน​หน้าที่​ตัด​ทิ้ง​ได้​จริง​ถ้า​รู้​ล่วงหน้า

ถาม​คำถาม​ที่​ใคร​ก็​ถาม ร้าน​ครัว​คุณ​แม่​มี​กี่​ออเดอร์

คำ​ตอบ​คือ 11,934 ออเดอร์ คิด​เป็น 5.97 % ของ​ตาราง และ​วิธี​เดียว​ที่ heap ตอบ​ได้​คือ​อ่าน​ทั้ง 3,153 หน้า​แล้ว​นับ

ราคา​นี้​ไม่​ขึ้น​กับ​คำ​ตอบ​เลย ถ้า​คำ​ตอบ​มี​แถว​เดียว​ก็ 3,153 หน้า ถ้า​มี​สอง​แสน​แถว​ก็ 3,153 หน้า เพราะ heap เก็บ​แถว​เรียง​ตาม​ลำดับ​ที่​เขียน​เข้าไป ไม่​ได้​เรียง​ตาม​ชื่อ​ร้าน

คำถาม​ที่​ตาม​มา​ทันที​คือ ถ้า​รู้​ล่วงหน้า​ว่า​หน้า​ไหน​ไม่มี​คำ​ตอบ จะ​ประหยัด​ได้​เท่าไร

ค่าผล​ที่​วัด​ได้
หน้าที่​ต้อง​แตะ3,153 หน้า
หน้าที่​มี​คำ​ตอบ​อยู่​จริง3,079 หน้า (97.65 % ของ file)
หน้าที่​อ่าน​มา​แล้ว​ทิ้ง​เปล่า74 หน้า

แถว​ของ​ร้าน​เดียว​ที่​กิน​แค่ 5.97 % ของ​ตาราง กระจาย​อยู่​บน 97.65 % ของ​หน้า เพราะ​แถว​ถูก​เขียน​เรียง​ตาม​เวลา​ที่​ออเดอร์​เข้า​มา ไม่​ได้​เขียน​แยก​ตาม​ร้าน

การกรอง​ระดับ​หน้า​จึง​ตัด​งาน​ทิ้ง​ได้ 74 หน้า​จาก 3,153 หน้า ซึ่ง​แทบ​ไม่​ต่าง​จาก​ไม่​กรอง​เลย

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

ก่อน​จะ​ไป​ต่อ มี​การ​ตรวจ​หนึ่ง​อย่าง​ที่​ต้อง​ผ่าน​ก่อน คือ​จำนวน​แถว​ราย​สถานะ​ที่​นับ​ได้​จาก​การ​เดิน heap ต้อง​ตรง​กับ​ที่ fixture.py พิมพ์​ออก​มา​ทุก​ค่า

statusจำนวน​แถว​ที่​นับ​ได้​จาก heap
Placed199
Confirmed383
Preparing583
PickedUp806
Delivered163,733
Rejected13,165
Cancelled21,131

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

เงื่อนไข​ที่​ทำให้ fill factor ของ​บท​นี้​สูง​ขนาด​นี้ และ​ข้อ​จำกัด​ที่​ทำให้​ต้อง​มี​บท​ถัด​ไป

99.19 % เป็น​ค่า​ของ heap ที่​เขียน​ต่อ​ท้าย​อย่าง​เดียว ไม่​เคย​ลบ​และ​ไม่​เคย​แก้​แถว​สัก​ครั้ง

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

ตัวเลข​ที่​วง​สนทนา​มัก​ยก​มา​คือ​หน้า​เต็ม​ราวๆ 70 % ซึ่ง​เป็น​ค่า​หลัง​ผ่าน​การ​ใช้งาน​มา​แล้ว ทั้ง​สอง​ตัวเลข​จริง​พร้อม​กัน​ได้ เพราะ​มัน​วัด​คนละ​ช่วง​ชีวิต​ของ file เดียวกัน

ทุก​ตัวเลข​ใน​บท​นี้​จึง​ติด​เงื่อนไข​เดียวกัน​หมด คือ​เป็น​ตัวเลข​ของ​ข้อมูล​ชุด​นี้ บน heap ที่​ยัง​ไม่​เคย​ถูกลบ​หรือ​แก้ และ​วัด​โดย​ไม่มี buffer pool

สิ่ง​ที่​ยก​ไป​ใช้​ต่อ​ได้​คือ​วิธี​วัด​กับ​ข้อ​สรุป​เชิง​โครงสร้าง เช่น overhead อยู่​ที่​แถว หรือ​จุด​คุ้ม​ทุน​ของ​การ​ไล่​หยิบ​ด้วย record id ต่ำ​กว่า​ที่​คน​เดา ไม่ใช่​ตัวเลข 99.19 % หรือ 1.58 % ที่​ยก​ไป​แปะ​กับ​ตาราง​อื่น​ได้​ทันที

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

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


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • The SQLite Database File Format (ตรวจ 2026-08-11) — รูปแบบ file ของ​จริง​ที่​จัด​หน้า​ด้วย​แนวคิด​เดียว​กับ​ที่​บท​นี้​สร้าง คือ cell pointer array ที่​โต​จาก​ต้น​หน้า กับ​เนื้อ​ข้อมูล​ที่​โต​สวน​กลับ​มา​จาก​ท้าย​หน้า ส่วน​ขนาด​หน้า 4096 byte ที่​คอร์ส​นี้​ใช้​ตรง​กับ​ค่า​ปริยาย​ของ SQLite พอดี บท capstone จึง​วาง​หน่วย​ของ​สอง​ฝั่ง​ทาบ​กัน​ได้
  • Alex Petrov, Database Internals, O’Reilly 2019, ISBN 978-1-492-04034-7 — บท​ที่ 3 File Formats ว่าด้วย slotted page กับ​การ​จัด​ที่​ว่าง​ใน​หน้า เป็นการ​อ่าน​ต่อ​สำหรับ​เรื่อง​ที่​บท​นี้​ตัด​ออก เช่น overflow page และ​การ​จัด​ที่​ว่าง​ใหม่​หลัง​ลบ​แถว

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

ข้อ 1 / 3

heap file ของบทนี้มี fill factor 99.19 % ถ้าแยกเนื้อที่ส่วนที่ไม่ใช่ข้อมูลแถวออกเป็นสองก้อน คือหางหน้าที่ใส่แถวถัดไปไม่ลง กับ slot directory ที่คิด 4 B ต่อแถว บวก header ของทุกหน้า ก้อนไหนกินที่มากกว่ากัน