MySQL_FIND_IN_SET函数详解

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 色变,以下场景它仍是合理选择:

  1. 小表/配置表:数据量几千行以内,全表扫描成本可忽略,换来的却是简洁的 SQL 和更少的表。
  2. 自定义排序 :ORDER BY FIND_IN_SET(id, '3,1,5') 配合 IN 使用,可读性好(当然 FIELD() 更好)。
  3. 存储过程 / 临时脚本:一次性数据修复、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 是"字段包含子串"------三者语义完全不同,别混。


九、总结

  1. FIND_IN_SET(str, strlist) 返回 str 在逗号分隔集合中的位置(1 起) ,找不到返回 0,遇 NULL 返回 NULL。
  2. 它的核心价值是按逗号做精确切分 ,避免 LIKE '%1%' 误匹配 10/21 的经典 bug。
  3. 三个高频坑:集合带空格 、待查值含逗号 、NULL 传染,上线前务必覆盖测试。
  4. 性能是最大短板 :FIND_IN_SET(常量, 字段) 必然全表扫描,大表上必须改造。
  5. 凡写成 FIND_IN_SET(字段, '常量串') 的,一律改写成 字段 IN (...),立刻可用索引。
  6. 长期方案首选关联表 ,MySQL 8.0.17+ 首选 JSON + 多值索引 + MEMBER OF。
  7. 排序场景优先用 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

如果本文对你有帮助,欢迎点赞收藏 🌹 有任何疑问或补充,评论区一起交流~

相关推荐
真上帝的左手16 分钟前
4. 关系型数据库-MySQL-Canal 进阶
数据库·mysql·canal
xiaoye-duck29 分钟前
《C++ 项目:手写大模型接入 SDK》DataManager数据管理模块:SQLite持久化存储
开发语言·数据库·c++·sqlite
codeejun1 小时前
每日一Go·MySQL-14、慢 SQL 产生原因全解
sql·mysql·golang
唐老板2 小时前
uuidv7 来了:为什么别再拿 uuidv4 当主键了
数据库
好的佩奇11 小时前
Dart 之集合元素
android·flutter·dart
Easyhood12 小时前
Android 开发入门实践(十四):Compose 副作用入门与四个常用 API
android·开发语言·kotlin·android jetpack·compose
FlightYe14 小时前
音视频修炼之容器与协议(四):RTSP详解
android·linux·ffmpeg·音视频·dash
SelectDB15 小时前
让变更流出 Doris:Apache Doris 5.0 基于增量计算的 CDC 技术解析
数据库
这个DBA有点耶15 小时前
MCP隔离层+行级语义审计+输出校验:AI访问数据库的安全边界拆解
数据库·人工智能·ai编程