ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MySQL字符串替换与脏数据清洗:REPLACE、批量UPDATE、正则替换

MySQL字符串替换与脏数据清洗:REPLACE、批量UPDATE、正则替换

一条 UPDATE 语句跑了四十分钟还没结束,最后不得不手动 kill 掉;更让人崩溃的是,表里明明看到字段中夹着空格,REPLACE执行完之后空格还在。这类场景我在处理 MySQL 字符串替换时遇到过不止一次,问题几乎都不出在函数本身,而是出在“我以为它懂我意思”这个假设上。MySQL 里做字符串替换,单一函数只有REPLACE()、REGEXP_REPLACE()那么几个,可真正落到批量修数据的现场,牵扯出来的是大小写敏感规则、排序规则、不可见字符的字节表示、批量更新的事务粒度、二进制日志膨胀和主从延迟这一整套东西。这篇内容我会把 REPLACE 函数、UPDATE 批量替换、正则替换这三条路线拆开讲,每一条都配可复现的语句和实测结论,适合手上正好有一批脏数据要清、又不想在线上翻车的开发者和运维同学参考。

1. REPLACE 函数的边界比你想象的窄

1.1 四个参数位各管什么,NULL 怎么传染

先把签名摆清楚:REPLACE(str, from_str, to_str),三个参数,分别是原字符串、被替换的片段、替换成的片段。它的行为是“把 str 里所有出现的 from_str 全部换成 to_str”,注意关键词是全部,没有“只换第一个”的选项。这一点和某些语言里的replace默认只换首个不同,很多人第一次用会误以为它一次只处理一处。

返回值语义上有三个必须记住的点。第一,str、from_str、to_str任意一个为 NULL,整个表达式返回 NULL,不存在“NULL 就跳过当没写”的宽容行为。第二,from_str为空字符串时,MySQL 直接原样返回str,不会进入死循环也不会在每两个字符之间插入内容,这个设计其实救过不少人。第三,REPLACE不看字符边界,是纯粹的字节/字符序列匹配,你在“中华人民共和国”里替换“人民”,它会老老实实把中间那两个符取出来换掉,不会考虑语义。

NULL 传染这一条在批量更新里有个经常被忽略的后果。假设某列里既有正常文本又有 NULL,你执行UPDATE t SET c = REPLACE(c, '#', ''),看起来像是在清理井号,实际上 NULL 行会被写成 NULL(因为REPLACE(NULL,...)返回 NULL),行的update_time可能被刷新,触发器和 binlog 也会照常记录。如果你的目的是只动真正含#的行,WHERE 条件必须写清楚。

-- 错误示范:全表更新,NULL 行和干净行都被"重写"一遍 UPDATE article SET title = REPLACE(title, '#', ''); -- 正确姿势:只捞真正需要改的行 UPDATE article SET title = REPLACE(title, '#', '') WHERE title LIKE '%#%';

顺便说一个嵌套的实用技巧。REPLACE可以层层套,做多规则清理,执行顺序是从内到外:

SELECT REPLACE(REPLACE(REPLACE(raw_text, '\r', ''), '\n', ' '), '\t', ' ') AS cleaned FROM raw_log WHERE raw_text LIKE '%\r%' OR raw_text LIKE '%\n%';

不过套超过三四层就该考虑换个思路了,正则或者应用层处理会更清楚。

1.2 大小写敏感这一条,能让批量脚本白跑一整晚

这是我在实战里踩得最深的一个坑。MySQL 的REPLACE()在查找from_str时区分大小写,而LIKE '...'的匹配是否区分大小写取决于列的排序规则。绝大多数线上表用的是utf8mb4_general_ci或utf8mb4_0900_ai_ci这类_ci(case insensitive)排序规则,于是就会出现下面这种诡异的组合:

-- 假设 c 列的排序规则是 utf8mb4_0900_ai_ci SELECT c FROM t WHERE c LIKE '%abc%'; -- 命中 120 行,包含 ABC、Abc、abc UPDATE t SET c = REPLACE(c, 'abc', 'xyz') WHERE c LIKE '%abc%'; -- 实际只改了 40 行,另外 80 行里是大写的 ABC 和 Abc

原因很简单:WHERE 用的是不区分大小写的比较,把ABC也捞进来了;但REPLACE内部用二进制方式比对,ABC里找不到abc,于是原样返回。这两套规则不一致的时候,就会出现“WHERE 明明有命中,替换却没生效”的假象。

解决办法有两个方向。要么把函数这一侧改成不敏感,要么把条件这一侧改成敏感。前者可以用LOWER()包一层,但会丢失原始大小写;后者更精准:

-- 方案一:条件侧也变成区分大小写,让 WHERE 和 REPLACE 口径一致 UPDATE t SET c = REPLACE(c, 'abc', 'xyz') WHERE c COLLATE utf8mb4_bin LIKE '%abc%'; -- 方案二:不区分大小写地替换,需要处理大小写变体 UPDATE t SET c = REPLACE(REPLACE(REPLACE(c, 'abc', 'xyz'), 'Abc', 'xyz'), 'ABC', 'xyz') WHERE c LIKE '%abc%';

方案二看起来很笨,但如果你要替换的是品牌名、敏感词、链接域名这类东西,把所有大小写组合穷举反而是最不容易漏的。我一般会先用SELECT DISTINCT把目标字段里含该片段的实际变体捞出来看看有多少种,再决定穷举还是正则。

匹配方式是否区分大小写常见排序规则下的表现
REPLACE(str,a,b)区分'ABCD'中替换'ab'无效果
LIKE '%a%'由排序规则决定_ci下命中A,_bin下不命中
LOCATE('a', str)由排序规则决定同 LIKE
REGEXP_REPLACE默认不区分(实测)可用(?-i)或 BINARY 强制区分

提示:写批量替换脚本之前,先用一条 SELECT 把“条件命中数”和“替换后真正变化数”对一下,两个数不一致就说明命中了大小写变体,别急着跑 UPDATE。

1.3 REPLACE INTO 和 REPLACE() 撞名,但完全是两回事

REPLACE INTO tbl VALUES (...)是插入语句,和字符串替换半点关系都没有。它的语义是:先尝试插入,如果主键或唯一索引冲突,就把冲突的旧行删掉再插新行。这个行为在“有自增主键 + 有其他唯一索引”的场景下会引发很隐蔽的问题——删旧行时如果又碰上另一条唯一索引冲突,可能会连带删掉第二行。

我见过有同学在搜索资料时把这两者混为一谈,照着REPLACE INTO的文档去写字符串替换,结果自然是跑不通。判断依据很简单:如果REPLACE后面紧跟的是INTO和表名,它是插入语句;如果后面跟的是括号和三个表达式,它是字符串函数。两者唯一的共同点是都叫这个名字。

社区里还有一类写法是REPLACE INTO用来做“有则更新无则插入”,这个用法本身没问题,但要注意它和INSERT ... ON DUPLICATE KEY UPDATE的差异:前者是删除再插入,自增 ID 会变,触发器会分别触发 DELETE 和 INSERT 两个动作,依赖 ID 稳定性的业务不建议用。这和本篇讲的字符串替换是两条线,放在这里只是提醒一句,别在搜索时混淆。

2. UPDATE 配 REPLACE 做整表批改的落地细则

2.1 先用 SELECT 摸清命中量,再决定更新策略

批量替换最大的风险不在于语法错,而在于“改了不该改的”。所以我形成了一套固定的前置动作:任何一次UPDATE ... REPLACE上线前,先跑三条 SELECT。

第一条数总量,确认表规模和过滤条件是否合理:

SELECT COUNT(*) AS total FROM article;

第二条数命中量,看看这次替换会波及多少行。如果命中量接近总量,说明条件太宽,很可能误伤:

SELECT COUNT(*) AS hit FROM article WHERE content LIKE '%旧域名%';

第三条抽样看替换前后的对比,尤其是长文本字段,肉眼核对几行最靠得住:

SELECT id, SUBSTRING(content, 1, 120) AS before_snippet, SUBSTRING(REPLACE(content, '旧域名', '新域名'), 1, 120) AS after_snippet FROM article WHERE content LIKE '%旧域名%' LIMIT 10;

这三条跑完,你对“影响面”和“改完长什么样”心里就有数了。命中量在几千行以内,可以直接一条 UPDATE 搞定;上万行就要考虑分批;十万级以上就得认真设计分批策略和回滚方案了。

还有一个容易被忽略的前置检查:LIKE条件里的通配符。如果你的目标片段本身含有%或_(比如替换某个含下划线的字段名前缀),LIKE '%user_name%'里的下划线会匹配任意单字符,命中范围会莫名扩大。这时候要么用ESCAPE显式转义,要么干脆换用LOCATE或INSTR做条件:

-- 下划线是通配符,这里会误伤 userXname SELECT id FROM t WHERE c LIKE '%user_name%'; -- 用 INSTR 更稳妥,纯字面量查找 SELECT id FROM t WHERE INSTR(c, 'user_name') > 0;

2.2 分批更新的主键步进写法与事务粒度选择

整表一条 UPDATE 的问题在于事务太大。InnoDB 虽然支持大事务,但代价是 undo log 撑爆、行锁持有时间长、binlog 事件巨大、从库回放跟不上导致延迟飙升。我曾见过一条没加 WHERE 的 UPDATE 在千万行表上跑了两小时,期间从库延迟涨到八千多秒,业务侧读到的全是旧数据。

分批的核心思路是用主键区间切片,每次处理固定行数,提交后再进行下一批。下面是我常用的存储过程写法,按主键范围推进,每批 2000 行:

DELIMITER $$ CREATE PROCEDURE batch_replace( IN p_from VARCHAR(255), IN p_to VARCHAR(255), IN p_batch INT ) BEGIN DECLARE v_min BIGINT DEFAULT 0; DECLARE v_max BIGINT DEFAULT 0; DECLARE v_cur BIGINT DEFAULT 0; SELECT MIN(id), MAX(id) INTO v_min, v_max FROM article; SET v_cur = v_min; WHILE v_cur <= v_max DO UPDATE article SET content = REPLACE(content, p_from, p_to) WHERE id >= v_cur AND id < v_cur + p_batch AND content LIKE CONCAT('%', p_from, '%'); SET v_cur = v_cur + p_batch; -- 给从库一点喘息时间,也降低锁冲突概率 DO SLEEP(0.05); END WHILE; END$$ DELIMITER ;

调用方式是CALL batch_replace('旧域名', '新域名', 2000);。几个设计意图值得说明:批大小取 2000 是个经验值,太小则提交次数多、总耗时长,太大则单事务锁范围过宽,2000 行在一般宽度的行上锁持有时间在几十毫秒级,对线上影响可控;WHERE里同时带LIKE是为了跳过不含目标片段的区间,减少无谓的行扫描和 binlog 写入;SLEEP是主动让出,避免把 IO 打满。

如果表没有连续自增主键(比如用 UUID 或雪花 ID,或者是联合主键),按 ID 区间切就不合适了。这时候退而求其次,用LIMIT配合游标:

-- 反复执行,直到 affected rows 为 0 UPDATE article SET content = REPLACE(content, '旧域名', '新域名') WHERE content LIKE '%旧域名%' ORDER BY id LIMIT 2000;

注意这条语句在没加ORDER BY时行为不确定,加了ORDER BY在大表上又要额外排序成本,所以它只适合中小表或者本身有合适索引的场景。另一种思路是“先建新表再改名”,把替换过程变成一次INSERT ... SELECT,避开 UPDATE 的锁和 undo 开销:

CREATE TABLE article_new LIKE article; INSERT INTO article_new SELECT id, title, REPLACE(content, '旧域名', '新域名'), created_at FROM article; -- 校验行数、抽样比对之后 RENAME TABLE article TO article_old, article_new TO article;

这个方案在业务可以接受短暂停写的窗口期时非常干净,回滚也简单——改回去就行。缺点是占用双倍磁盘,且新表要重建索引,上亿行的表要掂量一下时间成本。

2.3 备份、回滚与上线前的检查清单

批量替换是不可逆操作,改错了只能靠备份或者 binlog 回溯。所以备份这一步不能省,而且要根据数据量选方式。几百 MB 的表,直接建一张备份表最快:

