先说一个很多人都绕过的弯路:把字段塞进 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'
