Skip to content

校验和对上了,数据就没坏吗 ​

一条报文里两个字段的内容互相换了位置,接收方算出来的校验和与发送方一模一样。内存条带 ECC,日志里一直只有「已纠正」的记录,某一天读出来的数据却是错的,而且没有任何报错。

校验码、纠错码都是往数据里加冗余位,让「出了错的数据」尽量不再是一个合法的取值。每一种编码能发现或纠正的错误都有明确的上限,超过上限之后,它不会告诉你「我处理不了」,而是把错误的数据当成正确的交出来,纠错码甚至会主动把数据改成另一个错误的值。

本文用穷举的方式把几种常见编码的边界数出来:奇偶位、求和校验、CRC、Hamming 码与 SECDED,再说明校验码和防篡改是两回事。实验在 JDK 25.0.4 上运行,见文末。

一、能力来自码字之间的距离 ​

把数据加上冗余位之后的整体叫码字。4 位数据有 16 种取值,编成 7 位之后,7 位能表示的 128 种取值里只有 16 种是合法码字。任意两个合法码字之间至少有几位不同,叫这种编码的最小距离。

最小距离 3:Hamming(7,4)A合法码字1A 错 1 位2A 错 2 位B合法码字纠正回 A被改成 B错 2 位时没有任何提示,得到的是另一个合法的数据最小距离 4:扩展 Hamming(8,4),即 SECDEDA合法码字1错 1 位2错 2 位3A 错 3 位B合法码字纠正回 A离两边一样远:报告不可纠正被改成 B能发现的错误位数 = 最小距离 - 1;能纠正的位数 = (最小距离 - 1) / 2 向下取整纠 1 位的同时发现 2 位,需要距离 4;超过设计能力的错误不会报错,而是被当成可纠正的错误处理
图 1 · 两个合法码字之间至少要改几位才能互相变成对方,叫最小距离。距离为 3 时,错 1 位的结果离原码字最近,可以改回去;错 2 位的结果离另一个码字只有 1 位,译码器会把它「纠正」成那个码字。距离为 4 时,错 2 位的结果落在正中间,至少能知道出了错。纠错的能力和把错误改得更错的风险来自同一个机制。

最小距离 d 决定了两件事:

  • 只做检错时,能保证发现 d - 1 位以内的错误。错的位数不到 d,结果不可能恰好变成另一个合法码字。
  • 做纠错时,能保证纠正 (d - 1) / 2(向下取整)位以内的错误。译码器的做法是「找离收到的值最近的合法码字」,错得太多,最近的那个就不是原来的了。

同一种编码,用来纠错还是只用来检错,是可以选的,但不能两头都要足:距离 3 的码可以发现 2 位错,或者纠正 1 位错。选了纠正 1 位,2 位错就会被当成另一个码字的 1 位错处理。

二、奇偶位与求和校验漏掉什么 ​

奇偶位加 1 位冗余,让码字里 1 的个数保持偶数(或奇数)。最小距离是 2。对 8 位数据加 1 位偶校验的 9 位码字穷举:1 位翻转发现 9/9,2 位翻转发现 0/36,3 位翻转发现 84/84。它只能发现奇数个位的错误,偶数个位的错误全部漏掉。

求和校验把数据按固定宽度切开后相加,取和(或它的反码)作为校验值。TCP、UDP、IP 首部用的都是 16 位反码和。加法满足交换律,所以它对「顺序」不敏感。实验里对一段 28 字节的报文做两种改动:

改动16 位反码和CRC-32
两个 16 位字交换位置不变变了
一处加 8、同一列另一处减 8(金额 100 变成 900,序号 0009 变成 0001)不变变了

第二种改动看起来像是刻意构造的,实际上硬件故障常常就是这个样子:某一根数据线或某一位固定出错,会让同一列的多个位置朝相反方向变化。求和校验的优点是计算便宜、可以增量更新,所以用在每一跳都要改首部的网络层;它不适合作为数据完整性的唯一保障。

三、CRC:有保证的部分和按比例漏掉的部分 ​

CRC 把数据看成一个二进制多项式,除以一个固定的生成多项式,取余数作为校验值。它有两类性质,要分开看。

保证发现的。 对 3 字节数据加 1 字节 CRC-8(生成多项式 x^8 + x^2 + x + 1)构成的 32 位码字穷举:

错 1 位错 2 位错 3 位错 4 位奇偶位(9 位)发现 9/9漏检 36/36发现 84/84未统计CRC-8(32 位)发现 32/32发现 496/496发现 4960/4960漏检 284/35960Hamming(7,4)纠正 112/112改错 336/336未统计未统计扩展 Hamming(8,4)纠正 128/128报错 448/448改错 896/896漏过 224/1120CRC-8 对长度不超过 8 位的突发错误漏检 0/3295;长度 9 的突发里漏掉 24 种,正好是生成多项式本身随机的大范围损坏:CRC-8 漏检 1/256(实测 0.3906%),n 位 CRC 漏检约 1/2^n冗余位占比:奇偶 1/9,Hamming(7,4) 3/7,扩展 Hamming(8,4) 4/8,内存常用的 (72,64) 为 8/72
图 2 · 实测:对每种编码穷举全部错误图样。奇偶位发现不了任何偶数位的错;CRC-8 在 32 位的码字上保证发现 3 位以内的错,4 位开始按比例漏检;Hamming(7,4) 遇到 2 位错全部改成另一个数据;扩展 Hamming(8,4) 把 2 位错全部报出来,但 3 位错又全部被改错。绿色是「发现或纠正」,红色是「没有任何提示地给出错误数据」。
  • 1、2、3 位错全部发现,4 位错的 35960 种图样里漏掉 284 种。也就是说,在这个长度上它的最小距离是 4。
  • 长度不超过 8 位的突发错误(一段连续区间内的任意损坏)全部发现。n 位 CRC 保证发现长度不超过 n 的突发错误,这是它相对求和校验最重要的优点:链路和存储介质上的错误往往成片出现。
  • 长度 9 的突发错误里漏掉的 24 种,每一种都恰好等于生成多项式本身(在 24 个不同的位置上)。CRC 漏掉的错误图样,正是那些能被生成多项式整除的图样。

按比例漏掉的。 超出保证范围之后,一个 n 位的 CRC 大约漏掉 1/2^n 的错误图样。实验里一个 24 位窗口内的全部 16777215 种图样,漏掉 65535 种,占 0.3906%,正好是 1/256。

这给出两个使用上的结论:

  • 能保证发现几位错,取决于多项式和数据长度。同一个 CRC-32,数据越长,保证能发现的错误位数越少。给很大的数据块配一个短校验值,保证的部分就所剩无几,只剩下「大约 2 的 32 次方分之一漏检」。
  • 1/2^n 是「数据已经大面积损坏」时的漏检比例。CRC-32 约为 2.3e-10,听起来很小,但如果链路或磁盘质量差,每天有成千上万个损坏的数据块经过校验,漏过去一个只是时间问题。校验失败的次数本身就是需要监控的指标。

四、纠错码会把数据改错 ​

Hamming(7,4) 给 4 位数据加 3 位校验,每个校验位覆盖一部分数据位。收到 7 位之后重新计算 3 个校验,得到的 3 位结果(校验子)为 0 表示没有发现错误,不为 0 时它的值就是出错的那一位的位置,把那一位翻回来即可。

穷举 16 个数据字:

  • 1 位错:112 种情况全部纠正为原数据。
  • 2 位错:336 种情况里,校验子为 0 的有 0 种,被「纠正」成另一个数据的有 336 种。

2 位错时校验子不为 0,译码器知道出了错,但它无法区分「这是 1 位错」还是「这是 2 位错」,于是按 1 位错处理,翻转第三个位,得到另一个合法码字。原来错了 2 位,纠正之后错了 3 位,而且通过了全部校验。

再加 1 位对全体做奇偶校验,就是扩展 Hamming(8,4),最小距离变成 4。校验子不为 0 而总奇偶正确时,说明错了偶数位,译码器报告「不可纠正」。这种「纠 1 位、发现 2 位」的码通常叫 SECDED,服务器内存的 ECC 常用同样结构的 (72,64) 码:64 位数据、8 位校验。

SECDED 同样有上限:

错误位数扩展 Hamming(8,4) 的结果
1 位128/128 纠正为原数据
2 位448/448 报告为不可纠正
3 位0/896 报告为不可纠正,896/896 被当作 1 位错纠正成另一个数据
4 位896/1120 报告为不可纠正,224/1120 没有任何提示地得到另一个数据

对运维的含义:

  • 「已纠正错误」的计数不是可以忽略的噪声。同一个位置反复出现可纠正错误,说明那里的器件在退化,下一步就是 2 位错(宕机或进程被杀)或更多位错(静默的数据错误)。Linux 上可以通过 EDAC 子系统读到这些计数。
  • 纠错码保护的只是它覆盖的那一段:内存 ECC 管不了数据在 CPU、总线、网卡、磁盘控制器里出的错。

五、校验码不防篡改 ​

CRC 是线性的。对三条等长的消息 a、b、c:

java
crc32(a ^ b ^ c) == crc32(a) ^ crc32(b) ^ crc32(c)   // true

实验里 a 是 amount=0100;to=alice,b 是 amount=0100;to=bobby,c 是 amount=9900;to=alice,三者按字节异或得到 amount=9900;to=bobby,它的 CRC-32 可以直接由另外三个 CRC 算出来,不需要知道任何密钥,因为 CRC 根本没有密钥。知道了一条消息和它的 CRC,就可以在修改消息的同时把 CRC 修正成对的。

同样的关系对 HMAC-SHA256 不成立(实验中为 false)。所以:

  • 防的是线路噪声、介质老化、软件缺陷造成的意外损坏,用 CRC。
  • 防的是有人故意修改,用带密钥的消息认证码(HMAC)或数字签名。
  • 要用内容的摘要做去重或内容寻址,用密码学哈希(SHA-256)。CRC 的 32 位空间太小,数万个不同的块里就大概率出现两个校验值相同的。

把 CRC 或 MD5 拼在请求参数后面当「签名」,起不到防篡改的作用。

六、放在系统里看 ​

每一层的校验只覆盖那一层。 以太网帧的 CRC-32 在每一跳重新计算,交换机内存里出的错,会被下一跳的 CRC 忠实地保护起来。TCP 的 16 位校验和覆盖端到端,但强度有限,并且在 TLS 终结点、代理、应用缓冲区里数据是裸露的。真正需要保证的数据,在产生它的地方计算校验值,在最终使用它的地方验证,中间经过多少层都不影响。存储和消息系统大多是这么做的:Kafka 的记录批带 CRC-32C,由生产者计算、broker 与消费者验证;InnoDB 每个数据页带校验和,读入时验证;Redis 的 RDB 文件末尾有 CRC-64。

校验值要在数据还正确的时候算。 数据先在内存里坏掉,再计算校验值写盘,之后每一次校验都会通过。备份的校验、分片上传的校验都有同样的问题:校验值必须来自数据的源头,而不是来自刚刚读出来的副本。

发现错误之后要有地方拿到正确的数据。 检错码本身不能修复任何东西,它只是把「静默的错误」变成「明确的失败」。修复靠的是另一份冗余:重传、副本、纠删码。只有校验没有冗余,结果是可靠地报错;只有冗余没有校验,结果是不知道哪一份是对的。

按错误的样子选编码。 偶发的单比特翻转(内存)适合 SECDED;成片的突发错误(链路、磁盘扇区)适合 CRC 检错加重传或副本;整块丢失(磁盘、节点故障)要用纠删码或多副本,这已经超出本文的范围。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权