身份证最后一位的 X,不是幸运数字是校验码

2026-07-01生成工具
分享到

做注册表单的时候,同事用 ^\d{17}[\dXx]$ 做身份证校验,结果测试同学随手敲的 123456190001011234 也通过了。他去质疑测试"你输的格式不对",测试反问:格式哪里不对?17 位数字加一位数字,完全合法。

问题出在把"格式合法"和"号码有效"划了等号。身份证号码的前 17 位是信息,第 18 位是由前 17 位算出来的校验码——就像二维码的纠错码、像银行账号的校验位。随手编的数字,第 18 位大概率对不上。这篇文章把这个算法拆开,顺手说清楚几个和它有关的常见误解。

18 位号码的完整结构

身份证号码结构

GB 11643-1999 标准定义的 18 位号码分四段:

地址码(第 1-6 位)。公民首次申领户口所在地的县级行政区划代码,遵循 GB/T 2260。两位省、两位市、两位县:11 北京、1101 市辖区、110105 朝阳区。这份区划表会随行政区划调整更新(撤县设区、撤地设市都会改代码),所以存在"现在查不到的地址码"——号码一经编定终身不变,行政区划倒是会变。

出生日期码(第 7-14 位)。YYYYMMDD,8 位。1984 年第一代身份证是 15 位(两位年份),1999 年升级为 18 位(四位年份 + 校验码),这就是为什么有些老号码升级后中间多了 19。

顺序码(第 15-17 位)。同一地址码辖区内、同一天出生的人按序分配的三位数字,奇数分配给男性,偶数分配给女性。所以看第 17 位就能知道性别,不需要第 18 位参与。

校验码(第 18 位)。由前 17 位按 ISO 7064 MOD 11-2 算出,取值是 0-9 或字母 X。

校验码到底怎么算

校验码计算过程

三步:

第一步,加权求和。 前 17 位每一位乘以固定系数再求和,系数从左到右是:

7  9  10  5  8  4  2  1  6  3  7  9  10  5  8  4  2

第二步,模 11。 对和取余数,得到 0 到 10 之间的一个数。

第三步,查表映射。 余数对应校验码如下表:

余数012345678910
校验码10X98765432

余数 2 对应的校验值是 10,一个格子放不下两位数,标准选了罗马数字 X 来表示。这就是 X 的全部来历——不是什么特殊人群标记,只是出生那天"恰好轮到"余数 2 的人群占比约十一分之一。

拿一个标准示例号码完整算一遍(11010519491231002X,这是 GB 标准文档里的经典例子):

号码前 17 位:1 1 0 1 0 5 1 9 4 9 1 2 3 1 0 0 2
固定系数:    7 9 10 5 8 4 2 1 6 3 7 9 10 5 8 4 2

乘积求和:
1×7 + 1×9 + 0×10 + 1×5 + 0×8 + 5×4 + 1×2 + 9×1
+ 4×6 + 9×3 + 1×7 + 2×9 + 3×10 + 1×5 + 0×8 + 0×4 + 2×2
= 7 + 9 + 0 + 5 + 0 + 20 + 2 + 9 + 24 + 27 + 7 + 18 + 30 + 5 + 0 + 0 + 4
= 167

167 mod 11 = 2  →  查表得 X

第 18 位是 X,校验通过。

用代码验证

完整校验逻辑不到二十行,比"格式正则"可靠得多:

function validateIdCard(id) {
  const idStr = String(id).trim().toUpperCase();
  if (!/^\d{17}[\dX]$/.test(idStr)) return false;

  // 出生日期合法性(正则查不出 13 月 40 日)
  const birth = idStr.slice(6, 14);
  const d = new Date(`${birth.slice(0,4)}-${birth.slice(4,6)}-${birth.slice(6,8)}`);
  if (isNaN(d.getTime())) return false;

  const weights = [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2];
  const codes = "10X98765432";
  const sum = weights.reduce((s, w, i) => s + Number(idStr[i]) * w, 0);
  return codes[sum % 11] === idStr[17];
}

validateIdCard("11010519491231002X");  // true
validateIdCard("123456190001011234");  // false,格式对但校验码错

几个实现细节:输入统一转大写(手输的 x 很常见);出生日期用 Date 再验一遍,因为正则 \d{8} 挡不住 19001340 这种月份日期都越界的值;15 位老号码遇到时按升位规则转换后再验(补 19 到年份 + 算校验码)。

这个算法的数学基础是 ISO 7064 的 MOD 11-2:单一模数、权重递变,能百分百检出单字符错误,也能检出绝大多数相邻位换位错误(当相邻两位系数不构成互补时)。选 11 做模数是因为 11 是质数,2 是它的原根,权重序列 2ⁿ mod 11 恰好覆盖系数表里那组数。二维码的 Reed-Solomon 是纠错(还能还原损坏数据),这里是校验(只能发现错误),目的一样但能力不同。

校验通过 ≠ 号码真实

这是最需要写进需求文档的一条:校验码算法是公开的,任何会算数的人都能编出校验通过的号码。校验能挡住的是"手滑输错"(占真实场景的大头),挡不住"刻意编造"。

所以身份证号类需求的正确姿势是分层的:

  • 前端:格式正则 + 校验码验证,拦截手误,即时反馈"号码格式有误";
  • 后端:同样再验一遍(别信前端),然后接权威核验服务(公安实名接口)确认"号码与姓名匹配";
  • 测试:绝对不要拿同事或用户捐赠的真实号码造测试数据,用身份证号生成工具批量产出格式合法的假号码——它就是按上面的算法算好校验码的,能通过各种格式校验,但对应的地址、日期都是随机的。

顺带一提隐私边界的常识:身份证号本身暴露的信息是户籍地(前 6 位)、出生日期、性别,不含姓名和住址。拿身份证归属地查询工具查前 6 位能定位到区县,这就是它泄露信息的上限——但出生日期加性别再加户籍地,在社工库里交叉匹配的威力不小,能不收集就别收集,必须展示时用 110105********002X 这种脱敏格式。

为什么不用更长的校验位

最后一个有意思的问题:MOD 11-2 只有 11 种余数,检错概率约 91%,为什么不加到两位校验码?

答案是历史和容量的折中。15 位升 18 位时只腾出了一位,而 11 选 1 已经能挡住全部单字符错误——输入场景里,单字符错(看错、打错、OCR 错)占绝对多数,两位换位是次高频,再往后的错误形态已经很罕见。校验位的设计从来不是"检出所有错误",而是"用最小的位数覆盖最高频的错误"。这个取舍思想在银行卡号(Luhn 算法,一位校验)、EAN 条形码(一位校验)、IBAN(两位校验)里一脉相承——位数越金贵的编号系统,校验位越抠门。

下次再看到表单里那个 X,你知道它是 mod 11 的产物;再看到"校验通过"的提示,也请记得它只证明了数学,不证明这个人。

评论