背景
MySQL在2015年中发布的5.7.8版本中首次引入了JSON数据类型。自此,它成了一种逃离严格列定义的方式,可以存储各种形状和大小的JSON文档,例如审计日志、配置信息、第三方数据包、用户自定义字段等。
虽然MySQL提供了读写JSON数据的函数,但你很快会发现一个显著的缺失:直接给JSON列建立索引的能力。
在其他数据库中,直接索引JSON列的最佳方法通常是使用一种叫做广义倒排索引(Generalized Inverted Index,简称GIN)的类型。然而,由于MySQL没有提供GIN索引,我们无法直接对整个存储的JSON文档建立索引。不过不必担心!MySQL确实为我们提供了一种间接索引存储在JSON文档中特定部分的方式。
根据所使用的MySQL版本,有两个选项可以给JSON建立索引:
- 如果使用MySQL 5.7,需要创建一个中间生成列(Generated Column) 。
- 从MySQL 8.0.13开始,可以直接创建函数索引(Functional Index) 。
接下来,我们以一个示例表为例,该表用于记录应用程序中的各种操作日志:
sql
CREATE TABLE `activity_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`properties` json NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
)
在该表的properties字段中插入如下结构的JSON文档:
perl
{
"uuid": "e7af5df8-f477-4b9b-b074-ad72fe17f502",
"request": {
"email": "little.bobby@tables.com",
"firstName": "Little",
"formType": "vehicle-inquiry",
"lastName": "Bobby",
"message": "Hello, can you tell me what the specs are for this vehicle?",
"postcode": "75016",
"townCity": "Dallas"
}
}
在本例中,我们将尝试索引request
对象内的email
键,这可以让用户快速找到由特定人员提交的表单。
方法一:通过"生成列"索引JSON
生成列(Generated Column) 可以视为计算列、派生列或公式列。它的值是某个表达式的运算结果,而不是直接的数据输入。表达式可以包含常量值、内置函数或对其他列的引用。表达式的结果必须是定量的(Scalar)且具有确定性(Deterministic)。
由于我们试图索引properties
列中的request.email
字段,生成列将使用JSON的解引用(Unquoting Extraction)运算符来提取该值。
首先,运行一个SELECT
语句来验证表达式是否正确:
sql
mysql> SELECT properties->>"$.request.email" FROM activity_log;
+--------------------------------+
| properties->>"$.request.email" |
+--------------------------------+
| little.bobby@tables.com |
+--------------------------------+
符号->>
是解引用运算符,它等价于如下的写法:
sql
mysql> SELECT JSON_UNQUOTE(JSON_EXTRACT(properties, "$.request.email"))
-> FROM activity_log;
+-----------------------------------------------------------+
| JSON_UNQUOTE(JSON_EXTRACT(properties, "$.request.email")) |
+-----------------------------------------------------------+
| little.bobby@tables.com |
+-----------------------------------------------------------+
上述两种写法,具体使用哪种方式可完全取决于个人偏好。
确认表达式的有效性和准确性后,我们使用它创建一个生成列:
sql
ALTER TABLE activity_log ADD COLUMN email VARCHAR(255)
GENERATED ALWAYS as (properties->>"$.request.email");
这条ALTER
语句的前半部分非常熟悉,添加了一个名为email
的列,并将其定义为VARCHAR(255)
类型。而后半部分声明该列为生成列,并定义它始终等于表达式properties->>"$.request.email"
的结果。
我们可以像其他列一样查询它,确认生成列已被成功添加:
sql
mysql> SELECT id, email FROM activity_log;
+----+-------------------------+
| id | email |
+----+-------------------------+
| 1 | little.bobby@tables.com |
+----+-------------------------+
从结果可以看到,MySQL将动态维护这个列。如果我们更新了JSON数据,生成列的值也会随之改变。
接下来,我们像其他普通列一样为这生成列添加索引:
sql
ALTER TABLE activity_log ADD INDEX email (email) USING BTREE;
现在已经成功为JSON中request.email
键建立了索引。可以通过EXPLAIN
验证索引是否会被用于查询:
ini
mysql> EXPLAIN SELECT * FROM activity_log WHERE email = 'little.bobby@tables.com';

结果显示MySQL计划使用email
索引来满足该查询。
索引生成列与优化器(Optimizer)
MySQL的优化器是一个强大但神秘的组件。当我们给MySQL下达命令时,它理解的是我们想要什么,而不是我们明确指定如何实现。通常,MySQL会稍微改写我们的查询,这通常是一件好事。
对于生成列上的索引,优化器能"透过"不同的访问模式以确保使用索引。例如,在以下查询中,我们通过JSON提取运算符访问数据,而不是直接使用生成的email
列:
sql
mysql> EXPLAIN SELECT * FROM activity_log
-> WHERE properties->>"$.request.email" = 'little.bobby@tables.com';
结果可以看到优化器仍然使用了email
索引。哪怕使用长写的表达式,也可以看到优化器仍然"穿透"表达式并利用了索引,甚至可以通过SHOW WARNINGS
查看优化器改写后的查询:
ini
mysql> SHOW WARNINGS;
显示结果表明查询被改写为直接参考了索引的列。
方法二:函数索引(Functional Index)
从MySQL 8.0.13开始,可以跳过创建生成列的中间步骤,直接创建表达式索引(Function Index)。例如:
sql
ALTER TABLE activity_log
ADD INDEX email ((properties->>"$.request.email")) USING BTREE;
然而,当你尝试运行上述语句时会遇到错误:
sql
ERROR: Cannot create a functional index on an expression that returns a BLOB or TEXT. Please consider using CAST.
这是因为MySQL自动推断JSON解引用操作返回LONGTEXT
类型,而无法对其直接建立索引。可通过CAST
将值转化为MySQL可索引的数据类型:
sql
ALTER TABLE activity_log
ADD INDEX email ((CAST(properties->>"$.request.email" AS CHAR(255)))) USING BTREE;
此外还需要解决字符集不匹配 的问题,需要显式设置排序规则为utf8mb4_bin
:
sql
ALTER TABLE activity_log
ADD INDEX email ((
CAST(properties->>"$.request.email" AS CHAR(255)) COLLATE utf8mb4_bin
)) USING BTREE;
运行EXPLAIN
后可以确认函数索引已成功被使用。
总结
尽管MySQL无法直接对JSON列建立索引,但通过生成列和函数索引的方式间接索引特定字段能够满足绝大多数场景。同时这种方式不仅适用于JSON,还适用于其它复杂或难以索引的模式。