ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Unihan的简繁汉字笔画部首MySQL库设计与查询实战

基于Unihan的简繁汉字笔画部首MySQL库设计与查询实战

简介:面向汉字信息化处理、姓名笔画测算与书法教学工具开发的简繁汉字笔画及Unicode数据库,整合笔画数量、笔顺顺序、Unicode与GB编码等字段,适合需要按字形特征检索、校核字符编码或构建字库应用的开发者、教师与研究者。压缩包为rar格式,共1个文件,主体是一份可在MySQL中使用的sql数据文件,体积约304KB,结构紧凑,便于建表后开展查询统计与批量核对。已有980人学习下载,具备一定的参考热度。其价值在于把简繁汉字的笔画数量、书写顺序及多套编码对照集中到结构化表中,每条记录围绕单个汉字组织,可支撑输入法词库整理、姓名与诗词笔画分析、书法教学示例筛选、文本清洗及编码兼容处理等场景,也便于按需拓展字段并接入既有系统,减少重复收集与人工校对成本。

1. 从一个真实需求说起:为什么非得有份汉字笔画库

去年给一个面向小学生的识字类应用做后端,需求里有一条「生字表按笔画从少到多排序,同笔画按横竖撇捺折排」。听起来就是一个ORDER BY的事,翻车的地方在于:库里只有汉字本身和拼音,笔画数一个字段都没有。当时第一反应是随手写张映射表,结果一查才发现,常用字加生僻字两万多个,简繁还得分开对;更麻烦的是同一个码位在不同字源下笔画数能差一画。后来把 Unicode 标准里的 Unihan 数据整出来,落成一套 MySQL 表,才有了这份简繁汉字笔画及 Unicode 数据库。它适合做识字教育、字帖排版、姓名笔画测算、中文排序、古籍繁简对照这类需要「字—笔画—部首—码位」四件套的场景,一份 SQL 导进去就能查。

2. 表结构拆解:三张表怎么把两万多个汉字和简繁关系装进 MySQL

拿到一份数据库类资源,第一眼看的不该是数据有多少行,而是表怎么设计。汉字笔画这件事看着简单,真正落到表上会碰到三个绕不开的问题:笔画数不是唯一的、部首和笔画是两套体系、简繁不是一对一。这套库的核心就是围绕这三点拆出来的三张表,外加两张辅助表。

2.1 主表 hanzi:一个字一行,把「笔画」拆成四个维度

很多人理解的笔画库就是「字 + 笔画数」两列,真用起来会立刻不够。做部首检字法要部首笔画,做字帖排版要知道除部首外还剩几画,做笔画序排序要知道首笔笔形。所以主表把笔画拆成了stroke_total(总笔画)、stroke_radical(部首笔画)、stroke_remain(剩余笔画)和first_stroke(首笔笔形)四个字段。

CREATE TABLE hanzi ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, ch CHAR(1) NOT NULL COMMENT '汉字本身,utf8mb4 下占一个字符', code_point INT UNSIGNED NOT NULL COMMENT 'Unicode 码位十进制,如 19968 = U+4E00', code_hex CHAR(8) NOT NULL COMMENT 'U+4E00 形式,排查数据时对着看', stroke_total TINYINT UNSIGNED NOT NULL COMMENT '总笔画数', radical_no SMALLINT UNSIGNED DEFAULT NULL COMMENT '214 部首序号,1-214', radical_ch CHAR(1) DEFAULT NULL COMMENT '部首字,如 水、木、口', stroke_radical TINYINT UNSIGNED DEFAULT NULL COMMENT '部首本身的笔画', stroke_remain TINYINT UNSIGNED DEFAULT NULL COMMENT '除部首外的笔画', first_stroke TINYINT UNSIGNED DEFAULT NULL COMMENT '首笔笔形:1横 2竖 3撇 4点捺 5折', structure VARCHAR(8) DEFAULT NULL COMMENT '结构:左右/上下/半包围/独体', pinyin VARCHAR(64) DEFAULT NULL COMMENT '多音用空格分隔,如 chang zhang', is_trad TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 表示繁体字形', PRIMARY KEY (id), UNIQUE KEY uk_code_point (code_point), KEY idx_stroke (stroke_total, first_stroke), KEY idx_radical (radical_no, stroke_remain) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

