☰
MySQL字符串函数实战:拼接截取、数据清洗与脱敏全解析
2026/10/17 13:00:19 网站建设 项目流程

昨天帮运营拉一份用户名单,需求其实很简单——手机号中间四位打码、昵称去掉首尾空格、再从邮箱里把用户名和域名拆出来。我刚写完几个 MySQL 字符串函数的组合,旁边新来的同事看了一眼就愣住了:"原来 SQL 里还能这么拆字符串?"

说实话,MySQL 字符串函数我用了三四年,最深的感受是:函数本身不难背,难的是知道在什么场景用哪个,以及那些文档里不会写、一跑就翻车的隐藏规则。这篇笔记不按官方手册的目录顺序来,而是按我平时写查询、做数据清洗时真实遇到的场景组织。从最常用的拼接和截取,到容易混淆的字节与字符计算,再到定位查找的选型、清理格式化的细节,最后用一个完整的脱敏清洗案例把这些函数串起来。不管你是刚接触 SQL 的新手,还是写过不少业务查询的老手,这篇应该都能帮你少踩几个坑。

1. 拼接不只是 CONCAT:CONCAT_WS、NULL 与隐式转换

1.1 CONCAT 最容易被 NULL 摆一道

CONCAT(str1, str2, ...)是字符串拼接最基础的函数,但它有一个很多人知道、却依然会踩的规则:只要任何一个参数是 NULL,整个结果就是 NULL。

SELECT CONCAT('user_', NULL); -- 结果为 NULL SELECT CONCAT('user_', '10001'); -- 结果为 user_10001 SELECT CONCAT(NULL, '-', 'abc'); -- 结果为 NULL

为什么会这样?因为 MySQL 遵循 SQL 的三值逻辑,NULL 表示"未知"。一个未知的值拼在任何字符串后面,结果依然是未知,所以干脆整体返回 NULL。这在业务上非常恶心,比如你拼一个用户展示名,中间某个字段没填,整行数据都显示空白。

解决思路也很直接:拼接前先处理 NULL。

SELECT CONCAT(IFNULL(first_name, ''), ' ', IFNULL(last_name, '')); SELECT CONCAT(COALESCE(remark, '暂无备注'), ' - ', nickname);

IFNULL只能处理两个参数,COALESCE可以给一串候选值,取第一个非 NULL 的。我的习惯是:只要拼接的字段可能为空,一律先包一层 COALESCE,省得后面排查数据时莫名其妙少了一整列。

1.2 CONCAT_WS:带分隔符拼接,强烈推荐日常使用

CONCAT_WS(separator, str1, str2, ...)的第一个参数是分隔符,后面是你要拼的内容。它最大的优势是:分隔符只出现在非 NULL 参数之间,NULL 参数会被自动跳过,不会像 CONCAT 那样一票否决。

SELECT CONCAT_WS('-', '2025', '01', '01'); -- 2025-01-01 SELECT CONCAT_WS('-', '2025', NULL, '01'); -- 2025-01 SELECT CONCAT_WS(', ', '北京', '上海', NULL, '广州'); -- 北京, 上海, 广州

看第二行和第三行,NULL 直接被忽略,分隔符也不会多出来。这个特性在做标签拼接、地址拼接、展示列拼接时非常省心。

但有两个细节要注意:

  1. 分隔符本身如果是 NULL,结果还是 NULL。这个逻辑不难理解,分隔符都没有了,拼接自然无从谈起。
  2. CONCAT_WS 是变长参数,不能直接传一个"数组"进去。如果你需要把一列多行的值拼成一行,那是聚合函数GROUP_CONCAT的活,别混了。
SELECT GROUP_CONCAT(nickname SEPARATOR '、') FROM users GROUP BY dept_id;

GROUP_CONCAT在分组内做拼接,SEPARATOR指定分隔符,默认是逗号。这个函数和 CONCAT_WS 看起来像,使用场景完全不同,一个是横向拼字段,一个是纵向拼行。

1.3 拼接数字时的隐式转换

CONCAT 遇到数字参数会自动转成字符串,所以CONCAT('订单号: ', order_id)这种写法没问题。但有个隐藏问题:当数字是浮点时,转换结果可能不符合你的预期。

SELECT CONCAT('金额: ', 12345.678); -- 金额: 12345.678 SELECT CONCAT('金额: ', 1e20); -- 金额: 1e+20(部分浮点数会变成科学计数法)

某些大浮点数在转换成字符串时会显示成科学计数法,这对金额展示来说完全不可接受。正确的做法是先用FORMAT或CAST把数字规范化,再参与拼接。后面第 5 节讲格式化时会细说。

个人经验:能选 CONCAT_WS 就不选 CONCAT,尤其是多个字段拼接时,NULL 处理规则能少很多事。CONCAT 只适合那种"必须让 NULL 暴露出来"的场景,比如排查数据质量问题。

2. 字节和字符不是一回事:LENGTH、CHAR_LENGTH 与中文编码

2.1 同样一个"中",LENGTH 是 3,CHAR_LENGTH 是 1

这是新手最容易栽的坑。LENGTH(str)返回字符串的字节长度,CHAR_LENGTH(str)返回字符个数。在utf8mb4字符集下,一个汉字占 3 个字节,一个 Emoji 占 4 个字节,而英文字母和数字只占 1 个字节。

SELECT CHAR_LENGTH('你好') AS chars, LENGTH('你好') AS bytes; -- chars = 2, bytes = 6 SELECT CHAR_LENGTH('A中') AS chars, LENGTH('A中') AS bytes; -- chars = 2, bytes = 4 SELECT CHAR_LENGTH('hello') AS chars, LENGTH('hello') AS bytes; -- chars = 5, bytes = 5

如果你拿LENGTH去判断"用户昵称是否超过 10 个字符",遇到中文用户会出现严重的误判——明明只有 5 个汉字,LENGTH已经返回 15 了。所有涉及"字数"限制的场景,都应该用 CHAR_LENGTH。

2.2 LEFT、RIGHT、SUBSTRING 都是按字符切,不按字节切

与此对应,LEFT(str, n)、RIGHT(str, n)、SUBSTRING(str, pos, len)这些截取函数,第二个参数全部是字符数,不是字节数。这点和 LENGTH 混在一起会让人十分混乱。

SELECT LEFT('你好world', 3); -- 你好w,截取的是 3 个字符,不是 3 个字节 SELECT RIGHT('你好world', 3); -- rld

我在帮别人排查数据时,经常看到这样的 SQL:WHERE LENGTH(name) > 20,本意是限制姓名长度,结果把一堆正常的中文姓名也筛出来了。正确的写法是WHERE CHAR_LENGTH(name) > 20。先搞清楚字节和字符的差异,截取和长度判断才不会出偏差。

2.3 为什么 VARCHAR 字段设计要关注字节

很多人不理解,为什么 VARCHAR(255) 在某些字符集下只能建那么长的索引。因为 InnoDB 索引键前缀有长度限制,一般按字节计算,utf8mb4 下一个字符最多 4 字节,因此一个 VARCHAR(255) 的列做索引时,索引键最大可能占用 255 × 4 = 1020 字节。如果表里索引列很多,很容易撞上索引长度上限。

实操上的建议是:存储路径、URL、长文本标签这类字符串,能别设太长就别设太长。不是每个业务字段都需要 VARCHAR(255),很多场景 VARCHAR(64) 绰绰有余。这个习惯配合后面讲到的索引优化,能帮你省掉不少麻烦。

3. 拆字段的三种姿势:SUBSTRING_INDEX、SUBSTRING 与正则的取舍

3.1 SUBSTRING_INDEX:一刀切到第 n 个分隔符

SUBSTRING_INDEX(str, delim, count)是我做数据清洗时使用频率最高的函数。它从字符串里按分隔符计数,count 为正数时从左往右数,count 为负数时从右往左数。返回值是:到第 count 个分隔符为止/之后的那一段。

SELECT SUBSTRING_INDEX('a@example.com', '@', 1); -- a SELECT SUBSTRING_INDEX('a@example.com', '@', -1); -- example.com SELECT SUBSTRING_INDEX('2025-01-01', '-', 2); -- 2025-01 SELECT SUBSTRING_INDEX('user-1001-dept', '-', 1); -- user

这个函数提取邮箱用户名、域名、日期前缀、路径中某一段时堪称神器。比如日志里存了api/v1/users/10001,你想取最右边的 ID:

SELECT SUBSTRING_INDEX('api/v1/users/10001', '/', -1); -- 10001

有个重要陷阱:当分隔符不存在时,函数返回整个字符串。

SELECT SUBSTRING_INDEX('no-delimiter-here', '@', 1); -- 返回 no-delimiter-here,而不是报错

也就是说,如果数据里没有@,邮箱域名的提取结果会是整个原始字符串。所以做提取前最好先确认数据确实包含分隔符,否则下游统计会把脏数据当成合法域名。可以用LOCATE先判断,或直接加WHERE email LIKE '%@%'过滤。

想取中间段怎么办?嵌套两层:

-- 取 user-1001-dept 中间那一段 1001 SELECT SUBSTRING_INDEX(SUBSTRING_INDEX('user-1001-dept', '-', 2), '-', -1);

先取前两段得到user-1001,再从右边取第一段得到1001。这种嵌套看着别扭,但实际用起来很稳定,比正则函数直观得多。

3.2 SUBSTRING 和 SUBSTR 是同义词,但起始位置从 1 开始

SUBSTRING(str, pos, len)和SUBSTR(str, pos, len)功能完全一致,还有一个 SQL 标准写法SUBSTRING(str FROM pos FOR len),三种都能用。

最经典的错误是:取第一个字符时用了SUBSTRING(str, 0, 1)。结果返回空字符串。因为这个函数的索引从 1 开始计数,不是从 0。正确的写法是:

SELECT SUBSTRING('abc', 1, 1); -- a SELECT SUBSTRING('abc', 2); -- bc SELECT LEFT('abc', 1); -- a

如果你是从 Python、JavaScript 切过来的,特别容易在这个位置卡一下。两者的下标语义完全不同,SQL 字符串位置一律从 1 开始。

3.3 正则函数:能不用尽量不用

MySQL 8.0 提供了REGEXP_SUBSTR、REGEXP_REPLACE、REGEXP_INSTR等函数,功能很强,但我一般只在用 SUBSTRING_INDEX 实在搞不定时才用。

原因有两点:

  1. 正则可读性差,几个月后回来看代码,REGEXP_SUBSTR(name, '^[a-z]+')得想半天当初是干什么用的。
  2. 性能开销大,正则匹配无法走索引,对全表数据做正则提取,数据量一大性能就崩。

举一个 SUBSTRING_INDEX 替代正则的例子:从"order_20250101_100"中提取日期部分20250101。正则写法是REGEXP_SUBSTR(str, '[0-9]{8}'),而用 SUBSTRING_INDEX 可以先按_拆,取第二段:

SELECT SUBSTRING_INDEX(SUBSTRING_INDEX('order_20250101_100', '_', 2), '_', -1);

虽然嵌套看起来啰嗦,但执行效率通常优于正则,也没那么难维护。优先用低成本的字符串函数,正则作为兜底方案,这是我个人的一个原则。

4. 查找定位函数别记混:LOCATE、INSTR、POSITION 与 LIKE 怎么选

4.1 四个函数,三种返回值,参数顺序还不同

字符串查找这一组,新手非常容易把参数顺序搞反。先把用法列清楚:

函数语法返回值找不到时
LOCATELOCATE(substr, str, [pos])首次出现的位置(从 1 开始)0
INSTRINSTR(str, substr)首次出现的位置(从 1 开始)0
POSITIONPOSITION(substr IN str)首次出现的位置(从 1 开始)0
LIKEstr LIKE pattern1 表示匹配,0 表示不匹配0

注意看参数顺序:LOCATE是子串在前,原串在后;INSTR是原串在前,子串在后。这两个恰恰是反的,特别容易写混。POSITION(substr IN str)其实等价于LOCATE(substr, str),只是写法更接近自然语言。

SELECT LOCATE('abc', 'xxabcxx'); -- 3 SELECT INSTR('xxabcxx', 'abc'); -- 3 SELECT POSITION('abc' IN 'xxabcxx'); -- 3

4.2 LOCATE 的第三个参数:从指定位置继续找

LOCATE 比 INSTR 多一个参数,可以在查找时指定起始位置。这在处理"第二次出现"的场景时非常实用。

比如字符串a.b.c.d,你想找点号第二次出现的位置,可以这样:

SELECT LOCATE('.', 'a.b.c.d', LOCATE('.', 'a.b.c.d') + 1); -- 4

先用内层的 LOCATE 找到第一个点号位置 2,然后从第 3 位开始找下一个点号,返回 4。这种写法在做路径解析、协议解析时很有用。

4.3 LIKE 和 LOCATE 的定位差异

LIKE 返回值是 0 或 1,适合直接放在 WHERE 条件里做过滤:

SELECT * FROM users WHERE nickname LIKE '%张三%';

LOCATE 返回的是位置,除了判断"是否存在",还可以顺便拿到"在哪里"。如果你的业务需要知道子串在字段里的具体位置,比如根据位置再去做截取,那用 LOCATE 更合适。

SELECT email, LOCATE('@', email) AS at_pos, SUBSTRING(email, 1, LOCATE('@', email) - 1) AS username FROM users;

这个 SQL 的逻辑是:先找到@的位置,再截取它前面的部分作为用户名。用 LOCATE 拿到位置后直接传给 SUBSTRING,两个函数配合得非常好。

我的选择标准:

  • 只判断存不存在,用LIKE或LOCATE(...) > 0,语义都很清晰。
  • 需要位置信息配合截取,用LOCATE。
  • 多个分隔符要精确定位,优先考虑 SUBSTRING_INDEX 而不是反复 LOCATE,后者容易嵌套成天书。

4.4 一个容易误判的语义:0 表示没找到

很多从 0 起步语言转过来的同学,看到LOCATE找不到返回 0,会下意识觉得"返回 0 是不是代表第一个字符?"千万注意:SQL 里位置从 1 开始,0 是"不存在"的专属返回值。所以判断时应该写WHERE LOCATE('abc', name) > 0,不要直接WHERE LOCATE('abc', name)。虽然 MySQL 里 0 在布尔上下文就是 false,但为了可读性,显式写> 0更安全。

4.5 如果要做复杂模式匹配

LIKE 支持%和_,但要做正则式的复杂匹配(比如"手机号以 13x 开头"),就该用REGEXP:

SELECT * FROM users WHERE phone REGEXP '^13[0-9]{9}$';

REGEXP返回的也是 1 或 0,和 LIKE 类似,但语法更灵活。代价是性能,这一点在最后一章会专门讲。

5. 清理和格式化:TRIM 家族、大小写、填充补位与金额显示

5.1 TRIM 不只是去空格,还能去指定字符

TRIM(str)默认去掉字符串两端的空格,LTRIM(str)只去左边,RTRIM(str)只去右边。日常数据清洗里最常见的用法是:

UPDATE users SET nickname = TRIM(nickname) WHERE nickname <> TRIM(nickname);

MySQL 8.0 开始,TRIM 还支持指定移除的字符:

SELECT TRIM('x' FROM 'xxxabcxxx'); -- abc SELECT TRIM(LEADING '0' FROM '00123'); -- 123 SELECT TRIM(TRAILING '0' FROM '1.2300'); -- 1.23

这里的逻辑是:从左边或右边连续移除指定的字符,不是全局删除。比如TRIM(TRAILING '0' FROM '1.2300')会去掉末尾连续的 0,得到1.23,但不会影响字符串中间的 0。

有一个容易忽略的点:TRIM 默认只处理空格字符,对于\t、\n、\r这些不可见字符不管用。清洗文本数据时,经常需要先REPLACE(str, '\t', '')、REPLACE(str, '\n', ' '),再做 TRIM。

SELECT TRIM(REPLACE(REPLACE(raw_text, '\n', ' '), '\t', ''));

5.2 UPPER / LOWER 与排序规则的微妙关系

UPPER(str)和LOWER(str)是大小写转换最简单的方式:

SELECT UPPER('mysql'); -- MYSQL SELECT LOWER('MySQL'); -- mysql

但有一个背景知识一定要知道:MySQL 的默认排序规则通常对大小写不敏感。比如utf8mb4_0900_ai_ci这种排序规则下,WHERE email = 'A@B.COM'和WHERE email = 'a@b.com'查出来的结果是一样的,因为字符串比较时大小写被视为相同。

那 UPPER / LOWER 什么时候才有用?当你把列显式改成binary或使用utf8mb4_bin排序规则时,比较变成大小写敏感,这时候可能需要 LOWER 把两边统一。另一种场景是生成一个新的全小写列用于分组统计:

SELECT LOWER(domain) AS domain_lower, COUNT(*) FROM emails GROUP BY LOWER(domain);

5.3 LPAD / RPAD 补位的反直觉细节

LPAD(str, len, padstr)和RPAD(str, len, padstr)用于在字符串左侧或右侧填充指定字符,达到固定长度。经典场景是订单号补零:

SELECT LPAD('123', 8, '0'); -- 00000123 SELECT RPAD('123', 8, '0'); -- 12300000

这里有一个非常反直觉的规则:第二个参数是最终总长度,不是补多少个字符。LPAD('123', 8, '0') 的意思是"把结果补到 8 位",而不是"补 8 个 0"。

更坑的是:如果原字符串长度已经超过 len,函数会做截断。

SELECT LPAD('123456789', 3, '0'); -- 123,前面被截掉了 SELECT RPAD('123456789', 3, '0'); -- 123,后面被截掉了

这意味着,如果你对一个可能超过 8 位的订单号直接LPAD(id, 8, '0'),超过的部分会被静默截断。订单号直接丢数据,这是线上事故级别的 bug。

更稳妥的写法是加一个判断:

SELECT IF(LENGTH(id) >= 8, id, LPAD(id, 8, '0')) FROM orders;

5.4 FORMAT:金额显示的规范姿势

FORMAT(X, D)把数字转为字符串,并保留 D 位小数,同时带上千分位分隔符:

SELECT FORMAT(1234567.891, 2); -- 1,234,567.89 SELECT FORMAT(1234567.891, 0); -- 1,234,568(四舍五入) SELECT FORMAT(1234.5, 2); -- 1,234.50

注意两点:

  1. 返回结果是字符串,不是数字。如果后续还要参与运算,记得CAST(FORMAT(x, 2) AS DECIMAL(10,2))转回去。
  2. 它自带四舍五入,和 ROUND 有点类似,但 FORMAT 的语义是"展示",ROUND 的语义是"计算"。简单说,要给人看的用 FORMAT,要参与计算的用 ROUND。

再配合拼接场景:

SELECT CONCAT('¥', FORMAT(price, 2)) AS display_price FROM products;

我之前见过有人直接CONCAT('¥', price),价格一上万,显示就会变得乱七八糟。先 FORMAT 再拼接,展示效果稳定很多。

6. 一次真实的数据清洗任务:手机号脱敏、昵称清理与域名统计

6.1 需求与表结构

纸上谈兵到此为止,接下来用一个完整案例把这些函数串起来。假设有一张模拟用户表users,字段如下:

CREATE TABLE users ( id BIGINT PRIMARY KEY, nickname VARCHAR(50), phone VARCHAR(20), email VARCHAR(100), created_at DATETIME );

业务方提了三个需求:

  1. 导出一份手机号脱敏的用户清单,手机号中间四位用****代替。
  2. 昵称可能存在首尾空格,需要清理。
  3. 按邮箱域名统计用户数量,方便做用户来源分析。

6.2 先写 SELECT 确认,再动手 UPDATE

第一次做清洗的人最容易犯的错是:直接UPDATE,跑完看不见数据变化,也不知道自己改错了多少行。我的习惯是先用 SELECT 把结果查出来,肉眼确认没问题,再执行 UPDATE。

手机号脱敏:

SELECT id, CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) AS masked_phone FROM users;

这里的逻辑很简单:LEFT(phone, 3)取前 3 位,RIGHT(phone, 4)取后 4 位,中间用****拼接。输出效果是138****5678。

昵称清理:

SELECT id, nickname AS old_value, TRIM(nickname) AS new_value FROM users WHERE nickname <> TRIM(nickname);

WHERE nickname <> TRIM(nickname)可以精确找出"清理前后不一致"的行,这样你能确认到底有多少条数据被影响。注意,如果nickname本身是 NULL,这个条件会把 NULL 排除掉,因为 NULL 和任何值比较都是未知。如果想覆盖 NULL 的情况,要再加OR nickname IS NOT NULL之类的条件,业务上自己判断。

检索确认无误后,执行 UPDATE:

UPDATE users SET nickname = TRIM(nickname) WHERE nickname <> TRIM(nickname);

更新昵称的同时,执行手机号脱敏的 UPDATE:

UPDATE users SET phone = CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) WHERE CHAR_LENGTH(phone) = 11;

为什么加CHAR_LENGTH(phone) = 11?因为如果某个手机号数据本身不合法、长度不对,脱敏会得到一串莫名其妙的字符串。先卡住长度,只处理标准 11 位手机号,稳妥得多。

6.3 邮箱域名统计

统计邮箱域名的核心是:用SUBSTRING_INDEX(email, '@', -1)取出@后面的部分,再分组计数。

SELECT SUBSTRING_INDEX(email, '@', -1) AS domain, COUNT(*) AS user_count FROM users WHERE email LIKE '%@%' GROUP BY domain ORDER BY user_count DESC;

WHERE email LIKE '%@%'是为了过滤没有@的脏数据,避开 SUBSTRING_INDEX 遇到无分隔符时返回整个字符串的坑。如果你还要顺便清洗邮箱两边空格,可以包一层 TRIM,或者干脆先执行一次 UPDATE 把 email 标准化。

6.4 把复杂逻辑收敛成函数

当多个业务方都依赖"手机号脱敏"这种逻辑时,把 SQL 片段写到处散落不是好主意。MySQL 支持自定义函数,可以把脱敏逻辑放进一个函数里:

CREATE FUNCTION mask_phone(p_phone VARCHAR(20)) RETURNS VARCHAR(20) DETERMINISTIC RETURN CONCAT(LEFT(p_phone, 3), '****', RIGHT(p_phone, 4));

之后所有查询都写:

SELECT id, mask_phone(phone) FROM users;

这样规则只维护一处。DETERMINISTIC表示同一输入永远返回同一输出,这个声明对函数优化和建索引都有帮助。自定义函数不可滥用,但对于"多处复用的固定字符串规则",它确实能降低维护成本。

6.5 清洗后的验证

数据更新完,别急着交付。几个简单的验证 SQL 能快速发现异常:

-- 检查手机号脱敏后格式是否符合预期 SELECT phone FROM users WHERE phone NOT REGEXP '^[0-9]{3}\*{4}[0-9]{4}$'; -- 昵称清理后是否还有多余空格 SELECT nickname FROM users WHERE nickname LIKE '% %' OR nickname LIKE ' %' OR nickname LIKE '% '; -- 邮箱域名统计是否为 0 行 SELECT domain, COUNT(*) FROM ( SELECT SUBSTRING_INDEX(email, '@', -1) AS domain FROM users WHERE email LIKE '%@%' ) t GROUP BY domain HAVING COUNT(*) = 0;

用正则去反查"不符合规则"的数据,比正向验证更高效。脱敏后如果还能找到连续 4 位星号之外的原始手机号模式,说明规则没跑对,或者有脏数据混进来了。

7. 性能与正确性的底线:索引失效、隐式转换和排序规则

7.1 WHERE 子句里对列用函数,索引基本就废了

