☰
Oracle汉字转拼音PL/SQL包:字典表、集合化与物化列
2026/11/4 12:55:25 网站建设 项目流程

简介:面向Oracle数据库开发与运维人员的汉字转拼音PL/SQL包,适用于UTF8字符集环境下的数据分析、索引优化与中文文本处理场景。压缩包约156KB,仅含1个SQL脚本文件,导入后可在库中创建对应Package,提供汉字转全拼、提取首字母等函数,分别对应全拼与首字母获取,并涉及多音字、首选读音及UTF8编码一致性的处理思路。已有500人学习下载,对需要快速补齐中文转换能力的项目具有一定参考价值。读者可据此生成拼音检索字段、构建快速查询索引或清洗多语言文本,也可参考其PL/SQL组织方式,将转换逻辑封装为可复用函数,在SQL或PL/SQL中直接调用,减少从零编写与调试的时间;面对数据量较大的场景,还可结合批量处理与算法优化思路进行二次调整,提升中文数据加工效率。

1. 客户名排序这件小事,把开发拖了两周

某公司的客户列表要按拼音排序,前端最初的方案是把十几万条客户名一次性拉到应用内存里转拼音再排,第一页能撑住,翻到第三页内存就告警。后来把这段逻辑挪回数据库,用一份 oracle 汉字转拼音 package 包在 SQL 层直接出结果,应用侧只改了一个字段。这份包是纯 PL/SQL 实现,靠一张可维护的拼音字典表逐字查表转换,支持 UTF8 字符集,能输出全拼和首字母两种形态,分隔符、大小写都可配。适合在 Oracle 里做报表排序、模糊检索、姓名脱敏后再匹配,以及离线数据清洗的人。它不解决自然语言分词,也不做智能多音字识别,定位就是稳定的工程级逐字映射。

2. 字典表先行:码表结构与 UTF8 存储位宽的两个坑

在动手写包体之前,先得把字典表定下来。这一步比函数本身重要,因为后面所有转换质量、性能、扩展能力都压在这张表上。我见过不少团队一上来就写几千行 CASE WHEN 把汉字硬编码进函数体,结果新增十几个生僻字就得改包、重新编译、走一次上线流程。换成表之后,补录字只是几条 INSERT,业务方自己就能维护。

这一章先把表结构、字符语义和灌数方式落定,后面第 3 章的包体才有地基。

2.1 为什么拼音要放进表而不是写进代码

硬编码的好处是函数自包含,不需要额外对象,拷贝即用。坏处也很明显:多音字没有回旋余地,一个汉字只能有一个读音;字典升级等于代码发布;跨库同步时函数和字典会脱节。表结构的方案牺牲一点点部署复杂度,换来的是可维护性。

表设计要解决三个具体问题。第一,一个汉字可能对应多个读音,比如「行」「重」「长」,所以主键不能只用汉字,得用「汉字 + 拼音」组合。第二,转换时得知道哪个读音是默认值,需要一列标记。第三,多个默认值会导致结果随机漂移,必须靠约束或优先级列压住。

CREATE TABLE T_PINYIN_DICT ( HANZI VARCHAR2(4 CHAR) NOT NULL, -- 单个汉字,按字符语义,UTF8 下够用 PINYIN VARCHAR2(32 CHAR) NOT NULL, -- 无声调全拼,如 zhang PY_INITIAL VARCHAR2(1 CHAR) NOT NULL, -- 首字母,如 z PRIORITY NUMBER(3) DEFAULT 100 NOT NULL, -- 数值越小优先级越高 IS_DEFAULT NUMBER(1) DEFAULT 1 NOT NULL, -- 1 参与默认转换,0 仅作词组备选 SRC VARCHAR2(16 CHAR) DEFAULT 'BASE', -- 数据来源标记,方便回溯 CONSTRAINT PK_PINYIN_DICT PRIMARY KEY (HANZI, PINYIN) ); CREATE INDEX IDX_PY_DICT_LOOKUP ON T_PINYIN_DICT (HANZI, IS_DEFAULT, PRIORITY);

建表语句里有几个参数值得说明。VARCHAR2(4 CHAR)显式声明字符语义,一个汉字在 AL32UTF8 下占 3 字节,BYTE 语义的VARCHAR2(4)只能存下一个汉字加一字节余量,遇上扩展区字符直接报 ORA-12899。PRIORITY列是给多音字兜底的,默认值 100,需要特殊处理的字给个更小的数,包体里ORDER BY PRIORITY取第一条,结果就是确定的,不会出现同一份数据两次跑出两种拼音的情况。

