Q先生的世界

面朝大海,春暖花开

经典算法深度解析|纠删码(一):从多副本到 Reed-Solomon、有限域与 MDS 本质

只要系统开始认真做存储,工程师迟早会面对一个非常现实的问题:

  1. 数据必须可靠
  2. 成本不能失控
  3. 故障恢复不能太慢
  4. 容量利用率还得说得过去

最直接的办法当然是多副本。

例如一份数据写三份:

  1. 丢一台机器,还有两份
  2. 读性能往往还不错
  3. 实现简单,运维心智负担低

所以很多系统的第一步都会是三副本。

但当数据规模变大以后,多副本会很快把你拉回现实。

假设原始数据是 1 PB:

  1. 两副本要 2 PB 原始容量
  2. 三副本要 3 PB 原始容量
  3. 如果还有跨机架、跨可用区冗余,真实成本只会更高

这时你会发现,分布式存储真正长期绕不开的问题,不只是“怎么把数据写进去”,而是:

怎么用更低的冗余成本,换到接近多副本的可靠性。

纠删码,或者更常见地写成 EC(Erasure Coding),就是这条路线上的核心技术。

很多人第一次听到 EC 时,往往会先记住一句口号:

把一份数据切成多块,再额外算出若干校验块,只要剩下的块足够多,就能把原始数据恢复出来。

这句话不算错,但远远不够。

如果你真的想把 EC 用到分布式存储里,至少得能回答这些问题:

  1. EC 和多副本相比,究竟在交换什么
  2. k+m 条带模型到底是什么意思
  3. 为什么大家总在讲 Reed-Solomon
  4. 有限域运算为什么是必须的,不能只靠普通整数加减乘除吗
  5. 生成矩阵和恢复矩阵到底在表达什么
  6. MDS 性质为什么如此重要
  7. 为什么 EC 明明很省容量,却没有把三副本彻底淘汰

这一组文章我准备先拆成三篇主线文章:

  1. 本文先把编码理论基础讲清楚:从副本问题、条带模型、有限域、生成矩阵一路讲到 Reed-Solomon 与 MDS
  2. 第二篇讲故障恢复、更新放大、修复带宽,以及 LRC、再生码这类“为修复优化”的路线
  3. 第三篇讲分布式存储里的真实工程:条带布局、降级读、后台重建、冷热分层、跨机架放置与系统取舍

另外再补一篇偏实战的姊妹篇,单独讲 Ceph、HDFS 和 Azure LRC 这些系统里的具体实现取舍。

对应链接如下:

  1. 纠删码(一):从多副本到 Reed-Solomon、有限域与 MDS 本质
  2. 纠删码(二):故障恢复、更新放大、修复带宽与 LRC 演进
  3. 纠删码(三):条带布局、故障域、降级读与分布式存储里的工程落地
  4. 纠删码(实战篇):Ceph、HDFS 与 Azure LRC 的实现取舍
  5. 纠删码(实现篇):GF 运算、SIMD、full-stripe write 与小写更新路径
  6. 纠删码(源码篇):最小 RS encoder/decoder 与 read-modify-write 伪代码
  7. 纠删码(性能篇):benchmark、NUMA、线程模型与 repair 限流
  8. 纠删码(案例排障篇):repair 打满网络、degraded read 尾延迟飙升与 partial write 放大
  9. 纠删码(架构选型篇):什么时候该用三副本、RS、LRC,什么时候根本不该上 EC

如果第一篇没建立起牢固模型,后两篇通常会越看越乱。所以我们先从最朴素的问题开始。


1. 先说本质:EC 解决的不是“压缩”,而是“可靠性与冗余成本的平衡”

很多人第一次接触纠删码,会把它和压缩算法混在一起。这是两个完全不同的问题。

压缩关心的是:

  1. 如何减少同一份信息的表示长度
  2. 是否允许有损
  3. CPU 开销和压缩比怎么权衡

而 EC 关心的是:

  1. 数据块丢失后能不能恢复
  2. 要为恢复能力付出多少额外冗余
  3. 恢复时需要读多少别的块、搬多少网络流量、消耗多少 CPU

