1. 项目概述与使用动机
1.1 为什么偏偏是 JSON_EXTRACT
我最初接触到JSON_EXTRACT这个函数,是在处理一张业务埋点表的时候。当时的需求说起来也不复杂,运营同事想要从一列名为extra_info的 JSON 字符串里,把用户的下单渠道、广告来源、页面停留时长这几个字段抽出来做分析。表里几百万行数据,extra_info里塞了十几个键值对,如果都用LIKE '%channel%'这种写法去匹配,不仅慢,而且提取出来的值还带着引号和花括号,根本不能直接用。
MySQL 从 5.7 版本开始正式支持 JSON 数据类型,配套提供了一批 JSON 函数,JSON_EXTRACT就是其中之一。它的作用一句话就能说清楚:从一个 JSON 文档里,按照你指定的路径,把对应的值取出来。听起来有点像 XPath 之于 XML,或者正则表达式之于文本,但它的定位更纯粹——专门用来从结构化程度不高的 JSON 数据中精准取数。
这个函数解决的核心痛点,是“非结构化数据的结构化查询”。在没有它之前,JSON 字段存储在 MySQL 里基本就是个摆设,要么把整个字符串捞出来在应用层二次解析,要么靠 LIKE 碰运气。有了JSON_EXTRACT之后,SQL 直接就能里操作 JSON 的内部结构,取数管道的中间层直接省掉,分析效率提升很明显。
适合读这篇文章的读者,我大致分三类。第一类是刚接触 MySQL JSON 功能的开发同学,想知道这个函数到底怎么用、和->、->>这些符号有什么区别。第二类是已经用上了 JSON 字段,但查询性能上不去、又不知道怎么建索引的 DBA 或后端工程师。第三类是纯业务侧的同学,SQL 写得不算多,但需要从业务表里抽一些半结构化字段来分析,看完这篇文章应该就能照着例子抄作业。
1.2 JSON_EXTRACT 的定位与周边生态
要理解JSON_EXTRACT的价值,得先把它放进 MySQL JSON 函数的整体版图里看。MySQL 5.7 之后提供了大约二十多个 JSON 相关函数,大致可以分成几类:创建 JSON 的(JSON_OBJECT、JSON_ARRAY、JSON_QUOTE)、修改 JSON 的(JSON_SET、JSON_INSERT、JSON_REPLACE、JSON_REMOVE)、查询 JSON 的(JSON_EXTRACT、JSON_UNQUOTE、JSON_SEARCH、JSON_CONTAINS、JSON_KEYS)、以及元数据相关的(JSON_LENGTH、JSON_DEPTH、JSON_TYPE)。
JSON_EXTRACT属于查询类函数里最基础、最常用的一支。它的返回值是一个 JSON 类型的片段——注意是 JSON 类型的片段,不是纯字符串。这就意味着如果提取出来的值本身是一个 JSON 对象或数组,你拿到的是带花括号和引号的合法 JSON 文本;如果是一个标量值,比如数字或字符串,MySQL 也会把它包成带引号的形式返回。这一点是很多初学者的第一个坑:明明取出来一个字符串,结果发现两头带着双引号,还得再套一层JSON_UNQUOTE才能拿到干净的值。
MySQL 8.0 提供了JSON_TABLE这个更强大的函数,可以把 JSON 数组拍平成关系表,和普通表做 JOIN 也没有问题。但在 5.7 环境下并没有这个函数,JSON_EXTRACT几乎是唯一的路径提取手段。即便在 8.0 里,JSON_EXTRACT也依然有一席之地,因为它的语义更简单直接,做一个字段的定向提取时,比JSON_TABLE的繁琐语法要清爽得多。
从官方文档的定义来看,JSON_EXTRACT(json_doc, path[, path] ...)接收一个 JSON 文档和至少一个路径表达式,返回路径对应位置的值。如果指定多个路径,函数会把这些路径对应的值合并成一个 JSON 数组返回。这个多路径的能力在批量提取时很实用,比如一次性抽出用户的name和age两个字段,返回结果是["张三", 28]这样的数组。
2. 核心语法与路径规则深度拆解
2.1 参数说明与返回值类型
先看基本语法:
JSON_EXTRACT(json_doc, path[, path] ...)两个最关键的部分是json_doc和path。
json_doc可以是 JSON 类型的列、JSON 类型的变量,或者一个合法的 JSON 字符串字面量。需要注意的是,如果传入的是一个字符串类型的列但里面存的确实是 JSON 文本,MySQL 会先做隐式转换把它解析成 JSON 文档再执行提取。但如果这个字符串本身不是合法的 JSON,函数会报错Invalid JSON text,而不是返回 NULL。这就是为什么我在实际操作中总是建议:JSON 字段就建成 JSON 类型,省去类型转换的额外开销和出错可能性。
path是 JSON 路径表达式,必须以$符号开头。$代表整个 JSON 文档的根节点,后面可以跟不同的操作符来表示层级关系。常用的路径操作符我整理成了下表:
| 操作符 | 含义 | 示例 | 示例说明 |
|---|---|---|---|
$ | 根节点 | $ | 整个文档 |
.key | 对象属性访问 | $.name | 获取name属性值 |
[n] | 数组第 n 个元素(从 0 开始) | $[0] | 获取数组第一个元素 |
[n to m] | 数组切片 | $[1 to 3] | 获取索引 1 到 3 之间的元素 |
** | 递归匹配所有层级 | $**.price | 查找任意层级的price属性 |
我用一个具体 JSON 来演示路径规则。假设有这样一条数据:
{ "user": { "name": "王明", "age": 28, "tags": ["vip", "new_user", "mobile"] }, "order": { "id": "A10001", "amount": 299.00 } }对应的提取示例:
-- 取整个 user 对象 SELECT JSON_EXTRACT(doc, '$.user'); -- 取 name 属性值,注意返回结果是带引号的 "王明" SELECT JSON_EXTRACT(doc, '$.user.name'); -- 取 tags 数组的第一个元素 SELECT JSON_EXTRACT(doc, '$.user.tags[0]'); -- 取整个 tags 数组 SELECT JSON_EXTRACT(doc, '$.user.tags');这些 SQL 的执行结果,第一个返回{"age": 28, "name": "王明", "tags": ["vip", "new_user", "mobile"]},第二个返回"王明",第三个返回"vip",第四个返回["vip", "new_user", "mobile"]。可以看到,无论取出来的是字符串还是数组,返回结果都保持着 JSON 的格式——字符串带引号,数组带方括号。
2.2 路径表达式的细节与边界情况
JSON 路径有几个容易忽略的细节,我实际开发中踩过,值得单独拿出来说。
属性名包含特殊字符时需要用双引号包裹。比如 JSON 里有"user-name"这样的键名,直接写$.user-name解析器会把横杠当成减号处理,导致路径失效。正确的写法是$."user-name"。这个规则在键名包含空格、点号、特殊符号或者本身就是数字开头时都必须遵守。
数组索引从 0 开始,这个和大多数编程语言一致。但要注意,如果路径的末尾落在数组本身而不是具体元素上,返回的就是整个数组的 JSON 文本。比如$.user.tags返回的是["vip", "new_user", "mobile"],而不是逗号分隔的纯文本。想要把数组元素逐个拆开,在 5.7 里需要借助JSON_TABLE或者自己写循环,JSON_EXTRACT本身只负责定位取值,不负责展开。
路径不存在时,函数返回 SQL NULL,而不是报错。这一点很重要,意味着如果你把路径写错了——比如把.name写成.Name——SQL 并不会告诉你路径有误,只会静默返回 NULL。而且属性名是大小写敏感的,$.User和$.user是两个完全不同的路径。这种静默失败的行为在排查数据问题时很容易迷惑人,我在后面的常见问题章节里会专门讲排查思路。
多路径参数的情况也很实用。当你需要从一条 JSON 记录里抽多个字段时,可以写成:
SELECT JSON_EXTRACT(doc, '$.user.name', '$.user.age');返回结果是["王明", 28],两个路径的值被合并到一个数组里。这在一条 SQL 里同时取多个维度字段的场合非常方便,省去了写多个JSON_EXTRACT再拼接的麻烦。
还有一类特殊情况需要注意:路径匹配到数组的多个元素时,返回的是一个由这些元素组成的数组,而不是分别的结果。例如对$.user.tags[0]和$.user.tags[1]两个路径同时提取,得到的是["vip", "new_user"]。这个行为和直觉一致,但在写业务逻辑时容易忽略:你拿到的是一个数组,需要再套一层才能拿到单个值。
2.3 与 JSON_UNQUOTE、->、->> 的搭配使用
单独用JSON_EXTRACT,返回的值往往不是最终想要的干净文本。举个最常见的例子:
SELECT JSON_EXTRACT('{"name": "王明"}', '$.name') AS name;执行结果是"王明",注意这里的双引号是结果的一部分,并不是 SQL 的语法装饰。在很多业务场景里,你希望拿到的是王明这个不带引号的纯字符串,此时就需要配合JSON_UNQUOTE:
SELECT JSON_UNQUOTE(JSON_EXTRACT('{"name": "王明"}', '$.name')) AS name;这样得到的结果就是王明。JSON_UNQUOTE的作用,顾名思义就是把 JSON 值外面的引号去掉,还原成一个 MySQL 原生字符串。
这么写还是有点啰嗦,所以 MySQL 提供了两个简写符号。->是JSON_EXTRACT的简写,->>是JSON_UNQUOTE(JSON_EXTRACT(...))的简写。举例:
-- 以下三种写法等价,返回结果都是带引号的 "王明" SELECT JSON_EXTRACT(doc, '$.name') FROM t; SELECT doc -> '$.name' FROM t; -- 注意 -> 和 ->> 的语法 SELECT doc -> '$.name' AS name FROM t; -- 返回 "王明" SELECT doc ->> '$.name' AS name FROM t; -- 返回 王明->>在 5.7.13 以后开始支持,所以如果你还在跑老版本的 MySQL,建议直接确认版本号后再决定是否使用。我在一个旧项目里就因为这个问题,代码全部是->>写法,结果部署到一台 5.7.12 的实例上报语法错误,排查了半天才发现是版本兼容性问题。从那以后我养成了一个习惯:写完 SQL 先看一眼目标库的版本,再决定用哪种简写。
还有一个细节是-和>不能有空格,写成->或->>都是合法的,但- >会被当成两个操作符解析,直接报语法错误。这是我见过的最无厘头的报错之一,多亏一次偶然的测试才发现。
3. 实操过程与核心应用场景实现
3.1 场景一:埋点日志 JSON 字段的定向提取
我在开头提到的那张埋点表,字段结构大致是这样的:
CREATE TABLE user_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, event_name VARCHAR(64) NOT NULL, event_params JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;event_params里存的是形如这样的 JSON:
{ "channel": "android", "source": "banner_click", "duration": 23.5, "is_new_user": false, "page": "home" }现在运营要拉一份“从 banner 点击进入、来自安卓端、且停留时长超过 20 秒”的用户明细。如果用 LIKE 来实现,SQL 长这样:
SELECT user_id FROM user_event WHERE event_name = 'page_view' AND event_params LIKE '%"channel": "android"%' AND event_params LIKE '%"source": "banner_click"%';这个写法有几个问题。第一是性能:LIKE '%...%'无法使用索引,几百万行的表全表扫描,响应时间从秒级到十几秒不等,和业务实时性要求完全冲突。第二是准确性:LIKE的匹配本质上是子串匹配,如果 JSON 里恰好存在某个值包含了相同片段,比如一个用户的昵称里包含"channel": "android"这样的字符串,就会误命中。第三是扩展性:如果后续要在 JSON 里再加十几个字段,每加一个维度就要多写一条 LIKE,SQL 丑陋且难维护。
用JSON_EXTRACT重写之后:
SELECT user_id FROM user_event WHERE event_name = 'page_view' AND JSON_EXTRACT(event_params, '$.channel') = '"android"' AND JSON_EXTRACT(event_params, '$.source') = '"banner_click"' AND JSON_EXTRACT(event_params, '$.duration') > 20;等一下,为什么= '"android"'?因为JSON_EXTRACT返回的是 JSON 格式,字符串值自带引号。这里有两种选择:要么比较时带上引号,要么用->>取干净的字符串再比较。我建议用第二种写法,更直观:
SELECT user_id FROM user_event WHERE event_name = 'page_view' AND event_params ->> '$.channel' = 'android' AND event_params ->> '$.source' = 'banner_click' AND event_params ->> '$.duration' > 20;这样写出来的 SQL 简洁清晰,每个条件一目了然。关键点是- >> '$.duration'返回的是字符串,这里 MySQL 做了隐式类型转换,把字符串转成数字再和 20 比较。如果 JSON 里的duration本来就是数字类型,直接用JSON_EXTRACT返回的数字也不带引号,可以直接和数字比较。但如果 JSON 存的字段被应用层统一编码成了字符串——比如"duration": "23.5"——那么- >>返回的就是字符串"23.5",和数字 20 比较时 MySQL 会先将字符串转成数字,转换逻辑是把前面的数字部分解析出来,如果字符串不是纯数字格式可能导致非预期结果。我踩过这种坑:某个字段历史上存的是"123.5",后来代码变了存储格式,变成了"123.5s"这种带单位的形式,隐式转换直接把s忽略掉,数字部分依然能比较,但数据语义已经变了。所以,凡是 JSON 里涉及数值比较的字段,一定要先确认它的真实存储类型,再决定要不要显式 CAST。
3.2 场景二:动态配置中心的 JSON 解析
还有一个高频场景是动态配置。很多系统会把一些不常变化的配置项存在数据库里,比如功能开关、推荐权重、白名单列表。这些配置以前往往是用多个字段分列存储,或者用一个长文本字段整体存储、应用层解析。MySQL 5.7 之后,JSON 字段成了一个很自然的中间态:既能结构化存储,又能在查询时直接用 SQL 解析。
假定有一张配置表:
CREATE TABLE app_config ( config_key VARCHAR(64) PRIMARY KEY, config_value JSON NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );某条配置里的config_value可能是:
{ "switch": { "feature_x": true, "feature_y": false }, "weights": { "a": 0.6, "b": 0.3, "c": 0.1 }, "whitelist": ["u_1001", "u_1002", "u_1003"] }现在要查某个用户u_1002是否在whitelist里,可以用JSON_CONTAINS:
SELECT config_value -> '$.switch.feature_x' AS x_enabled FROM app_config WHERE config_key = 'default_config';但如果要检查白名单里是否包含某个用户,更合适的函数是JSON_CONTAINS,它可以判断一个值是否存在于 JSON 数组或对象的属性值中:
SELECT JSON_CONTAINS(config_value, '"u_1002"', '$.whitelist') AS in_whitelist FROM app_config WHERE config_key = 'default_config';JSON_CONTAINS的第二个参数也必须是合法 JSON 文本,所以字符串要写成'"u_1002"'而不能写成'u_1002'。这个细节如果你不知道为什么,就记住一条规律:JSON 函数处理的值全是 JSON 文档,字符串就是带双引号的 JSON 字符串。
这种动态配置的妙处在于,业务上要新增一个配置项,只需要修改 JSON 内容,完全不需要动表结构。在没有 JSON 字段之前,意味着你要ALTER TABLE加一列,在不停服的情况下做这个操作往往牵一发动全身。现在配置和结构化查询都兼顾了,这也是我为什么在不少项目里建议用 JSON 字段替代一部分稀疏的独立列。
3.3 场景三:数组型 JSON 的逐元素解析
有时候 JSON 里存的是数组,每个元素又是一个对象,这种结构在 MySQL 里处理起来需要多一步拆解。比如一条订单记录里存了商品明细:
{ "order_id": "A10001", "items": [ {"sku": "sku_001", "name": "苹果", "price": 5.5, "qty": 2}, {"sku": "sku_002", "name": "香蕉", "price": 3.2, "qty": 5} ] }要取第一个商品的 SKU,可以直接:
SELECT JSON_EXTRACT(doc, '$.items[0].sku') AS first_sku;但如果你想把整条订单下的所有商品都拆成行,每行一个商品,JSON_EXTRACT就有点力不从心了。它只能按路径取到一个值,无法把数组铺平成多行。这个场景下需要用JSON_TABLE(MySQL 8.0+)或者通过交叉关联的方式来实现。
5.7 环境下的做法通常是配合JOIN一个数字序列表,手动按索引取值:
SELECT JSON_EXTRACT(doc, '$.order_id') AS order_id, JSON_EXTRACT(doc, CONCAT('$.items[', seq.n, '].sku')) AS sku, JSON_EXTRACT(doc, CONCAT('$.items[', seq.n, '].price')) AS price FROM orders JOIN ( SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 ) AS seq WHERE JSON_EXTRACT(doc, CONCAT('$.items[', seq.n, ']')) IS NOT NULL;这个方案适合数组长度固定或较小、且你知道最大可能长度的场景。数组变长的话,序列表要动态扩展,SQL 写起来就比较笨重了。8.0 下有更好的选择,但如果你暂时不能升级,上面这种“路径拼接+序列表”的做法是能用的。我曾在 5.7 环境里用这个方案跑一张 200 万行的订单表,性能固然说不上快,但配合适当的筛选条件下推,业务上是可以接受的。
3.4 场景四:半结构化标签系统的快速筛选
用户画像和标签系统是 JSON 字段的另一个典型场景。一个用户可能有几十上百个标签,如果每个标签都是独立的布尔列或短字符串列,表会宽得吓人,索引也不好做。用 JSON 字段把标签打包进去,查询时用JSON_EXTRACT定向取数,是一个相对轻量的折中方案。
CREATE TABLE user_profile ( user_id BIGINT PRIMARY KEY, tags JSON, profile JSON );某条记录的tags字段可能是:
["vip", "high_value", "android", "north_region"]要查所有 VIP 且来自安卓的用户,可以用JSON_CONTAINS(连续两个JSON_CONTAINS的 AND)或者JSON_SEARCH:
SELECT user_id FROM user_profile WHERE JSON_CONTAINS(tags, '"vip"') AND JSON_CONTAINS(tags, '"android"');但如果标签信息嵌套在对象里,而不是纯数组,还是JSON_EXTRACT更顺手。比如:
{ "level": "vip", "device": "android", "region": "north" }查询就变成:
SELECT user_id FROM user_profile WHERE JSON_EXTRACT(tags, '$.level') = '"vip"' AND JSON_EXTRACT(tags, '$.device') = '"android"';这里的= '"vip"'带引号写法容易写错,我更推荐统一用- >>:
SELECT user_id FROM user_profile WHERE tags ->> '$.level' = 'vip' AND tags ->> '$.device' = 'android';四类场景看下来,其实可以发现一个共性:JSON_EXTRACT在路径定位上非常灵活,能覆盖绝大多数“在 SQL 里直接读取 JSON 内层数据”的需求。它最大的局限是只能取值、不能直接展开数组,但这一点可以通过配合其他函数或升级到 8.0 来弥补。
4. 性能优化:JSON 字段的索引与执行计划
4.1 为什么 JSON_EXTRACT 通常不走索引
很多人第一次用JSON_EXTRACT后跑了一次全表查询,发现速度很慢,然后很自然地想做索引加速。但 MySQL 有一个关键限制:JSON 列本身不能直接建索引。这不是JSON_EXTRACT的问题,而是 JSON 类型的存储特点决定的——JSON 在 InnoDB 里是以二进制的 BSON 格式存储的,并不是传统的定长或变长文本字段,传统 B+ 树索引结构对它不适用。
那有没有办法让 JSON 查询也走索引?答案是:可以,但不是直接建在 JSON 列上,而是通过“生成列”间接实现。
-- 添加一个生成列,提取 name 属性并转换为字符串类型 ALTER TABLE user_event ADD COLUMN channel VARCHAR(32) GENERATED ALWAYS AS (event_params ->> '$.channel') STORED; -- 在生成列上创建索引 ALTER TABLE user_event ADD INDEX idx_channel (channel);这里我用的是->>来提取,得到的是干净的字符串,直接赋给生成列。STORED关键字意味着这个列的值会真正存储到磁盘上,索引可以直接引用它;也可以不写STORED,默认是VIRTUAL,虚拟列不占额外存储空间,但二级索引不能在虚拟列上建立。所以要想加索引,选STORED。
生成列创建好之后,查询语句改成直接查这个列:
SELECT user_id FROM user_event WHERE channel = 'android';这条 SQL 的执行计划就能用到idx_channel索引,查询速度通常是原来的十倍甚至更多。注意,如果你仍然写WHERE event_params ->> '$.channel' = 'android',优化器大概率还是全表扫描,因为条件是作用在原 JSON 上的函数表达式,索引帮不上忙。
4.2 8.0 的函数索引方案
MySQL 8.0 引入了函数索引(Functional Index),可以直接在 JSON 提取表达式上建索引,省去了显式定义生成列的步骤。写法如下:
ALTER TABLE user_event ADD INDEX idx_channel ((event_params ->> '$.channel'));这个语法里的外层双括号不能少,因为表达式需要被明确标记为函数索引的一部分。建好之后,执行计划会对event_params ->> '$.channel' = 'android'这样的查询自动匹配索引。
如果目标是 MySQL 5.7,那就老老实实用生成列方案。5.7 里生成列是官方推荐的 JSON 查询加速方式,可维护性也不差,只是多了一个物理列,数据写入时需要额外维护这个列的值。不过对于查询频繁的字段,这点写入开销完全值得。
4.3 执行计划与慢查询分析实操
我自己在优化 JSON 查询时,最常用的工具是EXPLAIN。拿上面那个例子来说:
EXPLAIN SELECT user_id FROM user_event WHERE channel = 'android';执行计划里如果type是ref或index,说明索引起作用了;如果type是ALL,说明在走全表扫描。这种定位问题的方式,比盲目加索引要靠谱得多。
还有一种更隐蔽的情况:JSON 里的值虽然看起来是数字,但在 JSON 文本里它以带引号的字符串形式存在,或者反过来。这种情况下用生成列时一定要指定合适的类型。比如 JSON 里存的是"age": 28这样一个数字类型值,生成列定义为INT:
ALTER TABLE user_profile ADD COLUMN age INT GENERATED ALWAYS AS (profile ->> '$.age') STORED;- >>返回的是字符串"28",MySQL 在生成列赋值时会尝试转成 INT,绝大多数情况下转换是成功的。但偶尔如果某个历史数据里存了"28岁"这种带单位的字符串,转换就会失败,生成列的值变为 0,而不是报错。这个行为很容易造成数据失真,生成列方案上线前最好对历史数据做一次全量校验。
注意:生成列的表达式必须是确定性的,也就是说同一个输入永远得到同一个输出。
JSON_EXTRACT是确定性的,没有关系;但如果你尝试在生成列里用NOW()、RAND()这类非确定性函数,MySQL 会直接拒绝创建。
4.4 查询建模:尽量缩小扫描范围
即使用了生成列和索引,也别忘了基本的关系型数据库优化原则——先缩小扫描范围再取值。JSON_EXTRACT是取值手段,不是检索加速器。比如查询条件里既有普通字段又有 JSON 内层字段,那应该先通过普通字段的索引把结果集缩到很小,再在结果集上做 JSON 提取。
举个例子,上面的埋点表如果有一个event_name字段,且它上面有索引idx_event_name (event_name, user_id),那么查询写法和没有索引的写法差别就很大:
-- 推荐:先按 event_name 缩小范围,再做 JSON 提取 SELECT user_id FROM user_event WHERE event_name = 'page_view' AND event_params ->> '$.channel' = 'android';如果优化器足够聪明,它会先走event_name索引把page_view的行筛出来,再做 JSON 提取过滤;如果 JSON 上的过滤条件也命中了函数索引,优化器可能会选择两个索引的合并方案。但无论如何,保持过滤条件下推,比写一个只依赖 JSON 提取的子查询要稳妥得多。
在做性能测试时,我习惯先用EXPLAIN看执行计划,再加ANALYZE看实际执行时间。MySQL 8.0 的EXPLAIN ANALYZE可以输出每个操作符的实际耗时、读取行数、返回行数,对于分析 JSON 查询的瓶颈非常有帮助。
5. 常见问题与排查技巧实录
5.1 路径写错但不报错:静默 NULL 的陷阱
这是JSON_EXTRACT最坑爹的行为之一:路径不存在时返回 NULL,SQL 不会报任何错误。如果你不知道这个机制,排查起来会非常困惑。
举一个真实案例。某次我从一个 JSON 里提取用户的注册来源,SQL 写的是:
SELECT user_id, JSON_EXTRACT(profile, '$.register.source') AS source FROM user_profile;结果source字段全部是 NULL。我一开始怀疑是数据问题,看了几条原始 JSON,发现字段明明存在于 JSON 里。后来仔细对比才发现,实际存储的键名是register_source,不是register.source。也就是说,我写的路径$.register.source试图访问的是register对象下的source属性,而不是register_source这个键。
这类问题的排查方法很简单:先取出一行数据的完整 JSON,人眼核对路径的每一层键名,注意大小写和特殊字符。再用JSON_KEYS函数查看某个对象下到底有哪些键:
SELECT JSON_KEYS(JSON_EXTRACT(profile, '$.register')) AS keys_list FROM user_profile LIMIT 1;执行结果会返回["register_source"]这样的数组,一眼就能看出你的路径写错在哪里。
5.2 字符串值带着引号:忘了 UNQUOTE
前文已经反复提到,JSON_EXTRACT返回的是 JSON 格式的片段,字符串值自带双引号。很多人在 SQL 里写完JSON_EXTRACT(doc, '$.name')之后,直接拿去和'王明'比较,结果当然是 false。因为"王明"和王明不相等。
解决方案有两个,要么比较时带上引号:
WHERE JSON_EXTRACT(doc, '$.name') = '"王明"'要么用JSON_UNQUOTE或->>:
WHERE JSON_UNQUOTE(JSON_EXTRACT(doc, '$.name')) = '王明' -- 或者 WHERE doc ->> '$.name' = '王明'这里我强烈建议统一使用->>,它不仅能少写一层函数,语义上也更明确——我拿的是普通文本,不是 JSON 片段。如果团队里有人习惯用->或者JSON_EXTRACT,也最好在代码评审时统一风格,避免不同的人写出两种风格混杂的 SQL。
5.3 属性名大小写敏感:别不信,一试一个准
JSON 对象里的键名是区分大小写的,$.Name和$.name指向的是两个完全不同的路径。这个行为和 MySQL 表名列名的大小写策略不同,容易让新人踩坑。
比如某个历史接口返回的 JSON 字段是UserName,后来新版本接口改成了username,两边数据混在了一张表里。写JSON_EXTRACT(doc, '$.username')只能取到一部分行的数据,另一部分行的路径匹配不到,返回 NULL。这个排查难度在于数据看起来都有效,只是键名不一样,如果你不知道有大小写差异,可能查半天都找不到原因。
我的建议是在应用层入表时统一规范 JSON 里的键名,全小写加下划线是一个常见的约定。哪怕新旧接口不一致,在写入数据库之前先做一层转换,保证表内数据的键名一致性,能省掉后续大量的查询兼容成本。
5.4 JSON 参数里带有转义引号或嵌套引号
MySQL 的 JSON 函数要求传入的路径和 JSON 文档本身都必须是合法 JSON。如果 JSON 里嵌套了引号,比如某个字符串值本身包含双引号,那么这个值在 JSON 序列化时会被转义为\"。你看到的 JSON 文本是这样的:
{"note": "他说:\"你好\",然后走了"}用JSON_EXTRACT取出$.note时,返回的结果是:
"他说:\"你好\",然后走了"这里需要注意,返回的 JSON 文本里带着反斜杠转义符,如果直接拿到应用层去用,反斜杠会成为字符串的一部分,显示出来就是他说:\"你好\",然后走了,和预期的他说:"你好",然后走了不一样。解决办法还是->>,JSON_UNQUOTE会把转义符一并还原:
SELECT doc ->> '$.note' AS note FROM t; -- 返回:他说:"你好",然后走了所以当 JSON 文本里出现嵌套引号、转义斜杠这类复杂字符时,->>几乎总是更安全的取数方式。
5.5 JSON 字段长度爆炸:TEXT 还是 JSON
有些开发同学会在 MySQL 里用TEXT或LONGTEXT类型来存 JSON 字符串,理由是某些 JSON 特别长,超过了 JSON 类型的默认限制。这个说法并不完全准确。MySQL 的 JSON 类型本身就是一种内部格式,底层以 BSON 二进制存储,本质上可以存储较大的数据,MySQL 官方对 JSON 类型的最大尺寸同样受max_allowed_packet限制,一般能到 1GB 到 4GB 级别,大多数业务场景根本用不满。
真正的问题在于:如果用了TEXT类型存 JSON,所有 JSON 函数虽然也能隐式解析它,但每次调用都要先做一次文本到 BSON 的转换,性能和稳定性都不如原生 JSON 类型。而且 TEXT 类型上无法创建生成列提取表达式,JSON 查询的优化空间就少了一大截。所以我的建议很直接:数据是 JSON 格式,就老老实实用 JSON 类型,只有当你明确知道这个字段不打算用 JSON 函数、纯粹是整存取整存的大块文本时,才考虑用 TEXT。
5.6 问题排查速查表
下面这张表是我在实际支持和写代码过程中总结的,建议收藏备用。
| 异常现象 | 可能原因 | 排查思路 | 解决方式 |
|---|---|---|---|
| 查询结果全是 NULL | 路径写错或键名不对 | 用 JSON_KEYS 或肉眼核对原始 JSON | 修正 path 表达式 |
| 字符串比较不相等 | JSON_EXTRACT 返回带引号 | 检查结果是否含引号 | 使用 JSON_UNQUOTE 或 ->> |
| 查询极慢,全表扫描 | 无索引或路径表达式导致索引失效 | EXPLAIN 查看 type=ALL | 建生成列索引或函数索引 |
| 报错 Invalid JSON text | 传入的不是合法 JSON 字符串 | 查看原始文本 | 清理数据,保证入库时合法 |
| 值类型不对(字符串转数字异常) | JSON 值存储类型与应用预期不一致 | 抽取几条样本肉眼确认 | 显式 CAST 或修正写入端 |
老版本不支持->> | MySQL 版本低于 5.7.13 | SELECT VERSION() | 改用 JSON_UNQUOTE + JSON_EXTRACT |
这套排查思路,能覆盖我日常遇到的绝大多数JSON_EXTRACT相关问题。如果碰到更古怪的行为,我的经验是先写一条最简 SQL,用一个手写的 JSON 字面量来测试函数行为,排除数据环境干扰,再逐步加入真实表的数据。比如这样:
SELECT JSON_EXTRACT('{"a": {"b": 1}}', '$.a.b');如果手写 JSON 的结果符合预期,问题基本就在数据侧;如果手写 JSON 的结果都不对,那就是函数用法有问题。这种二分法排查,比在几百万行数据上反复试错有效率得多。
6. JSON_EXTRACT 与其他 JSON 函数的选型建议
6.1 功能矩阵对比
写 JSON 相关的 SQL 时,很多人分不清JSON_EXTRACT、JSON_UNQUOTE、JSON_CONTAINS、JSON_SEARCH的区别。我做了一张对照表,方便按需选型。
| 函数 | 核心定位 | 典型使用场景 | 与 JSON_EXTRACT 的关系 |
|---|---|---|---|
JSON_EXTRACT | 按路径取值 | 定向提取 JSON 内某个字段 | 本身 |
JSON_UNQUOTE | 去除 JSON 值的引号 | 把 JSON 字符串转换成普通文本 | 常配合使用 |
-> | JSON_EXTRACT 的简写 | 快速取值 | 等价 |
->> | JSON_UNQUOTE + JSON_EXTRACT | 快速取干净的文本 | 等价 |
JSON_CONTAINS | 判断一个 JSON 文档是否包含目标值 | 数组包含、对象嵌套判断 | 互补,常用于过滤 |
JSON_SEARCH | 在 JSON 文档中搜索符合条件的值 | 返回满足条件的路径 | 用于未知路径的场景 |
JSON_KEYS | 返回对象的键列表 | 查看 JSON 对象的键名 | 用于调试路径 |
JSON_LENGTH | 返回 JSON 文档的长度(数组元素数或对象键数) | 统计数组长度 | 不直接提取值,但可以辅助逻辑 |
JSON_TABLE | 把 JSON 数组展开成关系表 | 数组逐行解析 | 相比 JSON_EXTRACT 更强大,但 8.0+ 才有 |
举一个JSON_SEARCH的典型场景:你只知道 JSON 里有某个值,但不确定它具体在哪一层路径下,比如想找所有包含"vip"这个值的文档。JSON_EXTRACT做不到,因为它的路径是写死的;JSON_SEARCH可以返回匹配的路径:
SELECT JSON_SEARCH(doc, 'all', 'vip') AS matched_path FROM user_profile;返回结果可能是"$.tags[0]"这样的路径字符串。
6.2 项目中的选择策略:JSON_EXTRACT 还是 JSON_TABLE
如果一个 JSON 数组需要被当成多行表来查询,JSON_TABLE在 MySQL 8.0 里是首选的方案,语法虽然没有JSON_EXTRACT简洁,但表达力强得多。下面是一个 JSON_TABLE 的示例:
SELECT t.order_id, item.sku, item.price FROM orders, JSON_TABLE(orders.doc, '$.items[*]' COLUMNS ( sku VARCHAR(32) PATH '$.sku', price DECIMAL(10, 2) PATH '$.price' )) AS item;注意这个用法中orders.doc前面没有JSON_EXTRACT,JSON_TABLE的 path 直接指向数组。它会把items数组里的每个元素展开成一行,列定义用 COLUMNS 子句完成,非常直观。
那JSON_EXTRACT还有用武之地吗?有,而且不少。第一个场景是只需要取一个值、不需要展开数组的时候,JSON_EXTRACT一行就能搞定,JSON_TABLE写起来反而笨重。第二个场景是老版本 MySQL 上的历史项目,5.7 没法用JSON_TABLE,只能靠JSON_EXTRACT和序列表组合。第三个场景是快速写查询脚本、验证数据、做临时分析时,JSON_EXTRACT的简洁性优势很明显。
在我实际做项目选型的时候,有一个经验:如果你发现 JSON 数组里的单个对象需要频繁地上钻下钻、和别的表做 JOIN 聚合,那说明这个数据的结构已经超出了“字段级 JSON”的合理范围,应该考虑在入表时把数组拆开,存成正常的行表。JSON 字段最适合的是“偶尔读一下某些字段”的半结构化数据,而不是“天天要按数组元素做分析”的核心业务数据。JSON 只是妥协方案,不是万能解药。
7. 从 JSON_EXTRACT 出发的工程化落地心得
7.1 写入端规范化:JSON 质量决定查询效率
JSON_EXTRACT能用得顺畅,底子还是写入数据的规范性。我在好几个项目里都吃过“历史数据不干净”的亏,比如同一张表里,有的行 JSON 键名是首字母大写,有的行是全小写,有的行数组和对象形式混用。这种不一致不会让查询报错,但会默默产出大量 NULL 和错误结果,等你发现的时候,要么数据已经污染了报表,要么需要写一堆兼容多个键名的 SQL 来补救。
所以我的建议是,在应用层写入数据库之前,统一做一次 JSON 规范化处理。至少包括三层:键名统一为小写下划线风格、数字类型字段保持数字类型而不写成字符串、缺失字段统一补齐为 NULL 而不是省略键名。这样后续查询JSON_EXTRACT路径时,写的路径表达式稳定可靠,不会因为数据源头的变化而静默失效。
7.2 文档化你的 JSON 路径
JSON_EXTRACT的用户体验有个天然缺陷:路径表达式是魔法字符串。$.user.address.city这种写法和JSON文档结构如果有出入,不会有编译期报错,也不会有类型检查,只能靠测试和线上数据来暴露。我的习惯是在项目文档里维护一份“JSON 字段字典”,记录每个 JSON 字段的完整结构、键名、类型、示例值以及对应的提取路径表达式。这样不仅是给自己省事,也能让接手项目的同事少踩很多坑。
7.3 从一个小函数到一套数据管线的演变
最后说点个人感悟。JSON_EXTRACT只是 MySQL 函数库里的一个小成员,单独看它,功能确实有限。但随着你把它和生成列、索引、JSON_TABLE、JSON_SEARCH这些周边能力串起来,它能支撑的系统复杂度会超出想象。我最早用它在埋点表上做了一个简单的渠道筛选,后来逐步演变成了一套小型的用户画像分析模块——这正是从一个小函数出发,逐步搭建起数据能力的典型路径。
如果你在建表时还在犹豫要不要用 JSON,不妨从一个低风险场景开始尝试:挑一张次要的表、加一个 JSON 字段、写几个JSON_EXTRACT查询,感受一下它在灵活性上的收益。等对它的行为边界有把握了,再往核心业务上铺开。毕竟,工具本身不复杂,复杂的是你在实际数据环境里建立起来的判断力。