先说一个很多人都绕过的弯路:把字段塞进 JSON 列之后,查询变慢才反应过来索引的问题。JSON 列存扩展字段本身是没错的,错的是直接把 JSON_EXTRACT 写进 WHERE 条件,然后期待数据库和以前一样快。这个场景我见过太多次了,包括我自己早期在项目里也踩过。今天这篇就把虚拟列和联合索引在 JSON 扩展字段查询上的用法完整拆一遍,从底层原理到可以直接抄的建表语句都给你。
正文会覆盖几个核心问题:JSON 字段为什么难查、虚拟列到底是怎么解决索引问题的、联合索引怎么和虚拟列配合应对高频组合查询、以及我在生产环境里踩过的那些类型转换和索引失效的坑。适合正在用 MySQL 5.7 或 8.0、需要在 JSON 扩展字段上做条件过滤和排序的团队参考。
1. 扩展字段为什么选了 JSON 列——以及代价从哪一刻开始
1.1 业务表结构演进:加列加怕了之后的选择
电商订单、用户资料、内容发布这类业务,天然逃不开扩展字段的需求。拿订单表来说,最初可能就是订单号、用户 ID、金额、状态这几个核心字段。后面产品开始加需求:订单来源渠道、用户备注、优惠券信息、活动标识、发票抬头、是否是预售单……如果你每个需求都去 ALTER TABLE 加一列,上线半年后表结构会膨胀到你自己都不想看。
更麻烦的是,很多扩展属性只是部分业务线在用。比如只有拼团订单才有“团长 ID”,只有跨境订单才有“海关申报信息”。这些字段加到主表里,非相关业务的数据都是 NULL,不仅占空间,还让表结构变得特别臃肿。
我经历过一个项目,订单表从上线初的 12 列一路加到 38 列,后来实在扛不住了,才把后续的扩展字段统一塞进一个 ext_info JSON 列。刚开始确实爽,代码里怎么写都行,前端的动态表单直接映射 JSON 字段,连表结构评审都省了。
但是注意,“塞进 JSON 列”这个动作本身没有解决查询问题,它只是把问题从表结构层面挪到了查询层面。原来你要按某个加出来的列做条件查询,直接走普通索引就行;现在字段变成了 JSON 内部的某个 key,SQL 写法从 WHERE channel = 'app' 变成了 WHERE JSON_EXTRACT(ext_info, '$.channel') = 'app'。后者在绝大多数情况下是走不了索引的。
1.2 JSON 列解决的是存储灵活性,不是查询性能
这是很多人一开始没想明白的地方。JSON 数据类型在 MySQL 5.7 引入时,设计初衷是让你在关系型数据库里也能优雅地存半结构化数据,而且它以二进制格式存储,比存字符串文本更省空间,解析性能也更好。但“存储友好”不等于“查询友好”。
MySQL 对 JSON 列的解析是在 SQL 执行阶段做的。JSON_EXTRACT(ext_info, '$.channel') 这个函数,对每一行数据都要执行一次 JSON 解析,去取出目标路径对应的值。这意味着在没有额外手段的情况下,查询只能全表扫描。哪怕你的表已经几百万行了,MySQL 也不会因为你 JSON_EXTRACT 里的路径写得很工整就自动建索引。
而且还有个细节:JSON 列本身是不能直接建普通索引的。MySQL 官方文档写得很清楚,JSON 列不支持直接创建索引,你需要通过生成列(Generated Column)来间接实现。这就把我们引向了虚拟列这条正路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 虚拟列是什么——以及它如何把 JSON 字段“翻译”成可索引的列
2.1 虚拟列和存储列的区别,别选错
MySQL 5.7 开始支持生成列,分两种:VIRTUAL 和 STORED。
VIRTUAL虚拟列:列值不实际存储在表中,而是在读取时根据表达式实时计算。InnoDB 支持在虚拟列上建立二级索引,这一点非常关键。STORED存储列:列值在插入或更新时计算并实际存储到磁盘,占用真实存储空间。
对 JSON 扩展字段来说,绝大多数场景用 VIRTUAL 就够了。因为你只是想让某个 JSON key 变成一个可以走索引的“虚拟字段”,而不是真的想把数据冗余一份到表里。虚拟列上的索引,InnoDB 会在索引结构中物化存储这些值(因为索引必须有实际数据),但表本身不会额外增加列占用的空间。
这里有个值得理解的底层逻辑:你建 VIRTUAL 列的时候,表扫描阶段不会额外存值;但你在虚拟列上建索引后,索引条目里会记录这个值。所以查询时,如果走的是这个索引,MySQL 可以直接从索引里拿到值,不需要回表再去解析 JSON。这正是性能提升的关键。
2.2 虚拟列建索引为什么能生效
普通情况下 WHERE JSON_EXTRACT(ext_info, '$.channel') = 'app' 走不了索引,是因为 MySQL 的执行器不知道这个函数调用的结果可以预先计算。而虚拟列等于把“JSON 路径提取”这个动作变成了一个确定性的列定义,MySQL 优化器知道这个列的值是稳定可复现的。
只要你查询时 WHERE 条件里用的表达式和虚拟列的定义表达式完全一致,或者直接使用这个虚拟列作为条件(前提是有索引),MySQL 就会把它当成普通列来优化,走索引查找。
但有一个必须注意的点:实际查询中最好直接使用虚拟列名,或者保证函数表达式和定义一致。如果你在 WHERE 里写 JSON_UNQUOTE(JSON_EXTRACT(ext_info, '$.channel')) = 'app',而虚拟列定义时也是这么写的,那还能匹配;如果定义时用了 JSON_UNQUOTE,查询时却只写了 JSON_EXTRACT,MySQL 就不知道你查的是同一份数据,索引照样用不上。
2.3 定义虚拟列时的类型选择:JSON 值要脱掉“引号”
初学者最容易漏掉的是 JSON_UNQUOTE。JSON_EXTRACT 返回的是 JSON 类型值,如果你取出的是一个字符串 "app",它返回的东西带着 JSON 的双引号。直接拿来做虚拟列,类型和值都会变得很别扭。
我习惯的写法是:
sql复制ALTER TABLE orders
ADD COLUMN ext_channel VARCHAR(32)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(ext_info, '$.channel'))) VIRTUAL;
用 JSON_UNQUOTE 把 JSON 值转成纯字符串,虚拟列的类型就是正常的 VARCHAR。这样后续比较、排序、建索引都符合直觉。
如果 JSON 里的值是数字,比如订单里的积分、数量,你还要考虑类型转换。比如:
sql复制ADD COLUMN ext_points INT
GENERATED ALWAYS AS (CAST(JSON_EXTRACT(ext_info, '$.points') AS UNSIGNED)) VIRTUAL;
注意这里用了 CAST,因为 JSON 数字虽然不带引号,但类型上和 MySQL 原生的 INT 还是有区别,显式转换后更稳妥。
2.4 一个能直接落地的建表示例
我把一个实际项目的订单表建表语句简化一下给你,新表可以直接这么建:
sql复制CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL,
ext_info JSON NULL,
ext_channel VARCHAR(32)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(ext_info, '$.channel'))) VIRTUAL,
ext_source VARCHAR(32)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(ext_info, '$.source'))) VIRTUAL,
ext_refund_flag TINYINT
GENERATED ALWAYS AS (CAST(JSON_EXTRACT(ext_info, '$.refund_flag') AS UNSIGNED)) VIRTUAL,
KEY idx_ext_channel (ext_channel),
KEY idx_user_channel (user_id, ext_channel),
KEY idx_ext_source (ext_source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
如果是已经存在的表,用 ALTER TABLE 加虚拟列就行,不需要重建全表(但如果是大表,最好在低峰期操作,并且提前评估元数据锁的影响)。
3. 联合索引怎么设计——高频查询不是单列索引能扛住的
3.1 单条件高频查询:虚拟列独立索引兜底
先处理最简单的情况:业务方高频查询是“按扩展字段中的某个值过滤”。
比如运营后台要查所有渠道来源为 'app' 的订单,SQL 长这样:
sql复制SELECT order_no, user_id, status, created_at
FROM orders
WHERE ext_channel = 'app'
ORDER BY created_at DESC
LIMIT 20;
这种情况下,给 ext_channel 建一个独立的单列索引即可。如果这个查询还带着 created_at 排序,你可能会琢磨要不要做 (ext_channel, created_at) 的联合索引。我的建议是:先看数据分布和查询频率。如果 ext_channel = 'app' 能过滤掉 90% 以上的行,那么单列索引已经够快,排序只需要处理剩下的少量数据。如果这个渠道的订单量依然庞大,那 (ext_channel, created_at) 联合索引的价值就体现出来了。
3.2 核心高频场景:固定业务字段 + JSON 扩展字段的组合
真正考验设计的是高频组合查询。例如:查询某个用户在某渠道下的所有有效订单。
SQL 可能是这样:
sql复制SELECT order_no, status, amount, ext_info
FROM orders
WHERE user_id = 12345
AND ext_channel = 'app'
AND status = 1
ORDER BY created_at DESC
LIMIT 20;
这种查询里有固定字段 user_id、status,也有 JSON 扩展字段映射出来的虚拟列 ext_channel。合理的设计是建一个联合索引:
sql复制ALTER TABLE orders
ADD KEY idx_user_channel_status (user_id, ext_channel, status);
这里我刻意把 user_id 放在联合索引的第一位,因为 user_id 是等值条件,而且选择度通常最高。然后是 ext_channel,最后是 status。联合索引的最左前缀原则你应该清楚:查询里要同时用这几个字段,就必须让索引列顺序和查询条件匹配路径一致。
如果把 ext_channel 放第一位,user_id 放第二位,查询时 MySQL 只有在 user_id 条件没生效时才会走索引扫描(但很多时候它自己也懵,会优化成别的路径)。所以设计联合索引时,先放等值条件、选择度更高、类型更稳定的列,这条经验无论在普通列还是虚拟列上都成立。
3.3 排序和分页场景:联合索引的排序红利
上面那个 SQL 里还有 ORDER BY created_at DESC。如果数据量很大,排序可能会成为瓶颈。联合索引的第二个红利就是:如果 created_at 也能被包含进索引,并且前面的等值条件把数据筛得非常窄,那么 MySQL 可以直接按索引顺序取出。
你可以考虑把 created_at 加进联合索引,变成:
sql复制ALTER TABLE orders
ADD KEY idx_user_channel_status_created (user_id, ext_channel, status, created_at);
注意,这会让索引变大,写入变慢一点。值不值得,取决于 ORDER BY created_at DESC LIMIT 20 这类查询的 QPS 高不高。如果只是后台导出、偶尔查询,那没必要,单列索引兜底就行。
3.4 联合索引和虚拟列的先后顺序,不要搞反
还有一点:联合索引里的虚拟列,索引定义顺序和虚拟列的创建顺序没有关系。你是先加虚拟列、后加联合索引,还是先建表时一起写死,效果相同。真正重要的是 WHERE 条件里的列出现顺序和索引列的顺序要能匹配上最左前缀原则。
我踩过一个坑:虚拟列定义存在,但联合索引里把 ext_channel 放在最前面,因为当时觉得“虚拟列很特殊,应该放前面”。结果 EXPLAIN 一看,MySQL 根本没法完全走联合索引,因为 user_id 这个等值条件被拆到后面了。后来把 user_id 挪到第一位,执行计划就正常了。所以不要把虚拟列“特殊化”,它在 SQL 优化器眼里就是一个普通列,联合索引的设计规则完全不改变。
4. 实测对比——不同查询写法的执行计划与耗时差异
4.1 测试环境与数据准备
我先准备了比较“脏”的数据来模拟真实场景。测试表有 200 万行订单数据,ext_info 里包含渠道、来源、活动 ID 等字段,渠道值分布大致是:app 占 60%,mini_program 占 25%,h5 占 12%,其余占 3%。
测试环境为 MySQL 8.0.32,InnoDB,innodb_buffer_pool_size 设为 4G,关闭查询缓存(8.0 本来也没有)。数据随机生成,尽量模拟线上分布,不让测试数据太理想化。
表结构:
sql复制CREATE TABLE orders_test (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL,
ext_info JSON NULL,
ext_channel VARCHAR(32)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(ext_info, '$.channel'))) VIRTUAL,
KEY idx_ext_channel (ext_channel),
KEY idx_user_channel (user_id, ext_channel)
) ENGINE=InnoDB;
4.2 不同写法的执行计划对比
写法一:直接对 JSON 列使用函数查询,不建任何虚拟列索引支持
sql复制EXPLAIN
SELECT * FROM orders_test
WHERE JSON_UNQUOTE(JSON_EXTRACT(ext_info, '$.channel')) = 'app'
LIMIT 20;
执行计划显示 type = ALL,rows = 2000000,就是全表扫描。这种查询在真实环境里,如果表大到一定程度,慢查询日志里榜首基本上就是它。
写法二:使用虚拟列查询
sql复制EXPLAIN
SELECT * FROM orders_test
WHERE ext_channel = 'app'
LIMIT 20;
执行计划走了 idx_ext_channel 索引,type = ref,rows 预计在 120 万左右(符合渠道分布比例),但因为是 LIMIT 20,实际找到前 20 条就停止了,速度明显快很多。
写法三:通过普通列和虚拟列联合查询
sql复制EXPLAIN
SELECT * FROM orders_test
WHERE user_id = 123456
AND ext_channel = 'app'
ORDER BY created_at DESC
LIMIT 20;
当实际命中行数较少时,执行计划会走 idx_user_channel(联合索引),type = ref,key_len 比单列索引更长,因为用到了两个字段。查询效率非常高。
4.3 实际耗时数据
在相同数据量和环境下,多次执行取平均值:
| 查询方式 | 执行计划类型 | 平均耗时 |
|---|---|---|
JSON_EXTRACT 直接过滤(无虚拟列索引) |
ALL,全表扫描 | 约 1.8s |
虚拟列 ext_channel 单列索引 |
ref,索引定位 | 约 20ms |
user_id + ext_channel 联合索引 |
ref,索引定位 | 约 5ms |
联合索引 + ORDER BY created_at LIMIT 20 |
ref,索引范围扫描 | 约 8ms |
这个数据量级差异非常直观:直接对 JSON 字段做函数查询,和走虚拟列索引,性能差距可能达到两个数量级。尤其是在高频查询场景下,1.8 秒和 20 毫秒不只是“快慢”的区别,而是“能不能上线”的区别。当然,真实环境里还受缓存、数据分布、并发量影响,但方向是确定的。
5. 生产环境里容易踩的坑——每一个都可能导致索引失效或数据错乱
5.1 JSON 字段类型不匹配:看起来走索引,实际没走
这是最常见的坑。JSON 里存的是数字,但虚拟列定义成了 VARCHAR,或者反过来。
举个例子,JSON 里 refund_flag 是整数 1,你如果用 JSON_UNQUOTE(JSON_EXTRACT(...)) 来取,得到的可能是字符串 '1'。此时虚拟列定义为 VARCHAR(8),值和 1 字符一样,索引也能建。但如果业务上某处把它当成数字比较:
sql复制WHERE ext_refund_flag = 1
MySQL 在比较时可能会做隐式类型转换。一旦虚拟列上发生了隐式类型转换,索引很可能就失效了。这是我当初排查慢查询时最崩溃的一幕:EXPLAIN 里明明显示用了 idx_ext_refund_flag,但 rows 扫描量还是很大。
解决方案是:虚拟列定义时就确定好正确类型。数字用 CAST(JSON_EXTRACT(ext_info, '$.refund_flag') AS UNSIGNED),字符串用 JSON_UNQUOTE(JSON_EXTRACT(...))。查询条件里的类型也尽量和虚拟列类型一致。
5.2 查询条件和虚拟列表达式不一致
MySQL 优化器匹配虚拟列索引时,要求查询里的表达式能明确对应到虚拟列。如果你写:
sql复制WHERE JSON_UNQUOTE(JSON_EXTRACT(ext_info, '$.channel')) = 'app'
而虚拟列定义也是 JSON_UNQUOTE(JSON_EXTRACT(ext_info, '$.channel')),MySQL 能用上索引。但如果你的查询是:
sql复制WHERE JSON_EXTRACT(ext_info, '$.channel') = 'app'
那这个表达式和虚拟列定义的返回类型(JSON 类型 vs 字符串类型)不一样,索引大概率用不上。
为了避免这种心智负担,最省事的办法是查询里直接用虚拟列名。让 ORM 层和业务代码统一使用列名,而不是到处写 JSON_EXTRACT。
5.3 虚拟列上再做函数运算,索引白建
就算虚拟列本身有索引,如果你在查询时对它做函数运算,比如:
sql复制WHERE UPPER(ext_channel) = 'APP'
这个索引基本废了,因为 MySQL 需要对每个索引值做函数计算再比较,无法直接利用索引的有序性。解决办法是定义虚拟列时就处理干净,比如定义时就用 UPPER(JSON_UNQUOTE(...)),然后查询直接 WHERE ext_channel = 'APP'。这也是“虚拟列表达式要提前想清楚”的原因。
5.4 JSON 里缺 key 时,虚拟列的值是什么
如果 JSON 里没有某个 key,JSON_EXTRACT 返回 NULL,虚拟列的值就是 NULL。在查询时,WHERE ext_channel = 'app' 不会命中 NULL 值,这符合预期,但如果业务逻辑里“没有渠道字段”和“渠道为空字符串”有不同含义,你要把 JSON 写入时统一规范好,不能有时候写 'channel': '',有时候直接不写这个 key。否则数据统计口径会不一致。
5.5 大表加虚拟列和索引的元数据锁问题
在生产大表上执行:
sql复制ALTER TABLE orders
ADD COLUMN ext_channel VARCHAR(32)
GENERATED ALWAYS AS (...);
虽然 MySQL 8.0 的 ALTER TABLE 支持在线 DDL,但虚拟列的添加和索引创建不一定完全无锁。5.7 里执行 DDL 时要小心复制延迟和锁竞争问题。建议先在一个低峰期窗口用 pt-online-schema-change 这类工具,或者在复制从库上先验证,再切主库执行。我在一个千万级表上直接执行过,结果拖了快十分钟,业务侧报警不断,从那之后凡是大表 DDL 我都走工具流程。
6. 进阶:数组类型 JSON 字段和 JSON_TABLE 查询
6.1 虚拟列只能处理单一 key,数组就不行了
虚拟列适合处理 $.channel 这种单值路径。如果你的扩展字段里存的是数组,比如商品标签 ["hot", "new", "sale"],你想查所有包含 "new" 标签的商品,虚拟列就有点力不从心了。
这种场景,MySQL 8.0.17 之后引入了多值索引(Multi-Valued Index),语法大致是:
sql复制CREATE INDEX idx_tags ON products (
(CAST(JSON_EXTRACT(tags, '$') AS CHAR(32) ARRAY))
);
查询时用 WHERE 'new' MEMBER OF (JSON_EXTRACT(tags, '$'))。这功能能解决一部分数组查询问题,但对 MySQL 版本有要求,8.0.17 以下不能用。实际项目里如果版本比较老,我一般建议用关系子表来存标签关系,不要硬扛。
6.2 复杂 JSON 结构用 JSON_TABLE 展开
如果你的 JSON 字段里存的不是一个 key,而是整张嵌套的子文档数组,且你需要按子文档里的字段做关联查询,那 JSON_TABLE 是比虚拟列更合适的工具。
虚拟列擅长把 JSON 里的某个标量提升为“列”,方便走索引;JSON_TABLE 擅长把 JSON 数组拍平成多行,方便做统计分析。但要注意,JSON_TABLE 用起来很灵活,代价是查询复杂度和执行计划不可控性都上升,不适合高频短查询,更适合报表统计类的离线或准实时场景。
6.3 什么时候该放弃虚拟列方案
虚拟列 + 联合索引的方案不是万能的。如果你发现扩展字段的查询条件经常变,今天按 channel 查,明天按 source 查,后天按 activity_id 查,你会陷入“不断加虚拟列、不断加索引”的循环。每个虚拟列和索引都有写入成本和存储成本,加多了之后,INSERT / UPDATE 明显变慢,表也变成“索引怪兽”。
我现在的判断标准是:
- 如果扩展字段里只有一两个字段是高频、固定的查询条件,优先用虚拟列 + 联合索引。
- 如果有多个字段都会参与查询,且频率相近,考虑把核心几个字段提升为正式列(从 JSON 里抽出来,做成普通列),而不是全都靠虚拟列。
- 如果查询条件本身很灵活、组合多变,那就老老实实走搜索服务(Elasticsearch 或专门的查询引擎),不要把 MySQL 的 JSON 列硬撑成主查询通道。
7. 一些可供直接参考的实践经验
最后分享几个我实际总结出来的判断依据和操作习惯,你可以直接拿去对照自己的项目。
第一,先确认是不是真的需要 JSON 列。 如果你在设计表的时候就知道某个扩展字段高频查询,它就不应该放进 JSON 里,而是直接做成普通列。JSON 列只适合承载“低频查询、高灵活度”的数据。把高频字段从 JSON 里抽出来这个过程,业内叫“字段上提”,是表结构治理里很常见的手段。
第二,虚拟列不是加得越多越好。 虚拟列的表达式虽然不占表空间,但它对应的索引会占空间和影响写入。我见过一张表加了 12 个虚拟列和 6 个索引,最后写入性能掉了 30% 以上。虚拟列要克制,够用就好。
第三,版本差异。 MySQL 5.7 和 8.0 对生成列的支持程度不同,8.0 对函数索引和优化器行为更完备。如果团队还在用 5.7,建议重点检查虚拟列索引的实际执行计划,因为 5.7 的优化器对生成列索引的匹配逻辑有较多限制,同一套方案在 5.7 和 8.0 上表现可能有差异。
第四,监控别落下。 加了虚拟列和索引之后,日常巡检不要只看慢查询日志,还要看 performance_schema 里的索引使用情况。如果某个虚拟列索引的 sys.schema_unused_indexes 视图里出现了,说明它在实际查询里根本没被用上,可以考虑删掉,别让索引躺在那里白白增加维护成本。
第五,从运维角度考虑备份和迁移。 虚拟列在备份恢复过程中是自动计算的,不需要额外处理。但如果你用了 STORED 存储列,备份文件会更大,mysqldump 导出和导入的时间也会变长。这也是我默认推荐 VIRTUAL 而不是 STORED 的原因之一。
这个方案我自己在多个线上项目里应用下来,配合 EXPLAIN 和慢查询监控,基本能做到“JSON 扩展字段查询和普通列查询体验一致”。当然,前提是你得把虚拟列的设计当成正式表结构来评审,而不是觉得“反正是虚拟的,随便加”。它确实是目前 MySQL 生态里解决 JSON 扩展字段高频查询最务实的手段,但用好的关键在于克制和规范。