几个字段值得单独说。code_point存十进制整数而不是字符串,是为了能直接做区间扫描,比如取出基本区所有字只需要一句BETWEEN 19968 AND 40959,走索引非常快;code_hex是冗余字段,只为人眼对照 Unihan 原始文件方便,别拿它做查询条件。radical_no用的是传统 214 部首序号,不是笔画数,这两者经常被新手搞混——部首「水」是第 85 号,本身 4 画,但「氵」这种偏旁形态在数据里仍然归到 85 号,靠stroke_remain区分。first_stroke是为排序准备的,光有总笔画数排出来同笔画内部是乱的,加上首笔笔形才能得到接近字典的笔画序。

主表的唯一索引压在code_point上,一个码位只能有一行,这保证了后续做简繁映射关联时不会因为重复行炸出笛卡尔积。两个普通索引分别服务「按笔画查字」和「按部首查字」两条最高频的路径。is_trad不是从 Unihan 直接读的,它取决于这一行代表的是简体字形还是繁体字形,通常靠码位落在哪张源表来判定,导入时计算写入。

pinyin字段留了 64 字符,因为多音字真的能有好几个读音,用空格分隔比存 JSON 或者另开一张读音表更省事,代价是模糊匹配拼音前缀时要写LIKE 'zhang%'而不是= 'zhang',这一点在业务层要提前约定好。

2.2 映射表 hanzi_variant:简繁不是一对一,别急着 JOIN

简繁关系是最容易掉进去的坑。直觉上「一个简体对应一个繁体」,实际上「发」对应「發」(出发)和「髮」(头发),「干」对应「乾」「幹」「干」三种情况。如果直接在主表上挂一个trad_code字段,一对多的时候要么丢数据,要么一个简体行复制出好几行,主键直接冲突。

正确的做法是单独一张关系表,把方向、类型、是否首选都标出来。

