一个校验用的正则 ^[A-Z0-9]+$ 拒绝了一个看起来完全合法的编号:全是大写字母和数字,长度也对,肉眼盯了十分钟找不出毛病。最后是 ascii() 把真相打了出来——第一个字符不是拉丁字母 H,是西里尔字母 Н。

🧩 现象:字符串和自己长得一样,但不相等

手上有个编号 Н241481Р01454,规则很简单:只允许大写字母和数字。校验直接判了不通过。

import re
pat = re.compile(r'^[A-Z0-9]+$')

bad  = 'Н241481Р01454'
good = 'H241481P01454'
print(bool(pat.match(bad)),  len(bad))
print(bool(pat.match(good)), len(good))
print(bad == good)

跑出来是这样:

False 13
True 13
False

两串在屏幕上、在编辑器里、在日志里完全一样,长度也都是 13,但 == 说它们不是同一个东西。到这一步基本可以确定:问题不在正则,在输入里有我看不见的字符。

🔍 第一步不是分析,是把差异显示出来

排查不可见字符,核心动作只有一个——换一种不依赖字形的方式把它打出来。Python 里最省事的是 ascii(),它会把所有非 ASCII 字符转成 \uXXXX 转义:

print(ascii(bad))
print(ascii(good))
'\u041d241481\u042001454'
'H241481P01454'

一行就结案了。第 0 位和第 7 位是 U+041DU+0420,剩下全是老老实实的数字。

想看得更细就逐字符打码点和 Unicode 官方名字:

import unicodedata
for i, (a, b) in enumerate(zip(bad, good)):
    mark = '  <-- DIFF' if a != b else ''
    print(f'{i:2d}  {a} U+{ord(a):04X} {unicodedata.name(a):<32}'
          f' | {b} U+{ord(b):04X} {unicodedata.name(b)}{mark}')
 0  Н U+041D CYRILLIC CAPITAL LETTER EN     | H U+0048 LATIN CAPITAL LETTER H  <-- DIFF
 1  2 U+0032 DIGIT TWO                      | 2 U+0032 DIGIT TWO
 ...
 7  Р U+0420 CYRILLIC CAPITAL LETTER ER     | P U+0050 LATIN CAPITAL LETTER P  <-- DIFF

不在 Python 里也一样能查。写进文件之后 xxd 看字节,UTF-8 编码的西里尔字母是两字节、以 d0 开头,混在一串 ASCII 数字里非常扎眼:

$ xxd id.txt
00000000: d09d 3234 3134 3831 d0a0 3031 3435 340a  ..241481..01454.
00000010: 4832 3431 3438 3150 3031 3435 340a       H241481P01454.

或者直接让 grep 把非 ASCII 的位置找出来,-b 顺便给字节偏移:

$ grep -o -b -P '[^\x00-\x7F]' id.txt
0:Н
8:Р

顺带说一个坑:不是所有语言的「转义打印」都会帮你。Python 的 ascii() 会转义,但 Go 的 %q 和 JavaScript 的 JSON.stringify 都直接把西里尔字母原样吐回来,看着还是一模一样:

Go   fmt.Printf("%q", bad)   => "Н241481Р01454"
JS   JSON.stringify(bad)     => "Н241481Р01454"

所以这两边要么自己按码点打,要么落到字节层面看。Go 里 len(bad) 是 15、len([]rune(bad)) 是 13,字节数和字符数对不上,本身就是个信号。


🪞 同形字:几个长得几乎没有区别的字母

西里尔字母表里有一批大写字母,字形和拉丁字母基本重合,但码点完全无关:

Н  U+041D  CYRILLIC CAPITAL LETTER EN   vs   H  U+0048  LATIN CAPITAL LETTER H
Р  U+0420  CYRILLIC CAPITAL LETTER ER   vs   P  U+0050  LATIN CAPITAL LETTER P
А  U+0410  CYRILLIC CAPITAL LETTER A    vs   A  U+0041  LATIN CAPITAL LETTER A
Е  U+0415  CYRILLIC CAPITAL LETTER IE   vs   E  U+0045  LATIN CAPITAL LETTER E
О  U+041E  CYRILLIC CAPITAL LETTER O    vs   O  U+004F  LATIN CAPITAL LETTER O
С  U+0421  CYRILLIC CAPITAL LETTER ES   vs   C  U+0043  LATIN CAPITAL LETTER C

这类「不同码点、相同或近似字形」的字符,Unicode 官方叫 confusables,中文一般叫同形字。真实世界里最出名的应用是 IDN 同形域名钓鱼:注册一个把某个字母换成西里尔同形字的域名,浏览器地址栏渲染出来和 example.com 难分真假。原理和我这个编号被拒是同一件事,只是攻守方向反过来了。

⚠️ 方向别搞反:这是假阴性,不是「混进来被放行」

我一开始下意识以为是「脏数据绕过了校验」,其实完全相反。