CREATE TABLE article_bak_20240115 AS SELECT * FROM article WHERE content LIKE '%旧域名%';

只备份命中的行,体积可控,回滚时用UPDATE ... JOIN把原值刷回去即可。上 TB 的表就不要整表备份了,改成按主键区间分批备份,或者依赖 binlog 的闪回能力,但闪回操作对大部分人来说门槛偏高,不如老老实实分批备份。

上线前我一般过一遍这个清单:

检查项不通过的后果
WHERE 条件是否精确误伤无关行,最严重的一类事故
是否已备份命中行改错后无法精准回滚
是否分批执行长事务、锁等待、主从延迟
是否有 UPDATE 触发器触发器副作用未预期
binlog 格式与空间ROW 模式下 binlog 体积暴涨撑爆磁盘
执行时间窗口高峰期执行影响线上读
字符集与排序规则替换后出现乱码或比较行为变化

其中 binlog 这一项特别值得说。当binlog_format=ROW时,一条更新一万行的 UPDATE 会把每一行的前镜像和后镜像都记下来,如果字段本身是长文本,单条 SQL 产生的 binlog 可能是几十 GB。磁盘告警往往就是这么来的。执行前先确认磁盘剩余空间,必要时把批大小调小,让 binlog 有机会被消费和清理。

3. REGEXP_REPLACE 在 8.0 之后能干什么

3.1 参数位含义与 occurrence 的取值语义

REPLACE只能处理固定字面量,遇到“把所有数字串换成占位符”“清掉所有 HTML 标签”这类需求就没辙了。MySQL 8.0.4 起提供了REGEXP_REPLACE(expr, pat, repl[, pos[, occurrence[, match_type]]]),六个参数,其中前三个常用,后面三个知道存在即可:

  • pos:从第几个字符开始匹配,从 1 开始计数,传 0 或负数会报错(这点和某些语言的 index 从 0 开始不同,容易错)。
  • occurrence:替换第几处匹配,默认 0 表示替换全部,传 1 表示只替换第一处,传 2 表示只替换第二处。这个参数在做“只改第一次出现的标签”时很有用。
  • match_type:匹配模式开关,c区分大小写、i不区分、m多行模式、n让.匹配换行、u表示 Unix 行结尾。

最常用的形态是三个参数,全部替换:

SELECT REGEXP_REPLACE('订单号 A1234 和 B5678 已创建', '[A-Z][0-9]{4}', '[单号]'); -- 结果:订单号 [单号] 和 [单号] 已创建

只替换第一处:

SELECT REGEXP_REPLACE('A1234-B5678-C9012', '[A-Z][0-9]{4}', '[单号]', 1, 1); -- 结果:[单号]-B5678-C9012

返回值的字符集和排序规则跟输入表达式相关。如果输入是utf8mb4的列,结果也是utf8mb4;如果输入是二进制串,结果是二进制串。导数据类型的时候要注意,别把二进制结果直接塞回字符列。

3.2 反斜杠在 SQL 字符串里的双重转义

这是正则替换最容易翻车的地方,我自己至少栽过三次。MySQL 的字符串字面量本身会把反斜杠当转义符处理,而正则引擎又要用反斜杠表示元字符,于是同一层反斜杠要写两遍。

-- 你的意图是删掉所有数字,正则写作 \d -- 但 '\d' 在字符串解析这一层就已经变成 'd' 了,正则看到的是字面量 d SELECT REGEXP_REPLACE('a1b2c3', '\d', ''); -- 结果仍是 a1b2c3,白白执行 -- 正确写法:字符串层写两个反斜杠,正则层才拿到 \d SELECT REGEXP_REPLACE('a1b2c3', '\\d', ''); -- 结果:abc

如果你是在应用代码里拼 SQL,那还要再加一层。以 Java 字符串为例,"\\\\d"才对应到 SQL 里的'\\d'。以 Python 的普通字符串为例,"\\\\d"同样如此,或者干脆用原始字符串r"\\d"少一次脑力换算。这个层数问题每次都要数一遍,我的习惯是能用字符组就不用元字符转义——[0-9]比\\d直观得多,[[:space:]]比\\s好数,牺牲一点书写长度换可读性和正确率,很划算。