所以 EC 本质上是一种 面向故障容忍的编码方案

它要回答的问题可以写成一句话:

在允许部分块永久丢失的前提下,怎样把原始数据映射成一组更大的编码块集合,并保证只要幸存块数量达到某个阈值,就能恢复原始数据。

这里最关键的不是“码”,而是“恢复约束”。

这也是为什么在存储系统里,大家讨论 EC 时总会同时讨论:

  1. 磁盘损坏
  2. 节点宕机
  3. 机架故障
  4. 网络隔离
  5. 重建时间
  6. 冗余比

这些词都比“编码本身”更接近工程目标。


2. 为什么三副本很好用,却依然不够经济

在进入 EC 之前,先别急着上公式,先把三副本的优势和代价看透。

2.1 三副本为什么长期流行

三副本受欢迎,不是因为大家不知道更高级的编码,而是因为它在工程上真的很顺手。

它有几个非常明显的优点:

  1. 写路径简单。客户端写入一份数据,系统只需要把它复制到多个副本。
  2. 读路径简单。随便找一份健康副本读即可。
  3. 恢复简单。丢了一份,直接从剩余某个完整副本拷贝回来。
  4. 局部更新简单。改一个字节,本质上就是把新内容同步到多个副本,没有复杂的校验块重算。
  5. 延迟通常较低。尤其是小 IO、随机写、热数据场景,多副本的行为非常稳定。

如果系统追求的是低复杂度、高可维护性、写多读多、小对象密集,那么三副本是很难被轻易打败的。

2.2 三副本的核心代价是什么

问题也同样直接:容量放大太高。

原始数据 1 TB,用三副本需要接近 3 TB 物理空间。更精确一点说:

  1. 原始数据占用 1
  2. 冗余再占用 2
  3. 有效容量利用率只有约 33%

当数据规模进入 PB 乃至 EB 级,这个成本不再是“贵一点”,而是系统设计会被它主导。

于是你自然会问:

我真的需要把整份数据完整复制三遍吗?如果我的目标只是承受若干块丢失,是否可以用更少的冗余换到同样的容错能力?

EC 的答案就是:可以。


3. 从一个最简单的例子理解 EC:k 个数据块,m 个校验块

纠删码最常见的表示方法是 k+m

含义是:

  1. 把原始数据切成 k 个数据块
  2. 再根据这 k 个数据块计算出 m 个校验块
  3. 一共得到 k+m 个块,分布存放到不同故障域中

例如 6+3

  1. 6 个数据块
  2. 3 个校验块
  3. 总共 9 个块
  4. 只要任意 6 个块仍然可用,就能恢复原始数据

这时容量放大是多少?

总块数除以数据块数,也就是:

  1. 9 / 6 = 1.5

换句话说,原始 1 TB 数据大致只需要 1.5 TB 存储空间,而不是三副本的 3 TB。

这就是 EC 最诱人的地方。

3.1 一个经常被忽略的前提

很多人看到这里会想:

那三副本岂不是立刻落后了?

没这么简单。

6+3 的意思不是“比三副本全方位更优”,而是:

  1. 在同样能承受若干块损坏的前提下,容量效率更高
  2. 但写入、恢复、更新、读放大、实现复杂度都会更高

你省下来的容量,往往会以别的形式付出去:

  1. 更多 CPU 编码开销
  2. 更多网络重建流量
  3. 更复杂的元数据和条带布局管理
  4. 更高的小写放大
  5. 更麻烦的降级读路径

所以理解 EC 的正确姿势不是“它比副本高级”,而是:

它是另一组成本函数。


4. 条带是什么:EC 的基本组织单位不是整份文件,而是 stripe

真实系统不会把整个 10 GB、100 GB 或 1 TB 文件当成一个整体去做一次编码,那样恢复和更新都会非常笨重。

实际做法通常是:

  1. 先把对象或文件切成很多较小 chunk
  2. 再按 k 个数据块为一组组织成一个 stripe(条带)
  3. 对每个 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

其中:

  1. D0..D3 是数据块
  2. P0..P1 是校验块

如果一个文件有很多条带,那么系统实际上是在重复做很多次局部编码,而不是维护一个全局巨大方程。

