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