
我先问一个很现实的问题当产品经理说“把商品表里 tags 字段包含数码标签的商品筛出来”你打开 MySQL 客户端第一反应是什么我见过不少同学直接写WHERE tags LIKE %数码%。如果你也是这个思路好消息是它偶尔也能跑通坏消息是它坑你的时候连声招呼都不打。MySQL 从 5.7 开始把 JSON 作为原生数据类型支持JSON_CONTAINS 就是官方提供的一组 JSON 检索函数专门用来判断一个 JSON 文档是否包含另一个 JSON 文档或值。这篇内容适合两类人一类是 JSON 已经用起来但每次做“包含”查询都靠 LIKE 硬顶的人另一类是知道 JSON_CONTAINS 存在但不清楚它的语义、边界和性能瓶颈想彻底搞明白的人。下面我会把语法、原理、实战案例和踩坑点一次讲完。1. JSON_CONTAINS 解决的是“语义包含”不是“文本包含”1.1 一个最典型的需求场景JSON 数组里的元素查找先看一个最常见的业务形态。现在很多系统喜欢把多值属性塞进一个 JSON 字段里比如商品标签CREATE TABLE goods ( id INT PRIMARY KEY, title VARCHAR(100), tags JSON ); INSERT INTO goods VALUES (1, 旗舰手机, JSON_ARRAY(数码, 新品, 包邮)), (2, 纯棉卫衣, JSON_ARRAY(服饰, 新品)), (3, 相机镜头, JSON_ARRAY(数码, 摄影));需求来了“把 tags 里包含‘数码’的商品全部查出来”。稍微有点 MySQL 基础的同学都会想到 LIKE但是你有没有想过tags是 JSON 数组不是普通字符串。LIKE 在这个场景下本质上是把 JSON 数组序列化后的文本拿来“字符匹配”而不是“数组元素匹配”。这两者看着像实际差别巨大。1.2 LIKE 为什么能跑通却总让你心里没底用 LIKE 跑这个需求你大概率会写SELECT * FROM goods WHERE tags LIKE %数码%;这条 SQL 在小数据量下确实能查出第 1 条和第 3 条。但问题从第一行数据开始就埋下了第一它会把“数码相机”这种包含子串的值也命中。如果产品要的是精确标签“数码”那么标签为“数码相机”的商品会被误查出来导致后台列表出现不该出现的数据。第二JSON 数据一旦换了个格式LIKE 的结果就飘忽不定。比如某个字段被写成对象数组[{name: 数码, level: 1}]你用LIKE %数码%还是能查到但如果你判断的是“是否存在 name 等于数码的对象”这个查询的语义根本表达不出来。第三语义问题还能忍性能问题不能忍。LIKE %xxx%意味着全表扫描因为前导通配符导致索引失效。表小的时候看不出来表到了几十万行这条查询配合 JOIN 一起跑会把接口拖死。有人可能会说那我加引号匹配写成LIKE %数码%不就能避免“数码相机”误匹配了吗确实能降低一部分误报但这也只是“看起来像 JSON 数组元素”的文本匹配。你会被 JSON 里的空格、转义符、Unicode 编码、嵌套结构反复教做人。简单说LIKE是文本游戏JSON_CONTAINS是数据结构化游戏后者才真正懂 JSON。1.3 JSON_CONTAINS 的正确语义值包含不是子串命中JSON_CONTAINS 做的事情非常明确给定一个目标 JSON 文档和一个候选 JSON 值判断这个候选值是不是“包含”在目标文档里。SELECT JSON_CONTAINS([1, 2, 3], 2); -- 1 SELECT JSON_CONTAINS([1, 2, 3], 4); -- 0 SELECT JSON_CONTAINS([数码, 新品], 数码); -- 1注意第二行的2和2完全不同。JSON 里2是数字2是字符串。这个区别我会在第 5 部分专门展开。理解这句话就够了JSON_CONTAINS 判断的是“文档里存不存在这个元素”而不是“文档文本里有没有这段字符”。所以“包含数码标签”这件事用 JSON_CONTAINS 写是天然的语义映射SELECT * FROM goods WHERE JSON_CONTAINS(tags, 数码);这一句话就把第 1 条和第 3 条准确筛出来既不会误伤“数码相机”也不会被 JSON 序列化文本的格式变化干扰。2. 语法与行为细节target、candidate、path 到底怎么配2.1 三个参数各自的职责JSON_CONTAINS 的完整语法是JSON_CONTAINS(target, candidate[, path])target要在哪里找也就是被检索的 JSON 文档。candidate要找什么也就是候选的 JSON 值或 JSON 文档。path可选参数指定从 target 的哪个子路径开始找。返回值很简单包含返回 1不包含返回 0。但如果任意参数为 NULL返回 NULL——这个 NULL 传导特性后面会单独讲它坑过不少人。基础用法看这两个例子SELECT JSON_CONTAINS({a: 1, b: 2}, 1, $.a); -- 1 SELECT JSON_CONTAINS({a: 1, b: 2}, 2, $.a); -- 0第二句意思是在$.a这个路径对应的值里找2而$.a的值是1所以返回 0。2.2 对象匹配是“子集匹配”不是“全等匹配”很多人第一次用 JSON_CONTAINS 判断对象时会踩一个理解偏差candidate 必须和 target 完全一样吗不需要。JSON_CONTAINS 用的是“包含”语义对对象来说就是 candidate 里的每一个键值对都必须能在 target 里找到相同键、相同值。SELECT JSON_CONTAINS({a: 1, b: 2}, {a: 1}); -- 1 SELECT JSON_CONTAINS({a: 1, b: 2}, {a: 1, b: 2}); -- 1 SELECT JSON_CONTAINS({a: 1, b: 2}, {b: 2, c: 3}); -- 0第三个返回 0 的原因很明确target 里没有c这个键。换句话说candidate 是 target 的“子集”才算包含。如果你的业务需要“target 完全等于某个 JSON 文档”那应该用JSON_EQUALS或者直接在条件里比较整个字段而不是 JSON_CONTAINS。对数组来说语义也是子集式的。只要 candidate 数组里的每个元素都在 target 数组里找得到就返回 1元素顺序不影响结果SELECT JSON_CONTAINS([1, 2, 3], [3, 1]); -- 1 SELECT JSON_CONTAINS([1, 2, 3], [1, 4]); -- 0需要提醒一句重复元素的处理在某些边界情况下和你直觉里的“多重集包含”不一定完全一致。如果你的业务数据里数组可能存在重复元素建议先跑几条数据验证再决定要不要依赖这个函数做幂等判断。2.3 path 参数把搜索范围圈定在指定子文档path 参数的作用是缩小查找范围。典型场景是 JSON 嵌套层级深你只关心某一段子树里有没有目标值。SELECT JSON_CONTAINS( {user: {permissions: [read, write]}}, write, $.user.permissions ); -- 1这个查询的意思是到$.user.permissions这个数组里找write。找到了返回 1。path 参数有几个关键行为需要记牢如果 path 对应的位置不存在返回 0不报错。如果 path 本身语法非法直接报错。如果 path 指向的是一个不存在的键结果同样为 0。我建议刚开始用的人先在 SELECT 列表里用JSON_EXTRACT验证路径是否取到了期望值确认无误后再放进 JSON_CONTAINS 的 path 里。两个函数组合起来用会省掉很多调试时间。3. 底层原理与性能特征别把 JSON_CONTAINS 当万能药3.1 一次 JSON_CONTAINS 调用在服务端做了什么理解性能首先要理解它内部干了多少活。MySQL 里 JSON 字段不是简单的字符串存储而是基于utf8mb4字符集的一种二进制格式存储。当你调用 JSON_CONTAINS 时MySQL 要做的事情大概是把 target 参数解析成内部 JSON 表示。把 candidate 参数解析成内部 JSON 表示。从 target 的根节点开始遍历树结构如果指定了 path则定位到对应子节点。对每个节点做类型判断和值比较直到找到等值节点或遍历完毕。所以它不是像 LIKE 那样做简单的字符串扫描而是“树形结构遍历 节点等值比较”。这意味着两个结果第一它确实比 LIKE 更懂 JSON 语义第二它的开销和 JSON 文档的大小、嵌套深度成正比。文档越大、嵌套越深遍历成本越高。3.2 索引视角原生 JSON 字段能不能走索引直接回答普通情况下WHERE JSON_CONTAINS(tags, 数码)无法走索引只能全表扫描。原因很简单JSON 字段本身没有全局有序性B 树索引需要有序的数据而 JSON 是层级结构没法直接排序。这不是 MySQL 偷懒是所有关系型数据库面对文档结构时的通病。那有没有办法让它走索引有但需要改变表结构或依赖 MySQL 8.0 的新特性也就是第 6 部分要讲的内容生成列加普通索引、函数索引、多值索引、以及 JSON_TABLE 展开。这些方案各有适用场景不是银弹但能解决大部分生产问题。3.3 量级测试与经验阈值到底多慢才算慢我自己的经验是在本地测试环境里给商品表造 10 万行数据每行 tags 字段平均 4-5 个标签跑一条WHERE JSON_CONTAINS(tags, 数码)的查询全表扫描大概在 200-500 毫秒之间。看着好像还能忍但注意这是单表、无 JOIN、无排序的情况。一旦把这个条件放进一个要关联订单表、用户表、还要做ORDER BY create_time DESC LIMIT 20的分页查询里响应时间直接翻几倍到秒级就很常见。百万级数据就更不用说了。我有一次在后台管理列表里用上了 JSON_CONTAINS商品表 120 万行筛选一次 8 秒页面直接超时。从那次之后我养成了一个习惯JSON_CONTAINS 可以用但要先估算“这个字段会被多少人、多少次、以什么频率查询”如果它是核心筛选条件必须在设计阶段就想好索引方案不能等到数据量涨上来再补救。4. 生产环境实战三个我用过的典型场景4.1 场景一商品标签“同时命中”和“任一命中”回到开头的商品标签场景。现在需求升级了要筛出“同时包含数码和新品”的商品或者“任意包含数码或摄影”的商品。同时包含可以这么写SELECT * FROM goods WHERE JSON_CONTAINS(tags, 数码) AND JSON_CONTAINS(tags, 新品);也可以写成一个数组SELECT * FROM goods WHERE JSON_CONTAINS(tags, [数码, 新品]);这两种写法在这个场景下效果等价都表示“tags 数组里同时存在数码和新品”。我个人更倾向拆成多个 AND 条件因为可读性更好后面如果要对某个标签单独做额外过滤改起来也直观。那“任一包含”呢直接用 OR 就行SELECT * FROM goods WHERE JSON_CONTAINS(tags, 数码) OR JSON_CONTAINS(tags, 摄影);这里要特别注意不要把“任一包含”写成JSON_CONTAINS(tags, [数码, 摄影])。上面说过数组 candidate 的语义是“全部包含”不是“包含任意一个”。所以如果这里传数组查出来的会是同时包含数码和摄影的商品语义完全反了。4.2 场景二权限位与能力匹配JSON 数组特别适合存用户的权限列表或设备能力位。比如用户表里有一个permissions字段[read, write, delete]判断用户是否拥有某个权限最自然的写法就是SELECT * FROM users WHERE JSON_CONTAINS(permissions, delete);判断是否同时拥有“读”和“写”两种权限SELECT * FROM users WHERE JSON_CONTAINS(permissions, [read, write]);这类查询在权限系统里很常见。但要留一个心眼如果权限列表很长比如一个用户有几十个权限点而且每次接口调用都要判断多次那 JSON_CONTAINS 的开销会累加。更稳妥的做法是把核心权限判断从 JSON 字段里拆出来放到独立的关联表或者至少生成一列“是否拥有 write 权限”的布尔列来做索引。权限场景对响应时间最敏感能物化的判断尽量物化。4.3 场景三配置表中的动态特征匹配还有一种场景是配置表结构不固定需要动态筛选。比如渠道投放配置表CREATE TABLE channel_config ( id INT PRIMARY KEY, config JSON );配置里可能有这样的文档{ platform: [ios, android], country: [CN, US], device_level: high }现在要查“平台包含 ios 且国家包含 CN 的配置”可以这样写SELECT * FROM channel_config WHERE JSON_CONTAINS(config, ios, $.platform) AND JSON_CONTAINS(config, CN, $.country);path 参数在这里的价值就体现出来了它把查找范围限定在platform和country子数组里避免跨字段误配。试想如果你直接写JSON_CONTAINS(config, CN)万一device_level字段里也出现字符串CN那就会产生错误命中。加了 path 之后这个风险就消失了。5. 踩坑清单类型、大小写、NULL、路径一个都不能少5.1 字符串必须写成合法 JSON差一个引号就报错这是我被问过最多的问题。看这个报错SELECT JSON_CONTAINS([a, b], a);执行后 MySQL 会直接报错因为a不是合法的 JSON 值。在 JSON 里字符串必须用双引号包裹。所以正确写法是SELECT JSON_CONTAINS([a, b], a); -- 1外层那个单引号是 SQL 字符串的界定符里面的元素值一定要写成a。写习惯了编程语言的同学最容易在这里翻车在 Java 或 PHP 里你写[a, b]没问题但到了 MySQL 的 JSON 函数里值必须按 JSON 标准来。同理判断对象里的字符串SELECT JSON_CONTAINS({name: tom}, tom, $.name); -- 15.2 数字、字符串、布尔之间的比较规则JSON_CONTAINS 的等值判断是“类型敏感”的这一点太容易踩了。SELECT JSON_CONTAINS([1, 2, 3], 2); -- 1 SELECT JSON_CONTAINS([1, 2, 3], 2); -- 0第二句返回 0因为2是字符串而数组里存的是数字 2类型不同就不相等。更隐蔽的是布尔值的处理SELECT JSON_CONTAINS([true, false], 1); -- 0 SELECT JSON_CONTAINS([1, 0], true); -- 0JSON 里的布尔值和数字之间是不相等的true不等于1false不等于0。这点和很多编程语言的隐式类型转换完全不同。如果你在前端把开关状态存成了true/false查询时就得老老实实传true/false。数字类型内部倒是有一个宽松的地方JSON 里1和1.0按数值相等处理。但我不建议依赖这个特性业务代码里还是尽量保持格式统一。5.3 NULL 传播与字段为空的陷阱JSON_CONTAINS 的返回规则里有一条只要 target、candidate、path 里有任何一个为 NULL整个函数返回 NULL。NULL 在 WHERE 条件里会被当作“不成立”于是这条记录直接过滤掉。比如你的表里 tags 字段允许为 NULL现在要查所有“没有数码标签”的商品你可能会写SELECT * FROM goods WHERE NOT JSON_CONTAINS(tags, 数码);如果某条记录的 tags 是 NULLJSON_CONTAINS 返回 NULLNOT NULL还是 NULL这条记录既不会出现在结果里也不会被当作“不包含数码”处理直接消失了。你预期的是“tags 为空的商品也属于不包含数码”但结果是它被静默过滤。这时候需要提前处理空值SELECT * FROM goods WHERE NOT JSON_CONTAINS(COALESCE(tags, JSON_ARRAY()), 数码);COALESCE(tags, JSON_ARRAY())把 NULL 转成空数组后JSON_CONTAINS 的语义就变成“空数组包含数码吗不包含”结果符合预期。5.4 大小写敏感与字符集问题JSON 里的字符串比较在默认情况下是区分大小写的。因为 JSON 字段的排序规则走的是二进制比较语义。SELECT JSON_CONTAINS([MySQL], mysql); -- 0日常业务里标签、权限名这类数据最怕大小写不一致。我在一个项目里就遇到过前端传了“Admin”后端存了“admin”导致权限判断永远命中不了。解决办法是在数据写入层做统一规范化把这类字段在写入前全部转成小写。不要指望查询时用 LOWER 临时转换因为 JSON 字段一旦套上函数就没法走任何索引了查询性能会雪上加霜。5.5 path 参数的边界行为path 相关的坑上面提到过路径不存在返回 0但还有一个隐藏问题不要在 path 里写通配符比如$[*]。JSON_CONTAINS 的 path 是用来“定位某个具体子节点”的不是用来“全数组扫描”的。虽然 MySQL 的 JSON 路径语法支持通配符但在 JSON_CONTAINS 这种函数里通配符的行为并不符合很多人的直觉。如果你确实想对数组里的所有元素做包含判断最自然的写法本来就是不带 path 的JSON_CONTAINS(tags, 数码)。遇到复杂路径的时候我建议先用 JSON_EXTRACT 验证路径返回的内容SELECT JSON_EXTRACT({user: {permissions: [read, write]}}, $.user.permissions);输出能看出路径有没有取对再决定 JSON_CONTAINS 里怎么写能省下很多精力。6. 性能优化进阶生成列、多值索引与 JSON_TABLE 改写6.1 生成列加普通索引把判断结果物化成列对于“某个标签是否存在”这类固定判断最直接的做法就是用生成列把这个判断结果存下来再建普通索引。ALTER TABLE goods ADD COLUMN has_digital TINYINT GENERATED ALWAYS AS (JSON_CONTAINS(tags, 数码)) STORED, ADD INDEX idx_has_digital (has_digital);之后查询直接写SELECT * FROM goods WHERE has_digital 1;这个方案等于把原来每次都要执行的 JSON 解析和遍历提前在写入时算好查询时走的就是一颗干净的 B 树。适合标签枚举相对固定的场景比如“数码、新品、包邮、服饰”这种二三十个以内、业务上能枚举完的标签。缺点也很明显标签如果经常动态新增你得不停加列表会越来越宽这就不太优雅了。6.2 MySQL 8.0 多值索引给 JSON 数组直接建索引如果你用的是 MySQL 8.0.17 及以上版本可以试试多值索引。它的作用是给 JSON 数组里的标量元素建立索引从而加速JSON_CONTAINS、MEMBER OF、JSON_OVERLAPS这类查询。比如文章表有一个tag_ids字段存的就是标签 ID 数组[1, 3, 7]建索引CREATE INDEX idx_tag_ids ON articles ((CAST(tag_ids AS UNSIGNED ARRAY)));查询时SELECT * FROM articles WHERE JSON_CONTAINS(tag_ids, 3);这条查询就有机会走idx_tag_ids了。如果数组里存的是字符串也可以建但字符串数组有一个很现实的坑必须显式指定长度比如CAST(tags AS CHAR(20) ARRAY)。我在真实项目里踩过一次当时给标签名数组建索引长度写小了结果多值索引对超长字符串做了截断导致JSON_CONTAINS判断明明存在的标签却返回 0。排查了很长时间最后发现是索引定义里字符长度不够。如果你要建字符串数组的多值索引长度一定要按业务最大值的上限来宁长勿短。6.3 JSON_TABLE 展开后做集合查询MySQL 8.0 的 JSON_TABLE 可以把 JSON 数组展开成一张虚拟表然后你就可以用标准 SQL 的集合语义做查询。比如“同时包含数码和新品”这个需求用 JSON_TABLE 写是这样SELECT g.id, g.title FROM goods g JOIN JSON_TABLE(g.tags, $[*] COLUMNS (tag VARCHAR(20) PATH $)) AS jt WHERE jt.tag IN (数码, 新品) GROUP BY g.id, g.title HAVING COUNT(DISTINCT jt.tag) 2;这段 SQL 的逻辑清晰很多先把每个商品的 tags 展开成一行一个标签然后用 GROUP BY 和 HAVING COUNT 做“集合包含”判断。如果以后从 2 个标签扩大到 5 个标签只需要把 IN 列表换成变量把 HAVING 的计数改掉非常灵活。它的代价是展开后行数会膨胀如果一张表全展开扫描量可能比直接 JSON_CONTAINS 更大。所以这种写法更适合那种“过滤条件数量动态变化、需要精确表达集合关系”的查询不适合核心链路里的高频简单筛选。6.4 什么时候该果断放弃 JSON拆成关联表这里我想说点掏心窝的话。JSON_CONTAINS 再优化也只是把一个“数据结构化查询”从不可用变成可用但它改变不了一个事实如果这个 JSON 字段承载的是核心业务关系那它本应是一张关联表。我自己的判断标准是你会不会按这个 JSON 里的元素做高频、核心的筛选你会不会对 JSON 里的元素做聚合统计比如算标签分布你未来会不会在这个 JSON 元素上扩展其他属性比如标签名称、标签层级、标签排序权重只要这三个问题里有两个回答“是”我就倾向于把 JSON 里的内容拆成独立的子表比如商品标签表goods_tag(goods_id, tag)。拆表之后索引、JOIN、聚合、事务一致性都变得非常简单MySQL 驾轻就熟的那套能力全都能用上。JSON 不是用来解这道题的工具它是用来承载“结构不确定、查询不频繁”的配置类数据的。我在商品标签这个场景里最终也是这么处理的100 万级商品JSON_CONTAINS 全表扫要 8 秒拆成关联表后同样的筛选在几十毫秒内返回还给后续做标签推荐、热门标签统计留了充分的操作空间。工具本身没有对错用错了地方就是灾难。