4.1 为什么条带化是必要的

因为它带来三个工程上的可控性:

  1. 恢复粒度可控。坏的是某个 stripe 的某几个块,不必扫描整个对象。
  2. 并行度可控。不同 stripe 可在不同线程、不同节点上独立编码恢复。
  3. 元数据可控。你只需要记录对象到条带、条带到块位置的映射,不需要维护一个无法局部修改的大编码结构。

你可以把条带理解成 EC 在存储系统中的最小“故障恢复单元”。


5. 一个错误但很自然的直觉:只做 XOR 行不行

很多人第一次理解校验块时,会先想到 RAID 里的奇偶校验。

例如三个数据块:

P = D0 XOR D1 XOR D2

如果只丢一个块,比如 D1,就可以恢复:

D1 = P XOR D0 XOR D2

这确实是编码。

而且它非常有用。

但它的能力有限:

  1. 一个 XOR 校验块通常只能容忍一个块丢失
  2. 想容忍多个块丢失,简单异或通常不够
  3. 仅靠普通按位 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

其中:

  1. k 行往往可以设计成单位矩阵,对应“原样保留数据块”
  2. m 行对应校验块的线性组合规则

也就是说,编码后的结果是:

[D0, D1, ..., D(k-1), P0, P1, ..., P(m-1)]^T

这里真正的难点不在“乘法”本身,而在:

  1. 在线性组合的系数应该取什么
  2. 这些系数应该在哪个代数系统里运算
  3. 怎样保证任意丢失 m 块后,幸存的 k 块仍能形成可逆方程组

第二个问题,也就是“在哪个代数系统里算”,是很多人第一次接触 RS 时最困惑的点。


7. 为什么必须用有限域,而不是普通整数

如果你直接在普通整数或浮点数里做线性代数,会立刻碰到工程问题:

  1. 数值会无限增长,不适合按字节块处理
  2. 浮点数有精度问题,不适合做确定性恢复
  3. 存储系统里的块数据本质上是字节序列,需要一个封闭、离散、确定的运算体系

所以 Reed-Solomon 一般在 有限域 上工作,最常见的是 GF(2^8)

7.1 GF(2^8) 到底是什么

先别被符号吓到。

GF(2^8) 可以先粗略理解成:

  1. 一共有 256 个元素
  2. 每个元素都可以用一个字节表示
  3. 加法、减法、乘法、除法都在这个 256 元素的封闭系统里完成
  4. 除了 0 之外,每个非零元素都有乘法逆元

这对存储系统非常友好,因为:

  1. 数据本来就是按字节组织的
  2. 每个字节位置都可以独立做域上的线性组合
  3. 编码和解码能稳定地在字节级实现

7.2 在 GF(2^8) 里,加法为什么常常就是 XOR

这是实际工程里一个非常有用的事实。

GF(2^8) 中,加法通常等价于按位异或,所以:

a + b = a XOR b

这使得很多编码实现非常高效。

但乘法就不再是普通整数乘法,它需要按照有限域定义来做。工程实现通常通过以下方式优化:

  1. 对数表 / 反对数表
  2. 预计算乘法表
  3. SIMD 指令
  4. 专门的 ISA-L、Jerasure、GF-Complete 一类库

所以你会发现,大家常说“EC 的计算不只是 XOR”,真正指的就是:

加法简单,乘法不简单。


8. Reed-Solomon 到底在做什么

Reed-Solomon 可以先用一句不那么形式化的话描述:

它为同一组数据构造出多个彼此线性独立的校验视角,因此只要幸存视角仍然够多,就能把原始数据反推出去。

在存储系统语境里,这通常意味着:

  1. k 个原始数据块
  2. 构造 m 个额外校验块
  3. 任意丢失不超过 m 个块,都可以恢复

这就是人们常说的 MDS 能力。

8.1 MDS 是什么

MDS 全称是 Maximum Distance Separable

对存储工程师来说,不需要先背完整编码理论定义,你只要记住这个最重要的结论:

