跳轉到

2.2 記錄與槽頁結構#

概念說明#

上一節的頁結構有明顯缺陷:刪除一筆記錄後浪費空間;記錄在頁內的位置一旦改變,所有指向它的指標都會失效。資料庫與檔案系統普遍採用槽頁(slotted page)解決這些問題,核心思想是把頁分成兩端:

┌────────────────────────┐
│ 頁首 header            │ ← slot_count, free_ptr, ...
│ slot[0] (offset,size)  │
│ slot[1]                │
│ ...                    │
│ slot[n-1]              │ ← slot 目錄與記錄資料相向增長
│ 空閒空間                │
│ record[n-1] (資料)     │
│ ...                    │
│ record[1]              │
│ record[0]              │ ← 頁尾
└────────────────────────┘
  • slot 目錄(slot directory):每個槽存放「記錄資料在頁內的偏移」與「長度」,槽編號固定,因此可用「(頁號, 槽號)」當作記錄的"位址",即使資料在頁內被搬移也不會失效。
  • 刪除記錄時,其資料區可以回收,槽可以標為空,或壓縮合併。
  • 更新記錄時:若新記錄,放不下,可以放溢位(overflow)頁;或把指標改為「間接引用」。

記錄(tuple)本身通常分成: - 定長記錄:每筆大小相同,可直接用槽號計算偏移,效率最高。 - 變長記錄:長度不一,需要槽;常見做法是「slot 內放 offset+size」,或記錄開頭放一個「類型/NULL 位圖 + 各欄長度」。

演算法步驟:槽頁的插入、刪除、更新#

  1. 插入:從 free_ptr 取得 free_start,寫入 slot[n] = (free_ptr - len, len),slot_count + 1。
  2. 刪除:
  3. 資料區不動,slot 標記為「空」或在 slot 目錄做壓縮排尋。
  4. 壓縮合併:把位於附近的 slot 去重。
  5. 更新:
  6. 新長度 ≤ 舊長度:原地壓縮(可選:< nil 時減 slot.size)。
  7. 新長度 > 舊長度:若頁內 free 空間夠,搬移資料;否則走溢位頁。
  8. 壓縮(compaction):把有效記錄往頁尾搬齊,讓 free 空間連續。
# 02.2 範例:完整的槽頁實作
import struct

PAGE_SIZE = 128
EMPTY = 0xFFFFFFFE

class SlottedPage:
    PAGE_HDR = 4      # u16 slot_count + u16 free_ptr
    SLOT_SIZE = 4     # u16 offset + u16 size

    def __init__(self, data: bytearray | None = None):
        self.p = data if data is not None else bytearray(PAGE_SIZE)
        struct.pack_into("<HH", self.p, 0, 0, PAGE_SIZE)   # (count, free_ptr)

    @property
    def count(self) -> int:
        return struct.unpack_from("<H", self.p, 0)[0]

    @property
    def free_ptr(self) -> int:
        return struct.unpack_from("<H", self.p, 2)[0]

    def _set_header(self, count, free_ptr):
        struct.pack_into("<HH", self.p, 0, count, free_ptr)

    def new_slot(self, rec: bytes) -> int:
        """插入記錄,回傳槽號;空間不足回 -1"""
        need = self.SLOT_SIZE + len(rec)
        slots_end = self.PAGE_HDR + self.count * self.SLOT_SIZE
        if self.free_ptr - slots_end < need:
            return -1
        off = self.free_ptr - len(rec)
        self.p[off:off + len(rec)] = rec
        struct.pack_into("<HH", self.p,
                         self.PAGE_HDR + self.count * self.SLOT_SIZE,
                         off, len(rec))
        self._set_header(self.count + 1, off)
        return self.count - 1

    def get(self, sid: int) -> bytes:
        base = self.PAGE_HDR + sid * self.SLOT_SIZE
        off, size = struct.unpack_from("<HH", self.p, base)
        return bytes(self.p[off:off + size])

    def delete(self, sid: int):
        base = self.PAGE_HDR + sid * self.SLOT_SIZE
        struct.pack_into("<HH", self.p, base, 0, 0)   # slot 清空

    def compact(self):
        """壓縮:把所有有效記錄往頁尾搬齊,slot 同步重排"""
        recs = []
        for i in range(self.count):
            base = self.PAGE_HDR + i * self.SLOT_SIZE
            off, size = struct.unpack_from("<HH", self.p, base)
            if size:
                recs.append(bytes(self.p[off:off + size]))
        new_p = bytearray(PAGE_SIZE)
        struct.pack_into("<HH", new_p, 0, len(recs), PAGE_SIZE)
        off = PAGE_SIZE
        for i, r in enumerate(recs):
            off -= len(r)
            new_p[off:off + len(r)] = r
            struct.pack_into("<HH", new_p, self.PAGE_HDR + i * self.SLOT_SIZE,
                             off, len(r))
        self.p = new_p


def demo():
    page = SlottedPage()
    ids = []
    for i in range(20):
        sid = page.new_slot(f"rec{i:03d}".encode())
        if sid >= 0:
            ids.append(sid)
    print(f"放入 {len(ids)} 筆記錄, free_ptr = {page.free_ptr}")
    page.delete(ids[3]); page.delete(ids[7])
    print("刪除 2 筆後:", sum(1 for i in range(page.count)
                               if struct.unpack_from('<H', page.p,
                                   page.PAGE_HDR+i*page.SLOT_SIZE+2)[0]))
    page.compact()
    print(f"壓縮後 free_ptr = {page.free_ptr}, 槽數 = {page.count}")

if __name__ == "__main__":
    demo()

複雜度與 I/O 成本分析#

  • 插入:O(1)(頁內),I/O 成本:1 讀 + 1 寫。
  • 點存取(給定頁號+槽號):O(1),1 筆 I/O。
  • 刪除:O(1);壓縮:O(頁內記錄數) 但僅頁內操作,無額外 I/O。
  • 頁空間利用率:槽頁跟純定長結構接近,會留 'slot 目錄 + free_ptr' 一些 overhead,一般可達 95% 以上。
  • 指標穩定性:(pid, sid) 是不變的"地址",即使頁內資料搬移或壓縮,指標仍有效;這是槽頁最重要的工程意義——索引、外鍵都依賴它。

練習#

  1. 改寫 compact() 支援「回收空槽」而非全量重建,讓槽號不位移(保留 dual-slot:有效 + 空槽)。分析這對誰有利、誰不利。
  2. 增加 update(sid, new_rec):若 new_rec 比舊記錄大且頁內放不下,配置一個新的溢位頁,並在原 slot 存「溢位指標」。實作這段程式。
  3. 想一個情境:為什麼「append-only(只插入不刪除)」的資料集使用「純定長頁」比使用槽頁更好?