经典算法深度解析|纠删码(一):从多副本到 Reed-Solomon、有限域与 MDS 本质
只要系统开始认真做存储,工程师迟早会面对一个非常现实的问题:
- 数据必须可靠
- 成本不能失控
- 故障恢复不能太慢
- 容量利用率还得说得过去
最直接的办法当然是多副本。
例如一份数据写三份:
- 丢一台机器,还有两份
- 读性能往往还不错
- 实现简单,运维心智负担低
所以很多系统的第一步都会是三副本。
但当数据规模变大以后,多副本会很快把你拉回现实。
假设原始数据是 1 PB:
- 两副本要 2 PB 原始容量
- 三副本要 3 PB 原始容量
- 如果还有跨机架、跨可用区冗余,真实成本只会更高
这时你会发现,分布式存储真正长期绕不开的问题,不只是“怎么把数据写进去”,而是:
怎么用更低的冗余成本,换到接近多副本的可靠性。
纠删码,或者更常见地写成 EC(Erasure Coding),就是这条路线上的核心技术。
很多人第一次听到 EC 时,往往会先记住一句口号:
把一份数据切成多块,再额外算出若干校验块,只要剩下的块足够多,就能把原始数据恢复出来。
这句话不算错,但远远不够。
如果你真的想把 EC 用到分布式存储里,至少得能回答这些问题:
- EC 和多副本相比,究竟在交换什么
k+m条带模型到底是什么意思- 为什么大家总在讲 Reed-Solomon
- 有限域运算为什么是必须的,不能只靠普通整数加减乘除吗
- 生成矩阵和恢复矩阵到底在表达什么
- MDS 性质为什么如此重要
- 为什么 EC 明明很省容量,却没有把三副本彻底淘汰
这一组文章我准备先拆成三篇主线文章:
- 本文先把编码理论基础讲清楚:从副本问题、条带模型、有限域、生成矩阵一路讲到 Reed-Solomon 与 MDS
- 第二篇讲故障恢复、更新放大、修复带宽,以及 LRC、再生码这类“为修复优化”的路线
- 第三篇讲分布式存储里的真实工程:条带布局、降级读、后台重建、冷热分层、跨机架放置与系统取舍
另外再补一篇偏实战的姊妹篇,单独讲 Ceph、HDFS 和 Azure LRC 这些系统里的具体实现取舍。
对应链接如下:
- 纠删码(一):从多副本到 Reed-Solomon、有限域与 MDS 本质
- 纠删码(二):故障恢复、更新放大、修复带宽与 LRC 演进
- 纠删码(三):条带布局、故障域、降级读与分布式存储里的工程落地
- 纠删码(实战篇):Ceph、HDFS 与 Azure LRC 的实现取舍
- 纠删码(实现篇):GF 运算、SIMD、full-stripe write 与小写更新路径
- 纠删码(源码篇):最小 RS encoder/decoder 与 read-modify-write 伪代码
- 纠删码(性能篇):benchmark、NUMA、线程模型与 repair 限流
- 纠删码(案例排障篇):repair 打满网络、degraded read 尾延迟飙升与 partial write 放大
- 纠删码(架构选型篇):什么时候该用三副本、RS、LRC,什么时候根本不该上 EC
如果第一篇没建立起牢固模型,后两篇通常会越看越乱。所以我们先从最朴素的问题开始。
1. 先说本质:EC 解决的不是“压缩”,而是“可靠性与冗余成本的平衡”
很多人第一次接触纠删码,会把它和压缩算法混在一起。这是两个完全不同的问题。
压缩关心的是:
- 如何减少同一份信息的表示长度
- 是否允许有损
- CPU 开销和压缩比怎么权衡
而 EC 关心的是:
- 数据块丢失后能不能恢复
- 要为恢复能力付出多少额外冗余
- 恢复时需要读多少别的块、搬多少网络流量、消耗多少 CPU
所以 EC 本质上是一种 面向故障容忍的编码方案。
它要回答的问题可以写成一句话:
在允许部分块永久丢失的前提下,怎样把原始数据映射成一组更大的编码块集合,并保证只要幸存块数量达到某个阈值,就能恢复原始数据。
这里最关键的不是“码”,而是“恢复约束”。
这也是为什么在存储系统里,大家讨论 EC 时总会同时讨论:
- 磁盘损坏
- 节点宕机
- 机架故障
- 网络隔离
- 重建时间
- 冗余比
这些词都比“编码本身”更接近工程目标。
2. 为什么三副本很好用,却依然不够经济
在进入 EC 之前,先别急着上公式,先把三副本的优势和代价看透。
2.1 三副本为什么长期流行
三副本受欢迎,不是因为大家不知道更高级的编码,而是因为它在工程上真的很顺手。
它有几个非常明显的优点:
- 写路径简单。客户端写入一份数据,系统只需要把它复制到多个副本。
- 读路径简单。随便找一份健康副本读即可。
- 恢复简单。丢了一份,直接从剩余某个完整副本拷贝回来。
- 局部更新简单。改一个字节,本质上就是把新内容同步到多个副本,没有复杂的校验块重算。
- 延迟通常较低。尤其是小 IO、随机写、热数据场景,多副本的行为非常稳定。
如果系统追求的是低复杂度、高可维护性、写多读多、小对象密集,那么三副本是很难被轻易打败的。
2.2 三副本的核心代价是什么
问题也同样直接:容量放大太高。
原始数据 1 TB,用三副本需要接近 3 TB 物理空间。更精确一点说:
- 原始数据占用 1
- 冗余再占用 2
- 有效容量利用率只有约 33%
当数据规模进入 PB 乃至 EB 级,这个成本不再是“贵一点”,而是系统设计会被它主导。
于是你自然会问:
我真的需要把整份数据完整复制三遍吗?如果我的目标只是承受若干块丢失,是否可以用更少的冗余换到同样的容错能力?
EC 的答案就是:可以。
3. 从一个最简单的例子理解 EC:k 个数据块,m 个校验块
纠删码最常见的表示方法是 k+m。
含义是:
- 把原始数据切成
k个数据块 - 再根据这
k个数据块计算出m个校验块 - 一共得到
k+m个块,分布存放到不同故障域中
例如 6+3:
- 6 个数据块
- 3 个校验块
- 总共 9 个块
- 只要任意 6 个块仍然可用,就能恢复原始数据
这时容量放大是多少?
总块数除以数据块数,也就是:
9 / 6 = 1.5
换句话说,原始 1 TB 数据大致只需要 1.5 TB 存储空间,而不是三副本的 3 TB。
这就是 EC 最诱人的地方。
3.1 一个经常被忽略的前提
很多人看到这里会想:
那三副本岂不是立刻落后了?
没这么简单。
6+3 的意思不是“比三副本全方位更优”,而是:
- 在同样能承受若干块损坏的前提下,容量效率更高
- 但写入、恢复、更新、读放大、实现复杂度都会更高
你省下来的容量,往往会以别的形式付出去:
- 更多 CPU 编码开销
- 更多网络重建流量
- 更复杂的元数据和条带布局管理
- 更高的小写放大
- 更麻烦的降级读路径
所以理解 EC 的正确姿势不是“它比副本高级”,而是:
它是另一组成本函数。
4. 条带是什么:EC 的基本组织单位不是整份文件,而是 stripe
真实系统不会把整个 10 GB、100 GB 或 1 TB 文件当成一个整体去做一次编码,那样恢复和更新都会非常笨重。
实际做法通常是:
- 先把对象或文件切成很多较小 chunk
- 再按
k个数据块为一组组织成一个 stripe(条带) - 对每个 stripe 独立计算
m个校验块
例如 4+2 的条带:
D0 D1 D2 D3 P0 P1
如果把对象连续切分再映射到多个 stripe,可以把它想成下面这样:
Object chunks:
C0 C1 C2 C3 C4 C5 C6 C7
| | | | | | | |
|--- Stripe 0 ---| |--- Stripe 1 ---|
Stripe 0: D0 D1 D2 D3 P0 P1
Stripe 1: D4 D5 D6 D7 P2 P3
其中:
D0..D3是数据块P0..P1是校验块
如果一个文件有很多条带,那么系统实际上是在重复做很多次局部编码,而不是维护一个全局巨大方程。
4.1 为什么条带化是必要的
因为它带来三个工程上的可控性:
- 恢复粒度可控。坏的是某个 stripe 的某几个块,不必扫描整个对象。
- 并行度可控。不同 stripe 可在不同线程、不同节点上独立编码恢复。
- 元数据可控。你只需要记录对象到条带、条带到块位置的映射,不需要维护一个无法局部修改的大编码结构。
你可以把条带理解成 EC 在存储系统中的最小“故障恢复单元”。
5. 一个错误但很自然的直觉:只做 XOR 行不行
很多人第一次理解校验块时,会先想到 RAID 里的奇偶校验。
例如三个数据块:
P = D0 XOR D1 XOR D2
如果只丢一个块,比如 D1,就可以恢复:
D1 = P XOR D0 XOR D2
这确实是编码。
而且它非常有用。
但它的能力有限:
- 一个 XOR 校验块通常只能容忍一个块丢失
- 想容忍多个块丢失,简单异或通常不够
- 仅靠普通按位 XOR,无法构造出通用的“任意丢
m块都能恢复”的方案
于是问题就升级了:
我们需要一种更一般的线性编码方法,让校验块不是简单复制,也不只是单一 XOR,而是若干数据块的线性组合,并且这些组合必须足够“独立”,这样丢了多个块仍然能解出来。
这就进入了 Reed-Solomon 的世界。
6. 进入 Reed-Solomon 之前,先把“线性组合”这件事想明白
EC 的核心思想其实不神秘:
把若干数据块看成向量,然后构造多个线性独立的组合结果作为校验块。
如果组合关系设计得足够好,那么即使丢失若干原块,也能把未知量重新解出来。
最直接的数学表达是矩阵乘法。
假设一个 stripe 有 k 个数据块:
[D0, D1, ..., D(k-1)]^T
我们构造一个 (k+m) x k 的生成矩阵 G,把它乘到数据向量上,得到 k+m 个输出块:
C = G * D
其中:
- 前
k行往往可以设计成单位矩阵,对应“原样保留数据块” - 后
m行对应校验块的线性组合规则
也就是说,编码后的结果是:
[D0, D1, ..., D(k-1), P0, P1, ..., P(m-1)]^T
这里真正的难点不在“乘法”本身,而在:
- 在线性组合的系数应该取什么
- 这些系数应该在哪个代数系统里运算
- 怎样保证任意丢失
m块后,幸存的k块仍能形成可逆方程组
第二个问题,也就是“在哪个代数系统里算”,是很多人第一次接触 RS 时最困惑的点。
7. 为什么必须用有限域,而不是普通整数
如果你直接在普通整数或浮点数里做线性代数,会立刻碰到工程问题:
- 数值会无限增长,不适合按字节块处理
- 浮点数有精度问题,不适合做确定性恢复
- 存储系统里的块数据本质上是字节序列,需要一个封闭、离散、确定的运算体系
所以 Reed-Solomon 一般在 有限域 上工作,最常见的是 GF(2^8)。
7.1 GF(2^8) 到底是什么
先别被符号吓到。
GF(2^8) 可以先粗略理解成:
- 一共有 256 个元素
- 每个元素都可以用一个字节表示
- 加法、减法、乘法、除法都在这个 256 元素的封闭系统里完成
- 除了 0 之外,每个非零元素都有乘法逆元
这对存储系统非常友好,因为:
- 数据本来就是按字节组织的
- 每个字节位置都可以独立做域上的线性组合
- 编码和解码能稳定地在字节级实现
7.2 在 GF(2^8) 里,加法为什么常常就是 XOR
这是实际工程里一个非常有用的事实。
在 GF(2^8) 中,加法通常等价于按位异或,所以:
a + b = a XOR b
这使得很多编码实现非常高效。
但乘法就不再是普通整数乘法,它需要按照有限域定义来做。工程实现通常通过以下方式优化:
- 对数表 / 反对数表
- 预计算乘法表
- SIMD 指令
- 专门的 ISA-L、Jerasure、GF-Complete 一类库
所以你会发现,大家常说“EC 的计算不只是 XOR”,真正指的就是:
加法简单,乘法不简单。
8. Reed-Solomon 到底在做什么
Reed-Solomon 可以先用一句不那么形式化的话描述:
它为同一组数据构造出多个彼此线性独立的校验视角,因此只要幸存视角仍然够多,就能把原始数据反推出去。
在存储系统语境里,这通常意味着:
- 有
k个原始数据块 - 构造
m个额外校验块 - 任意丢失不超过
m个块,都可以恢复
这就是人们常说的 MDS 能力。
8.1 MDS 是什么
MDS 全称是 Maximum Distance Separable。
对存储工程师来说,不需要先背完整编码理论定义,你只要记住这个最重要的结论:
对于一个 k+m 的 MDS 码:
- 任意选出
k个块,都足以恢复原始数据 - 因此它最多可以容忍任意
m个块丢失
这几乎就是“在同等恢复能力下冗余最省”的一类码。
换句话说,Reed-Solomon 之所以流行,不是因为名字响,而是因为它给了你一个非常强的性质:
任意 m 块故障都不怕,而冗余只需 m 块。
9. 用生成矩阵看 RS:为什么“任意 k 行可逆”就是安全性的根
现在把前面的概念合起来。
设数据向量是:
D = [D0, D1, D2, D3]^T
做一个 4+2 码,总共输出 6 个块。一个系统化的生成矩阵可以写成:
G =
[1 0 0 0]
[0 1 0 0]
[0 0 1 0]
[0 0 0 1]
[a b c d]
[e f g h]
如果把它画成“数据向量经过生成矩阵变成编码块”的视图,会更直观:
Data vector Generator matrix Encoded blocks
[D0] [1 0 0 0] [D0]
[D1] --------> [0 1 0 0] ----------> [D1]
[D2] [0 0 1 0] [D2]
[D3] [0 0 0 1] [D3]
[a b c d] [P0]
[e f g h] [P1]
前 4 行是单位矩阵,表示数据块原样输出:
- 第一行生成
D0 - 第二行生成
D1 - 第三行生成
D2 - 第四行生成
D3
后两行定义两个校验块:
P0 = aD0 + bD1 + cD2 + dD3
P1 = eD0 + fD1 + gD2 + hD3
这里所有加法、乘法都在有限域里做。
那怎样保证丢任意 2 块都能恢复?
关键条件就是:
从这个 6x4 矩阵中任意取 4 行,都必须构成一个可逆的 4x4 矩阵。
为什么?
因为恢复时你手里只剩 4 个幸存块。它们对应生成矩阵里的 4 行。如果这 4 行张成的矩阵可逆,你就能解出原始 4 个数据块;如果不可逆,就意味着某些故障组合下信息不够。
所以你可以把 MDS 的工程判定方式理解成:
幸存任意
k块时,都能从对应的子矩阵反推出原始数据。
这句话其实比很多抽象定义更适合程序员。
10. Vandermonde 矩阵为什么经常出现
只要你稍微看过 RS 实现,几乎一定会碰到 Vandermonde matrix。
这是因为它很自然地满足“构造很多线性独立行”的需求。
一个典型的 Vandermonde 结构长这样:
[1 1 1 1]
[1 x1 x1^2 x1^3]
[1 x2 x2^2 x2^3]
[1 x3 x3^2 x3^3]
...
在有限域里,只要这些 x1, x2, x3... 选得合适,就能得到足够好的独立性。
10.1 它为什么适合做生成矩阵
因为它的每一行都像是在不同“采样点”上观察同一个多项式空间,这会带来很强的线性独立性。
对存储实现来说,不需要每次都重新证明它,只需要知道:
- 这种矩阵容易构造
- 容易证明很多子矩阵可逆
- 工程上早已有成熟实现
所以很多 RS 编码库在本质上都绕不开 Vandermonde 思路。
11. Cauchy 矩阵又是什么,为什么很多实现更偏爱它
除了 Vandermonde,你还会看到 Cauchy Reed-Solomon。
它流行的一个核心原因是:
它更容易转成高效的 XOR 级实现。
严格说,Cauchy 不是把 RS 的数学基础换掉了,而是提供另一种矩阵构造方式,使得编码矩阵可以被拆成适合位运算和查表优化的形式。
工程上这意味着:
- 编码、解码速度可能更好
- 实现更容易做位切片或 SIMD 优化
- 在某些库里,它比直接 Vandermonde 版本更实用
所以现实世界不是“理论上知道 RS 就够了”,而是:
- 理论上要理解 MDS 和可逆子矩阵
- 实现上要关心具体矩阵构造是否方便高性能计算
12. 编码过程到底长什么样
说了这么多矩阵,回到最接地气的问题:
系统一次编码,真正做了什么?
以 k=4, m=2 为例,一个 stripe 的编码大致是这样:
- 把原始数据切成
D0 D1 D2 D3四个等长块 - 准备生成矩阵
G - 直接保留
D0..D3作为系统化输出 - 按矩阵最后两行的系数,对四个数据块逐字节做有限域线性组合
- 生成
P0 P1 - 把这 6 个块分布写到不同磁盘、节点或故障域
注意这里有一个很关键的工程约束:
同一个 stripe 内的所有块长度通常必须对齐。
原因不复杂:
- 矩阵系数作用的是“同一位置的字节列”
- 如果块长不一致,必须补齐或做末尾特殊处理
- 真实系统往往会用固定 chunk size,避免编码路径出现太多分支
13. 恢复过程到底长什么样
假设还是 4+2,现在 D1 和 P0 丢了,还剩:
D0 D2 D3 P1
恢复步骤可以概括为:
- 找到幸存块对应的生成矩阵行
- 取出这 4 行,形成一个 4x4 子矩阵
G' - 因为 RS 是 MDS,
G'可逆 - 计算
G'的逆矩阵G'^-1 - 用
D = G'^-1 * C'解出原始数据块 - 如果只需要重建
D1,理论上可以只求所需结果;如果实现简单,也可能先恢复出全部原数据块
这个过程本质上就是线性方程组求解。
所以很多人第一次真正理解 RS 的瞬间,不是在“知道它能恢复”,而是在意识到:
存储恢复这件事,被转化成了有限域上的线性代数问题。
14. 系统化编码为什么重要
上面一直提到“系统化”这个词。
所谓系统化编码,就是编码后的前 k 个块直接就是原始数据块本身,而不是全部重新混成一组新块。
它的重要性非常大。
14.1 对读路径更友好
如果对象没故障、没降级,读一个完整 stripe 时:
- 系统可以直接读取数据块
- 不必每次先解码
- 热路径更简单,CPU 开销更低
14.2 对实现更友好
系统化编码让“正常读”和“故障恢复”两条路径分得更清楚:
- 正常情况下把它当普通分片数据读
- 故障时再进入解码/重建逻辑
这就是为什么很多存储系统采用的 RS 方案几乎都是系统化的。
15. k 和 m 应该怎么选
这是最典型的工程问题之一。
从理论上说:
k越大,容量效率越高m越大,容错能力越强
但现实里没有白送的午餐。
15.1 k 变大意味着什么
好处:
- 固定
m下,冗余比更低 - 原始容量利用率更高
代价:
- 一个 stripe 涉及更多块,调度更复杂
- 编码和恢复需要接触更多节点
- 小写入更麻烦
- 单块故障恢复时,可能需要从更多幸存块拉数据
15.2 m 变大意味着什么
好处:
- 能承受更多同时故障
- 数据耐久性更强
代价:
- 冗余成本上升
- 编码 CPU 增加
- 写入需要落更多块
- 条带放置更难,尤其跨机架时
所以参数选择常常取决于存储介质、节点规模、故障模型和业务访问模式,而不是“理论最优”。
常见的如:
6+38+210+412+4
这些组合的存在,背后几乎都是具体系统权衡,而不是某个统一答案。
16. 为什么 EC 没有彻底替代副本
到这里很多人会问:
既然 RS 这么省空间,为什么 HDFS、Ceph、对象存储、数据库、缓存系统里仍然大量保留副本?
因为存储系统优化的不是单一指标。
EC 明显更擅长:
- 大对象
- 顺序写
- 冷数据或温数据
- 对容量效率敏感的场景
而副本明显更擅长:
- 小对象
- 高频随机写
- 低延迟读写
- 元数据更新频繁
- 强调实现简单和快速恢复的场景
你可以把它们理解成两种完全不同的系统哲学:
- 副本是在用空间换简单和性能
- EC 是在用计算与复杂度换空间效率
真实系统经常会两者并用:
- 热数据先多副本
- 冷却后转 EC
- 元数据仍保留副本
- 大块数据走 EC
这不是折中失败,而是工程上最自然的结果。
17. 这一篇最该真正记住的几个点
到这里,第一篇其实已经把 EC 的骨架搭起来了。
如果你准备继续往后看,至少要把下面这些点记牢:
- EC 解决的是可靠性与冗余成本平衡,不是压缩
- 存储系统里真正编码的基本单位通常是 stripe
k+m表示k个数据块和m个校验块- Reed-Solomon 本质上是有限域上的线性编码
- 生成矩阵的关键要求是:任意
k行形成可逆子矩阵 - MDS 的工程含义是:任意
k个幸存块都能恢复数据 - EC 节省了容量,但把成本转移到了编码、更新、恢复和系统复杂度上
如果这些点还没有真正顺手,第二篇里一碰到“修复带宽”“更新放大”“局部重建”,就很容易又只剩概念名词。
18. 下一篇讲什么
把编码原理讲清楚之后,真正有意思的问题才开始出现。
因为现实系统并不是“块坏了,解个矩阵就结束”。
真正困难的是:
- 为什么改一个小块,可能要连带重算多个校验块
- 为什么单块故障恢复,会拉起远超丢失块大小的网络流量
- 为什么很多系统会嫌 RS 的修复代价太高,转而引入 LRC
- 再生码、最小存储再生码、最小带宽再生码到底在优化什么
所以下一篇我们会进入 EC 最容易被低估的一面:
它不是“能恢复就够了”,而是“恢复代价是否可接受”。
这也是 EC 从数学走向分布式存储工程时,真正开始变难的地方。
如果你更想先看真实系统怎么取舍,可以直接跳到这篇姊妹篇: