二维码说能恢复 30%,前提是墨水别撒开
- 作者:Bougie
- 创建于:2026-10-18
上周在一家咖啡店,桌角那张点单码被杯子压出了一圈水渍,边缘磨得发白。我举着手机对了三秒,扫出来了。旁边那张贴在墙上的码完好无损,反而扫不出来——因为它印得太小。
这件事让我有点不爽。我一直以为二维码的容错是「坏掉多少面积」的问题,那天才发现它更像是「坏在哪个位置、坏成什么形状」的问题。回家我把这件事写成了代码:生成二维码,按各种方式涂坏,再丢给解码器看它认不认。

下面所有数字都来自我写的脚本:Node v24.16.0,Linux x86_64,用 qrcode 生成符号、用 jsQR 解码。生成和解码都在内存里完成,不涉及真实相机和真实打印,所以下面讲的都是纠错码能救的部分——对焦糊掉、手抖、反光这类问题不在里面,那些跟 Reed-Solomon 一点关系都没有。
# 一、一半的小方块本来就不装数据
先说个跟纠错无关但很影响直觉的事实。
一个 v1 的二维码是 21×21,总共 441 个小方块。其中真正用来装内容的只有 208 个,剩下 233 个——52.8%——是定位角、分隔符、时序图案、格式信息、校正图形这些「功能图案」。它们存在的唯一目的是让扫描器知道「这是一张二维码、它朝哪边、多大、用的什么参数」。
版本越大,这个比例越低:
| 版本 | 尺寸 | 总模块 | 功能图案 | 占比 |
|---|---|---|---|---|
| v1 | 21×21 | 441 | 233 | 52.8% |
| v2 | 25×25 | 625 | 266 | 42.6% |
| v5 | 37×37 | 1369 | 290 | 21.2% |
| v10 | 57×57 | 3249 | 481 | 14.8% |
| v20 | 97×97 | 9409 | 726 | 7.7% |
| v40 | 177×177 | 31329 | 1681 | 5.4% |
所以那些印得小小的、只装一个短网址的码,有一半面积是「包装」。麻烦的是包装不参与纠错——污损落在定位角上,这张码就当场死掉,一个码字的冗余都救不了。对小码来说,随机撒开的脏点有超过一半的概率砸在这片不容错的区域上。

