跳轉到

1.3 SSD 與快閃記憶體的特性#

概念說明#

固態硬碟(SSD)以 NAND 快閃記憶體取代機械結構,徹底改變了磁碟演算法的取捨。要理解 SSD,必須認識它的三層結構與「寫入不對稱」規則:

結構層級: - 頁(page):讀寫最小單位,典型 4~16KB。 - 區塊(block,erase block):抹除最小單位,由 64~512 頁組成,約 256KB~4MB。 - 平面、晶片、通道:SSD 內部可並行存取多個平面,因此能靠「內部並行」達到高吞吐。

關鍵規則: 1. 讀寫不對稱:讀一頁約 25~100μs;寫一頁約 200~900μs;抹除一個區塊需數毫秒,且抹除的速度慢於寫。 2. 寫之前必須先抹除:頁不能「原地覆寫」,只能把整個區塊抹成全 1 之後才能讓其中的頁從 1 變 0。 3. 寫入放大(write amplification):SSD 內部的快閃轉譯層(FTL)會做垃圾回收(garbage collection),把有效頁搬到新區塊、抹除舊區塊,導致主機寫 4KB,內部可能實際寫了數十 KB。 4. 磨損(wear):每個 cell 抹寫次數有限(TLC 約 1000~3000 次),FTL 做磨損均衡(wear leveling) 平均使用所有 cell。

因此 SSD 的演算法設計原則轉為: - 順序寫入優於原地更新(LSM 樹、log-structured 檔案系統皆秉持此精神)。 - 頁要對齊(alignment):請求大小與位址對齊頁邊界,可減少讀-改-寫(read-modify-write)。 - 一次寫大頁、一次寫多頁:攤提寫入成本,減少垃圾回收壓力。 - 避免過度隨機小寫:雖然 SSD 隨機讀很快,隨機小寫仍會觸發寫入放大與磨損。

演算法步驟:模擬 SSD 的寫入與垃圾回收#

我們建立一個簡化的頁→區塊 FTL 模擬器,展示:寫入、邏輯位址重映射(copy-on-write)、垃圾回收的成本。

# 01.3 範例:SSD FTL 與垃圾回收模擬
class FlashBlock:
    def __init__(self, num_pages: int):
        self.pages = [None] * num_pages   # None 表示完全空白(已抹除)
        self.valid = 0                    # 有效(未被更新取代)的頁數

class SimpleFTL:
    def __init__(self, blocks=64, pages_per_block=8):
        self.blocks = [FlashBlock(pages_per_block) for _ in range(blocks)]
        self.map = {}                     # 邏輯頁 -> (block, page)
        self.write_cursor = 0             # 循序寫入游標
        self.stats = {"write": 0, "erase": 0, "gc_copy": 0, "amp_bytes": 0}

    def _new_block(self):
        # 簡化示範:循環使用區塊,需 GC 時,把還 valid 的頁搬到新區塊
        free_idx = next((i for i, b in enumerate(self.blocks) if not b.valid
                         and b.pages[0] is None), None)
        if free_idx is not None:
            return free_idx
        # 找有效頁最少的區塊做 GC
        victim = min(range(len(self.blocks)), key=lambda i: self.blocks[i].valid)
        v = self.blocks[victim]
        self.stats["gc_copy"] += v.valid
        self.stats["amp_bytes"] += v.valid * 4096
        dst = self.blocks[next(i for i, b in enumerate(self.blocks)
                                if b is not v)]
        di = 0
        for pi, d in enumerate(v.pages):
            if d is not None:
                dst.pages[di] = d
                di += 1
        v.pages, v.valid = [None] * len(v.pages), 0
        self.stats["erase"] += 1         # 抹除 victim
        self.write_cursor = victim       # 送到空出的區塊
        return victim

    def write_page(self, lpid: int, data: bytes = b""):
        self.stats["write"] += 1
        self.stats["amp_bytes"] += 4096
        if lpid in self.map:              # 舊頁失效(copy-on-write)
            b, p = self.map[lpid]
            self.blocks[b].valid -= 1
        bi = self._new_block()
        fb = self.blocks[bi]
        pi = next(i for i, d in enumerate(fb.pages) if d is None)
        fb.pages[pi] = lpid
        fb.valid += 1
        self.map[lpid] = (bi, pi)

# 展示:反覆原地更新同一頁時,寫入放大有多嚴重
ftl = SimpleFTL()
for i in range(40):
    ftl.write_page(i % 8)                 # 反覆寫同一 8 頁的邏輯位址
s = ftl.stats
print(f"主機寫入 40 頁, 內部 GC 搬移 {s['gc_copy']} 頁, 抹除 {s['erase']} 個區塊")
print(f"寫入放大 = {(s['gc_copy'] * 4096) / (s['write'] * 4096):.2f}x")

執行可觀察到:當我們只寫 8 個邏輯頁但反覆寫 40 次,內部 GC 需要多次搬移有效頁,寫入放大遠大於 1。這正是「原地更新」對 SSD 不友善的直接證據。

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

操作 HDD 隨機 SSD 隨機
讀 4KB 5~12ms 25~100μs
寫 4KB 5~12ms 200~900μs
抹除 2MB 區塊 (無此操作) 數 ms
  • IOPS 差距:SSD 可達數萬~百萬 IOPS,HDD 只有百餘 IOPS,百倍到千倍差距。
  • 寫放大 WA = 內部寫入量 / 主機寫入量,理想接近 1,最差可達 5~10 倍,直接影響壽命與效能。
  • 磨損均衡:若一個區塊被寫 10⁴ 次,TLC 只能承受 10³ 次,需要磨損均衡把寫入分攤到全盤,因此 SSD 總壽命 ≈ 容量 × P/E 次數 / 寫放大。

由此可以理解第 6 章 LSM 樹的崛起:它只做「順序 append + 後台 compaction」,把隨機寫轉為順序寫以擁抱 SSD 的本質。

練習#

  1. 使用上面的 SimpleFTL,把工作集從 8 頁改為 64 頁,觀察 GC 搬移次數如何下降,解釋工作集大小與寫放大的關係。
  2. 假設 TLC 的 P/E 生命為 3000 次、容量 1TB、寫放大 3,估算總累積寫入量(TBW)是多少 TB?若每天寫 50GB,能撐幾年?
  3. 討論:為什麼資料庫把 fsync 一次控制在 4KB~1MB 更有效率?SSD 上「一個大寫 vs 多個小寫」的性價比與 HDD 有什麼本質不同?