本章要解决什么
如何用闪存构建 SSD?如何应对擦除(erase)操作的高昂代价?反复覆盖写会把器件"写坏"(磨损,wear out),如何让设备用得久?
How can we build a flash-based SSD? How can we handle the expensive nature of erasing? How can we build a device that lasts a long time, given that repeated overwrite will wear the device out?
前几章我们一直在和机械硬盘打交道:寻道、旋转、顺序比随机快几百倍。这一章换成另一种持久存储——固态存储(solid-state storage),它没有任何机械部件,由晶体管构成,却和 DRAM 不同,断电后数据不丢。我们关注的技术是 NAND 闪存(NAND-based flash)。
闪存有两个"怪脾气":要写一个页,必须先擦除它所在的整个块(而且擦除很慢);同一个块擦写太多次会磨损报废。SSD 内部的闪存转换层(FTL,flash translation layer)要在这两个限制下,对外假装成一块普通的"按块读写"的磁盘。你会看到,第 43 章日志结构文件系统(LFS)的思想——追加写 + 映射 + 垃圾回收——在这里被原样搬进了设备内部。
核心概念速览
页(page)与块(block)
页约 4KB,是读和编程的单位;块(擦除块)约 128KB–2MB,含几十到几百页,是擦除的单位。
擦除 → 编程 → 读
擦除把整块置为全 1(几毫秒);编程把已擦除页的某些 1 改成 0(几百微秒);读一页只要几十微秒。
页状态 i / E / V
INVALID → 擦除 → ERASED → 编程 → VALID。VALID 页不能再编程,只能等整个块被擦除。
FTL
把主机对逻辑块的读写,翻译成对物理块/页的 read / erase / program;目标是高性能、高可靠。
写放大(write amplification)
FTL 发给闪存的总写入量 ÷ 主机发给 SSD 的写入量。越接近 1 越好。
日志结构 FTL
写总是追加到当前块的下一个空闲页,用映射表记录"逻辑页 → 物理页";覆盖写留下垃圾。
垃圾回收(GC)
挑一个含垃圾页的块,把其中存活页搬到日志末尾,再擦除整块回收空间。
磨损均衡(wear leveling)
让所有块被擦写的次数大致相同,避免少数"热"块先坏掉;代价是额外的数据搬迁。
从比特到块和页
一个单元存几个比特:SLC / MLC / TLC
闪存芯片把一个或多个比特存进一个晶体管:晶体管里困住的电荷量高低被映射成二进制值。
- 单层单元(SLC,single-level cell):每个晶体管只存 1 个比特(0 或 1),只需区分两种电荷水平。
- 多层单元(MLC,multi-level cell):存 2 个比特,00、01、10、11 分别对应低、偏低、偏高、高四种电荷水平。
- 三层单元(TLC,triple-level cell):存 3 个比特,要区分 8 种电荷水平。
块和页:闪存的两级组织
闪存芯片由若干 bank(也叫 plane)组成,每个 bank 里有大量单元,按两种不同大小的单位访问:
- 块(block,也叫擦除块 erase block):通常 128KB 或 256KB(原书术语总结里给出的范围是 128KB–2MB);
- 页(page):几 KB(例如 4KB;范围 1KB–8KB)。每个 bank 里有很多块,每个块里有很多页。
注意术语!这里的"块"和"页"跟磁盘的块、虚拟内存的页不是一回事。原书提示:"What is a block to you may be a page to someone else"——在闪存语境里,页是读/写的小单位,块是擦除的大单位。
三种基本操作:读、擦除、编程
闪存芯片支持三种底层操作:读用来读页;擦除和编程配合起来完成写。
| 操作 | 粒度 | 效果 | 耗时 |
|---|---|---|---|
| 读(read) | 页 | 给出页号即可读出,与位置和上一个请求的位置基本无关——闪存是随机访问设备 | 几十 µs |
| 擦除(erase) | 块 | 把块内每个比特置为 1,销毁整块内容;执行前必须先把块里还有用的数据搬走 | 几 ms(最贵) |
| 编程(program) | 页 | 在已擦除的页上把某些 1 改成 0,写入页内容 | 几百 µs |
最好用页的状态来理解这些操作:页一开始是 INVALID(i);擦除所在块后,块内所有页变成 ERASED(E)——内容被重置,并且可编程;编程一个页后它变成 VALID(V),内容已设定、可以读取。读不改变状态。一个页一旦被编程,想改它的内容只能擦除整个块。
一个闪存块:擦除、编程、读
原书的详细示例:块里 4 个页都已 VALID(内容 00011000 11001110 00000001 00111111)。想把页 0 改成 00000011,必须先擦除整个块(4 页全变 11111111),再编程页 0。坏消息是:页 1、2、3 的旧内容全没了!所以覆盖块内任意一页之前,必须先把我们关心的数据搬到别处(内存或另一个闪存块)。擦除的这个性质,将决定整个 SSD 的设计。
闪存的性能与可靠性
| 器件 | 读(µs) | 编程(µs) | 擦除(µs) |
|---|---|---|---|
| SLC | 25 | 200–300 | 1500–2000 |
| MLC | 50 | 600–900 | ~3000 |
| TLC | ~75 | ~900–1350 | ~4500 |
原书 Figure 44.2:裸闪存的性能特征(数字来自大众媒体 [V12])。
可靠性:磨损与扰动
闪存是纯硅器件,不像硬盘那样会出现磁头划伤盘面(head crash)这类机械故障。它最主要的问题是:
- 磨损(wear out):每次擦除和编程,块里都会积累一点额外电荷;积累多了,就越来越难区分 0 和 1,最终整块不可用。厂商给 MLC 块标称 10,000 次 P/E(Program/Erase)周期,SLC 因为每个晶体管只存 1 比特,通常标称 100,000 次。不过研究发现实际寿命比预期长得多 [BD10]。
- 扰动(disturbance):访问某个页时,相邻页的某些比特可能被翻转。读引起的叫读扰动(read disturb),编程引起的叫编程扰动(program disturb)。为了减少编程扰动,FTL 通常在一个已擦除块内按页号从低到高顺序编程。
从裸闪存到 SSD:FTL
标准的存储接口很简单:按块地址读写 512 字节(或更大)的扇区。SSD 的任务就是在裸闪存芯片之上提供这个标准块接口。
原书的 TIP:定义稳定的接口,两边就能各自创新而保持互操作——文件系统看到的 SSD 和硬盘一样都是"块设备"。但接口过于僵化也有代价,比如 ZFS 就通过重新思考文件系统与 RAID 的分工,得到了更好的整体设计。后面的 trim 接口也是"实现反过来塑造接口"的例子。
SSD 内部有三样东西:若干闪存芯片(持久存储);一些易失性内存(如 SRAM,用来缓存和缓冲数据、存放映射表);以及协调一切的控制逻辑。控制逻辑里最核心的就是 FTL:把对逻辑块的读写请求,变成对物理块、物理页的 read / erase / program 命令。
FTL 的设计目标:
- 高性能:① 并行使用多个闪存芯片(现代 SSD 都这么做,本章不展开);② 降低写放大(write amplification)——FTL 发给闪存芯片的总写流量(字节)÷ 客户端发给 SSD 的总写流量(字节)。
- 高可靠:① 磨损均衡(wear leveling)——把写负载尽量均匀地分散到所有块上,让它们差不多同时磨损完;② 减少编程扰动——在已擦除块内按页号从低到高顺序编程。
糟糕的方案:直接映射
最简单的 FTL 叫直接映射(direct mapped):读逻辑页 N 就读物理页 N。写逻辑页 N 就麻烦了:FTL 要先读出 N 所在的整个块,然后擦除这个块,最后把旧页和新页一起重新编程。
直接映射在性能和可靠性上都很糟:性能上,每次写都要读整块(贵)、擦除(很贵)、再编程(贵),写放大与每块页数成正比;可靠性上,如果文件系统元数据或用户数据被反复覆盖,同一个块就被反复擦写,很快磨损坏掉——直接映射把磨损的控制权完全交给了客户端负载。
日志结构 FTL
因此今天大多数 FTL 都是日志结构(log structured)的——这个思想既可以用在存储设备里(本章),也可以用在设备之上的文件系统里(第 43 章 LFS)。写逻辑块 N 时,设备把它追加(append)到当前正在写的块的下一个空闲页;为了之后能读 N,设备维护一张映射表(mapping table),记录每个逻辑块所在的物理地址。
原书示例:SSD 有若干 16KB 的块,每块 4 个 4KB 的页;客户端依次执行 Write(100)=a1、Write(101)=a2、Write(2000)=b1、Write(2001)=b2。一开始所有页都是 INVALID,所以 FTL 决定把第一个写放到物理块 0 时,必须先擦除块 0;随后按顺序编程页 0、1、2、3,并记下 100→0, 101→1, 2000→2, 2001→3。读逻辑块 100 时,查表得到物理页 0,直接读即可。
和直接映射相比:擦除只是偶尔发生,昂贵的读-改-写完全消失;FTL 还可以把写分散到所有页上(磨损均衡)。下面的模拟器可以逐步重现原书的每一张状态图。
FTL 模拟器:直接映射 vs 日志结构(仿 ssd.py)
w逻辑页:内容(内容可省略)、r逻辑页 读、t逻辑页 trim、gc块号 手动回收某块断电后内存里的映射表就没了,这对"持久"存储设备是不可接受的。最简单的办法是在每个页旁边的带外区域(OOB,out-of-band)记录一些映射信息(比如"这一页存的是逻辑块 100")。重启时扫描所有 OOB 区重建映射表——但大容量 SSD 全盘扫描很慢,高端设备会用更复杂的日志和检查点(checkpoint)技术加速恢复(参见第 42 章崩溃一致性、第 43 章 LFS)。
日志结构并非没有代价。原书指出两个缺点:① 覆盖写产生垃圾,需要垃圾回收,过多的 GC 会推高写放大、降低性能;② 内存中的映射表很大,设备越大需要的内存越多。下面两节分别讨论。
垃圾回收(Garbage Collection)
接着原书的例子:逻辑块 100 和 101 再次被写入,内容为 c1、c2。它们被写到下一个空闲页(物理页 4、5)——在此之前设备必须先擦除块 1;映射表更新为 100→4, 101→5。现在问题来了:物理页 0 和 1 虽然状态仍是 VALID,里面却是 100、101 的旧版本——垃圾(garbage)。
找出垃圾块(也叫死块,dead block)并回收的过程叫垃圾回收。基本流程很简单:
- 找到一个含有垃圾页的块(例如块 0:页 0、1 是死的,页 2、3 存活);
- 读出其中的存活页(页 2、3);
- 把存活页写到日志末尾(页 6、7);
- 擦除这个块,供以后写入。
GC 怎么知道一页是死是活?每个块要在某处记下"每一页存的是哪个逻辑块"(类似 LFS 的段摘要)。GC 查映射表:块 0 存的是 100、101、2000、2001,映射表是 100→4, 101→5, 2000→2, 2001→3——页 2、3 仍被映射表指着,是活的;页 0、1 没有被指着,可以回收。
可见 GC 可能很昂贵:要读、要重写存活数据。最理想的回收对象是只含死页的块——直接擦除就能复用,无需任何数据搬迁。
硬盘的接口基本只有 read 和 write(外加缓存刷新)。对于日志结构的 SSD(以及任何维护"逻辑→物理"灵活映射的设备),多一个 trim 操作很有用:它告诉设备某个地址(范围)上的块已被删除,设备不必再维护它的映射,GC 时可以直接把对应物理页当垃圾回收,而不用傻傻地搬迁。对硬盘来说 trim 几乎没用,因为它的地址映射是静态的。这是"实现反过来塑造接口"的好例子。在上面的模拟器里试试 t逻辑页。
为了降低 GC 代价,一些 SSD 会预留空间(overprovision):多装一些闪存容量,这样清理就可以推迟、放到后台、在设备空闲时做;更多容量也带来更多内部带宽用于清理,不影响客户端感知到的带宽。本章的 C 实验会定量展示预留比例与写放大的关系。
映射表的大小
日志结构的第二个代价是映射表可能非常大:每个 4KB 页一个表项。原书的例子:1TB 的 SSD,每 4KB 页一个 4 字节表项,光映射表就要 1GB 内存!所以纯页级 FTL 不现实。
映射表要多大?
块级映射(block-based mapping)
一种思路是每个块只存一个指针,映射信息缩小为原来的 Sizeblock⁄Sizepage。这类似于虚拟内存里用更大的页:VPN 位数更少、偏移量更长。逻辑地址被切成"块号(chunk number)+ 偏移(offset)"两部分:每块 4 页时偏移占 2 位,其余高位是块号。
原书例子:逻辑块 2000~2003(内容 a、b、c、d)位于物理块 1(物理页 4~7)。页级映射要 4 个表项 2000→4, 2001→5, 2002→6, 2003→7;块级映射只需一个 500→4(2000~2003 的块号都是 500,偏移 0~3)。读 2002:块号 500 → 查表得 4 → 加偏移 2 → 物理页 6,读出 c。
块级映射的地址翻译
500 → 4(每块 4 页)。输入逻辑块地址,看 FTL 如何拆出块号和偏移。但块级映射对小写(small write,小于一个物理块)很不友好。客户端写 2002(新内容 c')时,FTL 必须读出 2000、2001、2003,再把四个逻辑块一起写到新位置(物理块 2,页 8~11),更新表项为 500→8,然后块 1 可以擦除复用。一次 4KB 的写引起了 3 次读和 4 次编程——物理块往往是 256KB 甚至更大,这种写放大太严重了。
混合映射(hybrid mapping)
为了兼顾灵活写入和较小的映射开销,许多现代 FTL 使用混合映射:
- FTL 保留少量已擦除的块,所有写都先写到这些块里,称为日志块(log block);日志块使用页级映射,记在小小的日志表(log table)里;
- 其余数据块使用块级映射,记在大一些的数据表(data table)里;
- 查找时先查日志表,没找到再查数据表。
关键是让日志块数量保持很少。FTL 要定期检查日志块,把它们"转换"成只需一个块指针就能表示的块。根据块中内容不同,有三种合并方式 [KK+02]:
混合映射的三种合并:switch / partial / full
- switch merge:客户端恰好按原顺序覆盖了 1000~1003(a'、b'、c'、d'),日志块 0 里的页排列与一个完整数据块一模一样——只需把数据表改成
250→0,旧块 2 擦除后当作新的日志块。所有页级指针被一个块指针取代,这是最好情况。 - partial merge:只覆盖了 1000、1001。要把块"凑完整",FTL 从旧块 2 读出 1002、1003 追加进日志块,结果与 switch merge 相同,但多了额外 I/O,写放大增加。
- full merge:日志块里是 0、4、8、12 这种分属不同逻辑块的页。FTL 要为 0 凑齐 0、1、2、3(从别处读 1、2、3,一起写出),对 4、8、12 重复同样的工作,最后才能释放日志块 A。频繁的 full merge 会严重伤害性能,应尽量避免 [GY+09]。
页映射 + 缓存
另一种更简单的办法:仍然用页级映射,但只把映射表的活跃部分缓存在内存中 [GY+09]。如果负载只访问一小部分页,它们的映射都在内存里,性能好且内存开销小;如果内存装不下工作集,每次访问都要先多一次闪存读把映射取进来;更糟的是,腾位置时若被淘汰的映射是脏的(还没持久化),还要额外写一次。好在大多数负载具有局部性——这和 TLB、页缓存的道理完全一样。
磨损均衡(Wear Leveling)
多次擦除/编程会磨损闪存块,因此 FTL 要尽量把这些工作均匀分摊到设备的所有块上,让它们差不多同时"寿终正寝",而不是几个"热门"块很快报废。
日志结构本身已经能较好地分散写负载,垃圾回收也有帮助。但有一种情况它们都无能为力:某个块装满了长期不被覆盖的冷数据——GC 永远不会回收它(里面没有垃圾),它也就得不到公平的写负载份额。解决办法:FTL 必须定期把这类块中的存活数据全部读出、重写到别处,让这个块重新可写。这种迁移增加了写放大、降低了性能——为了让所有块磨损速度一致,只能付出额外 I/O。
SSD 的性能与成本
SSD 没有机械部件,在"随机访问"这一点上更像 DRAM。和硬盘相比差别最大的是随机读写:硬盘每秒只能做几百次随机 I/O,SSD 则好得多。原书 Figure 44.4 给出了三款 SSD 和一款顶级硬盘的数据:
| 设备 | 随机读 MB/s | 随机写 MB/s | 顺序读 MB/s | 顺序写 MB/s |
|---|---|---|---|---|
| Samsung 840 Pro SSD | 103 | 287 | 421 | 384 |
| Seagate 600 SSD | 84 | 252 | 424 | 374 |
| Intel SSD 335 SSD | 39 | 222 | 344 | 354 |
| Seagate Savvio 15K.3 HDD | 2 | 2 | 223 | 223 |
从表中可以读出几点:
- 随机 I/O 差距最惊人:SSD 随机读写有几十到几百 MB/s,这块"高性能"硬盘只有约 2 MB/s(还是向上取整的)。
- 顺序性能差距小得多:如果只需要顺序性能,硬盘依然是不错的选择。
- SSD 的随机读反而不如随机写:原因正是日志结构设计——它把随机写变成了顺序写。
- SSD 的顺序与随机 I/O 之间仍有差距,所以前面为硬盘设计文件系统的许多技巧(如 FFS 的局部性)对 SSD 仍然适用。
成本
既然 SSD 这么快,为什么没有完全取代硬盘?答案是每单位容量的成本。原书写作时([A15]):250GB 的 SSD 约 150 美元,即每 GB 60 美分;1TB 的硬盘约 50 美元,即每 GB 5 美分——相差 10 倍以上。所以:看重性能(尤其随机读)选 SSD;要存海量数据的数据中心会倾向于硬盘;也可以混合使用——少量 SSD 存"热"数据,大量硬盘存"冷"数据。只要价格差距存在,硬盘就不会消失。
先预测再验证
w100:c1 之前。预测:c1 会被写到哪个物理页?这一步之前需要擦除哪个块?执行后映射表是什么?看答案
100→4, 101→1, 2000→2, 2001→3,物理页 0(a1)变成垃圾。再写 c2 后页 5 = c2,页 1 也变成垃圾,此时页 6、7 为 E,块 2 仍为 i——与原书图一致。gc0 回收块 0 之前,预测 GC 需要多少次闪存读、编程和擦除?回收后映射表和块 0 的状态是什么?看答案
100→4, 101→5, 2000→6, 2001→7,块 0 四页全为 E,块 1 为 c1 c2 b1 b2——正是原书 44.8 节最后那张图。看答案
w0 w1 w2 w3 然后对逻辑页 0 覆盖写 4 次。先预测直接映射下擦除、编程、读各多少次,写放大多少;再点"同样请求 → 日志 FTL"对比。看答案
看答案
看答案
动手实验:C 语言 FTL 模拟器
在 labs/44-ssd/ 里有一个几百行的 C 程序 ftlsim.c,实现了三种 FTL:direct(直接映射)、log(页映射日志结构 + 贪心 GC,预留 10% 空间)、log+wl(再加静态磨损均衡)。闪存为 256 块 × 64 页;先把逻辑空间顺序写满一遍,再测 20 万次写。四种负载:顺序覆盖、均匀随机、80/20 热点、cold90(只改写 10% 的逻辑页,其余是冷数据)。
cd labs/44-ssd && make && ./ftlsim -op
示例输出(模拟是确定性的,你的机器上应得到相同数字):
workload FTL host prog erase read WA wear time(s)
seq direct 200000 12800000 200000 12600000 64.00 768/832 10830.0
seq log 200000 200000 3125 0 1.00 0/14 159.4
seq log+wl 200000 200000 3125 0 1.00 0/14 159.4
random direct 200000 12800000 200000 12600000 64.00 708/882 10830.0
random log 200000 1010167 15785 810167 5.05 56/69 845.5
random log+wl 200000 1010167 15785 810167 5.05 56/69 845.5
hot80/20 direct 200000 12800000 200000 12600000 64.00 158/3310 10830.0
hot80/20 log 200000 1052361 16444 852361 5.26 47/91 881.2
hot80/20 log+wl 200000 1049956 16407 849956 5.25 53/83 879.2
cold90 direct 200000 12800000 200000 12600000 64.00 0/7978 10830.0
cold90 log 200000 246984 3861 46984 1.23 0/84 199.2
cold90 log+wl 200000 722492 11290 522492 3.61 37/68 601.9
预留空间比例 vs 写放大(log FTL,均匀随机写)
OP WA erase
5% 9.82 30683
7% 7.11 22211
10% 5.05 15785
15% 3.44 10747
20% 2.63 8230
28% 1.95 6108
40% 1.45 4546
50% 1.24 3868
怎么读这些数字:
- direct 的写放大恒为 64 = 每块页数,每次主机写都擦除一次;cold90 下热点块被擦了 7978 次而冷块 0 次——直接映射"把磨损的控制权交给了负载"。
- log 对顺序覆盖写放大 1.00:整块数据一起失效,GC 只需擦除,不用搬迁。对均匀随机写约 5:受害块里还混着存活页。
- 预留越多、写放大越低(5% → 9.8,50% → 1.24):预留空间让 GC 更容易找到"几乎全是垃圾"的块。
- 磨损均衡的代价:cold90 负载下纯 log 的写放大仅 1.23,但冷数据块一次都没被擦除(0/84);加上磨损均衡后擦除次数分布变均匀(37/68),写放大却涨到 3.61。
自测题
常见误区与小结
- "SSD 的块 = 文件系统的块":不对。闪存的"页"(~4KB)才接近文件系统块大小;闪存的"块"是擦除单位,大得多。
- "覆盖写就是原地改写":闪存不能原地改写;日志结构 FTL 总是写到新位置,旧版本变成垃圾。
- "擦除只擦一页":擦除总是整块,会销毁同块的其他页。
- "写放大只由 GC 造成":直接映射的读-改-写、块级映射的小写、混合映射的 merge、磨损均衡的迁移都会产生写放大。
- "SSD 上随机和顺序一样快":Figure 44.4 显示差距仍在,局部性优化依旧有价值。
- 闪存以页读和编程、以块擦除;页状态 INVALID → ERASED → VALID,VALID 页不能重编程。
- 读几十 µs、编程几百 µs、擦除几 ms;SLC/MLC/TLC 每单元存 1/2/3 比特,越多越便宜、越慢、越不耐用。
- 可靠性问题:磨损(P/E 次数有限)和读/编程扰动(块内按顺序编程可缓解)。
- FTL 把逻辑块读写翻译成闪存操作。直接映射性能和寿命都糟;日志结构 FTL 追加写 + 映射表是主流。
- 日志结构的两个代价:垃圾回收(推高写放大;trim、预留空间可缓解)与映射表太大(块级映射、混合映射、映射缓存可缓解)。
- 磨损均衡定期搬迁冷数据,用额外写放大换取整体寿命。
- SSD 随机 I/O 远超硬盘,但每 GB 成本贵 10 倍以上,冷热分层混合使用很常见。
与后续章节的联系:第 45 章"数据完整性"会讨论设备可能以各种方式悄悄出错(包括闪存的比特翻转),以及校验和如何检测它们;而本章的日志结构、映射与垃圾回收思想和第 43 章 LFS 一脉相承——同一个想法,既可以放在文件系统里,也可以放在设备固件里。