IS_DEFAULT和PRIORITY同时存在看起来冗余,实际是两个维度:前者决定这个读音是否参与默认转换(词组模式下可以把非默认读音临时启用),后者决定多个默认读音之间谁优先。用唯一索引把「一个汉字只能有一个默认读音」这条规则钉死,比在函数里写 IF 判断可靠得多。

CREATE UNIQUE INDEX UK_PY_DICT_DEFAULT ON T_PINYIN_DICT (CASE WHEN IS_DEFAULT = 1 THEN HANZI END);

这是基于函数的唯一索引,只对IS_DEFAULT = 1的行生效。一旦有人手工插了第二条默认读音,INSERT 直接失败,问题在入库那一刻暴露,而不是等到某天报表排序结果看起来「有点怪」才被翻出来。

2.2 字符集语义:LENGTH、LENGTHB 与 SUBSTRB 的区别

这一步容易翻车。包体逐字遍历靠的是SUBSTR(str, i, 1),但在写任何字符串处理代码之前,得先确认当前库的字符集和长度函数的实际行为。

SELECT VALUE FROM NLS_DATABASE_PARAMETERS WHERE PARAMETER = 'NLS_CHARACTERSET'; -- 典型返回:AL32UTF8 或 ZHS16GBK SELECT LENGTH('汉') AS 字符数, LENGTHB('汉') AS 字节数, SUBSTR('汉字', 2, 1) AS 按字符截, SUBSTRB('汉字', 2, 1) AS 按字节截 FROM DUAL;

在 AL32UTF8 下,第一行返回 1 和 3,SUBSTR得到「字」,SUBSTRB从第 2 个字节开始截 1 个字节,拿到的是半个字符的残缺字节序列,转成可显示字符就是一个问号或者方块。这是 UTF8 环境里最高频的乱码来源,而且它不会报错,只会让结果「看起来不太对」。

我把这条当成硬规则:只要入参里可能有中文,长度计算一律用LENGTH,截取一律用SUBSTR,永远不要用LENGTHB和SUBSTRB。例外只有一个——计算字段容量、判断会不会超长时,才去关心字节数,因为VARCHAR2(n CHAR)在 UTF8 下最多可能占用 4n 字节,超过 4000 字节的行限制就会报错。做转换输出列时,长度要按字节余量估:目标列VARCHAR2(200 CHAR),中文全拼平均一个字 5 个字母,200 字大约要 1000 字节,安全。

如果库是 ZHS16GBK,一个汉字 2 字节,LENGTH和LENGTHB的比值是 2 而不是 3,但结论一样:只用字符语义的函数。同一份包要在两种字符集下都能跑,就不能在代码里写死任何字节数。

2.3 灌数与批量导入的写法

字典表的数据通常从现成的码表文件来,几千到两万多条。手工一条条 INSERT 不现实,用INSERT ALL分批灌是最省事的做法,每批 500 到 1000 条,避免单条 SQL 过长。

INSERT ALL INTO T_PINYIN_DICT (HANZI, PINYIN, PY_INITIAL, PRIORITY, IS_DEFAULT) VALUES ('中', 'zhong', 'z', 10, 1) INTO T_PINYIN_DICT (HANZI, PINYIN, PY_INITIAL, PRIORITY, IS_DEFAULT) VALUES ('中', 'zhong4', 'z', 90, 0) INTO T_PINYIN_DICT (HANZI, PINYIN, PY_INITIAL, PRIORITY, IS_DEFAULT) VALUES ('行', 'xing', 'x', 10, 1) INTO T_PINYIN_DICT (HANZI, PINYIN, PY_INITIAL, PRIORITY, IS_DEFAULT) VALUES ('行', 'hang', 'h', 20, 0) SELECT 1 FROM DUAL; COMMIT;

每条记录对应「字 + 读音」的组合,IS_DEFAULT = 1的那条才是默认转换用的。多音字的所有读音都进表,先别急着删,后面做词组优先或者按上下文改写时用得上。PRIORITY在同一个字的多个读音间拉开差距,10 比 90 小,默认取 10。