[A-Z] 在正则里是一个码点区间,等价于 U+0041–U+005A。西里尔字母在 U+0400 往上,根本不在这个区间里。所以结果是校验拒绝了它——一个假阴性:数据是脏的,但表现出来是「合法输入被误杀」,于是排查方向很容易一头扎进正则本身。

这一点各语言行为一致,我在 Go、JavaScript、Python 三边都跑了同一组输入,^[A-Z0-9]+$Н241481Р01454 全都是 false[A-Z] 是码点区间这件事是规范层面的,不存在某个实现「顺手帮你把西里尔算进去」。

顺便一提,大小写转换也救不了:bad.casefold().upper() 之后再匹配,仍然是 False——Cyrillic EN 的大写还是 Cyrillic EN。

🧪 NFKC 不能解决这个问题(我验证过)

看到「Unicode 字符不一致」,第一反应通常是上 unicodedata.normalize('NFKC', s)。这次不行,实测四种规范化形式全都原样返回:

for form in ('NFC', 'NFD', 'NFKC', 'NFKD'):
    n = unicodedata.normalize(form, bad)
    print(form, bool(pat.match(n)), ascii(n))
NFC  False '\u041d241481\u042001454'
NFD  False '\u041d241481\u042001454'
NFKC False '\u041d241481\u042001454'
NFKD False '\u041d241481\u042001454'

原因是 NFKC 处理的是兼容等价——同一个字符的不同表现形式,比如全角、上标、连字。西里尔 Н 和拉丁 H 是两个语义完全不同的字母,只是碰巧画得像,它们之间不存在任何等价关系,所以规范化没有任何理由去动它。

拿全角字母做个对照就很清楚了,同样是 NFKC,这个就折叠成功了:

fullwidth '\uff28241481\uff3001454' -> NFKC 'H241481P01454'  match True
cyrillic  '\u041d241481\u042001454' -> NFKC '\u041d241481\u042001454'  match False

要真做同形字检测,得靠 Unicode 的 confusables 数据(confusables.txt 里的 skeleton 映射),或者走「限制脚本混用」的路子。

🛡 实际能用的防御

我更倾向于收紧输入,而不是事后去猜用户想输什么。按性价比排:

1. 白名单码点。 编号这种字段的合法字符集是明确的,直接逐字符查表,不合法就报出位置和码点,让错误信息自己解释清楚:

import unicodedata
ALLOWED = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')

def check_charset(s):
    bad = [(i, c) for i, c in enumerate(s) if c not in ALLOWED]
    if bad:
        detail = ', '.join(
            f'pos {i}: {c!r} U+{ord(c):04X} {unicodedata.name(c, "?")}'
            for i, c in bad
        )
        raise ValueError(f'非法字符 -> {detail}')
    return s
非法字符 -> pos 0: 'Н' U+041D CYRILLIC CAPITAL LETTER EN, pos 7: 'Р' U+0420 CYRILLIC CAPITAL LETTER ER

区别只在错误信息,但这条信息足以把半小时的排查压缩成十秒钟。^[A-Z0-9]+$ 只会告诉你「不匹配」,它不会告诉你不匹配在哪、为什么。

2. 检测脚本混用。 统计字段里出现了几种 Unicode script,多于一种就拒绝。这条对付「一串里混了两种字母表」很有效:

'H\u041d241481P01454'      scripts: ['CYRILLIC', 'LATIN']   <- 混用,拒绝

但要老实说一句:这条对本文这个输入无效Н241481Р01454 里的字母恰好全是西里尔的,脚本检测跑出来是单一 ['CYRILLIC'],一点问题都看不出来:

'\u041d241481\u042001454'  scripts: ['CYRILLIC']            <- 单一脚本,放行

所以 single-script 检查必须配上「预期脚本」才有意义——不是「只要不混用就行」,而是「这个字段只允许 Latin」。这也是为什么第 1 条的白名单更实在:它天然就带着预期。

3. 在入口处规范化并落库。 先 NFKC(能收掉全角、兼容字符这一类),再过白名单,通过了才写进存储。别让脏码点漏到下游——一旦存进去,之后所有基于等值比较的查询都会静默地查不到,那时候排查成本比现在高得多。

收束

三条经验:

  • 肉眼不是验证手段。 字符串排查的第一步永远是换个表示形式打出来:ascii()、逐字符码点、xxd。字形骗人,码点不骗人。
  • 「合法输入被拒」和「脏输入被放行」要分清。 这次是前者,方向搞反了就会一直去改正则。
  • 校验器要能解释自己。 一个只会返回布尔值的正则,把「为什么不通过」这个信息整个丢掉了;换成逐字符白名单,多写十行,省下的是一个下午。

至于同形字本身——它不是 bug,Unicode 就是这么设计的:字形归字形,码点归码点。指望编码层帮你把「看起来一样」变成「就是一样」,方向从一开始就错了。