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 的区别
| 维度 | JSON | JSONB |
|---|---|---|
| 存储 | 原文保留,含空格/键序 | 二进制解析后存储,键重排 |
| 索引 | 不支持 | 支持 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 | 二进制校验存储,虚拟列 + 索引提速 |
| PostgreSQL | JSONB 语义存储,GIN 索引 + @> 查询 |
| 契约 | CHECK 约束 + 文档定义键结构与类型 |
| 索引 | 可预测的子字段查询才值得建索引 |
| 迁移 | 虚拟列 → 双写 → 回填 → 下线,渐进演进 |
JSON 与灵活 Schema 是在"结构稳定"与"演进速度"之间做的权衡。用好了,它是应对多变业务的利器;用滥了,它就是性能与一致性的黑洞。判断标准始终是那句话:能被稳定建模的字段,就别让它待在 JSON 里。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。