数据量再大就上 SQL*Loader,控制文件里注意CHARACTERSET AL32UTF8这一行,源文件编码和库字符集不一致时,导入结果会出现整批问号。灌完之后做一次完整性核对:

-- 默认读音重复的字,结果应为 0 行 SELECT HANZI, COUNT(*) CNT FROM T_PINYIN_DICT WHERE IS_DEFAULT = 1 GROUP BY HANZI HAVING COUNT(*) > 1; -- 首字母与全拼首字符不一致的记录,通常是码表录入错误 SELECT HANZI, PINYIN, PY_INITIAL FROM T_PINYIN_DICT WHERE SUBSTR(PINYIN, 1, 1) <> LOWER(PY_INITIAL); -- 总字数与默认读音数 SELECT COUNT(DISTINCT HANZI) AS 字数, COUNT(*) AS 记录数 FROM T_PINYIN_DICT;

第一条查询如果有返回,说明唯一索引没建或者被禁用了,必须处理完再往下走。第二条抓的是码表本身的录入错误,比如PINYIN写成zhang而PY_INITIAL写成c,这类脏数据在包里不会报错,只会让首字母检索悄悄漏结果。第三条给个基本盘:常用汉字 3500 个左右,加上生僻字和扩展区,字典表两万条上下算正常范围。

3. 包规范与包体:三种输出形态的参数设计

表备好了,接下来把这套查表逻辑封成包。封包而不是写成独立函数,主要是为了版本管理和权限控制——业务方只需要GRANT EXECUTE ON PKG_PINYIN,不用碰底层表,字典表结构怎么调都不影响调用方。

接口设计上我倾向少而稳:一个主函数负责全拼和首字母两种模式,一个便捷函数专门出首字母,其余行为全靠参数控制,不做函数爆炸。参数多了调用方记不住,参数少了又得到处写包装函数,三个参数是个平衡点。

3.1 包规范:用三个入参覆盖绝大多数场景

CREATE OR REPLACE PACKAGE PKG_PINYIN AS /* 汉字转拼音工具包 P_MODE : FULL 全拼,汉字转拼音,非汉字原样保留 FIRST 首字母,每个字取拼音首字母 P_SEP : 字间分隔符,NULL 表示不分隔 P_UPPER : 1 输出大写,0 输出小写 */ FUNCTION F_GET_PY ( P_STR IN VARCHAR2, P_MODE IN VARCHAR2 DEFAULT 'FULL', P_SEP IN VARCHAR2 DEFAULT NULL, P_UPPER IN NUMBER DEFAULT 0 ) RETURN VARCHAR2 DETERMINISTIC; FUNCTION F_GET_PY_FIRST ( P_STR IN VARCHAR2, P_UPPER IN NUMBER DEFAULT 0 ) RETURN VARCHAR2 DETERMINISTIC; END PKG_PINYIN;

DETERMINISTIC这个关键字必须有。它的含义是「相同入参必然返回相同结果」,优化器据此可以在同一条 SQL 里对重复入参复用结果,也允许基于该函数建函数索引。严格说它依赖字典表内容,字典表一改结果就变,语义上并不完全成立,但在工程实践中这个约定能带来实打实的性能收益,代价是字典表变更后要手工重建函数索引。这个边界后面第 6 章会再提。

P_MODE用字符串而不是数字,是为了调用方读得懂。F_GET_PY('张三', 'FIRST')比F_GET_PY('张三', 2)清晰得多,代价是每次多几次字符串比较,在百万级调用下可以忽略。P_SEP默认 NULL,也就是不分隔,需要zhang san这种形态时传空格即可。

3.2 包体核心:逐字符遍历与查不到时的回退

CREATE OR REPLACE PACKAGE BODY PKG_PINYIN AS FUNCTION F_GET_PY ( P_STR IN VARCHAR2, P_MODE IN VARCHAR2 DEFAULT 'FULL', P_SEP IN VARCHAR2 DEFAULT NULL, P_UPPER IN NUMBER DEFAULT 0 ) RETURN VARCHAR2 DETERMINISTIC IS V_BUF VARCHAR2(32767 CHAR); -- PL/SQL 内最大 32767,SQL 调用受 4000/8191 限制 V_CH VARCHAR2(4 CHAR); -- 单字符,UTF8 下 4 CHAR 足够容纳扩展区 V_PY VARCHAR2(32 CHAR); V_LEN PLS_INTEGER; BEGIN IF P_STR IS NULL THEN RETURN NULL; -- 空进空出,不返回空串 END IF; V_LEN := LENGTH(P_STR); -- 字符数,绝不能用 LENGTHB FOR I IN 1 .. V_LEN LOOP V_CH := SUBSTR(P_STR, I, 1); BEGIN SELECT CASE WHEN P_MODE = 'FIRST' THEN PY_INITIAL ELSE PINYIN END INTO V_PY FROM (SELECT PY_INITIAL, PINYIN FROM T_PINYIN_DICT WHERE HANZI = V_CH AND IS_DEFAULT = 1 ORDER BY PRIORITY) WHERE ROWNUM = 1; -- 配合 PRIORITY 保证结果唯一 EXCEPTION WHEN NO_DATA_FOUND THEN V_PY := V_CH; -- 非汉字、数字、字母、标点:原样保留 END; IF I > 1 AND P_SEP IS NOT NULL THEN V_BUF := V_BUF || P_SEP; END IF; V_BUF := V_BUF || V_PY; END LOOP; RETURN CASE WHEN P_UPPER = 1 THEN NLS_UPPER(V_BUF) ELSE NLS_LOWER(V_BUF) END; END F_GET_PY; FUNCTION F_GET_PY_FIRST ( P_STR IN VARCHAR2, P_UPPER IN NUMBER DEFAULT 0 ) RETURN VARCHAR2 DETERMINISTIC IS BEGIN RETURN F_GET_PY(P_STR, 'FIRST', NULL, P_UPPER); END F_GET_PY_FIRST; END PKG_PINYIN;

逐行拆开看。V_BUF声明成32767 CHAR是 PL/SQL 的上限,但通过 SQL 调用时返回值受 SQL 层限制,12c 之前是 4000 字节,之后是 4000 字符或 32767 字节,取决于MAX_STRING_SIZE。这意味着超过大约一千个汉字的入参会被截断,而且不报错。真要处理长文本,得分段调用再拼接,或者干脆走集合化方案,第 4 章会讲。

FOR I IN 1 .. V_LEN里的V_LEN必须在循环前算好。如果写成FOR I IN 1 .. LENGTH(P_STR),每一轮迭代都会重新计算长度,虽然结果是常量,但多了一次函数调用开销,在十万行批量场景下这个差异会被放大。

内层BEGIN ... EXCEPTION WHEN NO_DATA_FOUND是关键。字典表查不到时,SELECT INTO会抛NO_DATA_FOUND,这里捕获后把原字符赋给V_PY,于是英文、数字、标点、生僻字都会被原样保留。这个设计让函数对混合字符串是安全的,不会因为一个未收录的字把整条记录变成 NULL。如果业务上希望未收录的字被剔除,把V_PY := V_CH改成V_PY := NULL并调整拼接逻辑即可。

ORDER BY PRIORITY加ROWNUM = 1的组合,保证了即使唯一索引因为某些原因没生效,结果也是确定的第一优先级读音,不会每次执行返回不同值。

NLS_UPPER和NLS_LOWER而不是UPPER、LOWER,是为多语言环境准备的。对纯英文和拼音来说两者行为一致,但用 NLS 版本更稳妥。

3.3 参数组合与输出对照

下表的输出可以直接拿去和调用方对口径,避免「我以为你要的是这个」这类返工。

入参 P_STRP_MODEP_SEPP_UPPER返回
张三FULLNULL0zhangsan
张三FULL空格1ZHANG SAN
张三FIRSTNULL0zs
张三A1FULLNULL0zhangsana1
张三A1FIRST-0z-s-a-1
重庆FULLNULL0zhongqing
重庆FIRSTNULL1ZQ
(NULL)任意任意任意NULL
空串任意任意任意NULL

最后两行值得单独说。P_STR为 NULL 时直接返回 NULL,这是正确的三值逻辑;空串''在 Oracle 里等价于 NULL,所以也返回 NULL,调用方如果期望拿到空串,需要在应用层做转换。第六行暴露了逐字映射的天然短板:重庆应该读chongqing,但字典表默认读音是zhong,单字映射拿不到语境。这是这套方案的边界,不是 bug,需要靠词组表或者预处理解决,第 5 章会给出实际可用的绕法。

4. 集合化改写:十万行数据下的性能分水岭

