JSON 与灵活 Schema 建模实践

详解 MySQL JSON 与 PostgreSQL JSONB 的差异、半结构化数据建模原则、JSON 字段索引方案,以及从灵活 Schema 演进迁移到规范化表结构的完整路径。

1. 灵活 Schema:从"多列可空"到 JSON

一句话总结: 业务字段经常变化时,与其反复 ALTER TABLE 加列,不如把不稳定字段放进 JSON 列——灵活 Schema 的本质是把"加列成本"转成"查询成本"。

传统关系建模要求列预先定义、类型固定。可现实业务常出现:商品属性千差万别、用户自定义字段随时新增、三方接口返回半结构化数据。硬要全部建模成列,会得到一张满是 NULL 的宽表:

CREATE TABLE product (
  id BIGINT PRIMARY KEY,
  name VARCHAR(128),
  color VARCHAR(32) NULL,      -- 只有服装类有
  weight_kg DECIMAL(8,2) NULL, -- 只有物流类有
  screen_size VARCHAR(32) NULL, -- 只有数码类有
  -- 每加一种商品类型就要加一列,列数失控
);

JSON 列的出现改变了这个局面:结构化列负责稳定的核心字段,JSON 列承载易变/稀疏的扩展字段。

什么时候用 JSON:字段频繁增删、结构随类型变化、查询很少针对这些字段。当某字段成为高频过滤/排序条件时,就该把它提升为独立列。

2. MySQL JSON 类型

2.1 基本使用