# 二、「能恢复 30%」说的是码字,不是小方块
你在很多地方都见过这句话:L 能恢复 7%、M 15%、Q 25%、H 30%。
问题在于「恢复」这个词没有指明单位。我把 v40(177×177,最大的那个)在四个等级下的码字构成算了一遍:
| 等级 | 数据码字 | 纠错码字 | 总码字 | 纠错码字占比 | 可纠错码字 | 可纠错码字 ÷ 总码字 |
|---|---|---|---|---|---|---|
| L | 2956 | 750 | 3706 | 20.2% | 375 | 10.1% |
| M | 2334 | 1372 | 3706 | 37.0% | 686 | 18.5% |
| Q | 1666 | 2040 | 3706 | 55.0% | 1020 | 27.5% |
| H | 1276 | 2430 | 3706 | 65.6% | 1215 | 32.8% |
最右边那列才是「能恢复百分之多少」的码字版本:L 10.1%、M 18.5%、Q 27.5%、H 32.8%。跟流传的 7/15/25/30 是一个量级,越高级越接近。
一个码字是 8 个小方块。 这是整篇文章里最要紧的一句话。
于是 32.8% 这个数字说的是「3706 个码字里可以有 1215 个出错」,而 1215 个码字 × 8 = 9720 个小方块,占 31329 个总方块的 31%(H 级,且假设这些错误恰好整整齐齐地分布在码字里)。听起来确实像「30% 的面积」。
但现实里的污损不会替你排好队。
# 三、一个码字里坏 1 个方块,和坏 8 个,价钱一样
Reed-Solomon 纠错的基本单位是一个码字(GF(256) 上的一个符号)。一个码字里错了 1 位和错了 8 位,对解码器来说是同一件事:这个符号错了。
我直接测了这件事。做法是把二维码的模块位置按标准顺序还原成码字序列,然后随机挑 k 个码字——第一种是把它 8 个方块全翻,第二种是每个码字只翻 1 个方块——看解码器还能不能认出来(50% 成功率的临界值):
| 版本 | 等级 | 理论可纠错码字 | 整码字全坏(实测) | 每码字坏 1 位(实测) |
|---|---|---|---|---|
| v5 | L | 13 | 13 | 13 |
| v5 | M | 24 | 21 | 22 |
| v5 | Q | 36 | 28 | 27 |
| v5 | H | 44 | 36 | 36 |
| v40 | L | 375 | 230 | 243 |
| v40 | M | 686 | 405 | 389 |
| v40 | Q | 1020 | 589 | 591 |
| v40 | H | 1215 | 703 | 723 |
两列实测值几乎重合。v5-L 那一行最干净:理论 13,整码字全坏 13,坏 1 位也是 13,一模一样。坏 1 个方块和坏 8 个方块,账单完全相同。
顺带说一句,v40 那几行的实测值只有理论上限的 55%~65%。这是 jsQR 这个纯 JS 解码器的实现水平,不是标准的极限——换 ZXing 那类解码器大概率更接近理论值。下面所有数字都建立在这一个解码器上,你把它当成「一个还算体面的普通解码器」就行。
# 四、撒开的墨水,比一整块墨迹致命十倍
既然费用按码字算,那么两条显而易见的推论就来了:错误越集中越划算(一堆坏方块挤在同一个码字里,只收一份钱),越分散越亏(每个坏方块都落在不同的码字上,各收一份)。
我测了这两种极端。左边是「把 N 个坏方块随机撒在整个数据区」,右边是「同样多的坏方块挤成中心一个正方形」:
| 版本 | 等级 | 撒开:50% 成功率的临界值 | 撒开占全图 | 中心方块最大边长 | 方块最大面积占比 |
|---|---|---|---|---|---|
| v5 | L | 14 个 | 1.02% | 9(24.3%) | 5.9% |
| v5 | M | 24 个 | 1.75% | 11(29.7%) | 8.8% |
| v5 | Q | 32 个 | 2.34% | 14(37.8%) | 14.3% |
| v5 | H | 42 个 | 3.07% | 16(43.2%) | 18.7% |
| v10 | L | 30 个 | 0.92% | 15(26.3%) | 6.9% |
| v10 | M | 54 个 | 1.66% | 20(35.1%) | 12.3% |
| v10 | Q | 79 个 | 2.43% | 25(43.9%) | 19.2% |
| v10 | H | 95 个 | 2.92% | 27(47.4%) | 22.4% |
| v20 | L | 84 个 | 0.89% | 27(27.8%) | 7.7% |
| v20 | M | 152 个 | 1.62% | 33(34.0%) | 11.6% |
| v20 | Q | 223 个 | 2.37% | 41(42.3%) | 17.9% |
| v20 | H | 259 个 | 2.75% | 39(40.2%) | 16.2% |
| v40 | L | 249 个 | 0.79% | 51(28.8%) | 8.3% |
| v40 | M | 410 个 | 1.31% | 66(37.3%) | 13.9% |
| v40 | Q | 660 个 | 2.11% | 83(46.9%) | 22.0% |
| v40 | H | 791 个 | 2.52% | 91(51.4%) | 26.4% |
最直接的对比是这一组:v40 的 H 级,791 个坏方块撒在整张图上,一半的样本扫不出来;而784 个坏方块挤成中心一个 28×28 的方块,稳稳扫出。数量几乎一样,结局完全相反。
(表里的中心方块边长是一格一格试出来的、遇到第一个扫不出的边长就停,所以会有一两格的抖动——比如 v20 的 H 行停在 39,比 Q 行的 41 还小。这不代表 H 比 Q 差,只是那两格恰好踩在边界上。)
再看最右边那列:v40 上能遮掉的面积上限是 L 8.3% / M 13.9% / Q 22.0% / H 26.4%。
这四个数字我看了很久。它们跟流传的 7 / 15 / 25 / 30 挨得非常近。我猜那句「能恢复 X%」最初想表达的就是这个意思——一张码能被遮住多大一块还能扫——只不过在口口相传里,它被安到了「码字」头上,然后又被读者理解成了「任意位置任意形状的 30% 面积」。
而真实情况是:这个 X% 只在错误连成一整块的时候才成立。

# 五、所以 logo 到底能贴多大
上面那张表的最右列,实际上就是「中心 logo 的面积上限」,而且是能直接拿去用的结论:
- L 级:logo 面积别超过 8%(边长约 29%)
- M 级:别超过 14%(边长约 37%)
- Q 级:别超过 22%(边长约 47%)
- H 级:别超过 26%(边长约 51%)
业界那句「logo 不要超过二维码面积的 20%」是对的,但它默认你用的是 Q 或 H。如果你图省事用了默认的 M——顺带说,qrcode 这个库的默认等级就是 M——那么 20% 已经越界了。
我用 v5 单独验了一遍「中心挖空」这件事(不是随机撒,是真的把中心一块涂黑):
| logo 边长占尺寸 | L | M | Q | H |
|---|---|---|---|---|
| 10%(4×4) | 能扫 | 能扫 | 能扫 | 能扫 |
| 20%(7×7) | 能扫 | 能扫 | 能扫 | 能扫 |
| 25%(9×9) | 扫不出 | 能扫 | 能扫 | 能扫 |
| 30%(11×11) | 扫不出 | 能扫 | 能扫 | 能扫 |
| 35%(13×13) | 扫不出 | 扫不出 | 能扫 | 能扫 |
| 40%(15×15) | 扫不出 | 扫不出 | 扫不出 | 能扫 |
一个 37×37 的小码,L 级连边长为 25% 的 logo 都扛不住,H 级能扛到 40%。如果你要往码中间贴东西,别犹豫,直接上 H。