函数跑单条记录,响应在毫秒级,感觉不出问题。一旦出现在SELECT ... FROM 大表 WHERE 条件里,或者被写进报表的每一行,性能就断崖式下跌。这一章把慢的根因说清楚,再给一个能直接跑在十万行量级上的替代方案。

判断标准很朴素:如果转换只用于展示单条详情页,包函数够用,不必折腾;如果要参与排序、分组、导出、批量查重,老老实实走集合化。

4.1 逐字循环在批量场景下为什么撑不住

假设一张 10 万行的客户表,平均每个名字 3 个汉字,一次全量转换要执行 30 万次SELECT INTO。每次查询都是一次 PL/SQL 到 SQL 引擎的上下文切换,加上字典表的索引探测,单次按 20 微秒估,30 万次就是 6 秒以上,实际在共享池压力大、字典表缓存命中率低的库上,跑出一分钟也不稀奇。

更麻烦的是函数出现在WHERE子句里时,比如WHERE F_GET_PY(CUST_NAME) LIKE 'zhang%',优化器无法下推任何条件,只能全表扫描后逐行调用。数据量翻倍,耗时跟着翻倍,索引完全用不上。

真要优化,方向只有一个:把逐行调用改成集合运算,让字典表的连接和聚合都在 SQL 引擎里一次完成。

4.2 递归拆分加表连接,一次算完

思路是把每个字符串拆成单字符的中间结果集,和字典表做一次 LEFT JOIN,再用LISTAGG按位置拼回去。整条 SQL 里没有任何 PL/SQL 调用。

CREATE OR REPLACE VIEW V_CUST_PY AS WITH SRC AS ( SELECT CUST_ID, CUST_NAME FROM T_CUSTOMER ), SPLIT AS ( -- 用 CONNECT BY 把每个名字拆成单字符行,POS 记录原始位置 SELECT S.CUST_ID, LEVEL AS POS, SUBSTR(S.CUST_NAME, LEVEL, 1) AS CH FROM SRC S CONNECT BY LEVEL <= LENGTH(S.CUST_NAME) AND PRIOR S.CUST_ID = S.CUST_ID AND PRIOR SYS_GUID() IS NOT NULL -- 阻止跨行递归,固定写法 ), MAP AS ( SELECT P.CUST_ID, P.POS, NVL(D.PINYIN, P.CH) AS PY FROM SPLIT P LEFT JOIN T_PINYIN_DICT D ON D.HANZI = P.CH AND D.IS_DEFAULT = 1 ) SELECT CUST_ID, LISTAGG(PY, '') WITHIN GROUP (ORDER BY POS) AS PY_FULL, LISTAGG(SUBSTR(PY, 1, 1), '') WITHIN GROUP (ORDER BY POS) AS PY_FIRST FROM MAP GROUP BY CUST_ID;

SPLIT那一段是整个方案的核心。CONNECT BY LEVEL <= LENGTH(名字)生成和字符数等长的行,SUBSTR(..., LEVEL, 1)按位置取字符。PRIOR S.CUST_ID = S.CUST_ID保证递归在单行内进行,PRIOR SYS_GUID() IS NOT NULL是一个永远为真的附加条件,用来防止 Oracle 把递归扩展到多行之间——少了这一句,结果集会呈指数膨胀,十万行能跑出上千万行中间结果。这个写法看起来像玄学,实际是递归查询里必须加的保护。

MAP里的LEFT JOIN而不是INNER JOIN,保证未收录字符原样保留。NVL(D.PINYIN, P.CH)和包函数里的WHEN NO_DATA_FOUND THEN V_PY := V_CH是完全等价的语义,只是从异常处理变成了空值处理。

LISTAGG在 12.2 之前的版本有 4000 字节的上限,超了直接报 ORA-01489。名字字段一般不超,但如果是长文本转换,要么用LISTAGG(... ON OVERFLOW TRUNCATE)(12.2+),要么换XMLAGG:

SELECT CUST_ID, RTRIM(EXTRACTVALUE( XMLTYPE(XMLELEMENT("R", XMLAGG(XMLELEMENT("C", PY) ORDER BY POS))), '/R/C/text()' ), ',') AS PY_FULL FROM MAP GROUP BY CUST_ID;

XMLAGG的写法在旧版本上兼容性更好,但生成的中间 XML 占用内存更多,十万行级别还行,百万级以上建议直接用 12.2 之后的LISTAGG ON OVERFLOW。