对于一个 k+m 的 MDS 码:

  1. 任意选出 k 个块,都足以恢复原始数据
  2. 因此它最多可以容忍任意 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 行是单位矩阵,表示数据块原样输出:

  1. 第一行生成 D0
  2. 第二行生成 D1
  3. 第三行生成 D2
  4. 第四行生成 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 它为什么适合做生成矩阵

因为它的每一行都像是在不同“采样点”上观察同一个多项式空间,这会带来很强的线性独立性。

对存储实现来说,不需要每次都重新证明它,只需要知道:

  1. 这种矩阵容易构造
  2. 容易证明很多子矩阵可逆
  3. 工程上早已有成熟实现

所以很多 RS 编码库在本质上都绕不开 Vandermonde 思路。


11. Cauchy 矩阵又是什么,为什么很多实现更偏爱它

除了 Vandermonde,你还会看到 Cauchy Reed-Solomon

它流行的一个核心原因是:

它更容易转成高效的 XOR 级实现。

严格说,Cauchy 不是把 RS 的数学基础换掉了,而是提供另一种矩阵构造方式,使得编码矩阵可以被拆成适合位运算和查表优化的形式。

工程上这意味着:

  1. 编码、解码速度可能更好
  2. 实现更容易做位切片或 SIMD 优化
  3. 在某些库里,它比直接 Vandermonde 版本更实用

所以现实世界不是“理论上知道 RS 就够了”,而是:

  1. 理论上要理解 MDS 和可逆子矩阵
  2. 实现上要关心具体矩阵构造是否方便高性能计算

12. 编码过程到底长什么样

说了这么多矩阵,回到最接地气的问题:

系统一次编码,真正做了什么?

k=4, m=2 为例,一个 stripe 的编码大致是这样:

  1. 把原始数据切成 D0 D1 D2 D3 四个等长块
  2. 准备生成矩阵 G
  3. 直接保留 D0..D3 作为系统化输出
  4. 按矩阵最后两行的系数,对四个数据块逐字节做有限域线性组合
  5. 生成 P0 P1
  6. 把这 6 个块分布写到不同磁盘、节点或故障域

注意这里有一个很关键的工程约束:

同一个 stripe 内的所有块长度通常必须对齐。

原因不复杂:

  1. 矩阵系数作用的是“同一位置的字节列”
  2. 如果块长不一致,必须补齐或做末尾特殊处理
  3. 真实系统往往会用固定 chunk size,避免编码路径出现太多分支

13. 恢复过程到底长什么样

假设还是 4+2,现在 D1P0 丢了,还剩:

D0 D2 D3 P1

恢复步骤可以概括为:

  1. 找到幸存块对应的生成矩阵行
  2. 取出这 4 行,形成一个 4x4 子矩阵 G'
  3. 因为 RS 是 MDS,G' 可逆
  4. 计算 G' 的逆矩阵 G'^-1
  5. D = G'^-1 * C' 解出原始数据块
  6. 如果只需要重建 D1,理论上可以只求所需结果;如果实现简单,也可能先恢复出全部原数据块

这个过程本质上就是线性方程组求解。

所以很多人第一次真正理解 RS 的瞬间,不是在“知道它能恢复”,而是在意识到:

存储恢复这件事,被转化成了有限域上的线性代数问题。


14. 系统化编码为什么重要

上面一直提到“系统化”这个词。

所谓系统化编码,就是编码后的前 k 个块直接就是原始数据块本身,而不是全部重新混成一组新块。

它的重要性非常大。

14.1 对读路径更友好

如果对象没故障、没降级,读一个完整 stripe 时:

  1. 系统可以直接读取数据块
  2. 不必每次先解码
  3. 热路径更简单,CPU 开销更低

14.2 对实现更友好

系统化编码让“正常读”和“故障恢复”两条路径分得更清楚:

  1. 正常情况下把它当普通分片数据读
  2. 故障时再进入解码/重建逻辑

这就是为什么很多存储系统采用的 RS 方案几乎都是系统化的。


15. km 应该怎么选

这是最典型的工程问题之一。

从理论上说:

  1. k 越大,容量效率越高
  2. m 越大,容错能力越强

但现实里没有白送的午餐。

15.1 k 变大意味着什么

