我在做数据清洗的时候遇到过不少类似场景:一张订单表里,某个字段存的是 JSON 数组,里面塞着用户选的 SKU、标签、渠道列表,业务上需要查“包含某个值的所有记录”。很多人的第一反应是 LIKE 一把梭,写个%xxx%去匹配,结果边界条件踩了一堆坑。后来换了 MySQL 原生的 JSON_CONTAINS,才算把这类查询从“能跑”变成了“靠谱”。这篇就专门聊聊这个函数,从语法、底层逻辑到实际场景、性能优化,一次讲透。
1. JSON_CONTAINS 到底帮你解决了什么问题
1.1 为什么 LIKE 和 FIND_IN_SET 都不够用
先说一个非常典型的业务需求:商品表里有个tag_list字段,存储结构是 JSON 数组,比如["爆款", "新品", "限时"],现在要找出所有包含“新品”标签的商品。
用 LIKE 的话是这样写的:
SELECT * FROM product WHERE tag_list LIKE '%新品%';这写法看着没毛病,但实际隐患很多。假设某个商品有个 tag 叫“新品尝鲜”,那么LIKE '%新品%'也会把它捞出来,这就是典型的模糊匹配误伤。而且如果 JSON 数组里的字符串包含特殊字符,比如引号、反斜杠,LIKE 的转义逻辑又是一堆细节。
还有人会用 FIND_IN_SET,但是它专为逗号分隔的字符串设计,和 JSON 数组的结构并不完全兼容。JSON 数组里元素可以包含逗号,比如["a,b", "c"],FIND_IN_SET 一遇到这种就废了。更重要的是,这两个方案都忽略了一个问题:JSON 是带类型的,数字1和字符串"1"在语义上完全不同,文本匹配根本无法区分这两个。
JSON_CONTAINS 的价值就在于:它按 JSON 文档的结构去判断包含关系,而不是按“字符串子串”去匹配。它知道数组里有哪些元素,知道你给的目标值是字符串还是数字,也就从根上避免了误匹配。
1.2 这个函数的应用边界
JSON_CONTAINS 的适用场景集中在三块:
- JSON 数组的条件过滤,判断数组是否包含一个或多个元素
- JSON 对象的键值匹配,判断一个嵌套对象里是否存在指定键值对
- 和虚拟列、函数索引配合,实现对 JSON 字段的高效查询
它解决的核心痛点是:在 MySQL 5.7 之后引入了 JSON 类型,但很多开发者还是习惯用老办法去处理 JSON 数据,结果既损失了 JSON 语义,又让查询性能变得难以预判。JSON_CONTAINS 补上的正是这个缺口。
适合使用 JSON_CONTAINS 的读者,主要是那些在业务里已经用 JSON 字段存储半结构化数据的人,比如标签系统、配置项、埋点数据、用户画像、商品属性集。如果你还在用 LIKE 搞这类查询,这篇文章值得仔细看。
2. 语法细节与参数解读
2.1 参数结构和返回规则
JSON_CONTAINS 的完整语法如下:
JSON_CONTAINS(target, candidate[, path])三个参数的含义:
target:被搜索的 JSON 文档,可以是 JSON 类型的列,也可以是表达式candidate:要查找的目标 JSON 文档,可以是标量、对象或数组path:可选的 JSON 路径表达式,比如$.tags,用于限定搜索范围
返回值很简单:命中返回 1,未命中返回 0。但如果传入的 target 或 candidate 是非法 JSON,或者 path 写错,会直接报错。
举个例子:
SELECT JSON_CONTAINS('["a", "b", "c"]', '"b"'); -- 返回 1注意 candidate 的写法,字符串b在 JSON 里是以"b"形式存储的,所以传参的时候必须写成带双引号的字符串字面量。很多初学者在这一步翻车,写的是JSON_CONTAINS('["a", "b"]', 'b'),结果返回 0,还以为是函数的问题。
candidate 是数组也可以:
SELECT JSON_CONTAINS('["a", "b", "c"]', '["a", "b"]'); -- 返回 1这个很好理解,语义是“target 里是否存在 candidate 数组的全部元素”,相当于数组子集的判断。但是如果 candidate 里有一个元素不存在,整体就是 0:
SELECT JSON_CONTAINS('["a", "b", "c"]', '["a", "d"]'); -- 返回 02.2 path 参数的用法和坑
实际开发中,JSON 不是总是顶层数组,更多时候是嵌套对象。比如下面这个存储用户信息的 JSON:
{ "name": "张三", "tags": ["老客户", "高净值"], "address": { "city": "北京", "district": "朝阳区" } }如果想判断 tags 里是否包含“高净值”,就要用到 path:
SELECT JSON_CONTAINS( '{"name": "张三", "tags": ["老客户", "高净值"]}', '"高净值"', '$.tags' );path 的写法是标准的 JSON 路径,$表示文档根,$.tags表示取根对象里的 tags 字段。如果路径指向一个对象里的某个键:
SELECT JSON_CONTAINS( '{"address": {"city": "北京"}}', '"北京"', '$.address.city' );需要注意两点。第一,path 指到的是一个对象时,candidate 也要传对象;path 指到数组时,candidate 传数组里的元素。第二,如果 path 写成了不存在的位置,返回结果不是 0,而是直接报错:
SELECT JSON_CONTAINS( '{"name": "张三"}', '"李四"', '$.address.city' ); -- ERROR 3143: Invalid JSON path expression实际开发中,我最常用的方式是先用 JSON_EXTRACT 或 JSON_KEYS 看看目标路径的结构,确认无误后再写 JSON_CONTAINS 的 path。
2.3 对象作为 candidate 时的行为
JSON_CONTAINS 对对象的匹配规则,是要求 target 中对应位置包含 candidate 对象里的所有键值对,但允许 target 有额外的键。
SELECT JSON_CONTAINS('{"name": "张三", "age": 30}', '{"name": "张三"}'); -- 返回 1 SELECT JSON_CONTAINS('{"name": "张三", "age": 30}', '{"name": "李四"}'); -- 返回 0 SELECT JSON_CONTAINS('{"name": "张三", "age": 30}', '{"name": "张三", "age": 30}'); -- 返回 1也就是说,candidate 对象是 target 对象的“子集”时,结果为 1。这个规则和数组的逻辑是呼应的:candidate 必须被完全包含。
这里容易忽略的是嵌套场景。比如 target 是{"info": {"name": "张三"}},如果用不带 path 的方式去查:
SELECT JSON_CONTAINS( '{"info": {"name": "张三"}}', '{"name": "张三"}' ); -- 返回 0原因是 target 的根节点是info对象本身,candidate 的{"name": "张三"}和根节点对不上。这种场景必须用 path 指到$.info,或者直接把 candidate 写成完整结构:
SELECT JSON_CONTAINS( '{"info": {"name": "张三"}}', '{"info": {"name": "张三"}}' );这两个写法结果都是 1,但语义完全不同。第一个是判断 info 内部结构,第二个是判断整个文档完全包含。写代码前先想清楚你要查的是哪个层级。
3. 实战场景:标签过滤、动态属性和多条件匹配
3.1 数组标签的精确匹配
回到开头说的商品标签场景。建一张简化表:
CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100), tag_list JSON ); INSERT INTO product (name, tag_list) VALUES ('商品A', '["爆款", "新品"]'), ('商品B', '["新品尝鲜", "限时"]'), ('商品C', '["限时", "清仓"]');查询包含“新品”这个独立标签的商品:
SELECT id, name FROM product WHERE JSON_CONTAINS(tag_list, '"新品"');这条 SQL 的结果只会返回商品A,不会误伤商品B,因为 JSON_CONTAINS 对比的是数组里的完整元素,而不是做子串匹配。这就是它和 LIKE 的本质差异。
再复杂一点,筛选同时包含“爆款”和“新品”两个标签的商品:
SELECT id, name FROM product WHERE JSON_CONTAINS(tag_list, '["爆款", "新品"]');这里 candidate 写成数组,语义是“必须同时包含这两个元素”。如果业务需要“包含其中一个就行”,那就要用 OR:
SELECT id, name FROM product WHERE JSON_CONTAINS(tag_list, '"爆款"') OR JSON_CONTAINS(tag_list, '"新品"');实测小数据量时这两条 SQL 性能差距不大,但到几十万行的表,OR 写法会让优化器更难走索引。后面性能部分再展开聊。
3.2 嵌套对象的关键值查询
再看一个用户画像场景,每个用户有一条 profile 的 JSON,包含多个维度的标签和属性:
{ "user_id": 1001, "level": "VIP", "preferences": { "category": ["数码", "家电"], "brands": ["某品牌A", "某品牌B"] } }查询偏好品牌里包含“某品牌A”的 VIP 用户:
SELECT user_id FROM user_profile WHERE JSON_CONTAINS(profile, '{"level": "VIP"}') AND JSON_CONTAINS(profile, '"某品牌A"', '$.preferences.brands');注意这里我同时用了两个条件,第一个验证 level,第二个深入嵌套路径。这个写法很直观,但要注意 JSON_CONTAINS 对同一 JSON 文档做两次扫描,如果 profile 特别大、数据量特别多,性能会成为瓶颈。
另一种做法是直接把整个 profile 都匹配一次:
SELECT user_id FROM user_profile WHERE JSON_CONTAINS(profile, '{ "level": "VIP", "preferences": { "brands": ["某品牌A"] } }');这个写法看起来一步到位,实际上有一个大坑:JSON 对象的包含判断要求 candidate 里的数组必须完整匹配 target 数组内容。也就是说,如果某用户 brands 字段是["某品牌A", "某品牌B"],candidate 写成["某品牌A"]是不行的,因为数组不是对象,不存在“子集”的宽松匹配规则。除非业务能保证 brands 只有一个元素,否则这个写法会漏数据。
所以实际线上我推荐用多条件 AND 方案,虽然多扫一次,但语义清晰,也不会因为数组长度变化而漏查。
3.3 和 JSON_EXTRACT、JSON_TYPE 的联合作战
JSON_CONTAINS 有时候不是单独用的,而是和其他 JSON 函数配合,先做结构判断,再做值提取。
比如,判断一个 JSON 字段里某个数组非空,且包含特定值时:
SELECT user_id FROM user_profile WHERE JSON_CONTAINS(profile, '"某品牌A"', '$.preferences.brands') AND JSON_EXTRACT(profile, '$.preferences.brands') IS NOT NULL;第二个条件其实是防御性的,防止preferences或brands键不存在导致 JSON_CONTAINS 报错。一般来说,如果 JSON 结构是程序写入且固定的,可以不写这个防御条件,但如果是历史数据或者第三方导入的数据,我建议加上,线上排查问题会省很多事。
还可以用 JSON_TYPE 判断字段类型,因为一个字段虽然名为 brands,但历史数据里可能存的是字符串而不是数组:
SELECT user_id FROM user_profile WHERE JSON_TYPE(JSON_EXTRACT(profile, '$.preferences.brands')) = 'ARRAY' AND JSON_CONTAINS(profile, '"某品牌A"', '$.preferences.brands');这类写法适合做数据质量检查时用,不太适合直接放到高频查询路径里,原因还是性能。JSON_TYPE 是全文档解析,开销不小。
4. 性能分析:从全表扫描到函数索引
4.1 为什么 JSON_CONTAINS 默认不走索引
MySQL 5.7 引入 JSON 类型后,一个常见误解是:JSON 字段也可以建索引。其实 MySQL 的普通索引无法直接建立在 JSON 列上,原因是 JSON 是文档型结构,内部数据经过了二进制格式化和压缩,不像 B+ 树的 key 那样可以按规则排序和比较。
所以一条带有 JSON_CONTAINS 的 WHERE 条件,默认情况下的执行计划就是全表扫描,对每一行做 JSON 解析和匹配。数据量超过十万行时,这个开销非常可观。
我之前在一个模拟项目X里面做过压测,一张 20 万行的表,JSON 字段平均大小约 1KB,一条 JSON_CONTAINS 查询的响应时间在 200ms 到 500ms 之间浮动,而相同条件的普通 B+ 树索引查询只需要几毫秒。差距就是这么明显。
4.2 虚拟列 + 函数索引的正确打法
既然 JSON 列本身不能直接建索引,业界通用做法是使用生成列(Generated Column),配合函数索引来加速。
思路是这样的:从 JSON 字段里把你要查的值提取成一个虚拟列,然后在虚拟列上建普通索引。MySQL 对表达式的值是有缓存逻辑的,插入和更新时自动计算,之后查询就能走索引。
建表语句如下:
CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100), tag_list JSON, first_tag VARCHAR(50) AS (JSON_UNQUOTE(JSON_EXTRACT(tag_list, '$[0]'))) STORED, INDEX idx_first_tag (first_tag) );这里把tag_list数组的第一个元素提取到first_tag列,并加上 STORED 关键字,让虚拟列持久化存储。然后查询:
SELECT id, name FROM product WHERE first_tag = '新品';这是对“提取固定位置元素”场景的优化。但更常见的 JSON_CONTAINS 判断是“数组里是否存在某个元素”,这个用虚拟列建索引的思路要变一下。
比如,你要经常查 tag_list 是否包含“爆款”,可以单独建一个生成列:
ALTER TABLE product ADD COLUMN has_burst VARCHAR(20) AS (CASE WHEN JSON_CONTAINS(tag_list, '"爆款"') THEN 'yes' ELSE 'no' END) STORED, ADD INDEX idx_has_burst (has_burst);然后查询就退化成等值比较:
SELECT id, name FROM product WHERE has_burst = 'yes';这个方法非常有效,但代价是业务需求一旦变化,比如要查“新品”,就得再加一个生成列。所以它适合标签集合相对固定、查询模式比较明确的场景,不适合那种随意筛选标签的 BI 报表环境。
4.3 大数据量下的替代方案
当数据量上升到百万级甚至更高,JSON_CONTAINS 搭配虚拟列仍然有限制,因为每个标签都要建一列根本不可维护。这时候我会考虑更务实的替代方案。
一是把高频查询的标签拆出来,单独建关联表。比如 product_tag 表,每行是 product_id 和 tag 两个字段,tag 列建普通索引,查询变成 JOIN 或 IN 子查询,性能完全是常规索引能力范围。
二是使用全文索引。如果 JSON 里的标签是短文本,而且允许一定的模糊匹配损耗,可以建全文索引,用 MATCH AGAINST 查询,性能比 JSON_CONTAINS 强很多。
三是在应用层做缓存。对于标签筛选这类查询条件相对固定、结果集更新不频繁的场景,可以用缓存维护筛选结果集合,业务侧直接读缓存,数据库只承担写入和兜底。
这三个方案的取舍没有标准答案,核心看你的查询频率、数据一致性要求、以及团队维护成本。在我的实践里,查询频率高的核心链路建议走关联表;分析类的临时场景保留 JSON_CONTAINS 完全没问题。
5. 常见问题与排查技巧
5.1 数字类型匹配总是返回 0
这是一个我见过的最高频问题。JSON 里存的数字是1,查询时写:
SELECT JSON_CONTAINS('[1, 2, 3]', '1'); -- 返回 0看上去没毛病,实际返回 0。原因是 JSON_CONTAINS 的 candidate 参数需要符合 JSON 文档规范,裸数字1不是合法的 JSON 文档,它实际上被当作字符串处理了。
正确的写法是:
SELECT JSON_CONTAINS('[1, 2, 3]', '1'); -- 错误 SELECT JSON_CONTAINS('[1, 2, 3]', '1'); -- 正确第二行的1用双引号包起来,是一个 JSON 字符串字面量。如果 target 中的元素是 JSON 数字,那么 candidate 要写成:
SELECT JSON_CONTAINS('[1, 2, 3]', '1');JSON 数字可以用1或者1.0表示。这里有个细节,JSON_CONTAINS 在比较时会区分整数和浮点数吗?实测下来,MySQL 对1和1.0的判断是相等的。不过保险起见,建议在代码层统一数字格式,不要让它依赖 MySQL 的隐式处理。
5.2 NULL 和非法 JSON 引发的连锁问题
如果 target 列本身是 NULL,JSON_CONTAINS 的返回值不是 0,而是 NULL。这在 WHERE 条件里不会报错,但配合 NOT 使用时会出问题。
比如你要查“不包含某标签”的商品:
SELECT id, name FROM product WHERE NOT JSON_CONTAINS(tag_list, '"新品"');如果有一行 tag_list 是 NULL,那么 JSON_CONTAINS 返回 NULL,NOT NULL 仍是 NULL,WHERE 条件不成立,这行数据就被过滤掉了。从业务语义上看,“没有标签的商品的应该算不包含新品”,但现在它没有被返回。
解决办法是显式处理 NULL:
SELECT id, name FROM product WHERE tag_list IS NULL OR NOT JSON_CONTAINS(tag_list, '"新品"');另一个问题是非法 JSON。JSON 类型的列本身有合法性校验,理论上不会存非法 JSON,但如果是从 TEXT 或 VARCHAR 字段强转过来的历史数据,就可能存在非 JSON 内容。JSON_CONTAINS 遇到这种数据会直接报错,整条 SQL 失败。
排查方法很简单,先把疑似有问题的行筛出来:
SELECT id, tag_list FROM product WHERE JSON_VALID(tag_list) = 0;5.3 关于路径和转义的几个高频翻车点
路径中的键如果含特殊字符,比如空格、汉字、引号,一般建议用双引号包裹键名:
SELECT JSON_CONTAINS( '{"用户 标签": ["A", "B"]}', '"A"', '$."用户 标签"' );注意$."用户 标签"这个写法,键名用双引号包裹。不加引号在某些版本下会报路径错误。
还有一种情况是 JSON 字符串里本身包含双引号,比如标签值是"a\"b"。在 SQL 里写 candidate 时,需要用反斜杠转义,还要处理 SQL 本身的引号转义规则,多层嵌套下来非常容易写错。
我的建议是:遇到这类场景,先单独跑一条 SELECT 把 JSON 内容打印出来,确认目标值的原始结构,再用 JSON_QUOTE 函数辅助生成合法 JSON 字符串字面量:
SELECT JSON_CONTAINS( '["a\"b"]', JSON_QUOTE('a"b') );JSON_QUOTE 能帮你在运行时生成正确的 JSON 字符串格式,避免手写转义。
6. 其他 JSON 条件函数的替代和对比
JSON_CONTAINS 不是唯一的 JSON 条件查询工具,实际开发中还要根据场景区分 JSON_CONTAINS、JSON_OVERLAPS 和 JSON_SEARCH 的取舍。
MySQL 8.0.17 之后的 JSON_OVERLAPS 是“两个 JSON 文档有交集”就返回 1,它和 JSON_CONTAINS 的区别很简单:JSON_CONTAINS 要求 candidate 完全被包含,JSON_OVERLAPS 只要有一个公共元素就算命中。
SELECT JSON_CONTAINS('["a", "b"]', '["b", "c"]'); -- 返回 0,因为 c 不在 target 中 SELECT JSON_OVERLAPS('["a", "b"]', '["b", "c"]'); -- 返回 1,因为 b 是公共元素业务含义就是“同时包含”和“包含任一”的区别。
JSON_SEARCH 则是返回匹配字符串在 JSON 文档中的路径,它的重点是“找位置”,不是“判断是否包含”。如果只是条件过滤,JSON_SEARCH 不是最佳选择,因为它的语义更接近模糊搜索,性能也不占优势。
按场景给一个直接的选型建议:
| 场景 | 推荐函数 |
|---|---|
| 数组是否包含某个元素 | JSON_CONTAINS |
| 数组是否包含多个元素 | JSON_CONTAINS |
| 数组是否和目标有交集 | JSON_OVERLAPS |
| 查找包含某值的路径 | JSON_SEARCH |
| 对象中是否存在键值对 | JSON_CONTAINS |
| 提取值后再做数值比较 | JSON_EXTRACT |
选函数的核心逻辑是:先确认查询的语义到底要“完全包含”还是“存在交集”,选错函数会导致数据漏选或多选,比性能问题更棘手。
7. 除了查询,JSON_CONTAINS 还能用在哪些地方
JSON_CONTAINS 不只是 WHERE 条件里的工具,它还能放在 SELECT 列表、JOIN 条件、CASE WHEN 表达式里,用起来比想象中灵活。
7.1 用布尔标记实现复杂筛选结果
有的业务需要把满足条件的记录打标,但不想真正过滤掉。比如统计每个商品是否属于“爆款标签分组”:
SELECT id, name, CASE WHEN JSON_CONTAINS(tag_list, '"爆款"') THEN '爆款' WHEN JSON_CONTAINS(tag_list, '"新品"') THEN '新品' ELSE '普通' END AS tag_group FROM product;这种情况下 JSON_CONTAINS 作为表达式参与计算,和普通 WHERE 条件用法不同。如果你要把多个标签匹配结果拼成一组,也能在 SELECT 里做布尔运算,只是注意 JSON_CONTAINS 返回值不是严格的 0/1,而是可能为 NULL,遇到 NULL 时 CASE 分支不会命中。
7.2 JOIN 条件里的点匹配
表 A 的某个字段存的是 JSON 数组,表 B 存的是具体值,要找出所有关联关系:
SELECT A.id, B.name FROM table_a A JOIN table_b B ON JSON_CONTAINS(A.tags, JSON_QUOTE(B.tag_name));这个写法把 B 的字段值先转成合法的 JSON 字符串,再和 A 的 JSON 数组做匹配。从可用性角度讲,它避免了动态拼接 SQL 的麻烦,但在大数据量表 JOIN 时性能会比较差,因为无法走索引。一般用在关联表数据量小、或一次性数据处理的场景。
7.3 需要注意的是,它不是判断“键是否存在”的工具
如果你想判断 JSON 对象里是否包含某个键,JSON_CONTAINS 并不是一个好选择,应该用 JSON_KEYS 或 JSON_EXTRACT 结合 IS NOT NULL 判断。
比如判断 profile 里是否有 age 字段:
SELECT JSON_EXTRACT(profile, '$.age') IS NOT NULL FROM user_profile;但这里也有坑:如果 age 的值本身就是 null,那么 JSON_EXTRACT 返回 JSON null,IS NOT NULL 结果会是 false,这就有歧义。更准确的写法是配合 JSON_CONTAINS_PATH:
SELECT JSON_CONTAINS_PATH(profile, 'one', '$.age');JSON_CONTAINS_PATH 专门用来判断路径是否存在,语义精确。JSON_CONTAINS 解决的是“值到底有没有”的问题,JSON_CONTAINS_PATH 解决的是“键到底有没有”的问题,两者分工不同。
8. 踩过几次坑之后的操作习惯
我用了几年 JSON_CONTAINS,踩过的坑不少,总结几个已经固化的操作习惯。
第一个习惯:写任何 JSON 查询前,先单独验证 JSON 文档本身的结构和值类型。用一个最小化的 SELECT 确认无误后再放到业务查询里。比如先在控制台跑:
SELECT JSON_EXTRACT(tag_list, '$') FROM product LIMIT 5; SELECT JSON_TYPE(JSON_EXTRACT(tag_list, '$[0]')) FROM product LIMIT 5;确认数组元素类型是字符串还是数字,再写 candidate。这一步能筛掉大多数“明明有值却查不出来”的问题。
第二个习惯:对历史数据或者外部导入数据,永远先做一次 JSON_VALID 检测。老表从 VARCHAR 迁移到 JSON 类型时,数据质量往往参差不齐,直接上线 JSON_CONTAINS 查询很可能在运行时炸出无效 JSON 的报错。
第三个习惯:构造复杂 JSON 条件时,尽量把 JSON 的字符串字面量交给函数生成,比如 JSON_QUOTE 和 JSON_ARRAY,而不是手写。手写多层引号转义的 SQL 极易出错,尤其在代码里嵌又一层语言变量时,双重转义让人崩溃。
我的最后一个经验是:JSON_CONTAINS 适合灵活性高、数据量中等的场景。如果你的数据量已经大到需要给它做各种索引优化和拆表,说明 JSON 不是最适合这种查询的结构,早一步拆出来建关系表,反而省事。做数据模型选型时不要把 JSON 列当成万能选项,它解决的是“结构不固定”的问题,代价就是查询和关联能力的削弱。
如果你正被 JSON 查询折腾得头疼,不妨想一下你的业务到底是查询固定字段多,还是结构变化多。前者用关系表更清爽,后者用 JSON_CONTAINS 这类函数恰到好处。做对了取舍,后面能省下不少维护成本。