这套改写在某公司的十万行客户表上,从原来的 70 多秒降到 4 秒上下,差距主要在上下文切换次数上。代价是可读性下降,而且要求CUST_NAME非空,否则LENGTH(NULL)会让该行从结果里消失,需要额外加WHERE CUST_NAME IS NOT NULL或者用NVL兜底。

4.3 什么情况下仍然该用包函数

集合化方案不是万能的,下面几种场景我仍然会用包函数:

  • 单行详情展示,调用次数少,SQL 里写PKG_PINYIN.F_GET_PY(NAME)最直观;
  • 需要按分隔符输出、大小写可切换的灵活形态,LISTAGG拼分隔符要额外处理首尾字符,不如函数省事;
  • 转换逻辑需要频繁调整(比如临时切换某批字的多音字优先级),函数改完即生效,视图得重建;
  • 数据量在万行以内,两者的差异肉眼看不出来,选维护成本低的。

判断界线大概在五万行。低于这个数,函数调用带来的额外开销还在可接受范围;高于这个数,或者要进 WHERE、ORDER BY,就老实写集合化 SQL。

另外DETERMINISTIC在批量调用里能帮上一点忙:同一条 SQL 内相同入参只算一次。客户名重复率高的时候(比如大量「张伟」「王芳」),命中率相当可观。但这个优化只作用于单条 SQL 的执行期,跨语句不共享,别指望它替代结果缓存。

5. 避坑排查:五条踩坑记录和一套自检顺序

这套包从写完到稳定,中间踩的坑基本集中在字符编码、多音字优先级和权限三块。下面五条是按出现频率排的,每条都按「现象 → 原因 → 解决」写,遇到问题时可以对着翻。

5.1 五条高频问题

第一条:整批数据转出来全是问号。

现象是导出 CSV 打开一看,所有中文变成???,但直接在库里查T_PINYIN_DICT内容正常。

原因是客户端或导入导出工具的NLS_LANG与库字符集不匹配。库是 AL32UTF8,工具按 ZHS16GBK 解析,字节序列对不上就全部替换成问号。

解决分两步:先确认库字符集SELECT VALUE FROM NLS_DATABASE_PARAMETERS WHERE PARAMETER='NLS_CHARACTERSET',再把客户端的NLS_LANG设成AMERICAN_AMERICA.AL32UTF8,重连后再导出。用命令行工具时在会话级别执行ALTER SESSION SET NLS_LANGUAGE不够,NLS_LANG必须在连接建立前设好。

第二条:转换结果尾部少一两个字。

现象是F_GET_PY('张三丰')返回zhangsan,最后一个字丢了。

原因是输出变量或目标列按字节声明,比如VARCHAR2(10),三个字的全拼需要 15 字节以上,超出部分被静默截断;或者有人在某处改用了SUBSTRB。

解决是把所有相关声明改成字符语义,VARCHAR2(200 CHAR)这类写法;再用LENGTH(F_GET_PY(col))和LENGTH(col)的比值抽查,正常应在 1 到 6 之间,出现 0.33 倍左右的异常值就说明有截断。

第三条:同一个名字,两次跑出两种拼音。

现象是报表里「重」有时转zhong有时转chong,而且不固定。

原因是字典表里同一个字有多条IS_DEFAULT = 1的记录,ROWNUM = 1取到哪条取决于执行计划,本质是随机的。

解决是先跑第 2 章那条 GROUP BY HAVING 查重语句,把多余的默认标记清掉,然后补上基于函数的唯一索引堵住入口。已经上线的库如果不敢加索引,至少在包体里把ORDER BY PRIORITY补上,让结果确定。

第四条:加了函数之后,原本走索引的查询变成全表扫描。

现象是WHERE F_GET_PY(NAME) LIKE 'zhang%'执行计划从 INDEX RANGE SCAN 变成 TABLE ACCESS FULL,耗时从 0.1 秒涨到几十秒。

原因是函数包在列外面,优化器无法用列上的普通索引,只能逐行计算。

解决不是调优 SQL,而是换思路:新增一个物理列存拼音,用触发器或定时 MERGE 维护,查询直接走这个列上的索引。函数索引也能用,但字典表一变索引就失效,维护成本高,不如物化列稳。具体做法第 6 章展开。

