MySQL 中的 FIND_IN_SET 详解:从入门到避坑(含性能优化与替代方案)
摘要:本文系统讲解 MySQL 内置函数
FIND_IN_SET()的语法、返回值规则、典型使用场景,深入剖析开发中极易踩的 7 个坑,并通过EXPLAIN实测说明它的性能瓶颈,最后给出关联表、JSON、多值索引等 4 种更优的替代方案。文末附FIND_IN_SET/IN/LIKE/FIELD的横向对比速查表。
一、为什么需要 FIND_IN_SET
在业务表设计中,我们经常会遇到"一个字段存多个值"的情况:
| 业务场景 | 字段示例 |
|---|---|
| 文章的多个标签 | tag_ids = '1,3,5' |
| 用户的多个角色 | role_ids = '2,4' |
| 商品的适用地区 | area_codes = '110000,310000' |
| 菜单的可见部门 | dept_ids = '10,21,35' |
这种"逗号分隔的字符串集合"违背了第一范式,但在历史项目、配置表、低频写入场景中大量存在。
当我们要查询"包含某个值"的记录时,直觉写法有三种:
sql
-- 写法 1:LIKE 模糊匹配(有严重 bug)
SELECT * FROM article WHERE tag_ids LIKE '%1%';
-- 写法 2:IN(语义错误,IN 后面是常量列表,不是字段内容)
SELECT * FROM article WHERE 1 IN (tag_ids); -- 错!
-- 写法 3:FIND_IN_SET ✅
SELECT * FROM article WHERE FIND_IN_SET(1, tag_ids) > 0;
写法 1 会把 10、11、21 都查出来(%1% 匹配任意位置);写法 2 的 IN 语义完全不同,它判断的是"1 是否等于 tag_ids 这个整体值"。
FIND_IN_SET() 就是 MySQL 专门为"在逗号分隔的集合中查找成员"提供的函数。
二、语法与参数
sql
FIND_IN_SET(str, strlist)
| 参数 | 说明 |
|---|---|
str |
要查找的目标字符串(可以是常量、字段、表达式) |
strlist |
逗号分隔的字符串列表(如 '1,3,5'),也可以是字段名 |
执行逻辑 :把 strlist 按英文逗号 , 切分成 N 个元素,逐个与 str 比较,返回第一个匹配元素的位置下标(从 1 开始)。
返回值规则(必记)
| 情况 | 返回值 |
|---|---|
str 存在于 strlist 中 |
返回其位置下标,1 ~ N |
str 不存在于 strlist 中 |
0 |
strlist 为空串 '' |
0 |
str 或 strlist 任一为 NULL |
NULL |
str 本身包含逗号 |
0(无法作为单元素匹配) |
sql
SELECT FIND_IN_SET('b', 'a,b,c'); -- 2
SELECT FIND_IN_SET('c', 'a,b,c'); -- 3
SELECT FIND_IN_SET('d', 'a,b,c'); -- 0
SELECT FIND_IN_SET('a', ''); -- 0
SELECT FIND_IN_SET(NULL, 'a,b'); -- NULL
SELECT FIND_IN_SET('a', NULL); -- NULL
SELECT FIND_IN_SET('a,b', 'a,b,c'); -- 0 ← 注意!str 含逗号直接失效
WHERE 中的惯用写法:返回值为 0 表示未命中,非 0 表示命中,所以判存在有两种等价写法:
sql
WHERE FIND_IN_SET(1, tag_ids) > 0 -- 推荐,语义清晰
WHERE FIND_IN_SET(1, tag_ids) -- 简写,非 0 即为真
三、基础实战:准备测试数据
以下示例基于 MySQL 5.7 / 8.0,可直接复制执行。
sql
DROP TABLE IF EXISTS article;
CREATE TABLE article (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
tag_ids VARCHAR(50) NOT NULL DEFAULT '' COMMENT '标签ID集合,逗号分隔,如 1,3,5'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
INSERT INTO article (title, tag_ids) VALUES
('MySQL 索引原理', '1,3'),
('Redis 持久化', '2,4'),
('JVM 调优实战', '1,2,5'),
('Kafka 消息堆积', '5'),
('Spring 循环依赖', '3,5,10');
场景 1:查询包含某个标签的所有文章
sql
SELECT id, title, tag_ids
FROM article
WHERE FIND_IN_SET(1, tag_ids) > 0;
+----+---------------+---------+
| id | title | tag_ids |
+----+---------------+---------+
| 1 | MySQL 索引原理 | 1,3 |
| 3 | JVM 调优实战 | 1,2,5 |
+----+---------------+---------+
注意 id=5 的 tag_ids='3,5,10' 没有 被查出来------这正是 FIND_IN_SET 相比 LIKE '%1%' 的核心价值:它按逗号做精确切分,10 不会被误判为包含 1。
场景 2:同时命中多个标签(AND / OR)
sql
-- OR:包含标签 1 或 2
SELECT * FROM article
WHERE FIND_IN_SET(1, tag_ids) OR FIND_IN_SET(2, tag_ids);
-- AND:同时包含标签 1 和 2
SELECT * FROM article
WHERE FIND_IN_SET(1, tag_ids) AND FIND_IN_SET(2, tag_ids);
场景 3:查询不包含某个标签的文章
sql
SELECT * FROM article WHERE FIND_IN_SET(1, tag_ids) = 0;
-- 或
SELECT * FROM article WHERE NOT FIND_IN_SET(1, tag_ids);
⚠️ 注意:如果
tag_ids可能为NULL,NOT FIND_IN_SET(...)的结果是NULL而非TRUE,这条记录会被过滤掉。安全写法:WHERE IFNULL(FIND_IN_SET(1, tag_ids), 0) = 0。
场景 4:自定义排序(按指定顺序返回)
sql
SELECT id, title
FROM article
WHERE id IN (3, 1, 5)
ORDER BY FIND_IN_SET(id, '3,1,5');
+----+------------------+
| id | title |
+----+------------------+
| 3 | JVM 调优实战 |
| 1 | MySQL 索引原理 |
| 5 | Spring 循环依赖 |
+----+------------------+
💡 这种"按给定枚举顺序排序"的场景,MySQL 提供了更专业的函数
FIELD(),见第八节对比。
场景 5:批量更新集合成员
sql
-- 给所有文章追加标签 7(仅当不存在时)
UPDATE article
SET tag_ids = CONCAT(tag_ids, ',7')
WHERE FIND_IN_SET(7, tag_ids) = 0 AND tag_ids <> '';
-- 移除标签 3(需要配合字符串替换,FIND_IN_SET 只能查不能改)
UPDATE article
SET tag_ids = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', tag_ids, ','), ',3,', ','))
WHERE FIND_IN_SET(3, tag_ids);
第二个 SQL 是"安全移除元素"的经典套路:先在首尾补逗号,把元素统一成 ,3, 形式再替换,最后 TRIM 掉多余逗号,避免 3 误伤 13、30。
四、7 个必踩的坑
坑 1:集合中带空格导致查不到
sql
SELECT FIND_IN_SET('b', 'a, b'); -- 0 ❌
SELECT FIND_IN_SET(' b', 'a, b'); -- 2
因为 'a, b' 切分后是 'a' 和 ' b'(带前导空格)。
解决 :入库前统一 TRIM,或查询时先去空格:
sql
SELECT * FROM t WHERE FIND_IN_SET('b', REPLACE(col, ' ', '')) > 0;
坑 2:待查值本身含逗号
sql
SELECT FIND_IN_SET('a,b', 'a,b,c'); -- 0
FIND_IN_SET 无法表达"元素是逗号分隔的复合值",这是函数的设计边界。
坑 3:NULL 传染
任一参数为 NULL,结果就是 NULL,在 WHERE 中会被当成假。这是线上"数据莫名其妙少了几条"的高频原因。
坑 4:数字类型的隐式转换陷阱
sql
SELECT FIND_IN_SET(1, '1,2,3'); -- 1 ✅
SELECT FIND_IN_SET(1.0, '1,2,3'); -- 0 ❌ 1.0 转字符串为 '1.0'
SELECT FIND_IN_SET(1, '01,2,3'); -- 0 ❌ '01' ≠ '1'
FIND_IN_SET 内部按字符串比较,数字会先转成字符串。所以集合里的值必须严格一致(不能有前导零、不能有小数点)。
坑 5:大小写由排序规则决定
sql
-- 默认 utf8mb4_general_ci / utf8mb4_0900_ai_ci(ci = case insensitive)
SELECT FIND_IN_SET('A', 'a,b'); -- 1 ← 不区分大小写
-- 若字段使用 *_bin 或 *_cs 排序规则
SELECT FIND_IN_SET('A', 'a,b' COLLATE utf8mb4_bin); -- 0
需要严格区分大小写时,显式指定 COLLATE ..._bin。
坑 6:FIND_IN_SET 不能使用索引(性能致命)
sql
EXPLAIN SELECT * FROM article WHERE FIND_IN_SET(1, tag_ids);
+----+-------------+---------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+---------+------+---------------+------+---------+------+------+----------+-------------+
| 1 | SIMPLE | article | ALL | NULL | NULL | NULL | NULL | 5 | 100.00 | Using where |
+----+-------------+---------+------+---------------+------+---------+------+------+----------+-------------+
type = ALL 即全表扫描 。即使给 tag_ids 加了普通 B-Tree 索引也无效------因为索引是按"整个字段值"排序的,而 FIND_IN_SET 要判断的是"字段内容中的某一段"。
数据量上万后,这类查询会成为慢 SQL 的重灾区。
坑 7:方向反了,白白丢掉索引
这是最容易被忽略的优化点:
sql
-- A:常量 在 字段集合 中查找 → 无法用索引,全表扫描
SELECT * FROM t WHERE FIND_IN_SET(100, ids_col);
-- 等价于:WHERE ids_col LIKE '100,%' OR ids_col LIKE '%,100,%' OR ...
-- B:字段值 在 常量列表 中查找 → 改写为 IN 后可以走索引 ✅
SELECT * FROM t WHERE FIND_IN_SET(id, '3,1,5');
-- 改写为 ↓
SELECT * FROM t WHERE id IN (3, 1, 5); -- 走主键/普通索引
凡是 FIND_IN_SET(字段, 常量串) 形式的查询,一律改写成 字段 IN (...),性能天差地别。
五、性能量化:一个小实验
构造 10 万行数据对比(示意结果,因机器而异):
sql
-- FIND_IN_SET:全表扫描
SELECT SQL_NO_CACHE COUNT(*) FROM big_t WHERE FIND_IN_SET(7, tag_ids);
-- 耗时约 0.28s
-- 改写为关联表 + 索引
SELECT SQL_NO_CACHE COUNT(*) FROM article_tag WHERE tag_id = 7;
-- 耗时约 0.002s
差距在 100 倍以上 ,且随数据量线性放大。结论:集合字段的"包含查询"必须走索引方案。
六、4 种替代方案(推荐)
方案 1:关联表(最推荐,符合第一范式)
sql
CREATE TABLE article_tag (
article_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (article_id, tag_id),
INDEX idx_tag (tag_id) -- 关键:反向索引
) ENGINE = InnoDB;
-- 查询包含标签 7 的文章
SELECT a.* FROM article a
JOIN article_tag t ON a.id = t.article_id
WHERE t.tag_id = 7; -- 走 idx_tag,毫秒级
适用于需要高频查询、数据量大的主业务表。
方案 2:JSON 类型 + JSON_CONTAINS(MySQL 5.7+)
sql
ALTER TABLE article ADD COLUMN tags JSON DEFAULT NULL;
UPDATE article SET tags = JSON_ARRAY(1, 3) WHERE id = 1;
-- 查询包含标签 3 的文章
SELECT * FROM article WHERE JSON_CONTAINS(tags, '3');
-- 注意:查字符串元素时 candidate 要带引号
SELECT * FROM article WHERE JSON_CONTAINS(tags, '"admin"');
JSON_CONTAINS 语义更严谨,但仍然不能直接用普通索引(除非配合方案 3)。
方案 3:多值索引 Multi-Valued Index(MySQL 8.0.17+,强烈推荐)
MySQL 8.0.17 起支持对 JSON 数组创建多值索引,这是官方给出的正解:
sql
CREATE TABLE article_j (
id INT PRIMARY KEY,
tags JSON,
INDEX idx_tags ((CAST(tags AS UNSIGNED ARRAY))) -- 注意双层括号
);
INSERT INTO article_j VALUES (1, '[1,3]'), (2, '[2,4]'), (3, '[1,2,5]');
-- 以下三种写法均可命中 idx_tags 索引
SELECT * FROM article_j WHERE 3 MEMBER OF (tags);
SELECT * FROM article_j WHERE JSON_CONTAINS(tags, '3');
SELECT * FROM article_j WHERE JSON_OVERLAPS(tags, '[3,5]');
验证:
sql
EXPLAIN SELECT * FROM article_j WHERE 3 MEMBER OF (tags);
-- key: idx_tags ✅
方案 4:冗余列 + 触发器 / 生成列(读多写少)
把集合关系冗余成多行,由触发器或应用层维护,读侧直接走索引。适合统计报表、权限缓存等读远多于写的场景。
方案选型建议
| 场景 | 推荐方案 |
|---|---|
| 主业务表、数据量大、查询频繁 | 关联表 / 多值索引 |
| MySQL 8.0.17+、必须用 JSON 存储 | 多值索引 |
| 配置表、字典表、数据量 < 1 万 | FIND_IN_SET 可用 |
| 临时脚本、一次性数据清洗 | FIND_IN_SET 可用 |
| 按指定顺序排序 | FIELD() |
七、FIND_IN_SET 依然好用的 3 个场合
不必谈 FIND_IN_SET 色变,以下场景它仍是合理选择:
- 小表/配置表:数据量几千行以内,全表扫描成本可忽略,换来的却是简洁的 SQL 和更少的表。
- 自定义排序 :
ORDER BY FIND_IN_SET(id, '3,1,5')配合IN使用,可读性好(当然FIELD()更好)。 - 存储过程 / 临时脚本:一次性数据修复、ETL 清洗,追求的是写起来快。
八、横向对比速查表
| 函数 / 语法 | 作用 | 能否用索引 | 典型误用 |
|---|---|---|---|
FIND_IN_SET(v, col) |
判断 v 是否在 col 的逗号集合中 | ❌ 全表扫描 | 大表上做主查询条件 |
FIND_IN_SET(col, 'a,b') |
判断 col 是否在常量集合中 | ❌(但可改写为 IN) | 应改写成 col IN ('a','b') |
col IN (v1,v2,v3) |
判断 col 是否等于列表中的某个值 | ✅ | 误以为能匹配"集合字段" |
col LIKE '%v%' |
子串模糊匹配 | ❌ | 误匹配 10、21 |
INSTR(col, 'v') > 0 |
子串位置查找 | ❌ | 同 LIKE,无边界控制 |
FIELD(col, v1,v2,...) |
返回 col 在列表中的位置(排序用) | ❌(仅 ORDER BY) | 与 FIND_IN_SET 混用 |
JSON_CONTAINS(json_col, 'v') |
判断 JSON 数组是否包含元素 | 需多值索引 | candidate 忘加引号 |
v MEMBER OF (json_col) |
同上,8.0.17+,语法更简洁 | ✅ 配多值索引 | 版本低于 8.0.17 |
一句话记忆:
IN是"字段 ∈ 常量集合",FIND_IN_SET是"常量 ∈ 字段里的集合",LIKE是"字段包含子串"------三者语义完全不同,别混。
九、总结
FIND_IN_SET(str, strlist)返回 str 在逗号分隔集合中的位置(1 起) ,找不到返回0,遇NULL返回NULL。- 它的核心价值是按逗号做精确切分 ,避免
LIKE '%1%'误匹配10/21的经典 bug。 - 三个高频坑:集合带空格 、待查值含逗号 、NULL 传染,上线前务必覆盖测试。
- 性能是最大短板 :
FIND_IN_SET(常量, 字段)必然全表扫描,大表上必须改造。 - 凡写成
FIND_IN_SET(字段, '常量串')的,一律改写成字段 IN (...),立刻可用索引。 - 长期方案首选关联表 ,MySQL 8.0.17+ 首选 JSON + 多值索引 +
MEMBER OF。 - 排序场景优先用
FIELD(),语义更准确。
参考
- MySQL 8.0 Reference Manual --- String Functions:
FIND_IN_SET() - MySQL 8.0 Reference Manual --- Multi-Valued Indexes
- MySQL 8.0 Reference Manual --- Functions That Search JSON Values
如果本文对你有帮助,欢迎点赞收藏 🌹 有任何疑问或补充,评论区一起交流~