作为一名常年与MySQL打交道的DBA,我见过太多因为函数使用不当导致索引失效的案例。今天我们就来深入剖析这个看似简单却经常被忽视的问题。
想象一下图书馆的书籍索引系统。普通索引就像按照书籍的ISBN号排序的目录 - 你可以快速找到特定ISBN的书。但如果你突然想找"所有书名包含'数据库'的书",这个按ISBN排序的目录就完全没用了,管理员不得不逐本检查每本书的标题。
MySQL的B+树索引工作原理类似。当我们对create_time字段建立普通索引时,MySQL会按照字段的原始值构建一个有序的B+树结构。这个结构对于精确匹配create_time值的查询非常高效,比如:
sql复制SELECT * FROM orders WHERE create_time = '2026-03-13 10:00:00';
但当我们在查询条件中对字段使用函数时:
sql复制SELECT * FROM orders WHERE DATE(create_time) = '2026-03-13';
MySQL无法直接使用原始索引,因为DATE()函数改变了字段的形态。就像图书馆的例子,我们需要的是按书名搜索,但目录却是按ISBN排序的。
MySQL 8.0.13引入的函数索引功能,本质上是在底层创建了一个隐藏的虚拟列,存储函数计算后的结果,然后对这个虚拟列建立普通索引。当执行查询时,优化器会识别出查询条件中的函数表达式与索引定义匹配,就可以直接使用这个预先计算好的索引。
这个过程可以分为三个步骤:
为了更直观地理解函数索引的价值,我做了个简单的性能测试:
测试环境:
| 查询方式 | 执行时间(ms) | 扫描行数 | 是否使用索引 |
|---|---|---|---|
| 无函数索引 | 1200 | 1000000 | 否 |
| 有函数索引 | 5 | 100 | 是 |
| 改写SQL(不使用函数) | 8 | 120 | 是 |
可以看到,函数索引将查询性能提升了240倍!即使是改写SQL避免使用函数的方法,性能也不如直接使用函数索引。
在MySQL 8.0.13+中创建函数索引的语法非常简单,但有几个关键细节需要注意:
sql复制-- 基本语法
CREATE INDEX 索引名 ON 表名((函数表达式));
-- 日期函数索引示例
CREATE INDEX idx_date_create_time ON orders((DATE(create_time)));
-- 字符串函数索引示例
CREATE INDEX idx_lower_name ON users((LOWER(name)));
-- 唯一函数索引示例
CREATE UNIQUE INDEX idx_unique_lower_email ON members((LOWER(email)));
重要提示:函数表达式必须用双括号包裹!这是MySQL区分普通列索引和函数索引的关键。如果只使用单括号,MySQL会报错或创建错误的索引。
这是最常见的函数索引应用场景。我们经常需要按天、月或年统计数据:
sql复制-- 按天查询
CREATE INDEX idx_day ON sales((DATE(transaction_time)));
-- 按月查询
CREATE INDEX idx_month ON sales((DATE_FORMAT(transaction_time, '%Y-%m')));
-- 按年查询
CREATE INDEX idx_year ON sales((YEAR(transaction_time)));
在用户系统中,我们经常需要忽略大小写查询用户名或邮箱:
sql复制-- 不区分大小写的用户名查询
CREATE INDEX idx_lower_username ON users((LOWER(username)));
-- 查询时也必须使用LOWER函数
SELECT * FROM users WHERE LOWER(username) = 'johndoe';
对于存储JSON数据的字段,函数索引可以极大提升查询性能:
sql复制-- 为JSON字段中的特定属性创建索引
CREATE INDEX idx_json_extract ON products((JSON_EXTRACT(specs, '$.weight')));
-- 查询JSON字段中的属性
SELECT * FROM products WHERE JSON_EXTRACT(specs, '$.weight') > 10;
对于MySQL 5.7或更早版本,我们可以使用"虚拟列+普通索引"的组合来模拟函数索引:
sql复制-- 1. 添加存储类型的虚拟列
ALTER TABLE orders
ADD COLUMN date_create_time DATE
GENERATED ALWAYS AS (DATE(create_time)) STORED;
-- 2. 在虚拟列上创建普通索引
CREATE INDEX idx_date_create_time ON orders(date_create_time);
-- 3. 查询虚拟列
SELECT * FROM orders WHERE date_create_time = '2026-03-13';
这种方法的缺点是:
这是最常见的错误。函数索引是"精确匹配"的 - 创建时使用的函数必须与查询时使用的函数完全一致。
sql复制-- 创建索引
CREATE INDEX idx_lower_name ON employees((LOWER(last_name)));
-- 这些查询将无法使用索引
SELECT * FROM employees WHERE UPPER(last_name) = 'SMITH';
SELECT * FROM employees WHERE last_name LIKE 'smith%';
解决方案:在设计函数索引时,要确保应用中的所有相关查询都使用相同的函数表达式。
MySQL不允许在函数索引中使用非确定性函数(每次调用可能返回不同结果的函数):
sql复制-- 这些尝试都会失败
CREATE INDEX idx_now ON logs((NOW())); -- 当前时间
CREATE INDEX idx_rand ON items((RAND())); -- 随机数
CREATE INDEX idx_uuid ON orders((UUID())); -- UUID
错误信息通常会提示:"Expression of functional index contains a disallowed function."
每个函数索引都会带来额外的开销:
我曾经遇到一个案例,添加5个函数索引后,写入性能下降了60%。最后通过精简到2个最常用的函数索引,将性能损失控制在15%以内。
对于小表(通常指行数少于1000的表),函数索引可能适得其反:
sql复制-- 对于只有几百行的小表
CREATE INDEX idx_func_small ON small_table((YEAR(create_time)));
-- 查询时优化器可能仍然选择全表扫描
EXPLAIN SELECT * FROM small_table WHERE YEAR(create_time) = 2023;
这是因为小表的全表扫描可能比索引查找更快(避免了回表操作)。
新手经常混淆函数索引和前缀索引:
sql复制-- 前缀索引:只索引字段的前N个字符
CREATE INDEX idx_prefix ON large_texts(content(20));
-- 函数索引:索引函数处理后的整个结果
CREATE INDEX idx_md5 ON large_texts((MD5(content)));
选择依据:
MySQL支持在多个函数表达式上创建复合索引:
sql复制-- 为姓氏和名字的组合创建索引
CREATE INDEX idx_fullname ON customers((CONCAT(last_name, ' ', first_name)));
-- 查询时必须使用完全相同的表达式
SELECT * FROM customers WHERE CONCAT(last_name, ' ', first_name) = 'Smith John';
有时候,我们可以通过重写查询来避免使用函数索引:
sql复制-- 原始查询(需要函数索引)
SELECT * FROM orders WHERE DATE(create_time) = '2026-03-13';
-- 重写为范围查询(可以使用普通索引)
SELECT * FROM orders
WHERE create_time >= '2026-03-13 00:00:00'
AND create_time < '2026-03-14 00:00:00';
这种重写技巧在低版本MySQL中特别有用。
我们可以通过performance_schema来监控函数索引的使用效率:
sql复制-- 查看索引使用统计
SELECT * FROM sys.schema_index_statistics
WHERE table_schema = 'your_db' AND table_name = 'your_table';
-- 查看未使用的索引
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'your_db';
定期检查这些视图,可以清理掉不必要的函数索引,减少存储和性能开销。
根据我的经验,以下是使用函数索引的黄金法则:
去年我参与了一个电商平台的数据库优化项目,其中函数索引发挥了关键作用。
该平台的订单表有3000万条记录,有两个痛点查询:
原始查询性能:
我们实施了以下优化:
sql复制-- 1. 创建日期函数索引
CREATE INDEX idx_order_date ON orders((DATE(created_at)));
-- 2. 创建邮箱小写函数索引
CREATE INDEX idx_lower_email ON users((LOWER(email)));
| 查询类型 | 优化前 | 优化后 | 提升倍数 |
|---|---|---|---|
| 日期统计 | 12s | 0.15s | 80x |
| 邮箱查询 | 8s | 0.08s | 100x |
此外,我们还发现了一些有趣的细节:
这个案例验证了函数索引的几个关键点:
在物理存储层面,函数索引与普通索引的主要区别在于:
MySQL在InnoDB内部为函数索引维护了一个特殊的字典,记录函数表达式到索引的映射关系。
普通索引查询流程:
函数索引查询流程:
MySQL优化器在评估是否使用函数索引时,会考虑以下因素:
我们可以通过EXPLAIN FORMAT=JSON查看详细的优化器决策过程。
虽然函数索引很强大,但它不是万能的。以下是几种常见替代方案的比较:
| 特性 | 函数索引 | 生成列 |
|---|---|---|
| MySQL版本 | 8.0.13+ | 5.7+ |
| 语法复杂度 | 简单 | 中等 |
| 存储开销 | 低 | 高(STORED)或低(VIRTUAL) |
| 查询灵活性 | 低 | 高 |
| 维护成本 | 低 | 中等 |
| 方法 | 优点 | 缺点 |
|---|---|---|
| 范围查询替代日期函数 | 兼容所有版本 | 只适用于部分场景 |
| 应用层预处理 | 灵活 | 增加应用复杂度 |
| 使用列存储原始值 | 简单 | 数据冗余 |
对于极低版本的MySQL,可以使用触发器来维护派生列:
sql复制-- 1. 添加派生列
ALTER TABLE orders ADD COLUMN order_date DATE;
-- 2. 创建触发器
CREATE TRIGGER set_order_date BEFORE INSERT ON orders
FOR EACH ROW SET NEW.order_date = DATE(NEW.created_at);
-- 3. 创建普通索引
CREATE INDEX idx_order_date ON orders(order_date);
这种方法虽然可行,但维护成本高,容易出错,只应作为最后手段。
MySQL 8.0对函数索引的支持还在不断增强:
一些MySQL分支如Percona Server已经实验性地支持:
对于新项目,强烈建议使用MySQL 8.0最新版本,以获得最完善的函数索引支持。对于老系统升级,需要充分测试兼容性。