另外,如果你要匹配的字符本身就是正则元字符,比如点号、括号、加号,必须转义。清 HTML 标签的时候.如果不转义,会连正常文字一起吃掉:

-- 想去掉 <p> 标签,但 . 没转义,可能吃掉过多内容 SELECT REGEXP_REPLACE(html, '<p>.+</p>', ''); -- 更稳的写法:非贪婪 + 转义斜杠 SELECT REGEXP_REPLACE(html, '<p>[^<]*</p>', '');

用[^<]*代替.*?是因为 MySQL 的 ICU 正则虽然支持非贪婪量词,但在跨多标签的长文本上,[^<]这种排除式写法性能更稳定,也不容易因为换行问题匹配失败。

3.3 大小写模式与匹配范围的控制

实测下来,在utf8mb4_0900_ai_ci排序规则下,REGEXP_REPLACE默认是不区分大小写的,这和REPLACE()的默认行为正好相反,两套函数混用的时候要特别小心。

-- 默认不区分大小写,ABC 也会被替换 SELECT REGEXP_REPLACE('abcABC', 'abc', 'x'); -- 结果:xx -- 需要区分时,用 match_type 的 c,或者用内联开关 SELECT REGEXP_REPLACE('abcABC', 'abc', 'x', 1, 0, 'c'); -- 结果:xABC SELECT REGEXP_REPLACE('abcABC', '(?-i)abc', 'x'); -- 结果:xABC

不同版本和不同排序规则下这个默认值可能表现不一致,最可靠的做法是在目标库上先跑一条SELECT REGEXP_REPLACE('aA','a','x')看看结果,别照着文档猜。

匹配范围上还有几个实用开关。n让.匹配换行符,清理多行文本里的整段内容时用得上。m让^和$匹配每行的开头结尾,处理按行组织的数据时有用:

-- 去掉每一行行首的序号,比如 "1. xxx" SELECT REGEXP_REPLACE( '1. 第一条\n2. 第二条', '(?m)^[0-9]+\\.\\s*', '' );

注意这里\\.是为了匹配字面的点号,^配合(?m)才能匹配到每一行开头而不只是全文开头。这种组合在清理日志、清洗导入的表格数据时非常高频。

提示:正则替换的性能远低于字符串替换。能先用 WHERE 把范围缩小就一定要缩,别指望REGEXP_REPLACE在千万行上全表跑。ICU 正则是 CPU 密集型操作,一条没加条件的正则更新足以把实例 CPU 打满。

4. 脏数据清洗:看不见的字符才是真麻烦

4.1 用 HEX 和 CHAR 把隐形内容揪出来

最让人抓狂的场景是:肉眼看到字段里有个空格,REPLACE(col, ' ', '')执行完,空格还在。原因通常是那个“空格”根本不是 U+0020,而是不换行空格 U+00A0、全角空格 U+3000、零宽空格 U+200B 这类看着一样的字符。

排查第一步是把真实字节打出来:

SELECT id, HEX(content) AS hex_view, LENGTH(content) AS byte_len, CHAR_LENGTH(content) AS char_len FROM article WHERE id = 12345;

HEX()给出的是十六进制字节序列,LENGTH是字节数,CHAR_LENGTH是字符数。在utf8mb4下,一个汉字占 3 字节,一个普通 ASCII 空格占 1 字节,而 U+00A0 占 2 字节(C2 A0),U+3000 占 3 字节(E3 80 80)。字节数和字符数对不上的时候,就说明里面有非 ASCII 内容。

知道编码之后,用字符集引导符加十六进制字面量精确匹配:

-- 干掉不换行空格 U+00A0 UPDATE article SET content = REPLACE(content, _utf8mb4 X'C2A0', ' ') WHERE content LIKE CONCAT('%', _utf8mb4 X'C2A0', '%'); -- 干掉全角空格 U+3000 UPDATE article SET content = REPLACE(content, _utf8mb4 X'E38080', ' ') WHERE content LIKE CONCAT('%', _utf8mb4 X'E38080', '%');

