多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL JSON类型完全指南:从函数使用到索引优化与JSON_TABLE实战

MySQL JSON类型完全指南:从函数使用到索引优化与JSON_TABLE实战 MySQL 的 JSON 数据类型从 5.7 引入到现在快十年了但我在实际项目里见到的大量用法还停留在“把 JSON 塞进 TEXT 字段查询时全表捞出来再用程序解析”这种原始阶段。等到数据量上来、接口响应变慢、想按 JSON 里的某个字段过滤却没法走索引的时候才意识到原生 JSON 数据类型和那一整套 JSON 函数不是为了让你多记几个函数名而是真的能改变表结构设计思路的东西。这篇文章就围绕 MySQL 的 JSON 数据类型展开把写入、查询、索引、展开成表、避坑这几块一次说透。内容不要求你有很深的底子只要写过一点 SQL、知道 JSON 长什么样就能跟着实际操作。看完你会明白什么场景适合用 JSON 列、什么场景千万别用也会知道 5.7 和 8.0 在 JSON 功能上的差距有多大。1. 先说清楚JSON 数据类型和 TEXT 存 JSON 有什么本质区别1.1 它和 TEXT、VARCHAR 比到底强在哪很多人觉得“既然都是存一段 JSON 文本用 VARCHAR 还是 JSON 有什么区别”。从存储结果上看确实都是字符但从 MySQL 内部处理方式来看完全是两回事。JSON 类型的值MySQL 会先校验合法性再做解析最后以二进制格式存储内部格式叫jsonb和 PostgreSQL 的 jsonb 思想类似。这种二进制格式有几个直接好处插入时语法错误直接报错不会等到程序读出来才发现数据是坏的。读取 JSON 字段时不需要每次都做文本解析查询性能更稳定。存储时会自动做键去重和键排序底层按 key 的二进制顺序排列查找某个键用的是二分查找而不是遍历整个文档。支持 MySQL 提供的一整套 JSON 函数做提取、更新、判断、展开操作。用 TEXT 存 JSONMySQL 完全不知道里面是什么无法校验无法索引其中的字段更别提做任何 JSON 函数操作。所有处理都要靠应用层自己来数据库只是一个“文件柜”。打个比方TEXT 字段像你把所有纸质文件装进一个大牛皮纸袋扔进仓库存取都是整袋搬。JSON 类型则是仓库管理员帮你把每份文件登记、编号、按规则摆放你只要说“把编号 9527 的文件第二页第三行的内容给我”管理员直接就能取出来不需要翻完整袋。对比项TEXT 存 JSONJSON 类型格式校验无非法文本也入库插入时强制校验存储形式原始文本二进制键排序去重字段级提取无法原生提取JSON_EXTRACT、- 等字段级索引无法对内部字段建索引生成列或函数索引部分更新全量替换支持部分更新8.0内存消耗全量读入二进制解析提取局部字段开销更小1.2 什么时候该用 JSON什么时候千万别用JSON 列适合那些“结构会变、查询不复杂、事务要求不高”的数据第三方接口的原始返回报文特别是字段经常加减的情况。用户扩展属性、偏好设置、埋点事件的动态参数。标签数组、属性集合一张表里每条记录包含的字段个数不同。配置类数据如商品的活动规则、优惠券的有效条件。不适合的场景也很明确需要和业务表频繁 JOIN 的字段JSON 列没法走正常的等值 JOIN 索引除非先抽成生成列。核心交易字段比如订单金额、库存数量这类字段需要强约束、外键、表级完整性检查应该做成普通列。高频检索字段比如按用户 ID、状态、地区过滤应该提出来建索引而不是埋在 JSON 里让数据库每次全文档扫。超大文档一条记录几 MB 甚至几十 MB 的 JSON读出来、写进去都是大开销数据库不是对象存储。我见过比较典型的反面案例有人把用户收货地址整个塞进 JSON 字段然后后台管理系统要按省份统计订单量每次只能把全表 JSON 捞到应用层再循环统计。数据量几万条时还能忍上了几十万条直接卡死。这种字段哪怕当时觉得“以后可能加字段”也该把省市区单独建列。2. JSON 写入与更新的几个函数用不对就会出 bug2.1 最基础的写入直接插字符串和函数构建最简单的写入方式就是直接给 JSON 类型的列插入一个 JSON 字面量比如INSERT INTO t_user_profile (user_id, extra_info) VALUES (1001, {level: 6, tags: [vip, active]});这里有个最常见的初学者错误JSON 里必须使用双引号不能使用单引号而且键和字符串值都要带引号。{level: 6}这种写法 MySQL 直接报Invalid JSON text。为了减少手写错误可以用JSON_OBJECT和JSON_ARRAY两个构建函数SELECT JSON_OBJECT( level, 6, tags, JSON_ARRAY(vip, active) );输出{level: 6, tags: [vip, active]}JSON_ARRAY可以嵌套、可以传多参数配合插入语句更稳妥而且不用考虑转义问题。2.2 JSON_SET、JSON_INSERT、JSON_REPLACE 三兄弟的区别这三个函数是日常更新 JSON 最常用的很多人搞混。核心区别在于对“已存在路径”和“不存在路径”的处理方式函数路径已存在路径不存在JSON_SET覆盖新值插入新键JSON_INSERT保留旧值插入新键JSON_REPLACE覆盖新值忽略不插入看一组实测就明白了。假设初始值是{level: 6}SET doc {level: 6}; SELECT JSON_SET(doc, $.level, 8, $.name, tom); -- 结果: {level: 8, name: tom} SELECT JSON_INSERT(doc, $.level, 8, $.name, tom); -- 结果: {level: 6, name: tom} SELECT JSON_REPLACE(doc, $.level, 8, $.name, tom); -- 结果: {level: 8}实际业务中更新用户积分这种就是JSON_SET因为积分存在就覆盖、不存在就创建插入配置文件中的可选参数就是JSON_INSERT避免把用户已自定义的值覆盖掉。2.3 数组操作JSON_ARRAY_APPEND 和 JSON_ARRAY_INSERT数组是需要单独处理的因为JSON_SET是针对键路径的直接赋值不适合往数组里追加元素。SET doc {tags: [mysql, json]}; SELECT JSON_ARRAY_APPEND(doc, $.tags, database); -- 结果: {tags: [mysql, json, database]} SELECT JSON_ARRAY_INSERT(doc, $.tags[1], nosql); -- 结果: {tags: [mysql, nosql, json]}JSON_ARRAY_APPEND把新元素追加到数组末尾JSON_ARRAY_INSERT指定位置插入。注意位置索引从 0 开始[1]表示插到第二个位置。删除键用JSON_REMOVESELECT JSON_REMOVE(doc, $.tags);这个函数可以传多个路径一次性删多个键。2.4 合并函数JSON_MERGE_PATCH 和 JSON_MERGE_PRESERVE合并两个 JSON 对象8.0 里有两个函数JSON_MERGE_PATCH和JSON_MERGE_PRESERVE。前者是标准 RFC 7396 的合并语义后者保留所有重复键的值。实际项目中推荐用JSON_MERGE_PATCH因为它符合直觉相同键后者覆盖前者。SELECT JSON_MERGE_PATCH({a: 1, b: 2}, {b: 3, c: 4}); -- 结果: {a: 1, b: 3, c: 4} SELECT JSON_MERGE_PRESERVE({a: 1, b: 2}, {b: 3, c: 4}); -- 结果: {a: 1, b: 2, b: 3, c: 4}注意MERGE_PRESERVE遇到重复键会生成数组比如b: [2, 3]的形式容易出现意外。如果你只是想让默认配置和用户配置合并用MERGE_PATCH。2.5 部分更新特性8.0 的性能红利MySQL 8.0 有一个容易被忽略的优化当你在一条UPDATE语句中用JSON_SET、JSON_REPLACE、JSON_REMOVE等函数操作 JSON 列时只要目标表使用的是binlog_formatROW且binlog_row_value_optionsPARTIAL_JSONMySQL 可以只更新 JSON 文档中变化的那一部分而不是整个 JSON 文档替换。这意味着大 JSON 文档的更新开销会显著下降重写日志的量也小很多。要确认这个特性是否开启可以检查SHOW VARIABLES LIKE binlog_row_value_options; -- 应显示 PARTIAL_JSON这个细节在 5.7 上是没有的也是我建议新项目直接用 8.0 的理由之一。3. 查询与提取路径表达式和 -、- 的区别3.1 JSON 路径写法入门JSON 函数里几乎都要用到路径表达式。先记住最基本的三种$.key表示对象某个键$[0]表示数组第一个元素索引从 0 开始$.arr[*]表示数组arr的所有元素。文档里有特殊字符的键怎么处理比如键名是user-name路径写法是$.user-name需要用双引号包住。遇到中文键名也可以直接写比如$.名称但一般不建议这样设计后续写 SQL 容易踩字符集相关的坑。3.2 JSON_EXTRACT 与 -、- 的等价关系JSON_EXTRACT是标准的提取函数返回的是 JSON 类型。比如SELECT JSON_EXTRACT({name: tom, age: 18}, $.name); -- 返回: tom 注意带着双引号-是JSON_EXTRACT的语法糖-是JSON_UNQUOTE(JSON_EXTRACT(...))的语法糖会去掉外层双引号。写法返回类型示例结果json_col - $.nameJSONtom带引号json_col - $.name字符串tom不带引号JSON_UNQUOTE(JSON_EXTRACT(...))字符串tom实测一条SELECT extra_info-$.level AS level_json, -- 返回 6 extra_info-$.name AS name_str; -- 返回 tom运行SELECT时如果发现查询结果为tom带着双引号说明你用了-。和字符串比较时直接-更省事。需要注意-返回的是字符串即使 JSON 里存储的是数字。如果要按数字比较需要CAST(extra_info-$.age AS UNSIGNED)。想保留数字类型可以考虑JSON_VALUE函数8.0.21 版本开始推荐使用它可以指定返回类型SELECT JSON_VALUE({age: 18}, $.age RETURNING UNSIGNED) 1; -- 返回 19是数字运算JSON_VALUE在 8.0.21 以后是官方很推荐的新函数能替代大部分JSON_EXTRACTCAST的组合语法也更紧凑。如果是 8.0.21 之前的版本只能老实CAST。3.3 判断路径是否存在和值是否满足条件检查某个路径是否存在用JSON_CONTAINS_PATHSELECT JSON_CONTAINS_PATH(doc, one, $.a, $.b); -- 第二个参数 one 表示任意一个存在即返回 1 -- all 表示必须全部存在判断 JSON 里是否包含某个指定值用JSON_CONTAINS。这个方法特别容易写错因为第二个参数必须是 JSON 格式的字符串。比如判断数组中是否包含字符串mysqlSELECT JSON_CONTAINS([mysql, json], mysql); -- 注意 mysql 必须带双引号更稳妥的写法是用JSON_QUOTE自动加引号SELECT JSON_CONTAINS([mysql, json], JSON_QUOTE(mysql));JSON_OVERLAPS是 8.0.17 新增的用来判断两个数组是否有交集SELECT JSON_OVERLAPS([mysql, json], [mysql, nosql]); -- 返回 1因为有交集这个函数配合多值索引有奇效后面讲索引时细说。3.4 观察 JSON 结构的辅助函数排错时经常用JSON_KEYS看外层键、JSON_LENGTH看长度、JSON_DEPTH看嵌套深度SELECT JSON_KEYS({a: 1, b: {c: 2}}), -- [a, b] JSON_LENGTH({a: 1, b: 2}), -- 2 JSON_DEPTH({a: {b: {c: 1}}}); -- 4JSON_LENGTH对对象返回外层键个数对数组返回元素个数对字符串或数字返回 1。JSON_DEPTH对空文档返回 1。4. 让 JSON 查询跑快生成列索引与多值索引完整姿势4.1 为什么不能直接对 JSON 列加普通索引MySQL 允许对 JSON 列本身建索引但这种索引对整个 JSON 二进制对象做排序几乎没什么实用价值。你想按$.create_time过滤数据MySQL 还是要扫描所有行把每行的 JSON 取出来解析再根据值判断是否符合条件。正确的姿势是用生成列Generated Column。4.2 虚拟生成列加索引5.7 到 8.0 通用方案建表时可以直接定义生成列也可以对已有表补充ALTER TABLE t_user_profile ADD COLUMN level_int INT GENERATED ALWAYS AS (extra_info-$.level) VIRTUAL, ADD INDEX idx_level (level_int);这条 SQL 里的生成列是 VIRTUAL 类型意思是它不实际占用磁盘存储只在读取时根据 JSON 内容计算出来。因为我们对它建了索引所以按level_int过滤时可以直接走idx_level索引。查询时直接用生成列名SELECT user_id, level_int FROM t_user_profile WHERE level_int 5;注意想让-返回的数字类型参与索引比较最好在建生成列时显式CAST(... AS UNSIGNED)或声明列类型为 INT否则比较时可能隐式转换导致索引失效。上面的写法$.level返回字符串MySQL 会自动转数字但碰到不规则数据容易出问题。推荐写法ALTER TABLE t_user_profile ADD COLUMN level_int INT GENERATED ALWAYS AS (CAST(extra_info-$.level AS UNSIGNED)) VIRTUAL, ADD INDEX idx_level (level_int);生成列还有个变体叫 STORED它实际占磁盘空间写入表中。一般只在大数据量查询、虚拟列计算开销明显时才用 STORED。日常优先 VIRTUAL省空间。4.3 8.0.17 的多值索引数组成员的福音如果 JSON 里存的是数组比如用户的标签[vip, gold, new]你想查拥有vip标签的所有用户普通的生成列只生成一个值建索引也没法覆盖数组里每一个元素。8.0.17 开始支持多值索引Multi-Valued Index专门解决这个问题。定义语法是在生成列表达式上用CAST(... AS ... ARRAY)ALTER TABLE t_user_profile ADD INDEX idx_tags ((CAST(extra_info-$.tags AS CHAR(32) ARRAY)));这里外层多写一对括号是函数索引的标准写法。建完索引后查询时必须用匹配多值索引的函数比如JSON_CONTAINS或JSON_OVERLAPSSELECT user_id FROM t_user_profile WHERE JSON_CONTAINS(extra_info-$.tags, JSON_QUOTE(vip));我用一个 50 万行的测试表对比过没有多值索引时这条查询耗时约 780ms全表扫描加 JSON 解析建立多值索引后耗时降到 15ms 以内。数据量再涨差距只会更大。多值索引有坑需要注意只能对数组值建立普通标量不行索引列的类型必须匹配查询时的类型字符串标签要用 CHAR(n) 数组数字 ID 要用 UNSIGNED 数组使用多值索引的查询条件种类有限制不能像普通索引那样随便加范围、排序、模糊条件一条查询里涉及多个数组键时优化器可能无法同时使用多个多值索引。4.4 8.0.13 之后的函数索引写法其实从 8.0.13 开始MySQL 支持在表达式上直接建索引不需要先把表达式定义为生成列CREATE INDEX idx_name ON t_user_profile ((extra_info - $.name));但这条 SQL 在 8.0 上有个限制表达式索引中使用的函数必须是确定的-是允许的。如果你用CAST(extra_info-$.name AS CHAR)这种通常也可以。我个人在项目里还是更喜欢生成列方案因为生成列有名字写查询时直接引用name_str列语义清晰而且后续如果要做分区、统计生成列都能正常使用表达式索引只能用于查询优化。5. 实战拆解用 JSON 函数处理业务数据5.1 场景设定假设我们在运营一个内容推荐系统有一张用户扩展信息表CREATE TABLE user_profile ( user_id INT PRIMARY KEY, extra_info JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );插入两条模拟数据INSERT INTO user_profile (user_id, extra_info) VALUES (1001, {name: tom, level: 6, tags: [vip, active, mysql], preferences: {theme: dark, lang: zh}}), (1002, {name: lucy, level: 3, tags: [new, active], preferences: {theme: light, lang: en}});5.2 典型查询组合场景一筛选 level 大于 4 的用户并返回用户名和主题偏好。SELECT user_id, extra_info-$.name AS user_name, extra_info-$.preferences.theme AS theme FROM user_profile WHERE CAST(extra_info-$.level AS UNSIGNED) 4;这里用了多级路径$.preferences.theme直接一层层写下来就行。结果1001 tom dark场景二找出 tags 里包含 “active” 的用户。SELECT user_id FROM user_profile WHERE JSON_CONTAINS(extra_info-$.tags, JSON_QUOTE(active));如果上面建了多值索引这条查询自动走索引。场景三统计所有用户标签的分布情况。这单靠 JSON 行内函数做不出来需要把数组展开成行也就是JSON_TABLE的活下一节细说。场景四给用户新增一个标签同时修改 level。UPDATE user_profile SET extra_info JSON_SET( JSON_ARRAY_APPEND(extra_info, $.tags, 2026), $.level, 7 ) WHERE user_id 1001;注意我用了嵌套写法先往 tags 数组追加元素再对结果设置 level。JSON_SET的第三个参数可以传路径和新值但如果路径是$.level7就需要对原文档操作所以我先做了一个数组追加。实际生产里经常要这样组合多个函数。场景五删除某个键。UPDATE user_profile SET extra_info JSON_REMOVE(extra_info, $.preferences.lang) WHERE user_id 1001;5.3 用 JSON 字段做分页和排序JSON 里的值也可以参与ORDER BY。注意数字和字符串排序的区别最好先 CASTSELECT user_id, extra_info-$.name AS user_name FROM user_profile ORDER BY CAST(extra_info-$.level AS UNSIGNED) DESC LIMIT 10;这里如果 level 已经建成生成列直接用生成列名排序性能更好SELECT user_id, level_int FROM user_profile ORDER BY level_int DESC LIMIT 10;生成列上如果建了索引排序也能走索引避免filesort。6. JSON_TABLE把数组撑开成一张临时表6.1 语法结构和基本用法前面提到标签分布统计JSON_TABLE就是干这个的。它把 JSON 数组展开成多行像一个临时表一样供SELECT查询、JOIN、GROUP BY使用。先看最基础的结构SELECT t.tag, COUNT(*) AS cnt FROM user_profile, JSON_TABLE(extra_info, $.tags[*] COLUMNS ( tag VARCHAR(20) PATH $ ) ) AS jt GROUP BY t.tag;执行后tagcntvip1active2mysql1new1用法拆解第一个参数extra_info是要展开的 JSON 文档来源第二个参数$.tags[*]是路径表达式表示取tags数组的每一个元素COLUMNS里定义展开后临时表的列名、类型和取值路径PATH $表示取当前元素本身AS jt必须给临时表起别名否则语法错误。如果 JSON 数组里的元素是对象可以一次取多个字段SELECT jt.id, jt.name FROM user_profile, JSON_TABLE(extra_info, $.items[*] COLUMNS ( id INT PATH $.id, name VARCHAR(50) PATH $.name ) ) AS jt;6.2 嵌套数组展开处理多层嵌套时在COLUMNS里嵌套NESTED PATHSELECT jt.category, jt.sub FROM doc, JSON_TABLE(doc, $.categories[*] COLUMNS ( category VARCHAR(20) PATH $.name, NESTED PATH $.items[*] COLUMNS ( sub VARCHAR(50) PATH $.name ) ) ) AS jt;这样能把分类下的子项目也撑开成行实现“二维表”展开。嵌套路径的列也拼进同一行里。6.3 保留原始行LEFT JOIN 写法默认写法FROM user_profile, JSON_TABLE(...)是内连接如果某行 JSON 数组为空这行就不会出现在结果里。想保留原行时用LEFT JOINSELECT u.user_id, jt.tag FROM user_profile u LEFT JOIN JSON_TABLE(u.extra_info, $.tags[*] COLUMNS ( tag VARCHAR(20) PATH $ ) ) AS jt ON TRUE WHERE u.user_id 1001;这里ON TRUE是常用的技巧因为 JSON_TABLE 本质上已经把每一行的 JSON 数组展开成了子行集合不需要额外的连接条件。6.4 JSON_TABLE 配合聚合统计统计级联数据时JSON_TABLE 比应用层循环解析高效得多。假设有一张订单表每条订单的 JSON 里有多个商品SELECT user_id, SUM(jt.price * jt.qty) AS total_amount FROM orders, JSON_TABLE(order_json, $.items[*] COLUMNS ( price DECIMAL(10,2) PATH $.price, qty INT PATH $.qty ) ) AS jt GROUP BY user_id;这种聚合逻辑如果用代码写要先查订单列表然后一条条解析 JSON、累加再分组合并不仅代码量多还会产生 N1 查询。用 JSON_TABLE 一条 SQL 搞定数据库内部批量处理。6.5 JSON_TABLE 的性能注意点JSON_TABLE 在数据量大时也会消耗内存毕竟是文档展开。如果一张表几万行、每行几百个数组元素展开后是百万级临时数据容易触碰内存阈值。我遇到过的情况是查询一执行临时表空间膨胀最后报The table /tmp/#sql... is full。排查后发现是tmp_table_size和max_heap_table_size太小展开的JSON_TABLE结果超过内存临时表上限落到磁盘临时文件又不够空间。调大这两个参数SET GLOBAL tmp_table_size 1024 * 1024 * 256; SET GLOBAL max_heap_table_size 1024 * 1024 * 256;但如果条件允许最好是先通过WHERE过滤掉不需要展开的行让参与 JSON_TABLE 的数据量小一些。7. 高频踩坑实录JSON 列的 12 个反直觉细节7.1 JSON 字符串里的 null和 SQL 的 NULL 完全不是一回事插入文档时写{name: null}提取出来的是 JSON null。它在 SQL 层面不是NULL而是NULL的 JSON 值表示。判断字段是否为 JSON null 时不能用IS NULL要用JSON_TYPE()判断返回类型或者json_col - $.name IS NULL判断因为提取的是字符串形式JSON null 会变成 SQL NULL。例如SELECT JSON_EXTRACT({name: null}, $.name) IS NULL; -- 返回 0因为提取结果是 JSON null SELECT JSON_EXTRACT({name: null}, $.name) CAST(null AS JSON); -- 返回 1实际开发中遇到“字段值在 JSON 里是 null”和“JSON 里根本没有这个键”这两种情况提取出来的 SQL 结果可能都是 NULL容易给应用层造成混淆。需要区分的话先JSON_CONTAINS_PATH判断键是否存在。7.2 键名大小写、字符集和排序规则问题JSON 对象的键名本身区分大小写{Name: 1}和{name: 1}是不同键。但对象底层做了键排序所以输出时顺序会和插入顺序不一样这是二进制存储的预期行为不是 bug。字符串值比较时按列的排序规则走如果你的表和字段是utf8mb4_general_cimysql和MYSQL会认为相等。这既是优点也是坑想精确匹配时要注意大小写敏感性必要时把列定义为utf8mb4_bin或utf8mb4_0900_as_cs。7.3 日期字符串排序容易出错JSON 里如果存的是2026-01-05这种 ISO 日期按字符串排序是对的但如果存的是2026-1-5字符串排序就完全乱了。强烈建议在应用层或者写入前统一格式日期时间写入 RFC3339 格式比如2026-01-05T10:30:00Z保证字典序和时间序一致。7.4 路径表达式访问数组时要注意越界$.tags[10]访问不存在的数组元素时不少版本会返回 NULL但早期版本可能出现ERROR 3148之类的报错。稳妥做法是先JSON_LENGTH判断数组长度再取元素。如果你只需要第一个元素可以直接$.tags[0]但要用JSON_TYPE校验它确实是数组否则$[0]在非数组上取到的结果是 NULL。7.5 JSON 更新时的全量重写问题5.7 上JSON_SET更新即使只改一个小键MySQL 也要把整个 JSON 文档读出来、解析、修改、重新序列化再写回存储引擎。一次 UPDATE 的 IO 开销和文档大小成正比。所以 5.7 时代不建议把大文档放 JSON 列。8.0 有了部分更新但也不是所有更新函数都触发部分更新。官方文档明确只有JSON_SET、JSON_REPLACE、JSON_REMOVE和JSON_INSERT这几个在满足条件时能够部分更新。你用JSON_ARRAY_APPEND改数组8.0 也支持部分更新。但如果你在应用层把整个文档读出来改完再整段写回那就享受不到这个优化。7.6 不要用SELECT *把大 JSON 列全部捞出来一个常见性能杀手前端接口本来只需要用户的 level但 ORM 默认查全表带extra_info每个请求都要传输几十 KB 的 JSON 数据。数据量一大网络和解析开销全部上去了。我在系统压测时见过一个接口只是改一行查询代码把extra_info从SELECT *中排除RT 从 260ms 降到 80ms。JSON 列一定按需取用不要无脑全字段带出来。7.7 5.7 与 8.0 的行为差异5.7 和 8.0 在很多 JSON 细节上不一样从 5.7 升级到 8.0 时要注意5.7 对 JSON 中的重复键会自动保留最后一个值8.0 同样如此但如果用JSON_OVERLAPS等新函数版本不兼容。JSON_MERGE在 5.7 是常用函数在 8.0 被拆成JSON_MERGE_PATCH和JSON_MERGE_PRESERVE老的JSON_MERGE在 8.0.3 之后被标记为 deprecated。8.0 新增JSON_TABLE、JSON_OVERLAPS、JSON_VALUE、多值索引。8.0.21 开始JSON_VALUE支持 RETURNING 类型之前想提取数字只能 CAST。如果你的项目跑在 5.7 上我建议你仔细评估一下升级到 8.0 的收益JSON 相关的性能差异不是 10%、20% 的差别而是量级上的差别。7.8 JSON 字段做 GROUP BY 的坑直接对 JSON 列做GROUP BY是可以的但注意 JSON 对象会被二进制序列化后比较出现两个内容相同但键顺序不同的文档会被判定为不同组。因为存储时键已经排序所以一般不会出问题但如果一个文档是通过程序拼接字符串写入的可能出现键顺序不一致聚合结果就乱了。7.9 注意 JSON 文档中的浮点数精度JSON 类型存储数字时使用的是 MySQL 的 DECIMAL 或 DOUBLE 能力。如果 JSON 里有{price: 0.1}提取出来后0.1 0.2可能不等于0.3因为底层按浮点处理。涉及金额计算应该在应用层使用 Decimal或者写入时就用字符串和DECIMAL(10,2)存储。7.10 用 JSON 列存日志时时间字段必须抽出来很多团队喜欢把日志整行塞进 JSON 列然后按created_at查最近十分钟的数据。如果created_at没有单独建列查询只能全表扫。实际设计时必须把最常用的过滤字段时间、用户 ID、事件类型单独建列其他动态字段才放进 JSON。8. 版本选择与 JSON 列的设计规范8.1 5.7、8.0、8.4 该怎么选搜索词里反复出现 5.7.44、8.0.44、8.4.11 LTS简单说一下我的建议5.7 已经停止官方更新新业务不建议再上。5.7 的 JSON 功能用起来总觉得“半成品”没有 JSON_TABLE没有多值索引部分更新也没有写复杂查询很痛苦。8.0 是目前的主流选择。JSON_TABLE、多值索引、部分更新、窗口函数都齐了社区资料也最多。8.4 是 LTS 版本适合追求长期稳定、计划跨大版本升级的企业。8.4 对 JSON 功能没有革命性变化但整体性能比 8.0 早期版本更好。MySQL 8.0 的 JSON_TABLE 和生成列索引让我重新思考了“结构化数据必须用关系模型”这个原则。很多原本要拆成多张表或引入 NoSQL 的场景现在用 JSON 列已经能优雅解决。8.2 JSON 列和 MongoDB 的关系型 NoSQL 该怎么选MySQL JSON 列适合“结构化数据为主、少量动态字段”的场景MongoDB 适合“文档就是数据主体、结构高度灵活”的场景。举几个例子用户的扩展偏好、优惠券的领用条件、日志事件详情用 MySQL JSON 合适。一套 CMS 系统的文章内容本身包含大量富文本块、组件、多语言版本这种文档主体用 MongoDB 更合适。需要频繁对嵌套数组做聚合统计、子数组筛选的MongoDB 的聚合管道比 MySQL JSON 函数更强。但如果你已经在用 MySQL且 JSON 查询复杂度可控没必要为了一个 JSON 字段再专门引入一套 NoSQL。8.3 建表时的设计规范建议踩了不少坑之后我总结了几条 JSON 字段的建表规范JSON 列命名统一加后缀_json比如extra_info_json、item_config_json避免和普通列混淆。必须为最常用的查询字段单独建生成列加索引不要指望每次都写路径表达式。日期、状态、用户 ID 这类高频过滤字段不要放进 JSON直接建普通列。写入 JSON 前统一键名风格建议 camelCase和值格式字符串、数字、时间全部统一方便后续 JSON_TABLE 展开。上线后加字段时要多利用JSON_SET、JSON_INSERT做兼容升级而不是要求应用层全量重写文档。备份和迁移时要注意 JSON 的二进制存储格式不同版本间不保证兼容升级前先在测试环境验证。9. 最后分享几个实战小技巧根据我个人经验JSON 函数最容易被低估的是JSON_QUOTE和JSON_VALID的组合。JSON_VALID可以在写入前做防御性检查脚本批量导入数据时特别有用INSERT INTO user_profile (user_id, extra_info) SELECT id, json_data FROM temp_import WHERE JSON_VALID(json_data) 1;这样可以直接跳过非法 JSON 行不会整个导入中断。还有一个经验使用 JSON 列时最好先写一批自动化回归测试覆盖“键不存在”、“值为 null”、“数组为空”这几种边界情况。因为 MySQL 对路径不存在返回NULL对 JSON null 返回 JSON null对空数组返回空数组三种结果在程序语言里的表现分别是null、null、[]但语义完全不同处理错了很容易出隐蔽 bug。最后说一个设计层面的体会JSON 列确实让 MySQL 在灵活性上向文档数据库迈进了一大步但它的核心优势仍然是配合关系型约束、事务和索引一起使用。不要把 JSON 列当成万能膏药凡是需要条件过滤、排序、JOIN 的字段都尽量抽出来做成普通列加索引JSON 只存真正动态的部分。用对了它是你表结构设计里很顺手的一把工具用错了它会成为 DBA 最头疼的性能隐患。
返回列表