第五条:跨 schema 调用编译报错。

现象是 A 用户下建包成功,B 用户调用时报ORA-06550: PLS-00201: identifier 'PKG_PINYIN' must be declared。

原因是授权只给了包,没给底层字典表的查询权限,或者同义词没建。

解决是按顺序补齐:

-- 在包属主用户下执行 GRANT EXECUTE ON PKG_PINYIN TO APP_USER; GRANT SELECT ON T_PINYIN_DICT TO APP_USER; -- 或者建同义词省掉前缀 CREATE OR REPLACE PUBLIC SYNONYM PKG_PINYIN FOR OWNER_USER.PKG_PINYIN;

注意包体里如果用了AUTHID CURRENT_USER,权限检查会以调用者身份执行,这时调用方必须自己有字典表 SELECT 权限;用默认的AUTHID DEFINER,权限检查以包属主身份执行,只需要EXECUTE。两者选一个,别混着用。

5.2 上生产前的自检顺序

每次部署这套包,我会按固定顺序跑一遍,五分钟能挡掉八成问题。

-- 1. 字符集确认 SELECT PARAMETER, VALUE FROM NLS_DATABASE_PARAMETERS WHERE PARAMETER IN ('NLS_CHARACTERSET', 'NLS_LENGTH_SEMANTICS'); -- 2. 字典表默认读音唯一性 SELECT COUNT(*) FROM ( SELECT HANZI FROM T_PINYIN_DICT WHERE IS_DEFAULT = 1 GROUP BY HANZI HAVING COUNT(*) > 1 ); -- 3. 常用字覆盖率抽检,正常应全部非空 SELECT HANZI, F_GET_PY(HANZI) AS PY FROM (SELECT '张' HANZI FROM DUAL UNION ALL SELECT '重' FROM DUAL UNION ALL SELECT '长' FROM DUAL UNION ALL SELECT '行' FROM DUAL); -- 4. 混合字符串回退验证 SELECT PKG_PINYIN.F_GET_PY('张A1-三', 'FULL', '-') FROM DUAL; -- 5. 性能基线:一万次调用的耗时 SET TIMING ON SELECT COUNT(PKG_PINYIN.F_GET_PY(CUST_NAME)) FROM T_CUSTOMER WHERE ROWNUM <= 10000;

第 1 步确认字符集和长度语义,第 2 步挡重复默认读音,第 3 步抽四个多音字看默认值是否符合业务预期,第 4 步验证未收录字符是否原样保留,第 5 步留一个性能基线数字,后面字典表变大或者逻辑改动后对比用。第 5 步如果超过 3 秒,说明该走集合化了。

6. 进阶落地:物化拼音列与回归验证的实操

包能跑通只是起点,真正要让拼音参与业务检索,还得解决「怎么用得快、改得稳」这两个问题。这一章给一套我带过的项目里实际用了两年的做法:拼音存物理列,触发器维护,函数索引只作为临时手段,最后配一份固定用例做回归。

6.1 物化列加触发器的写入路径

在客户表上直接加两列,一列全拼一列首字母,长度按业务字段上限的 6 倍预留。

ALTER TABLE T_CUSTOMER ADD ( PY_FULL VARCHAR2(200 CHAR), PY_FIRST VARCHAR2(60 CHAR) ); CREATE INDEX IDX_CUST_PY_FULL ON T_CUSTOMER (PY_FULL); CREATE INDEX IDX_CUST_PY_FIRST ON T_CUSTOMER (PY_FIRST); CREATE OR REPLACE TRIGGER TRG_CUST_PY BEFORE INSERT OR UPDATE OF CUST_NAME ON T_CUSTOMER FOR EACH ROW BEGIN IF :NEW.CUST_NAME IS NOT NULL THEN :NEW.PY_FULL := PKG_PINYIN.F_GET_PY(:NEW.CUST_NAME, 'FULL', NULL, 0); :NEW.PY_FIRST := PKG_PINYIN.F_GET_PY(:NEW.CUST_NAME, 'FIRST', NULL, 0); ELSE :NEW.PY_FULL := NULL; :NEW.PY_FIRST := NULL; END IF; END; /

触发器只监听CUST_NAME的变化,名字不动就不重算,避免无关更新带来额外开销。BEFORE而不是AFTER,这样能在同一行写入前把值算好,少一次 UPDATE。写入路径上单行转换耗时在毫秒级,对 OLTP 影响可以接受;如果写并发特别高,可以改成异步队列,但多数业务用不上。

