← 返回文章列表

两个「看起来一样」的字符串为什么不相等:Unicode 归一化

字符编码避坑

问题从何而来

带重音的字符有两种写法:一个预组合码位(例如 é,单个码位),或基础字母 + 组合符号(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)

四条实操建议

  1. 入库前统一 NFC:让同一文本在库里只有一种形态;
  2. 比较前归一化:用户名、邮箱、标签等需要精确匹配的值尤其要处理;
  3. 哈希前固定形态:否则同一段文本会算出不同摘要,去重与校验都会失效;
  4. 大小写转换别想当然:土耳其语等语言的大小写规则与英语不同,涉及排序时显式指定 locale。

还有两个坑

  • 截断与长度限制:emoji、组合字符、国旗由多个码位组成,按码位切片会出现半个字形,建议按字素簇处理;
  • 安全过滤的顺序:应先归一化再做黑名单匹配,否则攻击者可用等价写法绕过关键字过滤。

动手试试

归一化后做校验:哈希计算、JSON 格式化。

NFC 与 NFD 在工程中的具体影响

同一个字符可以有多种等价表示,归一化形式不同会导致“看起来一样但比较不等”:

  1. 用户名重复注册:用户用不同输入法输入的名字可能分别是 NFC 与 NFD,若入库前未统一归一化,唯一的用户名会重复;
  2. 文件名与路径:macOS 的历史实现对文件名做 NFD 归一化,跨平台同步时同一文件可能出现两份;
  3. 检索与排序:不做归一化会让检索漏掉同形字符,排序结果也与语言直觉不符;
  4. 长度校验:带组合字符的字符串“字符数”小于“码点数”,按码点计算的长度限制会误伤用户。

落地的三条规则

  • 入口统一归一化:所有外部输入在进入系统边界时统一转换为 NFC,并在数据库中保持一致;
  • 比较前归一化:任何会用于比较、查找、去重的字段,比较前先归一化,必要时再做大小写折叠;
  • 显示用原值、索引用归一值:保留用户原始输入用于展示,另存一份归一化值用于索引与唯一约束,两者兼得。

在数据库与检索中的落地

  1. 唯一约束要建在归一化列上:对用户名、邮箱这类需要唯一性的字段,应新增一列存放归一化结果,并在该列上建立唯一约束,否则归一化逻辑再正确也无法阻止重复。
  2. 模糊匹配前先归一化双方:检索时查询串与存储值都应归一化后再比较,只处理其中一方会导致漏检。
  3. 排序规则要与归一化配合:不同排序规则对标点与大小写的处理不同,跨国业务应明确采用哪种规则,并在数据库中固定配置。归一化解决同形问题,排序规则解决顺序问题,两者不可互相替代。
  4. 注意索引体积:归一化后的字符串长度可能变化,建立索引时应按归一化后的最大长度规划,避免截断造成误匹配。
  5. 迁移历史数据:引入归一化时需要对历史数据做一次性回填,并在回填期间处理重复项,否则唯一约束无法创建成功。

归一化属于“早做便宜、晚做昂贵”的工作。在系统规模尚小时统一规则,成本远低于在数据量庞大后再做迁移与去重。