后台那个身份证输入框看着只有一格背后能坑掉人一下午。身份证正则表达式这几个字几乎是每个做表单校验的人都会搜一遍的东西搜出来的答案十有八九是^\d{17}[\dXx]$这一行抄过去能跑测试环境也过了然后上线之后照样有各种脏数据漏进来000000000000000000能过、110101199002310011能过、甚至一堆明显不对的号码也能过。问题不在正则写得短而在于多数人把格式匹配当成了合法性校验这两件事压根不是一回事。这篇东西就是想把这件事从头拆一遍。身份证号码的编码规则是什么、正则能卡到什么程度、卡不到的部分要靠什么补、几个主流语言落地时有哪些不一样的写法、以及线上真实数据里那些让人抓头的脏数据是怎么来的。适合正在写表单校验的同学、做数据清洗和数据治理的同学、以及需要处理日志脱敏和测试数据构造的同学看。哪怕你正则不熟照着文中的写法抄也能用如果你已经写了很多年第 4 章和第 5 章里的几个坑应该会让你有点共鸣。1. 先认清对象18位号码到底由什么组成写正则之前先把被匹配对象的编码规则吃透这是所有字符串校验的通用前提。很多人正则写不对不是语法不熟是根本不知道号码每一位在表达什么于是只能写\d{17}[\dXx]这种长度对了就行的糊弄版。1.1 四段式结构拆解现行 18 位号码是四段拼起来的从左到右依次是地址码、出生日期码、顺序码、校验码各段长度固定含义完全独立。段位位置长度含义取值约束地址码第 1-6 位6首次登记时的行政区划代码首位非 0需在有效区划表内出生日期码第 7-14 位8出生年月日YYYYMMDD必须是真实存在的日期顺序码第 15-17 位3同地址同日出生者的顺序编号纯数字第 17 位奇男偶女校验码第 18 位1前 17 位按加权算法算出0-9 或 X地址码这 6 位最容易被忽略。它不是随便生成的随机数而是对应到某一次行政区划编码前两位定位省级中间两位定位地市最后两位定位区县。区划是会调整的撤县设区、合并、更名编码表隔几年就要更新一次。所以任何在正则里写死具体地址码区间的做法都是在给自己埋雷——今天全的表三年后就有漏网的号码。出生日期码看着最规矩其实是脏数据重灾区。19900231、20000230、19991301这类月份日期合法但组合不存在的号码纯靠正则的(0[1-9]|1[0-2])和(0[1-9]|[12]\d|3[01])是拦不住的因为正则不理解大小月和闰年。这属于必须挪到代码里二次校验的部分第 3 章会展开。顺序码的三位里前两位只是流水第三位也就是整个号码的第 17 位承载了性别信息奇数为男、偶数为女。这一位在实际业务里有个不太显眼但很实用的地方做数据质量核查时可以拿它跟库里存的性别字段做交叉比对能捞出不少历史遗留的错误数据。当然这属于敏感属性用之前先确认业务上是否允许。1.2 校验码那一位是怎么算出来的第 18 位是很多人心里的一道坎觉得神秘其实就是个加权求和再取模的固定流程跟银行卡号、条码那套思路同源。规则是对前 17 位数字每一位乘以对应的权重求和和除以 11 取余数余数再去映射表里查出对应的字符。权重序列是固定的 17 个数字7 9 10 5 8 4 2 1 6 3 7 9 10 5 8 4 2。这个序列的推导方式是 2 的幂对 11 取模第 i 位的权重等于 2^(18-i) mod 11不用自己算记下来直接用。余数到字符的映射表是余数 0→1、1→0、2→X、3→9、4→8、5→7、6→6、7→5、8→4、9→3、10→2。写成字符串就是10X98765432用余数当下标直接索引比写一长串 switch 干净得多。注意映射表里余数 2 才对应 X不是余数 10。这一点在抄别人代码时特别容易抄错一旦表反了所有带 X 的号码都会校验失败而这类号码在数据库里占比不高测试的时候很容易漏掉。拿一个具体号码走一遍假设前 17 位是11010119900307001逐位乘权重1×7 1×9 0×10 1×5 0×8 1×4 1×2 9×1 9×6 0×3 0×7 3×9 0×10 7×5 0×8 0×4 1×2 79050429540027035002 154 154 mod 11 0映射表下标 0 对应字符 1所以末位是 1。这个算法的好处是能拦住绝大多数手误和伪造随机改一位校验码对上的概率只有 1/11 左右。它拦不住的是刻意构造的号码——只要知道算法反推出合法校验位是分分钟的事。所以校验码的定位是防误输不是防伪造别指望它承担身份核验的职责那需要走权威核验通道。1.3 15位老号码与非内地证件的差异老系统里经常能翻出 15 位的老号码结构上少了世纪位和校验位6 位地址码 6 位出生日期YYMMDD 3 位顺序码。转换规则是把年份补成 19XX再算出校验码补在第 18 位。做数据迁移的时候这个转换一定要做但要注意一个坑并不是所有 15 位号码都能一次性转对如果没有原始资料佐证补出来的世纪位只能按 19 推断遇到真正的百岁老人就错了——虽然概率极低但数据治理项目里这种事一旦出现就是一条错误数据。还有一类容易漏掉的港澳台居民居住证号码也是 18 位结构跟内地一致但地址码段用的是另外几个特定前缀。如果系统面向的用户群体包含这部分人群正则里的地址码首位约束就不能只按内地的规则来否则会误伤。同理外籍人士的证件号格式完全不同不能用同一套正则去卡。所以第 0 步永远是搞清楚这个输入框到底允许哪几类证件。允许一类就写一类的正则允许三类就写三条各自独立校验别指望一条正则通吃所有情况。混着写的最终结果通常是对谁都不严格。2. 正则怎么写从能匹配到不误伤2.1 最朴素的写法为什么不够用搜出来最多的版本是^\d{17}[\dXx]$它的全部含义就是17 个数字加 1 个数字或 X。这个写法有两个致命弱点一是把地址码首位的非零约束丢掉了000000000000000000这种全零串能大摇大摆通过二是完全不约束出生日期段999999999999999999也能过。稍微好一点的版本开始加约束^[1-9]\d{5}\d{8}\d{3}[\dXx]$首位非零加上了但\d{8}还是裸的日期段依旧不设防。而且这里有个更隐蔽的问题如果正则没有加^和$锚点abc110101199003070011xyz这种串也能匹配成功因为正则引擎默认是包含即匹配。在表单校验场景里这等于形同虚设更糟的是在做数据清洗时误匹配会把一长串文本误判成号码直接污染结果集。2.2 逐段收紧把能表达的约束都写进去把四段分别展开日期段用枚举式的字符组效果会比\d好很多^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$拆开看每一段的意图。[1-9]\d{5}保证地址码首位非零后五位放开——这里刻意不用更精确的省级前缀区间因为区划调整频繁写死了会误伤新号段。(?:18|19|20)\d{2}把年份限定在 18xx 到 20xx 之间覆盖了目前所有在世人口可能的出生年份同时挡住了 0000、9999 这种明显不可能的年份。月份段(?:0[1-9]|1[0-2])是标准的 01 到 12 枚举日期段(?:0[1-9]|[12]\d|3[01])是 01 到 31 枚举。注意这里必须补零如果不允许前导零1990-3-7这种写法就混进来了位数一乱整个正则就崩了。顺序码用\d{3}就够了不必要在这里判断奇偶。末位[\dXx]同时接受大小写 X是为了容忍大小写不一致的数据——很多系统存的是小写 x表现层显示大写 X如果正则只认大写从数据库读出来再校验就会大面积失败。2.3 三个可直接抄的版本与取舍实际项目里根据严格程度通常会准备三档版本正则严格度适用场景宽松^\d{17}[\dXx]$极低只做初步长度过滤如日志扫描中等^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$中表单前端即时提示严格中等版 日期库校验 校验码计算高后端入库、数据清洗选哪一档取决于这一步校验失败之后的处理成本。前端即时提示用中等版足够用户输入过程中不必一上来就卡校验码体验太差后端入库必须用严格版因为后端是最后一道闸放进去的脏数据以后再清就是几倍的功夫。提示前端校验的作用是减少无效请求不是保证数据质量。任何人打开浏览器控制台都能绕过前端校验所以后端那道关一个都不能少。另外还有一个常被忽略的细节如果需要兼容 15 位老号码不要在一个正则里用|硬拼两个分支那样可读性会很差后续维护的时候改一个错一个。分成两条独立的正则先判断长度再分别匹配逻辑清楚得多。3. 正则管不了的部分必须补的两道语义校验正则本质上只认识字符不认识语义。它知道两位数字但不知道 2 月和 30 号不能凑一块儿它知道这一位是数字但不知道这个数字得由前 17 位算出来。所以完整校验一定是正则 代码两段式缺一不可。3.1 日期真实性与闰年边界校验日期最省事的办法不是自己写闰年判断而是丢给标准库的日期解析函数。绝大多数语言里把YYYYMMDD这个格式串丢进解析函数非法日期会直接抛异常或者返回失败闰年、大小月、月末天数这些都是库自己处理的比手写可靠得多。Python 里就是datetime.strptime(s[6:14], %Y%m%d)19900231会直接抛出ValueError20000229会正常通过19000229会失败1900 不是闰年因为它能被 100 整除但不能被 400 整除。这个边界用正则根本表达不出来用库一行搞定。JavaScript 的Date有个坑得提一下new Date(2023-02-30)不会报错而是自动滚动到 3 月 2 日。所以不能只看isNaN得把解析出来的年月日再跟原字符串比对一遍不一致就判为非法。这是个很典型的库替你做决定的陷阱实测下来很多项目就是栽在这里。Java 的LocalDate.parse默认是严格模式非法日期会抛DateTimeParseException相对省心。但要注意用DateTimeFormatter.ofPattern(yyyyMMdd)配合ResolverStyle.STRICT默认的 SMART 模式在某些边界上会做宽松解析。还有一个容易漏的场景出生年份下限。如果有人输入18900101格式完全合法校验码也能算对但这显然不合理。业务上通常会加一个年龄区间约束比如不允许早于某一年、不允许晚于当前日期。这个约束最好也做进校验函数里别让后面的业务逻辑去判断。3.2 校验码计算与地址码白名单校验码计算前面已经把算法过了一遍落到代码里就是三段切片取前 17 位、逐位乘权重求和、查表比对。这里有个细节要注意拿到的字符串可能是小写 x一定要先统一转大写再做比对否则末位是 x 的号码会全部失败。地址码这块的取舍更微妙一些。最严格的做法是维护一张完整的行政区划码表6 位精确匹配对不上就拒绝。但这张表需要定期更新而且历史数据里存在已撤销的编码——一个 2005 年登记的号码地址码对应的区划可能 2015 年就撤销了如果只用现行码表校验这批历史数据会全部报错。比较务实的折中是三层策略第一层正则保证首位非零、长度正确第二层校验省级前缀是否在已知范围内前两位大概有几十个有效值维护成本很低第三层对 6 位完整码用宽松策略命中现行码表就通过未命中则记录到待核查表但不直接拒绝。这样既挡住了明显的垃圾数据又不会把历史数据一刀切掉。注意不要把地址码白名单直接硬编码在代码里。这类数据变化频率不高但确实会变硬编码意味着每次调整都要发版。放在配置表里加个版本号改起来从容得多。顺序码的奇偶校验也值得提一句。第 17 位奇数为男、偶数为女这条规则在数据治理场景里非常好用——把号码里的性别位跟库里的性别字段做批量比对能一次性捞出一批录入错误或者数据串行的问题。但这种交叉校验的前提是你要确认这批数据在历史上没有被人为改写过。4. 各语言落地写法差异与工程封装4.1 JavaScript 与 PythonJavaScript 里最需要注意的是正则字面量的复用。每次调用都重新创建 RegExp 对象在高频调用场景下会有不必要的开销直接定义成模块级常量就行。另外如果用了g标志的test()lastIndex会在多次调用之间残留导致同一个正则检测同一个字符串两次结果不同——这个坑非常经典校验场景下千万别加g。const ID_PATTERN /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dX]$/; const WEIGHTS [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2]; const CHECK_MAP 10X98765432; function validateId(raw) { if (typeof raw ! string) return { ok: false, reason: 类型错误 }; const s raw.trim().toUpperCase(); if (!ID_PATTERN.test(s)) return { ok: false, reason: 格式不合法 }; const y s.slice(6, 10), m s.slice(10, 12), d s.slice(12, 14); const dt new Date(y, m - 1, d); if (dt.getFullYear() ! y || dt.getMonth() ! m - 1 || dt.getDate() ! d) { return { ok: false, reason: 出生日期不存在 }; } let sum 0; for (let i 0; i 17; i) sum (s[i]) * WEIGHTS[i]; if (CHECK_MAP[sum % 11] ! s[17]) return { ok: false, reason: 校验码不匹配 }; return { ok: true, gender: (s[16]) % 2 1 ? M : F }; }注意上面那个日期校验的写法先构造 Date再把年月日拆出来跟原值逐一比对这是绕开自动滚动陷阱的标准做法。返回结构化对象而不是布尔值是为了让调用方拿到失败原因方便做针对性的提示文案——格式不对和校验码不对给用户的指引完全不同。Python 版本反过来re模块建议用compile预编译datetime.strptime处理日期整体会比 JS 顺畅。import re from datetime import datetime _ID_RE re.compile( r^[1-9]\d{5} r(?:18|19|20)\d{2} r(?:0[1-9]|1[0-2]) r(?:0[1-9]|[12]\d|3[01]) r\d{3}[\dX]$ ) _WEIGHTS [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] _CHECK_MAP 10X98765432 def validate_id(raw: str): s (raw or ).strip().upper() if not _ID_RE.match(s): return False, 格式不合法 try: datetime.strptime(s[6:14], %Y%m%d) except ValueError: return False, 出生日期不存在 total sum(int(c) * w for c, w in zip(s[:17], _WEIGHTS)) if _CHECK_MAP[total % 11] ! s[17]: return False, 校验码不匹配 return True, ok4.2 Java 与 Go 的坑Java 的Pattern建议编译成静态常量不要每次调用Pattern.compile。日期解析记得指定严格模式字符串先toUpperCase(Locale.ROOT)再比对不要用不带 Locale 的版本——在某些区域设置下toUpperCase对特定字符的行为会有差异虽然对 ASCII 字母不构成问题但养成习惯没坏处。private static final Pattern ID_PATTERN Pattern.compile( ^[1-9]\\d{5}(?:18|19|20)\\d{2}(?:0[1-9]|1[0-2]) (?:0[1-9]|[12]\\d|3[01])\\d{3}[\\dX]$); private static final int[] WEIGHTS {7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2}; private static final String CHECK_MAP 10X98765432;Go 这里有个真坑Go 的regexp包底层是 RE2 引擎它不支持非捕获组(?:...)。上面那条正则原样搬到 Go 里regexp.MustCompile会直接 panic报错信息是invalid or unsupported Perl syntax。解决办法很简单把(?:换成(用普通捕获组反正校验场景不关心捕获内容。var idPattern regexp.MustCompile( ^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]$)这个差异如果不提前知道从 Java 或者 JS 项目迁移过去必然要花时间 debug。我把几个常见引擎的差异整理成表特性JSPythonJavaGo (RE2)非捕获组(?:)支持支持支持不支持命名捕获支持支持支持支持后行断言(?)支持支持支持不支持回溯型引擎是是是否ReDoS 风险有有有无最后一行值得多说一句。RE2 是线性时间引擎天生免疫回溯爆炸代价是不支持后行断言和一些高级特性。如果脱敏逻辑必须用后行断言在 Go 里就得换一种实现方式这个后面会讲。4.3 封装成统一校验函数多语言项目里最怕的是校验逻辑各写一遍前端一套后端一套跑着跑着就不一致了。合理的做法是定义一个统一的接口契约各端按同一份规范实现并且共用同一批测试用例。接口返回值至少包含三样是否通过、失败原因枚举、以及需要时可用的派生信息如性别位。失败原因用枚举而不是自然语言文案文案交给前端做映射这样后端换语言、前端换文案都不影响契约。5. 真实场景里的典型问题与排查5.1 输入预处理全角、空格、科学计数法线上数据里最容易出现的脏东西往往不是用户恶意输入而是输入法和复制粘贴带来的。中文输入法状态下打出来的数字是全角的这种串用\d是匹配不上的——在多数正则引擎里\d默认只匹配 ASCII 数字全角数字不在范围内。解决方式不是把正则写宽而是在校验之前先做一次全角转半角。从 Excel 粘贴过来的数据同样麻烦。18 位纯数字在 Excel 里会被识别为数值超过 15 位精度之后低位直接变成 0而且显示成1.10101E17这种科学计数法。这类数据一旦进了数据库号码本身就已经被破坏了任何校验都救不回来只能打回重新收集。所以做批量导入功能的时候模板里对应列一定要预先设成文本格式并且加数据校验提示这是从源头解决问题的做法。还有一类不可见字符不换行空格\u00A0、零宽空格、BOM 头。这些字符肉眼看不见trim()也不一定处理得掉不同语言对trim的处理范围不一样。稳妥的做法是在预处理阶段用正则清理一遍非可见字符s.replace(/[\s\u00A0\u200B-\u200D\uFEFF]/g, )把所有空白和零宽字符统一去掉再进校验。审查输入时还有一个隐含假设要检查你拿到的到底是不是字符串。前端传 JSON 过来如果字段没有引号包裹很多时候会变成一个 number 类型超过 15 位之后精度丢失拿到手就已经错了。这类问题排查起来很费劲因为校验函数本身没毛病是数据在传输环节就已经失真了。校验函数入口加一个类型判断能挡住一批。提示校验失败日志里一定要把原始输入按原样记录下来脱敏后不要只记校验失败。很多时候光看失败原因根本定位不了问题得看那个字符串长什么样才知道是被全角字符污染了还是被精度截断了。5.2 性能与回溯风险前面给的那条正则结构上是一串定长片段顺序拼接没有嵌套量词也没有(a)这种经典的灾难性回溯结构所以时间复杂度是线性的性能上不用担心。真正需要警惕的是后续维护时被人改成更灵活的写法——比如把日期段写成(\d{2}){3}或者加个\d*之类就有可能引入回溯。如果校验场景是批量处理百万级数据光靠单条正则的性能其实不是瓶颈真正的瓶颈通常在做日期解析上。每次调strptime都要走一遍格式化解析开销比正则匹配高一个量级。数据量大的时候可以做个优化先用正则整体过一遍把明显格式不对的直接剔掉剩下的再进日期解析。实测下来能省掉相当一部分时间因为脏数据里格式错误的比例往往比想象的高。还有一个容易忽略的点是预编译。Python 的re.match内部有缓存机制但显式compile更可控Java 的Pattern.compile开销不小一定要提成静态常量JS 用字面量形式本身就是编译期处理的但要注意别在循环里用new RegExp()。5.3 脱敏与日志场景的正则改造日志、导出报表、客服工单系统里经常会带号码这些场景的需求恰好相反不是校验是识别并替换。这时候正则要放松一点因为日志里出现的可能是被截断的、加了空格的、被 JSON 包裹的号码用严格的入库校验正则去匹配命中率会很低。常见的脱敏策略是保留前 6 位后 4 位中间 8 位打星号这样既保留了地区信息便于排查又不会泄露完整的生日。JS 实现const MASK_RE /(?!\d)(\d{6})(\d{8})(\d{3}[\dXx])(?!\d)/g; const masked text.replace(MASK_RE, $1********$4);前后各加一个非数字的负向断言是为了防止匹配到更长数字串的中间部分——比如一个 20 位的流水号里恰好有 18 位符合结构没有边界断言就会被误打码。这个细节在日志脱敏里非常关键边界处理不到位会造成大面积误伤。Go 不支持后行断言所以这个写法搬不过去。替代方案是用捕获组把前后字符一起吃进来再吐回去(\D)(\d{6})(\d{8})(\d{3}[\dXx])(\D)替换成$1$2********$5代价是当号码在字符串首尾时匹配不到得额外处理。这就是引擎能力差异带来的实现成本提前知道能省不少时间。还要注意脱敏的时机。比较靠谱的原则是数据一落盘就脱敏只在需要的环节解密而不是在展示层临时替换。很多事故就出在以为只展示给内部看上日志采集系统、监控告警、错误追踪工具这些环节都会把原始字符串带走一份。6. 踩坑之后的一些经验6.1 什么该交给正则什么不该写过几轮之后我形成了一个比较固定的判断标准让正则只负责形状语义和逻辑全部交给代码。具体来说正则管长度、字符集、锚点、明显的区间约束首位非零、月份 01-12日期真实性、校验码、地址码有效性、年龄区间这些一律放到代码里。这样做的好处是可维护性。正则一旦超过两行改起来就很容易出岔子而正则的 bug 往往非常隐蔽——它不会报错只会静默地少匹配或者多匹配等发现的时候可能已经跑了大半年。把复杂逻辑拆到代码里至少是能被单元测试覆盖住的。还有一个取舍是关于严格程度的。校验过严的代价是误伤真实数据这个成本在业务上通常比放过几条脏数据高得多。特别是面对历史数据或者多端录入的场景宁可放行后进待核查队列也不要一刀切拒绝——用户填不进去的投诉永远比数据脏一点更紧急。6.2 测试用例怎么设计才不心虚校验函数的测试用例光靠随手编几个号码是不够的因为随手编的号码大概率校验码都是错的测了等于没测。合理的做法是覆盖几个维度维度用例示例期望正常号码合法校验码的 18 位串通过末位 X校验码为 X 的号码含大小写通过校验码错正常号码改末位拒绝日期不存在2 月 30 日、13 月拒绝闰年边界20000229 通过19000229 拒绝见说明地址码非法全零地址码拒绝长度异常17 位、19 位拒绝字符污染全角数字、含空格、含换行拒绝预处理后通过类型异常null、数值、空串拒绝且不抛异常关于闰年那条2000 年能被 400 整除是闰年20000229应该通过1900 年能被 100 整除但不能被 400 整除不是闰年19000229应该拒绝。这一对用例是最能体现你到底有没有真做日期校验的试金石比测十个普通号码有价值。构造测试数据的时候有个原则要守住不要用随机生成的号码直接写进测试 fixture因为随机号码理论上可能撞上真实存在的号码一旦测试数据泄漏出去就是麻烦。比较稳妥的做法是用明显不真实的固定前缀比如用连续重复的数字段或者干脆在测试环境用假号码并在文档里明确标注为虚构数据。最后一个体会是关于验证方式的反向思考写完之后拿真实业务数据跑一遍统计一下拒绝率。如果拒绝率明显超过预期先别急着下数据质量差的结论很可能是正则写严了或者预处理没做。我遇到过一次拒绝率高得离谱排查半天发现是上游导出时把号码列做了数值格式化所有低位的 0 都被吃掉了。这种问题从规则本身是看不出来的必须回到数据源头去看。真正跑过几轮之后你会发现那个输入框背后最难的部分从来不是那行正则而是对上游数据到底长什么样的理解。正则只是最后一公里的守门员前面每一环的水分最后都会在这里集中体现出来。