拆表是常见的优化手段——把一张宽表拆成两张窄表,减少冗余、提升查询效率。但拆表之后,原本一条 INSERT 就能搞定的事,变成了需要事务包裹的两条 INSERT。从自动提交的单条写入,变成一个事务块,工作负载的形态变了,锁的持有时间也变了。
MySQL 9.7 的 JSON Duality View 给了另一种解法:对着视图做一条 INSERT,数据库在内部把数据拆到两张表里,原子性由视图保证,客户端只发了一次请求。
拆表之后的问题
假设原来有一张 orders 表,既存订单基本信息,又存收货地址。地址信息冗余严重,于是拆成两张表:
CREATE TABLE orders (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) DEFAULT 'created',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_addresses (
order_id BIGINT PRIMARY KEY,
city VARCHAR(50),
district VARCHAR(50),
street VARCHAR(100),
FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
拆完之后,创建一笔订单需要这样写:
START TRANSACTION;
INSERT INTO orders (user_id, status) VALUES (1001, 'created');
-- 需要拿到 LAST_INSERT_ID() 才能写第二张表
SET @oid = LAST_INSERT_ID();
INSERT INTO order_addresses (order_id, city, district, street)
VALUES (@oid, '北京', '海淀区', '中关村南大街5号');
COMMIT;
两个问题立刻浮现:
- 两次往返——客户端和数据库之间多了一轮交互(拿
LAST_INSERT_ID),网络延迟被放大。 - 事务形态变了——原本是自动提交的单条
INSERT,现在变成显式事务,锁持有时间从一条语句拉长到整个事务块,并发吞吐可能下降。
Duality View 怎么解决
MySQL 9.7 引入的 JSON Duality View,本质是定义一个 JSON 文档结构和底层关系表的映射关系。对视图做 CRUD,数据库自动把操作翻译到底层表上。
创建视图的语法:
CREATE JSON DUALITY VIEW order_view AS
orders
{ _id: '$.order_id',
user_id: '$.user_id',
status: '$.status',
created_at: '$.created_at',
address:
order_addresses
{ city: '$.city',
district: '$.district',
street: '$.street'
}
};
这段定义说的是:order_view 对应的 JSON 文档,顶层字段来自 orders 表,嵌套的 address 字段来自 order_addresses 表,通过 order_id 关联。
现在插入一笔订单,只需要一条语句:
INSERT INTO order_view VALUES (
'{"user_id": 1001, "status": "created", "address": {"city": "北京", "district": "海淀区", "street": "中关村南大街5号"}}'
);
数据库内部做了三件事:
- 解析 JSON,把顶层字段写入
orders,拿到生成的order_id。 - 把嵌套的
address字段连同order_id写入order_addresses。 - 整个操作在一个内部事务里完成,对外表现为一条语句的自动提交。
客户端只发了一次请求,没有 LAST_INSERT_ID 的往返,没有显式事务,锁的持有范围回到单语句级别。
实操:从建表到验证
以下脚本可以在 MySQL 9.7+ 上直接运行。先确认版本:
mysql -u root -p -e "SELECT VERSION();"
# 期望输出类似 9.7.x
完整建表、建视图、插入、验证流程:
-- 1. 建表
CREATE TABLE orders (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) DEFAULT 'created',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_addresses (
order_id BIGINT PRIMARY KEY,
city VARCHAR(50),
district VARCHAR(50),
street VARCHAR(100),
FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
-- 2. 建 Duality View
CREATE JSON DUALITY VIEW order_view AS
orders
{ _id: '$.order_id',
user_id: '$.user_id',
status: '$.status',
created_at: '$.created_at',
address:
order_addresses
{ city: '$.city',
district: '$.district',
street: '$.street'
}
};
-- 3. 单条 INSERT 插入两张表
INSERT INTO order_view VALUES (
'{"user_id": 1001, "status": "created", "address": {"city": "北京", "district": "海淀区", "street": "中关村南大街5号"}}'
);
-- 4. 验证:两张表都有数据
SELECT * FROM orders;
SELECT * FROM order_addresses;
-- 5. 通过视图查询,拿到完整 JSON 文档
SELECT * FROM order_view;
SELECT * FROM order_view 会返回类似这样的 JSON:
{
"_id": 1,
"user_id": 1001,
"status": "created",
"created_at": "2025-07-10T12:00:00",
"address": {
"city": "北京",
"district": "海淀区",
"street": "中关村南大街5号"
}
}
更新和删除同样走视图
更新只改地址,不需要知道 order_id:
UPDATE order_view
SET doc = JSON_SET(doc, '$.address.city', '上海')
WHERE _id = 1;
删除会同时清理两张表的关联行:
DELETE FROM order_view WHERE _id = 1;
-- orders 和 order_addresses 对应行都被删掉
边界与取舍
Duality View 不是万能的,有几个需要注意的点:
| 方面 | 说明 |
|---|---|
| 版本门槛 | 需要 MySQL 9.7+,目前还在快速迭代,生产环境需评估稳定性 |
| 映射复杂度 | 嵌套层级越深,视图定义越复杂,调试成本上升 |
| 性能开销 | JSON 解析和拆解有额外 CPU 开销,高吞吐场景需要基准测试 |
| 直接写底层表 | 仍然可以直接对 orders 和 order_addresses 做 INSERT,但绕过了视图的一致性保障,地址行可能孤立 |
| 索引策略 | 视图本身不建索引,索引仍在底层表上;按 JSON 字段查询走视图时,能否命中索引取决于底层表的设计 |
适合用的场景:拆表后的写入路径是你想优化的瓶颈,且你的数据模型天然是"主表 + 从属子表"的父子结构。不适合的场景:两张表之间是多对多关系,或者嵌套层级超过三层——视图定义会变得难以维护。
上手检查清单
- 确认 MySQL 版本 ≥ 9.7,且
CREATE JSON DUALITY VIEW语法可用。 - 识别你想拆的宽表,确认拆出的是从属子表(逻辑上是主表的嵌套属性)。
- 建底层两张表,外键指向主表。
- 定义 Duality View,
_id映射主表主键,嵌套对象映射子表。 - 用一条
INSERT INTO view VALUES ('{...}')替换原来的事务块。 - 基准测试:对比原方案(事务 + 两次往返)和新方案(视图 + 单次往返)的延迟和吞吐。
- 确认应用代码不再直接写子表,所有写入走视图,避免数据不一致。
拆表不再意味着必须接受事务膨胀和多次往返。Duality View 把"拆"的代价压到了最小——一条 INSERT,两张表,一次网络请求。