用CHAR(9)、CHAR(10)、CHAR(13)处理制表符和换行符也很方便,因为这三种都是单字节 ASCII,CHAR()返回的值直接能用在REPLACE里:

UPDATE article SET content = REPLACE(REPLACE(REPLACE(content, CHAR(13), ''), CHAR(10), ' '), CHAR(9), ' ') WHERE content LIKE CONCAT('%', CHAR(13), '%') OR content LIKE CONCAT('%', CHAR(10), '%') OR content LIKE CONCAT('%', CHAR(9), '%');

这里有个细节:Windows 换行是\r\n两个字符,先删\r再换\n比较稳妥;如果顺序反过来,把\n换成空格之后原串里会留下\r单独存在,看着像空格但又不是,又是一轮新的排查。

4.2 全角半角与首尾空白的归一化

从 Excel、Word 里粘过来的文本,标点大概率是全角的。中文场景下全角标点本身没错,但如果这列要拿去做英文检索或者拼接口参数,就必须归一。全角字符的码位是有规律的:全角!是 U+FF01,半角!是 U+0021,两者相差 0xFEE0。理论上可以用正则一次转换,但 MySQL 的REGEXP_REPLACE不支持回调函数,没法做码位运算,只能逐个映射。

实际做法是用REPLACE嵌套,把高频的那几个标点先干掉:

UPDATE article SET title = REPLACE(REPLACE(REPLACE(REPLACE(title, ',', ','), '。', '.'), ':', ':'), ';', ';') WHERE title REGEXP '[,。:;]';

嵌套层数一多就难维护,我的经验是超过五层就改用应用层脚本处理,或者建一张映射表用存储过程循环替换。后者代码量不小,但规则可配置、可复用,长期维护成本反而低。

首尾空白是另一个高频需求。MySQL 的TRIM()只能去掉 ASCII 空格,去不掉全角空格和不换行空格,所以真实项目里经常出现TRIM完看着还有空隙的情况。稳妥的写法是组合处理:

UPDATE article SET title = TRIM(REPLACE(REPLACE(title, _utf8mb4 X'C2A0', ' '), ' ', ' ')) WHERE title <> TRIM(REPLACE(REPLACE(title, _utf8mb4 X'C2A0', ' '), ' ', ' '));

这里' '就是全角空格的字符本身,源码里直接写出来比写十六进制直观,前提是文件编码和连接字符集都是utf8mb4,否则存进去的可能又是另一个字符。我一般更倾向于写_utf8mb4 X'E38080',虽然不直观,但绝不会有编码歧义。

4.3 5.7 环境没有正则替换时的退路

还在用 5.7 的环境没法用REGEXP_REPLACE,这时候有三条路可走。第一条是把数据导出来处理再写回去,适合一次性任务,简单粗暴:

mysql -h host -u user -p db -e "SELECT id, content FROM article WHERE content LIKE '%旧域名%'" \ | python3 clean.py > cleaned.tsv

Python 脚本里用re.sub做替换,生成一批UPDATE ... WHERE id = ?写回去。注意要保证导出和写回之间的数据没有被业务改动,最好在低峰期做,或者加版本号字段做乐观校验。

第二条路是在数据库里造一个替换循环函数,用SUBSTRING_INDEX拆串拼接。思路是反复定位分隔符位置,把两侧拼起来:

-- 5.7 环境下删掉所有 "-" 的过程化思路示意 -- 思路:每轮找到第一个 "-",把左边和右边拼起来,直到找不到为止 -- 实现上需要借助存储函数 + WHILE 循环,性能一般,只适合小数据量

用SUBSTRING_INDEX有天然的局限:它按分隔符切,如果你的目标片段恰好是分隔符本身还行,如果是更长的字符串就不太顺手,最终还是要落到循环里逐次定位。数据量超过十万行的话,这条路的时间成本很难接受。

第三条路是把替换逻辑放到应用层,用批处理任务跑。好处是可以加日志、可以断点续跑、可以限速,坏处是要占用业务代码的地方,且要考虑并发更新的一致性问题。我的选择标准是:一次性小批量用方案一,规则频繁变动的用方案三,方案二基本只在特殊情况下用。