这是字符串函数最容易被忽略的代价。下面这些写法看着没毛病,但实际上都会让索引失效,引发全表扫描:

-- 无法走索引 SELECT * FROM users WHERE LENGTH(nickname) > 10; SELECT * FROM users WHERE UPPER(email) = 'A@B.COM'; SELECT * FROM users WHERE CONCAT(last_name, first_name) = '张三';

原因很简单:索引是按原始列值组织的,你把它包进函数后,索引里的顺序对函数结果没有参考价值,优化器只能放弃索引。小表无所谓,大表这是灾难。

对策之一是生成列。MySQL 5.7 以上可以创建一个存储生成列,把函数结果事先算好,再对这个列建索引:

ALTER TABLE users ADD COLUMN email_lower VARCHAR(100) GENERATED ALWAYS AS (LOWER(email)) STORED; CREATE INDEX idx_email_lower ON users(email_lower);

之后查询WHERE email_lower = 'a@b.com'就能正常走索引。代价是存储空间,换来的是查询性能,适合那种高频查询场景。

7.2 字符串与数字的隐式转换

MySQL 在比较字符串和数字时,会做隐式类型转换。这个转换方向经常造成两个问题:索引失效和精度丢失。

-- 手机号列是 VARCHAR 类型 SELECT * FROM users WHERE phone = 13800138000;

这条 SQL 中,13800138000是数字字面量,MySQL 会把 phone 列转换为数字再比较。一旦对列做转换,索引就废了。更严重的是,超过一定位数的数字在转换过程中可能出现精度问题,尤其是用浮点表示大整数时,末尾几位可能对不上。

正确的写法是保持字符串类型一致:

SELECT * FROM users WHERE phone = '13800138000';

再看一个更隐蔽的例子:

SELECT 2 = '02'; -- 返回 1,因为 '02' 被转成了数字 2 SELECT '10abc' = 10; -- 返回 1,非数字部分被忽略

'10abc' = 10返回 1 这件事,应该能直观感受到隐式转换的野性。所以业务查询里,能用字符串就用字符串,别依赖 MySQL 的"智能转换"。

7.3 字符集和排序规则不一致,可能直接报错

字符串函数运算时,如果两个参数来自不同的字符集或排序规则,MySQL 可能报错:

Illegal mix of collations

比如一张历史表用utf8mb3,新表用utf8mb4,JOIN 时对两个表的字符串列做 CONCAT,就会触发这类错误。解决办法是显式统一字符集:

SELECT CONCAT(old_column COLLATE utf8mb4_0900_ai_ci, new_column) FROM old_table JOIN new_table ON ...

或者用CONVERT:

SELECT CONCAT(CONVERT(old_column USING utf8mb4), new_column)

在建表时统一用utf8mb4,并统一排序规则,能省掉后续大量麻烦。临时的跨表比对,用COLLATE显式指定即可。

7.4 不是所有字符串处理都必须放在 SQL 里

最后说一点系统层面的经验。SQL 的字符串函数适合做"轻量级、规则简单、在数据源就近完成"的操作,比如字段裁剪、格式统一、简单脱敏。但如果清洗规则很复杂,比如要解析多层嵌套的 JSON、需要循环判断多个分隔符、根据上下文决定保留哪一段,这些工作交给 Python、Java 等外部脚本处理会更清晰。

原因有二:

  1. SQL 写复杂逻辑很难调试,也没有成熟的测试环境。
  2. 大数据量下,一个嵌套五层的函数表达式会明显拉高单条查询耗时。

我的习惯是:能用一条简单 SQL 说清楚的规则,就用 SQL;超过两三层嵌套或者需要条件判断分支的,拉出来交给脚本。这样数据库压力更小,脚本逻辑也更可控。

回头看看这些年写过的 SQL,字符串函数本身不复杂,真正让人在线上栽跟头的,几乎都是字符集不一致、隐式转换、函数包裹索引列这些"函数之外"的问题。函数是兵器,字符集和类型是内功。内功不稳,再好的兵器也会伤到自己。

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

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

立即咨询