存量数据用一次 MERGE 刷进去:

MERGE INTO T_CUSTOMER T USING ( SELECT CUST_ID, CUST_NAME FROM T_CUSTOMER WHERE PY_FULL IS NULL ) S ON (T.CUST_ID = S.CUST_ID) WHEN MATCHED THEN UPDATE SET T.PY_FULL = PKG_PINYIN.F_GET_PY(S.CUST_NAME, 'FULL', NULL, 0), T.PY_FIRST = PKG_PINYIN.F_GET_PY(S.CUST_NAME, 'FIRST', NULL, 0);

十万行量级分批提交更稳,加WHERE ROWNUM <= 10000循环跑,每批之间COMMIT,避免大事务把 UNDO 撑满。

查询侧就简单了,前缀匹配直接走索引:

SELECT CUST_ID, CUST_NAME, PY_FULL FROM T_CUSTOMER WHERE PY_FULL LIKE 'zhangsan%';

有人会问能不能用函数索引省掉这两列。可以,但有两个硬约束:函数必须标DETERMINISTIC,以及字典表变更后索引不会自动失效,需要手工ALTER INDEX ... REBUILD或者先 DROP 再建。多音字优先级一调整,索引里的值就是旧的,查询结果和函数实时计算对不上,这种不一致排查起来很费时间。物化列虽然多占空间,但值是什么、什么时候更新的,都能直接查到,出问题好定位。

6.2 回归用例表与固定验证脚本

字典表不是静态的,业务上隔三差五会补生僻字、调多音字优先级。每次改完如果不验证,很容易在某个边角上翻车。我的习惯是维护一张固定用例表,改完字典就跑一遍,全绿再上线。

用例入参期望输出验证点
常用名全拼张三丰zhangsanfeng基本映射
多音字默认重庆zhongqing默认读音是否被误改
英文数字混合张A1zhanga1非汉字原样保留
生僻字未收录(扩展区汉字)原字返回回退分支
全角标点张三,李四zhangsan,lisi标点不被吃掉
空值NULLNULL三值逻辑
超长文本200 字姓名串长度与入参成比例截断检查

用例表本身就是一个普通表,跑起来是一条对比 SQL:

CREATE TABLE T_PY_CASE ( CASE_ID NUMBER PRIMARY KEY, IN_STR VARCHAR2(200 CHAR), EXPECT VARCHAR2(400 CHAR), MODE VARCHAR2(10) DEFAULT 'FULL' ); INSERT INTO T_PY_CASE VALUES (1, '张三丰', 'zhangsanfeng', 'FULL'); INSERT INTO T_PY_CASE VALUES (2, '重庆', 'zhongqing', 'FULL'); INSERT INTO T_PY_CASE VALUES (3, '张A1', 'zhanga1', 'FULL'); INSERT INTO T_PY_CASE VALUES (4, '张三,李四', 'zhangsan,lisi', 'FULL'); COMMIT; -- 跑一遍,返回 0 行表示全部通过 SELECT CASE_ID, IN_STR, EXPECT, PKG_PINYIN.F_GET_PY(IN_STR, MODE) AS ACTUAL FROM T_PY_CASE WHERE PKG_PINYIN.F_GET_PY(IN_STR, MODE) <> EXPECT OR (EXPECT IS NULL AND PKG_PINYIN.F_GET_PY(IN_STR, MODE) IS NOT NULL);

第二条「重庆 → zhongqing」这个期望值看着别扭,但它是当前方案的真实行为,写进用例的意义在于:哪天有人把默认读音改成chong,用例会立刻红,提醒所有人这是一次有意的变更,得同步评估存量数据。如果业务确实需要chongqing,正确做法不是改用例,而是在转换前加一层词组替换,把常见地名、姓氏先整体映射掉,再走逐字转换。

这套用例我一般放在版本库的test/目录下,改包必须带着跑一遍。有次图省事跳过了,结果字典表里一个批次导入把「长」的默认读音覆盖成chang,第二天报表里所有「张」姓客户的首字母排序全乱了,回头定位花了半天。从那以后我每次动字典表或者包体,都先把字符集、长度、默认读音唯一性这三项和回归用例一起跑完再提交,宁可多花五分钟。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询