5. 一次“替换不生效”的完整排查链路

5.1 症状:WHERE 有命中,UPDATE 显示影响 0 行

说个真实复盘。某次清洗任务要把商品标题里的“包邮”两个字删掉,因为这批商品要转到另一个渠道重新上架。执行的语句是:

UPDATE goods SET title = REPLACE(title, '包邮', '') WHERE title LIKE '%包邮%';

返回结果Rows matched: 328, Changed: 0。命中了 328 行,但一行都没变。当时第一反应是权限问题,检查了一遍账号有 UPDATE 权限;又怀疑触发器把更新拦了,查了information_schema.TRIGGERS也没有。折腾了十几分钟才意识到方向错了。

Changed: 0这个信息其实很明确:值根本没有发生变化,不是“没执行”,是“执行了但结果和原来一样”。这时候唯一的原因就是REPLACE没找到目标片段。

5.2 定位:把可疑行的字节级内容打出来

下一步是取样看真实内容:

SELECT id, title, HEX(title), LENGTH(title), CHAR_LENGTH(title) FROM goods WHERE title LIKE '%包邮%' LIMIT 5;

结果很清楚:这些标题里的“包邮”是繁体字“包郵”。LIKE之所以能命中,是因为表的排序规则是utf8mb4_0900_ai_ci,在这个排序规则下,简体“邮”和繁体“郵”被视为等价字符,比较时认为相等;但REPLACE做的是字节序列比对,简繁两个字在 UTF-8 编码里分别是E9 82 AE和E9 83 B5,完全不同的字节,自然匹配不上。

这就是排序规则带来的“等价字符”陷阱,除了简繁,还有带重音的拉丁字母(é和e在某些_ci规则下等价)、德语ß和ss等。凡是排序规则认为相等的字符,LIKE都可能命中,而REPLACE不会。

5.3 修复与回归验证

知道原因后修复就很直接了,把两种写法都覆盖上:

UPDATE goods SET title = REPLACE(REPLACE(title, '包邮', ''), '包郵', '') WHERE title LIKE '%包邮%' OR title LIKE '%包郵%';

为了保证以后不再踩,我在替换脚本里加了一个前置统计,把“条件命中数”和“实际需要变动的行数”做对比:

SELECT SUM(title LIKE '%包邮%') AS like_hits, SUM(title LIKE '%包邮%' AND title <> REPLACE(title, '包邮', '')) AS actual_changes FROM goods;

如果两个数差得多,就说明命中的行里有一批是“看起来命中但替换不生效”的,必须把变体捞出来:

SELECT DISTINCT title FROM goods WHERE title LIKE '%包邮%' AND title = REPLACE(title, '包邮', '') LIMIT 50;

这条 SELECT 挖出来的就是所有“LIKE 命中但 REPLACE 无效”的样本,一眼就能看出是简繁差异还是大小写差异还是别的什么。这个技巧比瞎猜快得多,现在已经成为我处理类似问题的固定第一步。

批量替换里还有一类问题跟字符串函数无关,但经常被归咎于它。比如更新之后应用侧读到的还是旧值,那属于缓存没刷;更新之后从库读到旧值,那是主从延迟;更新之后页面乱码,那是连接字符集和服务端字符集不一致导致的写入时就错了,替换只是把问题暴露出来。分清楚是替换本身没生效,还是替换生效了但下游没同步,排查路径完全不同——前者看数据,后者看链路。我现在的习惯是先SELECT确认库里数据真的变了,变了就往下游查,没变再回来折腾 SQL。

最后分享一个小技巧,关于替换后的自检。批量更新完成之后,跑一条“残留检查”确认目标片段是否彻底清除:

SELECT COUNT(*) AS remain FROM goods WHERE title LIKE '%包邮%' OR title LIKE '%包郵%';

理论上应该返回 0,如果不为 0,看看剩下的这些是不是新写入的数据(可以用时间字段过滤),避免把业务正常写入的内容误判成没替换干净。这个检查我一般会和更新脚本放在同一个批处理里,跑完自动输出结果,省得第二天再回头核对。

RELATED READING

延伸阅读

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