好处:

  1. 固定 m 下,冗余比更低
  2. 原始容量利用率更高

代价:

  1. 一个 stripe 涉及更多块,调度更复杂
  2. 编码和恢复需要接触更多节点
  3. 小写入更麻烦
  4. 单块故障恢复时,可能需要从更多幸存块拉数据

15.2 m 变大意味着什么

好处:

  1. 能承受更多同时故障
  2. 数据耐久性更强

代价:

  1. 冗余成本上升
  2. 编码 CPU 增加
  3. 写入需要落更多块
  4. 条带放置更难,尤其跨机架时

所以参数选择常常取决于存储介质、节点规模、故障模型和业务访问模式,而不是“理论最优”。

常见的如:

  1. 6+3
  2. 8+2
  3. 10+4
  4. 12+4

这些组合的存在,背后几乎都是具体系统权衡,而不是某个统一答案。


16. 为什么 EC 没有彻底替代副本

到这里很多人会问:

既然 RS 这么省空间,为什么 HDFS、Ceph、对象存储、数据库、缓存系统里仍然大量保留副本?

因为存储系统优化的不是单一指标。

EC 明显更擅长:

  1. 大对象
  2. 顺序写
  3. 冷数据或温数据
  4. 对容量效率敏感的场景

而副本明显更擅长:

  1. 小对象
  2. 高频随机写
  3. 低延迟读写
  4. 元数据更新频繁
  5. 强调实现简单和快速恢复的场景

你可以把它们理解成两种完全不同的系统哲学:

  1. 副本是在用空间换简单和性能
  2. EC 是在用计算与复杂度换空间效率

真实系统经常会两者并用:

  1. 热数据先多副本
  2. 冷却后转 EC
  3. 元数据仍保留副本
  4. 大块数据走 EC

这不是折中失败,而是工程上最自然的结果。


17. 这一篇最该真正记住的几个点

到这里,第一篇其实已经把 EC 的骨架搭起来了。

如果你准备继续往后看,至少要把下面这些点记牢:

  1. EC 解决的是可靠性与冗余成本平衡,不是压缩
  2. 存储系统里真正编码的基本单位通常是 stripe
  3. k+m 表示 k 个数据块和 m 个校验块
  4. Reed-Solomon 本质上是有限域上的线性编码
  5. 生成矩阵的关键要求是:任意 k 行形成可逆子矩阵
  6. MDS 的工程含义是:任意 k 个幸存块都能恢复数据
  7. EC 节省了容量,但把成本转移到了编码、更新、恢复和系统复杂度上

如果这些点还没有真正顺手,第二篇里一碰到“修复带宽”“更新放大”“局部重建”,就很容易又只剩概念名词。


18. 下一篇讲什么

把编码原理讲清楚之后,真正有意思的问题才开始出现。

因为现实系统并不是“块坏了,解个矩阵就结束”。

真正困难的是:

  1. 为什么改一个小块,可能要连带重算多个校验块
  2. 为什么单块故障恢复,会拉起远超丢失块大小的网络流量
  3. 为什么很多系统会嫌 RS 的修复代价太高,转而引入 LRC
  4. 再生码、最小存储再生码、最小带宽再生码到底在优化什么

所以下一篇我们会进入 EC 最容易被低估的一面:

它不是“能恢复就够了”,而是“恢复代价是否可接受”。

这也是 EC 从数学走向分布式存储工程时,真正开始变难的地方。

如果你更想先看真实系统怎么取舍,可以直接跳到这篇姊妹篇:

  1. 纠删码(实战篇):Ceph、HDFS 与 Azure LRC 的实现取舍
  2. 纠删码(实现篇):GF 运算、SIMD、full-stripe write 与小写更新路径
  3. 纠删码(源码篇):最小 RS encoder/decoder 与 read-modify-write 伪代码
  4. 纠删码(性能篇):benchmark、NUMA、线程模型与 repair 限流
  5. 纠删码(案例排障篇):repair 打满网络、degraded read 尾延迟飙升与 partial write 放大
  6. 纠删码(架构选型篇):什么时候该用三副本、RS、LRC,什么时候根本不该上 EC