CREATE TABLE product_ext (
  id BIGINT PRIMARY KEY,
  attrs JSON NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 插入:自动校验 JSON 合法性,非法直接报错
INSERT INTO product_ext (id, attrs) VALUES
  (1, '{"brand":"Apple","color":"gray","storage":256}');

-- 读取:-> 返回 JSON,->> 返回去引号文本
SELECT attrs->>'$.brand' AS brand,
       attrs->'$.storage' AS storage
FROM product_ext WHERE id = 1;

2.2 内部存储格式

MySQL JSON 用二进制格式(不是纯文本)存储,支持快速路径读取子值;每次修改会重新序列化整列,所以 JSON 列不适合高频小字段更新。

特性说明
校验写入时强校验 JSON 合法性
存储二进制格式,比文本略省空间
更新整列重写,JSON_SET 局部更新实际是全量重写
限制列最大 4GB(受 max_allowed_packet 约束)

3. PostgreSQL JSONB 类型

3.1 JSON 与 JSONB 的区别

维度JSONJSONB
存储原文保留,含空格/键序二进制解析后存储,键重排
索引不支持支持 GIN 索引
更新保留原结构重写
对比按文本按语义(键序无关)
-- PG 创建 JSONB 表
CREATE TABLE product_ext (
  id BIGSERIAL PRIMARY KEY,
  attrs JSONB NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- 查询与索引
CREATE INDEX idx_attrs_brand ON product_ext USING gin (attrs);
SELECT * FROM product_ext WHERE attrs @> '{"brand":"Apple"}';

一句话总结: MySQL 的 JSON 偏"存储校验型",PostgreSQL 的 JSONB 偏"查询索引型"。MySQL 要建索引得靠虚拟列,PG 用 GIN 直接索引整个文档。

4. 半结构化建模原则

4.1 稳定列 vs JSON 列的划分

字段特征归属例子
高频过滤/排序独立列状态、价格、时间
强一致/外键独立列用户 ID、订单 ID
低频查询/稀疏JSON 列自定义属性、扩展参数
易变结构JSON 列三方接口原始返回
划分三问:
  1. 会不会 WHERE/ORDER BY 用它?     → 会 → 独立列
  2. 会不会 JOIN/唯一约束?           → 会 → 独立列
  3. 变更频率高、旧数据不统一?       → 会 → JSON 列

4.2 给 JSON 定"契约"

灵活不等于没有约定。必须在文档/校验层定义 JSON 的键结构、类型、可空性:

{
  "brand": "string 必填",
  "color": "string 可选",
  "storage": "int 可选,单位 GB",
  "extras": "object 自由扩展,限 3 层"
}
-- 用 CHECK 约束兜底契约(PG 示例)
ALTER TABLE product_ext ADD CONSTRAINT attrs_shape CHECK (
  jsonb_typeof(attrs->'brand') = 'string'
);

4.3 控制 JSON 列的大小与复杂度

JSON 列是「灵活性的缓冲区」,但无限膨胀会反噬性能:

约束建议
单 JSON 值大小控制在 KB 到几十 KB 级,避免 MB 级大文档
嵌套深度不超过 3~4 层,过深难索引难维护
键数量单文档键控制在 20~50 个以内
数组用法数组元素要同构,便于索引与校验
推荐形态:小且平的 JSON
  {"a": 1, "b": "x", "c": [1,2,3]}                ✅ 好:3 层内、键少
  {"a": {"b": {"c": {"d": {"depth": 5}}}}}        ❌ 坏:深嵌套、难查

一句话: JSON 列装「扩展点」而不装「整个对象」。过大过深的 JSON,既不透明,也让虚拟列/GIN 索引形同虚设。

5. JSON 上的索引:MySQL 虚拟列 + PG GIN

5.1 MySQL:虚拟列 + 普通索引

MySQL 不能直接索引 JSON 子字段,但可以建虚拟列(Generated Column),把子值"抽"成真实列再建索引:

CREATE TABLE product_ext (
  id BIGINT PRIMARY KEY,
  attrs JSON NOT NULL,
  brand VARCHAR(64) AS (attrs->>'$.brand') STORED,   -- 虚拟列
  INDEX idx_brand (brand)                            -- 索引虚拟列
);

-- 查询时直接用虚拟列条件 → 命中索引
SELECT * FROM product_ext WHERE brand = 'Apple';
类型说明
VIRTUAL不占存储,查询时计算,索引为"函数索引"性质
STORED占用存储,写入时物化,索引更常规

5.2 PostgreSQL:GIN 索引与包含查询

-- GIN 索引:支持 @>、?、?| 等操作符
CREATE INDEX idx_attrs ON product_ext USING gin (attrs jsonb_path_ops);

-- 常用查询
SELECT * FROM product_ext WHERE attrs @> '{"color":"gray"}';     -- 包含
SELECT * FROM product_ext WHERE attrs ? 'brand';                 -- 存在键
SELECT * FROM product_ext WHERE attrs ?| ARRAY['color','size'];  -- 任一键

一句话总结: 想对 JSON 子字段提速,MySQL 走"虚拟列抽值 + 普通索引",PostgreSQL 走"GIN 索引 + 包含查询"。两者都要求查询条件可预测。

5.3 函数索引与表达式索引

MySQL 8.0 与 PostgreSQL 都支持「对表达式建索引」,比虚拟列更直接:

-- MySQL 8.0+:函数索引
CREATE INDEX idx_attrs_color ON product_ext ((CAST(attrs->>'$.color' AS CHAR(32))));

-- PostgreSQL:表达式索引
CREATE INDEX idx_attrs_brand ON product_ext ((attrs ->> 'brand'));

-- 查询要写与索引表达式完全一致的形式才能命中
SELECT * FROM product_ext WHERE CAST(attrs->>'$.color' AS CHAR(32)) = 'gray';
方案写法适用
MySQL 虚拟列先抽列再索引查询稳定、结构清晰
MySQL 函数索引直接索引表达式不想加列
PG GIN整文档索引查询模式多样
PG 表达式索引单表达式单一高频路径

索引表达式与查询表达式必须逐字一致才能命中,大小写、空格、类型转换都要对得上。这也是「JSON 查询难优化」的常见陷阱。

6. 演进迁移:从 JSON 到规范化

灵活 Schema 的代价是查询与约束变弱。当某字段从"低频"成长为"高频",就该把它抽取为独立列——这通常伴随一次平滑迁移。

6.1 字段晋升的三阶段

阶段一:保持 JSON,新增虚拟列 + 索引(读路径无感)
阶段二:双写,写入时同时维护 JSON 与独立列(新老字段并存)
阶段三:数据回填 + 代码切换,老代码只读 JSON,新代码读独立列
阶段四:下线 JSON 字段(回收冗余)
-- 阶段一示例:为高频字段建虚拟列与索引
ALTER TABLE product_ext
  ADD COLUMN storage_gb INT AS (attrs->>'$.storage') STORED,
  ADD INDEX idx_storage (storage_gb);

-- 阶段二示例:应用层双写(伪代码)
-- product.set_storage(256); product.attrs['storage'] = 256; 两者同步写

6.2 回填与校验

-- 阶段三:把历史 JSON 数据回填到独立列
UPDATE product_ext
SET brand = attrs->>'$.brand'
WHERE brand IS NULL AND JSON_EXTRACT(attrs, '$.brand') IS NOT NULL;

-- 回填后对比抽查:JSON 与独立列是否一致
SELECT COUNT(*) FROM product_ext
WHERE brand <> attrs->>'$.brand';   -- 期望 0

一句话总结: JSON 字段的"晋升"要渐进:先虚拟列加速读,再双写对齐,再回填切换,最后才下线冗余。不要一步到位直接改表结构。

6.3 迁移监控与回滚

四阶段迁移是「长周期变更」,每一步都要可观测、可回退:

阶段观测指标回退方式
虚拟列新查询是否命中索引删索引即可
双写JSON 与独立列一致性对比停止独立列写入
回填回填进度、一致性为 0回填可重跑
下线独立列覆盖全部路径保留 JSON 一周后再删
-- 双写阶段的一致性巡检(每天跑一次)
SELECT COUNT(*) FROM product_ext
WHERE brand IS NOT NULL AND attrs->>'$.brand' IS NOT NULL
  AND brand <> attrs->>'$.brand';            -- 期望 0

-- 回填进度监控
SELECT COUNT(*) AS total,
       SUM(brand IS NOT NULL) AS filled
FROM product_ext;                             -- filled/total 趋近 100%

迁移最大的风险不是「没迁完」,而是「迁错了没发现」。一致性巡检脚本必须在整个迁移周期内常驻运行,直到下线前一刻。

7. 避坑清单

坑表现对策
全表当 JSON 垃圾桶所有字段塞进 JSON,查询全 miss稳定高频字段必须独立列
JSON 子字段无索引WHERE 子字段全表扫MySQL 虚拟列 / PG GIN
大 JSON 高频更新整列重写,写放大拆分热点小字段为列
无键结构契约键名/类型漂移CHECK 约束 + 文档约定
拿 JSON 做 JOIN/唯一键语义差、性能差关键标识字段独立列
直接改表结构迁移停服/数据不一致四阶段渐进迁移

7.1 何时应该弃用 JSON

信号说明
该字段进入高频 WHERE命中率敏感,需索引
该字段参与 JOIN/去重需要语义化
需要唯一约束JSON 内无法做唯一
需要强类型/范围校验如金额、枚举
JSON 列超过整行一半该「转正」为列了

弃用 JSON 不是否定灵活性,而是承认它已完成了「撑过演进期」的使命。及时转正,是灵活 Schema 的正解。

8. 总结

环节要点
选型稳定高频列独立,易变稀疏字段进 JSON
MySQL二进制校验存储,虚拟列 + 索引提速
PostgreSQLJSONB 语义存储,GIN 索引 + @> 查询
契约CHECK 约束 + 文档定义键结构与类型
索引可预测的子字段查询才值得建索引
迁移虚拟列 → 双写 → 回填 → 下线,渐进演进

JSON 与灵活 Schema 是在"结构稳定"与"演进速度"之间做的权衡。用好了,它是应对多变业务的利器;用滥了,它就是性能与一致性的黑洞。判断标准始终是那句话:能被稳定建模的字段,就别让它待在 JSON 里。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. Supabase 平台与 PostgreSQL 边缘函数实践
  2. PostgreSQL 事件触发器与审计日志实现
  3. Kubernetes 上 PostgreSQL 运维与 CloudNativePG 实战