问题从何而来
带重音的字符有两种写法:一个预组合码位(例如 é,单个码位),或基础字母 + 组合符号(e 后面跟一个重音符号)。两者渲染完全一致,但字节不同、长度不同,用等号比较必然返回 false。这就是 Unicode 归一化要解决的问题。
四种归一化形式
| 形式 | 做法 | 适用 |
|---|---|---|
| NFC | 优先合成单一码位 | 存储与比较的默认选择 |
| NFD | 拆成基础字符 + 组合符 | 需要按音标处理时;macOS 文件名常见 |
| NFKC | 合成 + 兼容替换 | 搜索、去重(会改变语义) |
| NFKD | 分解 + 兼容替换 | 同上,分解形态 |
兼容形式要慎用:NFKC 会把全角字符、连字(fi)、带圈数字(①)等替换成兼容形式。搜索去重很有用,但用于存储会丢失原始信息。
代码示例
// JavaScript:比较前先归一化
const equal = 'a'.normalize('NFC') + 'é'.normalize('NFC') === 'é'.normalize('NFC')
// 注意长度差异(emoji、组合字符会拆成多个码位)
[...'é'].length // NFC 下为 1,NFD 下为 2
// Python
import unicodedata
unicodedata.normalize('NFC', s)
四条实操建议
- 入库前统一 NFC:让同一文本在库里只有一种形态;
- 比较前归一化:用户名、邮箱、标签等需要精确匹配的值尤其要处理;
- 哈希前固定形态:否则同一段文本会算出不同摘要,去重与校验都会失效;
- 大小写转换别想当然:土耳其语等语言的大小写规则与英语不同,涉及排序时显式指定 locale。
还有两个坑
- 截断与长度限制:emoji、组合字符、国旗由多个码位组成,按码位切片会出现半个字形,建议按字素簇处理;
- 安全过滤的顺序:应先归一化再做黑名单匹配,否则攻击者可用等价写法绕过关键字过滤。
动手试试
NFC 与 NFD 在工程中的具体影响
同一个字符可以有多种等价表示,归一化形式不同会导致“看起来一样但比较不等”:
- 用户名重复注册:用户用不同输入法输入的名字可能分别是 NFC 与 NFD,若入库前未统一归一化,唯一的用户名会重复;
- 文件名与路径:macOS 的历史实现对文件名做 NFD 归一化,跨平台同步时同一文件可能出现两份;
- 检索与排序:不做归一化会让检索漏掉同形字符,排序结果也与语言直觉不符;
- 长度校验:带组合字符的字符串“字符数”小于“码点数”,按码点计算的长度限制会误伤用户。
落地的三条规则
- 入口统一归一化:所有外部输入在进入系统边界时统一转换为 NFC,并在数据库中保持一致;
- 比较前归一化:任何会用于比较、查找、去重的字段,比较前先归一化,必要时再做大小写折叠;
- 显示用原值、索引用归一值:保留用户原始输入用于展示,另存一份归一化值用于索引与唯一约束,两者兼得。
在数据库与检索中的落地
- 唯一约束要建在归一化列上:对用户名、邮箱这类需要唯一性的字段,应新增一列存放归一化结果,并在该列上建立唯一约束,否则归一化逻辑再正确也无法阻止重复。
- 模糊匹配前先归一化双方:检索时查询串与存储值都应归一化后再比较,只处理其中一方会导致漏检。
- 排序规则要与归一化配合:不同排序规则对标点与大小写的处理不同,跨国业务应明确采用哪种规则,并在数据库中固定配置。归一化解决同形问题,排序规则解决顺序问题,两者不可互相替代。
- 注意索引体积:归一化后的字符串长度可能变化,建立索引时应按归一化后的最大长度规划,避免截断造成误匹配。
- 迁移历史数据:引入归一化时需要对历史数据做一次性回填,并在回填期间处理重复项,否则唯一约束无法创建成功。
归一化属于“早做便宜、晚做昂贵”的工作。在系统规模尚小时统一规则,成本远低于在数据量庞大后再做迁移与去重。