在 SpringBoot 项目里用 MySQL 8.0 的 JSON 字段存半结构化数据,再搭配函数索引保证查询性能,这个组合我已经在三个生产项目里验证过效果了。今天这篇就围绕这套方案,把从选型、建表、映射到踩坑的完整链路梳理出来,帮需要处理自定义属性、动态配置、扩展字段这类场景的同学少走弯路。
标题里的四个关键词是串联这条链路的主线:SpringBoot 负责应用层的数据映射与查询封装,JSON 字段负责灵活存储,MySQL 8.0 提供 JSON 类型和函数索引能力,函数索引则解决"字段里存了数据,但查询时必须把 JSON 剥开才能过滤"的性能问题。
适用人群很明确:正在遭受 EAV 表 join 爆炸、或者用 Text 字段存 JSON 后查询慢如蜗牛的开发同学,还有既舍不得关系型数据库的事务能力、又想拥抱半结构化数据灵活性的架构师。全文会包含可复现的 DDL、Java 映射代码、真实压测数据和排查思路。
1. 为什么最终选了 JSON 字段:EAV、宽表和 Text 三条老路的真实痛点
1.1 我们遇到的实际场景
需求来自订单中心:每一个订单除了统一的订单号、金额、状态之外,不同业务线还要挂自己的一组自定义属性。A 业务线要传"预约安装时间""安装师傅 ID",B 业务线要传"发票抬头""发票类型",C 业务线哪天突然说要加一个"是否使用优惠券"。
第一反应都是加列。订单表加一个 install_time、再加一个 installer_id、再加一个 invoice_title……三个月下来订单表多了二十几个字段,而且大量行这些字段都是 NULL,真正用到这些字段的业务可能一天就几千条。更麻烦的是,每次新业务接入都要走一次 DDL 评审,光审批和发版节奏就把人卡死了。
这时候才意识到,这是一个典型的半结构化数据场景:主体结构稳定,但扩展维度完全不确定。
1.2 EAV 模式第一个被排除
团队里有人提出用 EAV(Entity-Attribute-Value)表,很多老系统里叫"扩展属性表":
sql复制CREATE TABLE order_ext (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
attr_key VARCHAR(64) NOT NULL,
attr_value VARCHAR(512),
KEY idx_order_attr (order_id, attr_key)
);
这套模式的问题是查询和写入都太痛苦。查一个订单的所有扩展属性,Order 主表 join 一次,取属性列表再行转列;要是想按某个扩展属性过滤订单(比如找所有 invoice_type = 'company' 的订单),SQL 写起来像在做数据透视:
sql复制SELECT o.id
FROM orders o
JOIN order_ext e ON e.order_id = o.id
WHERE e.attr_key = 'invoice_type' AND e.attr_value = 'company';
看起来还行?但真实场景里,订单列表页需要同时过滤时间范围、金额区间、三个扩展属性,这时要 join 三次同一张 ext 表,数据量一上来就是噩梦。我之前在一张千万级 ext 表上做过这种查询,执行时间直接奔着 800 毫秒以上去了,而且 SQL 可读性极差,新来的同事根本不敢碰这段代码。
1.3 频繁 DDL 的大宽表模式也不省心
大宽表方案是另一条路:把所有可能用到的属性都提前设计成列。这套方案在地推业务早期是可行的,因为字段数量可控、迭代速度不快。但业务一旦进入快速迭代期,每个月都在加列,问题会集中爆发:
- 每次 ALTER TABLE 在千万级数据量上执行,即使 MySQL 8.0 用了 INPLACE 算法,也会产生额外的锁等待和主从延迟风险。
- 字段数量过多后,单行数据在 InnoDB 里的存储开销变大,行大小限制(65535 字节)也会成为约束,最终不得不拆表。
- 真正致命的缺陷是,你永远无法提前预测所有可能出现的属性,每次需求的第一个动作永远是"先加个字段"。
1.4 用 Text 存 JSON 后查询慢得离谱
之前有个临时方案是 Text 字段存 JSON 字符串,应用层用 Jackson 做序列化和反序列化。这个方案在写入场景完全没问题,但一旦涉及查询就露馅了。
比如想找出所有 invoice_type = 'company' 的订单,SQL 只能写成 LIKE '%"invoice_type":"company"%'。这种写法有两个致命问题:
- LIKE 无法走索引,全表扫描,百万级数据量下响应时间直接破秒。
- 语义上根本不安全。如果配置字段里恰好有另一个值
invoice_type_other,或者 JSON 的 key 顺序变了,LIKE 的匹配结果完全不可控。
这个坑我们踩了整整两周,后面接了个数据量暴涨的活动,每天晚上慢查询日志里全都是这个 LIKE 在刷屏,然后把整个库的查询拖慢了。那次事故之后才下定决心迁移到 MySQL 8.0 的 JSON 类型加函数索引。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MySQL 8.0 的 JSON 类型和函数索引到底解决了什么
2.1 JSON 类型不是"带校验的 Text"
MySQL 8.0 的 JSON 类型和 Text 存储的本质区别,不仅是数据库会校验 JSON 格式合法性,更关键的是它采用了二进制存储格式(BSON 风格),而不是单纯保存文本。
这意味着什么?JSON 文档在被写入时会经过解析、键值拆分、排序去重,最终以二进制结构存储。查询时不需要每次把整段文本拖出来再解析,MySQL 可以基于内部结构直接定位目标值。举个例子:
sql复制SELECT extra->>'$.invoice_type' FROM orders WHERE order_id = 1001;
这条 SQL 在 JSON 类型的列上执行时,MySQL 内部会按 key 定位到 invoice_type 对应的值并转换为字符串,而在 Text 列上,同等功能需要先取出整个字符串,再在应用层解析。单条数据看不出差别,但批量处理 10 万条时差异是数量级的。
JSON 类型还自带了一层合法性约束。写入的数据必须是合法 JSON,否则直接报错,这比 Text 字段里存了一堆格式不对的"伪 JSON"要靠应用层保证安全得多。我们接入过外部系统推送的数据,对方时不时传个 {invoice_type: "company"} 这种不带引号的 JSON,在 JSON 类型下直接写入失败,在 Text 类型下会静默存入,直到查询解析时才炸。
2.2 提取 JSON 值的三件套:->、->> 和 JSON_EXTRACT
既然数据存进了 JSON,那查询时必须能拿到内部的值才能做过滤。MySQL 8.0 提供了三个最常用的提取方式:
| 操作符 | 作用 | 返回类型 | 示例 |
|---|---|---|---|
-> |
提取 JSON 值 | JSON | extra->'$.invoice_type' |
->> |
提取 JSON 值并转换为字符串 | VARCHAR/TEXT | extra->>'$.invoice_type' |
JSON_EXTRACT(doc, path) |
显式调用提取函数 | JSON | JSON_EXTRACT(extra, '$.invoice_type') |
实际开发中,->> 是过滤和排序场景的主力。因为 -> 返回的 JSON 类型值的比较规则和普通字符串有区别,比如字符串 '123' 和数字 123 在 JSON 语义里是不同的值,拿去和 WHERE 条件比较时容易踩坑。->> 统一转成字符串,反而更符合大多数业务过滤需求。
2.3 函数索引:生成列上的索引和直接索引两种写法
函数索引是 MySQL 8.0.13 引入的能力,解决的正是"对字段做函数计算后无法走索引"的老问题。Oracle 早就支持基于函数的索引,MySQL 一直到 8.0.13 才补上这个能力。它可以有两种实现形式:
写法一:使用生成列(Generated Column)辅助
sql复制ALTER TABLE orders
ADD COLUMN invoice_type VARCHAR(64)
GENERATED ALWAYS AS (extra->>'$.invoice_type') STORED,
ADD INDEX idx_invoice_type (invoice_type);
写法二:直接创建函数索引(8.0.13+)
sql复制ALTER TABLE orders
ADD INDEX idx_extra_invoice_type ((extra->>'$.invoice_type'));
两种方式最终效果一样,在 InnoDB 里都会维护一个额外的索引结构,索引键值就是 JSON 表达式提取出来的具体值。查询时只要 SQL 里的表达式和索引表达式完全一致,优化器就会选择走这个索引。
两种写法的取舍细节:生成列方式有更强的语义约束,因为它把提取结果变成了一个可查询的虚拟列/存储列,有时候应用层甚至可以直接把列当普通字段用,不用关心 JSON 操作语法;直接函数索引则更轻量,少了列定义的开销,但要求所有查询都严格复写表达式,对团队的 SQL 规范要求更高。
我个人的习惯是优先用生成列方案。原因是它把"从 JSON 里提取字段"这个逻辑固定在了表结构层面,后续如果有人用 SQL 客户端直查,看到 invoice_type 列就知道这里有业务约束;而直接函数索引隐藏在索引定义里,新人排查问题时经常忽略这个索引存在的意义。
3. SpringBoot 端的落地:实体映射、写入与查询封装
3.1 依赖与配置
SpringBoot 项目接入 MySQL 8.0 的 JSON 字段,依赖层面没有特殊要求,只需要数据库驱动版本对齐。
xml复制<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.1.0</version>
</dependency>
如果用的是 MyBatis-Plus 3.5.3+,连接驱动的版本必须 >= 8.0.13 才能确认 JSON 相关的函数索引特性。连接串建议关闭字符串的非法转换:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
driver-class-name: com.mysql.cj.jdbc.Driver
3.2 实体类怎么映射 JSON 字段
MyBatis-Plus 方案
MyBatis-Plus 对 JSON 字段有内置支持,关键在 @TableName 和 @TableField 注解:
java复制@Data
@TableName("orders")
public class OrderEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private BigDecimal amount;
@TableField(value = "extra", typeHandler = JacksonTypeHandler.class)
private ExtraInfo extra;
@Data
public static class ExtraInfo {
private String invoiceType;
private String invoiceTitle;
private String installerId;
}
}
配置要点有两个:
JacksonTypeHandler由 MyBatis-Plus 提供,底层用的 Jackson 库,它可以自动完成Java 对象 <-> JSON 字符串 <-> JSON 列的映射。- 必须指定
@TableField(typeHandler = ...),否则 MyBatis-Plus 默认按字符串处理,会把 JSON 对象转换成com.baomidou.mybatisplus...ExtraInfo@1234这种对象的toString(),写入数据库直接报错。
有一个很容易忽略的坑:如果 ExtraInfo 里的字段名和 JSON 里的 key 不完全一致,需要在 ExtraInfo 内部用 @JsonProperty 指定别名。比如外部系统传的是 invoice_type,Java 字段叫 invoiceType:
java复制@Data
public static class ExtraInfo {
@JsonProperty("invoice_type")
private String invoiceType;
}
否则反序列化时字段全是 null,而写入时 key 又全部变成小驼峰,和查询时的表达式不匹配。
Spring Data JPA 方案
用 JPA 的话,需要在实体字段上加 @JdbcTypeCode(SqlTypes.JSON) 注解,并且在 Hibernate 6 下配置 Jackson 作为 JSON 映射器:
java复制@Entity
@Table(name = "orders")
public class OrderJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNo;
@JdbcTypeCode(SqlTypes.JSON)
private ExtraInfo extra;
}
Hibernate 6 默认是通过 Jackson 序列化并映射到 JSON 列的。这个方案要注意的是,ExtraInfo 类必须有无参构造函数,且所有字段要有 getter/setter,Hibernate 的反射机制比 MyBatis 更严格。
3.3 数据访问层的查询怎么封装
按 JSON 字段等值查询
先看查询需求:根据发票类型找出所有订单。如果能确认 invoice_type 对应的值是字符串,SQL 长这样:
sql复制SELECT * FROM orders
WHERE extra->>'$.invoice_type' = 'company';
在 MyBatis 里:
java复制@Select("SELECT * FROM orders WHERE extra->>'$.invoice_type' = #{invoiceType}")
List<OrderEntity> findByInvoiceType(@Param("invoiceType") String invoiceType);
这里最核心的细节是:extra->>'$.invoice_type' 这个表达式必须和建索引时的表达式完全一致,包括空格。MySQL 8.0 的优化器在这方面有字面量匹配的硬约束,多一个空格都会导致索引无法识别。
使用 JPA 时,需要在 Repository 里写 @Query 原生 SQL 或者使用 Specification 构造:
java复制@Query(value = "SELECT * FROM orders WHERE extra->>'$.invoice_type' = :invoiceType", nativeQuery = true)
List<OrderJpaEntity> findByInvoiceType(@Param("invoiceType") String invoiceType);
按 JSON 字段范围查询
如果某个属性是数值型,比如自定义的"优先级":
sql复制SELECT * FROM orders
WHERE extra->>'$.priority' >= 10
在索引设计时,要确保生成列的类型和比较类型一致。生成列定义时如果没有显式指定类型,MySQL 会根据表达式推导,->> 的推导结果默认是 VARCHAR,所以 >= 10 这种比较实际上发生的是字符串比较,数字 9 会被判定为大于 10。这个坑会导致"看起来走了索引,但结果集是错的"。
解决办法是生成列显式转换:
sql复制ALTER TABLE orders
ADD COLUMN priority INT
GENERATED ALWAYS AS (extra->>'$.priority') STORED,
ADD INDEX idx_priority (priority);
这样 MySQL 在生成列时会尝试把提取的字符串转换成 INT,转换失败的行会写入 NULL(不会报错),查询和索引都按整数语义执行。
JSON 包含关系查询
除了等值和范围,还有一种常见需求是"JSON 里是否包含某个键"。比如 extra 中是否存在 coupon_code:
sql复制SELECT * FROM orders WHERE JSON_CONTAINS_PATH(extra, 'one', '$.coupon_code') = 1;
或者是"对象里某个数组包含目标值":
sql复制SELECT * FROM orders WHERE JSON_CONTAINS(extra, '"VIP"', '$.memberTags');
注意 JSON_CONTAINS 的第二参数是 JSON 值,所以字符串必须带引号写。函数索引可以对 JSON_CONTAINS 表达式建索引吗?MySQL 官方文档指出,函数索引支持 JSON_EXTRACT 和 JSON_UNQUOTE 但官方文档中对 JSON_CONTAINS 是否能建索引没有明确承诺,实际测试在不同小版本上行为不一致,我的建议是:不要在 JSON_CONTAINS 上建函数索引,等值查询走 ->> 索引,包含查询允许全表扫描并控制数据量。如果包含查询确实是高频路径,更务实的做法是把关联的标签数组单独拆一张子表,用普通 B+Tree 索引解决。
4. 一组真实压测数据:函数索引到底把查询提速了多少
4.1 压测环境与数据构造
为了验证效果,我在一台 4C8G 的云主机上做了基准测试。MySQL 8.0.34,InnoDB,200 万行订单数据,其中大约 15% 的行有 invoice_type 属性,其余行的 extra 只是空 JSON 对象。
表结构:
sql复制CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64),
amount DECIMAL(10,2),
extra JSON,
invoice_type VARCHAR(64) GENERATED ALWAYS AS (extra->>'$.invoice_type') STORED,
KEY idx_invoice_type (invoice_type)
) ENGINE=InnoDB;
测试查询语句:
sql复制SELECT COUNT(*) FROM orders WHERE invoice_type = 'company';
4.2 对比结果
| 方案 | 数据量 | 平均耗时 | 扫描行数 | 说明 |
|---|---|---|---|---|
| Text 字段 + LIKE | 200 万 | 1.8s | 200 万 | 全表扫描,慢查询日志天天刷屏 |
JSON 字段 + 无索引 ->> 查询 |
200 万 | 1.2s | 200 万 | 全表扫描,但省去了 LIKE 的字符串匹配开销 |
| JSON 字段 + 函数索引 | 200 万 | 12ms | 30 万 | 走索引,扫描行数精确匹配命中行数 |
命中行数是 30 万,占总数的 15%,这个选择性不高不低,但对 B+Tree 索引来说依然在高效区间。如果阈值属性是那种海量数据集中在一个取值上(比如 90% 行都是 invoice_type='normal'),索引可能并不会更优,MySQL 优化器会倾向于全表扫描。这是选择性的基本原理:索引的价值来自把扫描范围缩小的能力,如果某个值出现频率过高,索引命中大量行反而比全表扫描更慢。
4.3 索引命中的 SQL 形态说明
压测中我观察到,WHERE invoice_type = 'company' 可以走索引,但 WHERE extra->>'$.invoice_type' = 'company' 也能走索引,因为两者底层都对应同一个生成列。
不过有个边界要特别注意:如果查询条件是 WHERE invoice_type IS NULL,生成列上的索引也会被使用,但 MySQL 对 IS NULL 的索引处理效率通常低于 =,因为 InnoDB 里 NULL 值不会被常规 B+Tree 索引节点直接覆盖(8.0 的行为是 NULL 值不进入二级索引,所以 IS NULL 会退化为全表扫描)。这个特性在业务上体现为:15% 的订单没有 invoice_type,按 invoice_type IS NULL 查 null 值订单时,慢查询仍然会出现。解决方案可以是额外加一个标志列标记"是否有关键扩展属性",或者直接避免用 IS NULL 做高频过滤条件。
4.4 联合索引的场景
如果查询需求是 invoice_type + create_time 组合过滤,生成列可以配合普通列建联合索引:
sql复制ALTER TABLE orders
ADD INDEX idx_invoice_type_time (invoice_type, create_time);
索引顺序遵循最左前缀原则,只有 invoice_type 在左侧,包含 invoice_type 的查询才能命中这个联合索引。注意 create_time 与 invoice_type 共同构成等值条件时,走这个索引在排序上也会受益,比如 ORDER BY create_time DESC 可以直接从索引逆序扫描,避免 filesort。这在订单列表页这种高频场景下提升非常明显。
5. 真实生产环境里踩过的坑和排查链路
5.1 表达式不一致,索引静默失效
这是最常见也是最隐蔽的坑。上线前测试环境数据量小,全表扫描也就几十毫秒,压根没注意到索引没生效。上了生产数据量达到千万级,才在慢查询日志里看到问题。
排查链路:
- 用
EXPLAIN检查执行计划,发现possible_keys是空的,Extra是Using where。 - 反复比对建索引表达式和查询表达式,发现建索引时写的是
extra->>'$.invoice_type',查询时同事把它写成了JSON_UNQUOTE(JSON_EXTRACT(extra, '$.invoice_type'))。 - 语义上两个表达式等价,但 MySQL 的优化器不会做表达式等价推导,它只看语法树是否一致。
- 修改查询 SQL 为完全一致的表达式,执行计划从全表扫描变成索引扫描,耗时从 900ms 降到 15ms。
经验教训是:JSON 字段的查询表达式必须统一管理。我们在项目里制定了一条硬规则,所有涉及 JSON 提取的 SQL 都要从同一个常量类里复用表达式,禁止手写语义等价但形态不同的 SQL。
5.2 隐式类型转换导致索引不生效
另一个高频坑是类型转换。生成列是 VARCHAR,查询参数传的是数字:
java复制orderMapper.findByInvoiceType(1001); // 参数是 Integer
MyBatis 最终拼出的 SQL 是 WHERE invoice_type = 1001。MySQL 会把 varchar 列的值往数字类型转换,此时即使在 invoice_type 上有索引,优化器也会认为"列被函数处理了",从而放弃索引。
排查时 EXPLAIN 的 Extra 列会出现 Using where; Using filesort(如果有排序),type 可能是 ALL 或者 index,而不是 ref。
解决方式:确保查询参数类型与生成列类型一致,Java 侧 DTO 字段统一用 String,或者 SQL 里显式转换:
sql复制WHERE invoice_type = CAST(#{invoiceType} AS CHAR)
当然更推荐前端就在类型上约束好,传的什么类型,查询就按什么类型来。
5.3 更新 JSON 字段时索引会自动维护
很多人问,更新了 extra 列里的某个 key,函数索引是不是就失效了?答案是:InnoDB 会自动维护生成列对应的索引。因为生成列的值是从 extra 列实时派生出来的,当你更新 extra 时,MySQL 会自动重新计算生成列的值,并同步更新索引条目。
这个过程中有一个性能代价:更新 JSON 列的某个 key 时,InnoDB 实际上会把整个 JSON 文档读出来、解析、修改、重新序列化并写入,同时更新所有依赖该 JSON 列的函数索引。所以如果你在同一个 JSON 对象里存了 10 个提取字段,每次更新这 10 个字段对应的索引都要重新计算。这会让写入变慢,8 个索引时实测写入耗时比不加索引高了约 30%。
这个代价在业务上是可以接受的,因为 JSON 字段的使用场景是"低频写入、高频查询",如果写入高频且字段都需要过滤,也许应该把它们提升为正式列而不是塞在 JSON 里。这个判断标准我在多个项目里用过,效果都还不错。
5.4 JSON 字段与字符集排序规则
生成列在提取字符串时的排序规则(collation)会继承 JSON 列的字符集。如果表默认用 utf8mb4_0900_ai_ci,生成列也是 utf8mb4_0900_ai_ci,查询时不区分大小写。这意味着 WHERE extra->>'$.invoice_type' = 'Company' 和 'company' 匹配结果一致。
如果业务需要区分大小写(比如存储的是大小写敏感的优惠码),建索引时要显式指定排序规则:
sql复制ALTER TABLE orders
ADD COLUMN coupon_code VARCHAR(64)
GENERATED ALWAYS AS (extra->>'$.coupon_code') STORED
COLLATE utf8mb4_bin,
ADD INDEX idx_coupon_code (coupon_code);
同样,查询时也要保证表达式和列定义一致。这个坑我们是靠线上数据发现异常的:某次活动优惠码 'ABC' 和 'abc' 被判定为同一个,导致部分用户优惠码失效。
5.5 何时真的不该用 JSON 字段
虽然这套方案解决了很多问题,但它不是银弹。有几种场景我建议谨慎:
- 高频更新 JSON 内部字段:每次更新都重新解析整个 JSON 文档,代价高。
- JSON 里嵌套层级超过 3 层:虽然 MySQL 8.0 支持嵌套,但查询和索引会越来越复杂,开发效率下降严重,可以考虑拆表。
- 需要跨 JSON 字段做多表 join 关联:JSON 提取值无法像普通列那样有清晰的统计信息,join 驱动和估算都可能出问题。
- 字段长度极大(超过 10KB):JSON 类型按文本存储时会做行外存储,索引提取只取部分值,但写入性能会有明显影响。
5.6 监控手段
项目上线后,维护是重头戏。我们给所有涉及 JSON 提取的 SQL 都加了 EXPLAIN 测试用例,在 CI 阶段自动校验执行计划的 type 是否为 ref 或 range,一旦发现是全表扫描就告警。另外在 mysqld 配置里把慢查询时间阈值设置到了 300ms:
ini复制slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.3
日常巡检时如果发现某个 JSON 查询从慢查询日志里频繁出现,第一步不是优化 SQL,而是回头检查它是否真的走了函数索引。90% 的案例最终都定位到表达式不一致或隐式类型转换。
回到项目本身,这套"SpringBoot + JSON 字段 + MySQL 8.0 函数索引"的组合,目前支撑着订单中心的全部自定义属性需求,已经平稳运行了一年多,查询侧没有再出现过因为扩展属性导致的慢查询问题。最后再分享一个细节操作:建表时如果提前预留了生成列,那么即使最初没有核心查询需求,也建议把日常最可能的过滤字段先建好索引,避免业务上线第二天临时加索引,导致高峰期 DDL 带来的锁等待。这个预留习惯,在后续项目里给我省了不止一次事故。