CREATE TABLE hanzi_variant ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, src_code INT UNSIGNED NOT NULL COMMENT '源字码位', dst_code INT UNSIGNED NOT NULL COMMENT '目标字码位', rel_type TINYINT NOT NULL COMMENT '1=简到繁 2=繁到简 3=异体字', preferred TINYINT(1) NOT NULL DEFAULT 1 COMMENT '同源多目标时是否为首选对应', PRIMARY KEY (id), UNIQUE KEY uk_pair (src_code, dst_code, rel_type), KEY idx_src (src_code, rel_type, preferred), KEY idx_dst (dst_code, rel_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

rel_type分方向存,看起来有冗余(简到繁和繁到简可以互推),但实际业务里两个方向查的频率都很高,双向都建索引比每次写UNION或者OR条件清晰得多。preferred这个字段是血泪经验:做简繁转换时如果拿到「发」直接返回所有繁体候选,前端展示会变成一串,体验很差;标记首选之后,默认取preferred = 1的那条,需要精确对照的场景再放开全部候选。

uk_pair这个唯一索引同时承担了两个作用:防止导入时同一条关系重复写入,以及在INSERT ... ON DUPLICATE KEY UPDATE场景下做幂等更新。Unihan 里kSimplifiedVariant和kTraditionalVariant两个字段各自可能带多个值,一个码位对应多个目标码位是常态,这里不做任何「取第一个」的截断,全部落表,把选择权留给查询层。

还值得一提的是异体字。rel_type = 3这一档收的是「為/爲」「裡/裏」这类没有简繁关系但字形不同的对,很多业务用不上,可一旦要做古籍对照或者字形检索,没有这一档就得回头补数据。

2.3 辅助表与字段选型:为什么笔画字段只用 TINYINT

除了两张主表,这套库还有一张部首字典表hanzi_radical(214 行,存部首序号、部首字、部首笔画、部首码位)和一张可选的笔顺表hanzi_stroke_order(存逐笔笔形串,如「一丨ノ丶」对应的数字编码)。部首表看着是冗余,因为主表里已经有radical_ch和stroke_radical了,但做「按部首笔画分组列出所有部首」这类下拉框时,从 214 行的小表查比SELECT DISTINCT扫两万多行主表快一个数量级,而且能保证部首顺序稳定。

字段类型的选择上有几个明确取舍。笔画相关字段全部用TINYINT UNSIGNED,范围 0-255,实际汉字最多也就 48 画左右,绰绰有余;如果用INT每个字段多占 3 字节,两万多行乘以四五个字段,索引体积会明显变大。radical_no用SMALLINT是因为要存到 214,TINYINT UNSIGNED上限 255 其实也够,但留点余量给未来扩展。ch用CHAR(1)而不是VARCHAR(1),定长在 utf8mb4 下占 4 字节,两万多行也就多几十 KB,换来的是行内读取不用解析长度前缀。

提示:CHAR(1)在 utf8mb4 字符集下能正确存下扩展区的四字节汉字,但如果你的库还是utf8(三字节),扩展 B 区之后的字会直接插入失败或者变成问号,建库那一步就必须定死字符集。

索引的设计也不是拍脑袋。idx_stroke (stroke_total, first_stroke)这个组合索引的顺序不能反,因为最高频的查询是「给定笔画范围取字」,stroke_total放前面才能做范围扫描;first_stroke放后面用于范围扫描后的排序,虽然 MySQL 8.0 之后对范围条件后的第二列排序未必能完全用上索引,但至少能减少回表后的排序量。idx_radical (radical_no, stroke_remain)同理,部首是等值匹配放前面,剩余笔画做范围或者排序。

真正决定这套表好不好用的,不是字段多全,而是每条高频查询路径上都有对应的索引。导入之前先把你业务里要写的 SQL 列出来,反过来核对索引,比导完之后再ALTER TABLE加索引安全得多——两万多行的表加索引不慢,但如果表上已经有业务在跑,加索引的锁表时间就够你喝一壶了。

3. 建库导入:从 Unihan 原始文本到可查询的库

表设计定下来之后就是落地。这一步的难点不在 SQL 写得多复杂,而在数据源格式和字符集两件事上。Unihan 的原始文件是一堆制表符分隔的文本,字段多、注释多、同一个字段还可能有多个值;字符集这件事则贯穿建库、连接、导入三个阶段,任何一环没对齐都会翻车。

3.1 建库建表:字符集从第一条语句就必须定死

建库这一步就一个原则:字符集和排序规则在CREATE DATABASE时写死,不要依赖服务端默认值。很多线上 MySQL 实例的默认字符集仍然不是 utf8mb4,靠默认值建出来的库后面全是坑。

-- 字符集和排序规则显式声明,不依赖服务端默认配置 CREATE DATABASE IF NOT EXISTS hanzi_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE hanzi_db; -- 连接级别也要对齐,否则客户端发过来的字在服务端就被截断了 SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;

utf8mb4_unicode_ci和utf8mb4_0900_ai_ci怎么选?前者在 MySQL 5.7 和 8.0 上行为一致,后者只有 8.0 才有,排序规则更新但跨版本迁移会出问题。这套库的数据以码位和笔画为主,几乎不做字符串比较,选utf8mb4_unicode_ci最省心。

SET NAMES utf8mb4这行看着多余,实际上决定了后面LOAD DATA或者批量 INSERT 时字节流怎么解释。少了这一句,客户端发的是 utf8mb4 字节,服务端按 latin1 解释,表里就会存进一堆乱码,而且这种乱码是不可逆的——存进去的是错误解码后的字节,再改字符集也救不回来。

建表语句按第 2 章的结构逐条执行即可,顺序上先建hanzi_radical(没有外键依赖),再建hanzi,最后建hanzi_variant。外键我一般不加,两万多行的表用应用层保证一致性更灵活,导入时也不会因为约束顺序卡住。

注意:如果你的 MySQL 是 5.7 且innodb_large_prefix没开,utf8mb4下带多个索引列的表可能建不出来,报「Specified key was too long」。这种情况把innodb_large_prefix=ON且行格式设为DYNAMIC即可,或者直接上 8.0。

3.2 解析 Unihan 文本:kTotalStrokes 那一列其实有两个值

Unihan 的数据文件是纯文本,每行三列:码位、字段名、字段值,用制表符分隔,#开头是注释。真正要解析的字段就四个:kTotalStrokes(总笔画)、kRSUnicode(部首与剩余笔画)、kMandarin(普通话读音)、kSimplifiedVariant/kTraditionalVariant(简繁对应)。

坑在kTotalStrokes上。很多码位的值不是一个数,而是两个,比如1 2,分别对应不同字源下的笔画统计。直接int()会抛异常,取第一个又会丢掉繁体字形的笔画。

import re # 逐行解析 Unihan 数据文件,抽出四个关键字段 PATTERN = re.compile(r"^U\+([0-9A-F]{4,6})\t(\w+)\t(.+)$") stroke_map = {} # 码位 -> 笔画数列表 radical_map = {} # 码位 -> (部首号, 剩余笔画) variant_map = [] # (源码位, 目标码位, 关系类型) with open("Unihan_IRGSources.txt", encoding="utf-8") as f: for line in f: if line.startswith("#") or not line.strip(): continue m = PATTERN.match(line.rstrip("\n")) if not m: continue cp, field, value = int(m.group(1), 16), m.group(2), m.group(3) if field == "kTotalStrokes": # 值可能是 "1" 或 "1 2",第一列对应简体字源,第二列对应繁体字源 stroke_map[cp] = [int(x) for x in value.split()] elif field == "kRSUnicode": # 格式如 "85.3" 或 "9'.0",带撇号表示简化部首,取数值部分 head, _, tail = value.partition(".") radical_no = int(head.replace("'", "")) radical_map[cp] = (radical_no, int(tail))

kRSUnicode那个撇号是另一个小坑,9'.0表示这个字用的是部首「人」的简化形态「亻」,序号仍然是 9。解析时把撇号去掉取数字就行,但如果要还原「这个字在字典里查哪个部首」,撇号信息其实有用,可以在主表里加个布尔列标记,我一般嫌麻烦就没加。

简繁字段在另一个文件Unihan_Variants.txt里,格式是空格分隔的多个U+XXXX:

with open("Unihan_Variants.txt", encoding="utf-8") as f: for line in f: if line.startswith("#") or not line.strip(): continue m = PATTERN.match(line.rstrip("\n")) if not m: continue cp, field, value = int(m.group(1), 16), m.group(2), m.group(3) if field == "kSimplifiedVariant": for token in value.split(): variant_map.append((cp, int(token[2:], 16), 2)) # 繁 -> 简 elif field == "kTraditionalVariant": for token in value.split(): variant_map.append((cp, int(token[2:], 16), 1)) # 简 -> 繁

这里int(token[2:], 16)是把U+53D1这种形式转成十进制,[2:]去掉前缀。rel_type的取值方向要和表定义对齐:1 是简到繁,2 是繁到简,别写反了,写反的后果是转换结果全变成反向,而且因为两边都能查到数据,测试时不容易发现。

解析完之后要做的第一件事不是入库,是统计。打印一下len(stroke_map)、len(radical_map)、len(variant_map),和前一次解析的结果比。Unihan 每个版本都会调整数据,某次更新后总量突然少了几百条,多半是文件换行了或者字段名改了,这时候入库就是往坑里跳。

3.3 批量写入与校验:导完必须跑的三条对账 SQL

入库用executemany批量插,别一条一条execute。两万多行单条插在本地可能几十秒,远程库上就是几分钟起步。

import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="***", database="hanzi_db", charset="utf8mb4", # 连接字符集必须显式指定 autocommit=False ) cur = conn.cursor() rows = [] for cp, strokes in stroke_map.items(): radical_no, remain = radical_map.get(cp, (None, None)) # 两个笔画值时,第一个当简体用,第二个当繁体用;只有一个值时简繁同用 simple_stroke = strokes[0] trad_stroke = strokes[1] if len(strokes) > 1 else strokes[0] rows.append(( chr(cp), cp, f"U+{cp:04X}", simple_stroke, radical_no, remain, 0, # is_trad = 0,简体行 )) if len(strokes) > 1: rows.append(( chr(cp), cp, f"U+{cp:04X}", trad_stroke, radical_no, remain, 1, )) cur.executemany( "INSERT INTO hanzi (ch, code_point, code_hex, stroke_total, " "radical_no, stroke_remain, is_trad) VALUES (%s,%s,%s,%s,%s,%s,%s) " "ON DUPLICATE KEY UPDATE stroke_total = VALUES(stroke_total)", rows ) conn.commit()

上面这段是简化版,真实脚本还要处理is_trad的判定(不能简单按「有几个笔画值」来定)和ch字段的填充。但核心思路是:同一码位在简繁两套数据下可能需要两行,但主表的唯一索引压在code_point上,两行会冲突。所以要么把唯一索引改成(code_point, is_trad)联合唯一,要么主表只存字形本身,简繁的差异笔画放到映射表里。

我这里用的是第二种:主表的uk_code_point改成uk_char_trad (code_point, is_trad),一个码位最多两行,简体行和繁体行分别记录各自的笔画数。这样is_trad就不只是个标记,而是唯一键的一部分,查询时必须带上,否则会取到两行。

导完必须跑三条对账:

-- 1. 总量核对,和前一次解析的统计对齐 SELECT COUNT(*) AS total, SUM(is_trad) AS trad_rows FROM hanzi; -- 2. 笔画缺失检查,0 画和 NULL 都是异常 SELECT COUNT(*) FROM hanzi WHERE stroke_total IS NULL OR stroke_total = 0; -- 3. 笔画分布,看有没有集中在某个值上的异常 SELECT stroke_total, COUNT(*) AS cnt FROM hanzi GROUP BY stroke_total ORDER BY stroke_total;

第三条尤其重要。正常分布应该是中间高两头低,5 到 12 画最密集,如果发现所有字的笔画都是 1,那多半是解析时字段位置错位了;如果 20 画以上的字数量为零,说明繁体字源那部分数据整段没导进来,回头查解析日志。

4. 查询实战:按笔画、部首、码位的几条真实 SQL

数据落库之后,真正决定这套库好不好用的是查询层。业务里的需求翻来覆去就那么几类:按笔画范围取字、按部首检字、简繁互转、码位区间统计。每一类都有一两个容易写错的细节。

4.1 按总笔画取字表:为什么 ORDER BY 只写 stroke_total 会乱

最基础的查询是「给我 5 到 8 画的常用字」。

SELECT ch, stroke_total, first_stroke, pinyin FROM hanzi WHERE is_trad = 0 AND stroke_total BETWEEN 5 AND 8 ORDER BY stroke_total, first_stroke, code_point LIMIT 300;

ORDER BY里三个字段一个都不能少。只写stroke_total,同笔画内部的顺序由存储引擎决定,两次查询结果可能不一样,做字帖分页的时候会出现同一页重复或者漏字,这种 bug 排查起来非常折磨人。加上first_stroke之后同笔画内按横竖撇捺折排,再补一个code_point兜底保证绝对稳定。

is_trad = 0这个条件必须带上。主表的唯一键是(code_point, is_trad),一个码位可能有简繁两行,不带这个条件取出来的字表会有重复字形。

想按笔画数分组统计每档有多少字,用一句聚合就够:

SELECT stroke_total, COUNT(*) AS cnt FROM hanzi WHERE is_trad = 0 GROUP BY stroke_total HAVING cnt > 10 ORDER BY stroke_total;

HAVING cnt > 10是过滤掉只有一两个生僻字的档位,实际展示分布图的时候好看很多。这个查询走idx_stroke的松散索引扫描,两万多行的表上基本是毫秒级。

如果业务需要「同笔画按起笔顺序排,且起笔相同的按第二笔排」,那就得在表里加stroke_seq字段存完整笔顺编码,排序改成ORDER BY stroke_total, stroke_seq。这套库的笔顺表是可选的,没有的话首笔排序已经能满足绝大多数教育类场景。

4.2 部首检字法:radical_no + stroke_remain 的组合查询

部首查字是所有字典类应用的核心交互:用户先选部首,再选「除部首外几画」。对应的查询是:

SELECT h.ch, h.stroke_total, h.stroke_remain FROM hanzi h WHERE h.radical_no = 85 -- 85 = 水部 AND h.stroke_remain = 5 -- 除部首外 5 画,合计 9 画 AND h.is_trad = 0 ORDER BY h.first_stroke, h.code_point;

这里有个新手常犯的错:以为「氵」是独立部首,去查radical_ch = '氵'。实际上传统的 214 部首体系里没有「氵」,三点水归在「水」部下,序号 85。同理「扌」归「手」部(64),「忄」归「心」部(61),「讠」归「言」部(149)。数据里radical_ch存的是正形部首「水」,要按偏旁形态查,得在应用层做一层映射,或者额外建一张偏旁到部首的对照表。

-- 偏旁形态到标准部首的映射表,业务层查字前先转换 CREATE TABLE radical_alias ( alias_ch CHAR(1) NOT NULL COMMENT '偏旁形态,如 氵 扌 忄', radical_no SMALLINT UNSIGNED NOT NULL COMMENT '对应的标准部首序号', PRIMARY KEY (alias_ch) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO radical_alias VALUES ('氵', 85), ('扌', 64), ('忄', 61), ('讠', 149);

有了这张小表,用户点「氵」的时候先SELECT radical_no FROM radical_alias WHERE alias_ch = '氵'拿到 85,再去主表查,两步都是主键查询,比在应用里硬编码 switch 好维护。

idx_radical (radical_no, stroke_remain)这个索引就是为这条查询建的,等值加等值,走索引非常干脆。如果业务还要按总笔画排,把stroke_total也加进索引末尾变成覆盖索引,能省掉回表。

4.3 简繁互查与码位区间扫描

简繁转换的查询依赖hanzi_variant表。给一个简体字「发」(码位 0x53D1),要拿到所有繁体候选:

SELECT s.ch AS simp, d.ch AS trad, v.preferred FROM hanzi_variant v JOIN hanzi s ON s.code_point = v.src_code AND s.is_trad = 0 JOIN hanzi d ON d.code_point = v.dst_code AND d.is_trad = 1 WHERE v.rel_type = 1 AND v.src_code = 0x53D1 ORDER BY v.preferred DESC;

MySQL 支持0x前缀的十六进制字面量,直接写码位比在应用层拼十进制可读性好。结果会返回「發」和「髮」两条,preferred = 1的排在前面。业务上做默认转换取第一条,做对照展示取全部。

反向查询把rel_type改成 2、src_code换成目标码位即可。注意不要写成rel_type IN (1,2)然后不加方向条件,那样两个方向的记录会混在一起,一个繁体能查出自己。

码位区间扫描主要用于数据统计和数据完整性核对:

-- 基本区汉字码位范围是 U+4E00 到 U+9FFF,共 20992 个码位 SELECT SUM(code_point BETWEEN 0x4E00 AND 0x9FFF) AS basic, SUM(code_point BETWEEN 0x3400 AND 0x4DBF) AS ext_a, SUM(code_point > 0x9FFF) AS beyond FROM hanzi;

这个查询的结果能直接告诉你数据覆盖到哪一层。如果ext_a是 0,说明扩展 A 区的字没收进来,做生僻字场景就会缺字;如果beyond是 0,扩展 B 区之后的四字节字符全没有,人名里那些生僻字基本都查不到。

提示:BETWEEN 0x4E00 AND 0x9FFF里的十六进制字面量在 MySQL 里是按整数比较的,不需要转成字符串,性能上等价于两个整数常量,索引照常走。

5. 避坑与排查:导入乱码、笔画对不上、扩展区查不到

这套库从下载到能稳定用起来,中间有几个坑几乎是必经的。下面五条是我自己和身边同行都踩过的,按「现象 → 原因 → 解决」写清楚,遇到问题直接对号入座。

5.1 导入后汉字全变成问号或乱码

现象:SQL 文件source进去之后,SELECT ch FROM hanzi LIMIT 10返回一堆???或者锟斤拷之类的乱码,LENGTH(ch)返回 1。

原因:三个环节里至少有一处字符集不是 utf8mb4。客户端连接没设、库的默认字符集是 latin1、或者 SQL 文件本身是 GBK 编码保存的。最隐蔽的是客户端连接这一层,mysql命令行工具在部分系统上默认字符集跟着my.cnf走,配置文件里写的是latin1就全乱。

解决:先把 SQL 文件用file -i确认编码,然后连接串上强制指定charset=utf8mb4(JDBC 是characterEncoding=utf8,注意写utf8而不是utf8mb4,驱动会自动映射),命令行先执行SET NAMES utf8mb4。已经存进乱码的数据救不回来,必须清表重导。

5.2 同一个字的笔画数比字典上多一画

现象:「黄」查出来 12 画,字典上是 11 画;「骨」查出来 10 画,字典上 9 画。

原因:Unihan 的kTotalStrokes里,带撇号或者分列的两个值对应不同字源地的字形标准,不同字源的笔画统计规则不一样。如果你导入时直接取了第二个值或者把两个值相加,就会系统性地多出几画。

解决:解析时明确取第一个值作为简体行笔画,第二个值作为繁体行笔画,并且两个值相同时只存一行。同时把kTotalStrokes原始值保留在一个stroke_raw字段里,出现争议时能对回去查是哪个环节取的数。

5.3 扩展区生僻字插入直接报错或存成问号

现象:导入一个人名里的生僻字,INSERT报Incorrect string value,或者不报错但存进去变成?。

原因:库或者表的字符集是utf8(三字节),扩展 B 区之后的汉字是四字节编码,utf8装不下。utf8mb4才能覆盖完整码位空间。

解决:库、表、连接三处全改成utf8mb4。如果是已经建好的表,ALTER TABLE hanzi CONVERT TO CHARACTER SET utf8mb4可以原地转,但要在业务低峰期做,两万多行的表转换几秒钟,但如果表上还有更大的关联表,时间会线性增长。

5.4 简繁转换 JOIN 之后结果行数爆炸

现象:查一个简体字对应的繁体,返回了十几行,排除重复之后还是多出好几条不相干的结果。

原因:hanzi_variant表里src_code和dst_code方向混着存,查询时只加了src_code条件没加rel_type,简到繁和繁到简两个方向的记录全被捞出来了;或者 JOIN 主表时忘了带is_trad条件,一个码位两行主表记录导致结果翻倍。

解决:查询里rel_type和is_trad两个条件必须同时存在,一个控方向一个控字形。另外可以用SELECT DISTINCT兜底,但根本解法是把条件写全,DISTINCT只是掩盖问题。

5.5 按部首查字查不到「江」「河」这类字

现象:用户点「氵」偏旁,查出来是空的,但「江」「河」明明在库里。

原因:数据里部首字段存的是正形部首「水」(85 号),用户点的是偏旁形态「氵」,两者字符串不相等,WHERE radical_ch = '氵'自然一条都查不到。

解决:建一张偏旁到部首的映射表,查询前先转换,或者直接在应用层用常量映射。这个问题在字典类应用里几乎是必踩的,提前把radical_alias表建好能省掉后面一次线上故障。

6. 二次开发:把笔画库接进业务系统的几种改法

数据能查之后,大部分人的下一步都是把它接进自己的系统。这里说三个我最常用的改造方向,都是改动量不大但收益明显的。

第一个是给业务表补笔画字段。不要每次查询都 JOIN 汉字库,直接在业务表上加stroke_total、first_stroke两个冗余列,写入时同步过来。代价是数据可能不一致,收益是列表页排序完全走业务表自己的索引,查询快一个数量级。同步逻辑我一般放在写入的 service 层,而不是数据库触发器,触发器出问题排查起来太黑匣子。

-- 业务表补冗余字段,列表页排序不再 JOIN ALTER TABLE word_item ADD COLUMN stroke_total TINYINT UNSIGNED DEFAULT NULL, ADD COLUMN first_stroke TINYINT UNSIGNED DEFAULT NULL, ADD KEY idx_sort (stroke_total, first_stroke);

第二个是做一层转换接口,把「简繁互转 + 首选标记 + 多候选」封装成一个服务方法,业务层只调convert(text, direction),不直接写 SQL。这样后面换数据源或者加缓存都不用动业务代码。

第三个是缓存。汉字库是典型的读多写少、数据量小、变更极少,全量两万多行完全可以一次性加载到 Redis 或者本地 Caffeine,key 用码位,value 存{ch, stroke_total, radical_no, ...}的序列化结构。我们线上这套做法之后,按笔画范围查字从 8ms 降到 0.3ms,QPS 上来之后数据库压力基本归零。

改造方式改动量适合场景注意事项
业务表冗余笔画字段小列表页排序、筛选写入时同步,定期对账
封装转换服务中简繁转换、多候选参数校验要严,空值要有默认
全量缓存到 Redis小高频查询、C 端数据更新时整体刷新,别做增量

最后提一个验证习惯:每次改完导入脚本或者表结构,先跑一遍总量对账和笔画分布对账,再看一眼随机抽的二十个常用字的笔画对不对。这套库的数据质量不上不下的时候最难受——大部分字是对的,就几个字错,业务方报过来你都不知道是数据问题还是查询问题。从那以后我每次动这份数据之前,都强制走一遍「总量 → 缺失 → 分布 → 抽样」四步核对,跑完心里才踏实。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进