# 六、等级越高,方块越小——这笔账很多人没算
这是我在写这篇文章之前完全没想过的一层。
纠错等级提高,冗余变多,同样的内容需要更大的版本才能装下。而如果你把码打印成固定的物理尺寸,版本变大意味着每个小方块变小。
同一段 62 字节的内容(一条再普通不过的网址),四个等级下是这样:
| 等级 | 版本 | 尺寸 | 总模块 | 打印成 2cm 见方时每模块 |
|---|---|---|---|---|
| L | v4 | 33×33 | 1089 | 0.606 mm |
| M | v4 | 33×33 | 1089 | 0.606 mm |
| Q | v6 | 41×41 | 1681 | 0.488 mm |
| H | v7 | 45×45 | 2025 | 0.444 mm |
从 L 提到 H,纠错能力翻了三倍多(375 → 1215 个可纠错码字),代价是每个方块从 0.606mm 缩到 0.444mm,小了 26.7%。
在小尺寸印刷上,这笔交易可能是亏的。方块越小,打印机的一点点扩散、纸张的一点点洇墨、镜头的一点点解析力不足,都会让整片区域一起失效——而整片失效是纠错码最怕的吗?不,恰恰相反,成片失效是它最擅长救的。真正麻烦的是边界模糊导致每个方块的判定都变成掷硬币,那就是撒开型的错误,回到上一节的表格:撒开型错误的预算只有零点几个百分点。
所以我的结论有点反直觉:如果你要把码印得很小,提高纠错等级未必是好事。 小尺寸下真正该做的是缩短内容——少一个字符可能就少一个版本,少一个版本就少 8 个模块。
顺带说说容量悬崖。字节模式下 M 级的跳档点是这样:
42 字符 → v3 29×29 ( 841 模块)
43 字符 → v4 33×33 (1089 模块) +29.5%
63 字符 → v5 37×37 (1369 模块) +25.7%
85 字符 → v6 41×41 (1681 模块) +22.8%
107 字符 → v7 45×45 (2025 模块) +20.5%
一个字符之差,面积多 25.7%。你往 URL 后面多加一个 ?,码就大了一圈,方块就小了一圈。那些长得吓人的带参追踪链接,代价是实打实的。
而 v40-L 的上限是 7089 个数字、4296 个大写字母数字、2953 个字节,换算成 UTF-8 中文只有 984 个字。v1-L 更惨,17 个字节,5 个汉字。中文在二维码里是三倍价钱,因为标准里的「汉字模式」是给日文 Shift-JIS 设计的(13 位一个字),中文基本都走字节模式,一个字 3 字节。

# 七、真正让码扫不出来的,往往不是污损
最后这几条跟纠错一点关系都没有,但它们解释了我在生活里遇到的大部分「扫不出来」。
定位角被挡住,纠错码一点忙都帮不上。 我把 v5-H 的三个角的 8×8 区域分别整个涂黑、再整个涂白,六种组合全部扫不出。定位角不参与纠错,它是网格坐标系本身,坐标系错了,后面的码字全读错位置,再强的纠错也白搭。所以那些把二维码塞进圆角容器、让 logo 压住一角、或者用手指捏着角拍照的设计,是直接废掉整张码。
静默区。 规范要求四周留 4 个模块的白边。我实测下来,jsQR 连 0 静默区都能认出来——但这是解码器在帮你兜底,规范写的是 4,别人的扫码枪不一定这么好脾气。留 4 个模块,成本几乎是零。
反色。 白码黑底的二维码很常见,我测的 jsQR 认不出来。有些手机相机 App 能扫,有些不能,取决于解码器有没有做反转重试。别做反色码,这是纯粹的赌博。
旋转和镜像反而没事。 我把码左右镜像、旋转 90°、旋转 180°,jsQR 全部认出来了。这归功于那三个定位角——它们不在对角线上,所以能唯一确定这张码的朝向。这是二维码比一维条形码强的地方之一:你随便怎么举手机都行。

# 八、写完之后的想法
二维码是 1994 年在日本电装(Denso Wave)做出来的,原昌宏那个小组的本意是给汽车零件做追溯——原来的条形码装不下太多字,工人的扫码枪还得对准,于是他们做了个能装几十倍信息、还能从一个角度瞬间读取的东西。Denso Wave 手里有专利,但公开表示不行使,这也是它能铺满全世界的原因之一。
三十多年过去,它被贴在了早餐摊、共享单车、共享充电宝和我的快递箱上,成了一个几乎没人会多看一眼的东西。
我这次把它拆开算了一遍,最想记下来的不是那些百分比,而是一个很朴素的道理:冗余是为某种特定的失败方式准备的,不是为所有失败方式准备的。 二维码的纠错码能扛住一整块墨迹,却扛不住撒开的灰尘;能扛住旋转和镜像,却扛不住一个被挡住的角。
我们给系统加的每一层容错大概都是这样。它买下的不是「更安全」,而是「在某种特定的坏法下更安全」。至于那种坏法是不是你真正会遇到的那种,得自己去试。
——而我发现,试的办法比想象的便宜:一台机器,一个下午,几万张被我涂坏的二维码。