SaaS系统中的数据库查询通常比普通单体应用复杂得多。一条订单查询可能同时涉及tenant_id、用户、状态、时间范围和排序;随着租户数量和业务数据持续增长,原本几毫秒的SQL可能逐渐变成几十毫秒甚至更高。
这时候,联合索引的重要性就体现出来了。
但联合索引最容易出现的误区,是把它变成一套固定口诀,例如“tenant_id一定放第一”“选择性最高的字段一定放第一”“时间字段一定放最后”。这些原则在部分查询中有帮助,却不能脱离具体SQL使用。真正合理的索引顺序,是让索引结构尽可能匹配核心查询的访问路径,同时控制存储空间和写入维护成本。
一、为什么SaaS系统特别容易需要联合索引
多租户系统通常会出现这样的查询:
SELECT *
FROM orders
WHERE tenant_id = 't123'
AND status = 'paid'
AND create_time >= '2026-01-01';
如果这类查询非常频繁,可以考虑:
CREATE INDEX idx_orders_tenant_status_time
ON orders (tenant_id, status, create_time);
这里的关键并不是“tenant_id是SaaS字段,所以必须排第一”,而是这条SQL恰好经常按照tenant_id、status和create_time进行过滤。
如果另一类核心查询完全不同,例如后台任务主要按照create_time扫描所有租户的数据,那么索引就可能需要采用不同结构。
因此,tenant_id经常是SaaS联合索引的重要组成部分,但并不是一条适用于所有查询的铁律。
二、联合索引真正重要的是左侧前缀
假设建立:
CREATE INDEX idx_orders
ON orders (tenant_id, status, create_time);
可以把它理解为按照:
tenant_id → status → create_time
形成有序的B+Tree索引结构。
它可以很好地支持:
WHERE tenant_id = ?
也可以支持:
WHERE tenant_id = ?
AND status = ?
以及:
WHERE tenant_id = ?
AND status = ?
AND create_time >= ?
但如果查询只有:
WHERE status = ?
就不能把这个索引当成一个以status为最左列的普通索引来使用。
这就是所谓的最左前缀原则。
MySQL官方文档明确说明,多列索引可以用于索引最左侧的一列、最左侧的两列,或者更长的左侧前缀。(dev.mysql.com)
需要特别注意的是,SQL中WHERE条件的书写顺序并不决定索引顺序。
下面两条SQL逻辑上是一样的:
WHERE tenant_id = 't123'
AND status = 'paid'
WHERE status = 'paid'
AND tenant_id = 't123'
不需要因为第二条SQL把status写在前面,就把索引改成(status,tenant_id)。
三、tenant_id为什么经常放在SaaS索引前面
如果一个SaaS数据库由很多租户共享,而且绝大多数业务查询都限定tenant_id,那么:
(tenant_id, status, create_time)
通常具有非常自然的业务意义。
例如:
WHERE tenant_id = ?
AND status = ?
AND create_time >= ?
索引可以首先进入某个租户对应的索引区域,再进一步按照status和create_time缩小范围。
但不能因此认为:
tenant_id
永远应该是所有索引的第一列。
例如:
SELECT *
FROM audit_logs
WHERE create_time >= ?
ORDER BY create_time
LIMIT 1000;
如果这是一个跨租户的后台归档任务,那么:
(create_time)
可能比:
(tenant_id, create_time)
更加符合这个查询。
索引设计服务的是查询,不是数据库里的字段分类。
四、状态字段为什么经常和tenant_id组成联合索引
SaaS订单系统中非常常见:
WHERE tenant_id = ?
AND status = 'paid'
因此:
(tenant_id, status)
可能非常有价值。
但status通常属于低基数字段。例如订单状态可能只有pending、paid、cancelled等几个值。
这并不意味着status不能建立索引。
如果单独建立:
(status)
由于值重复程度高,单独使用时可能没有很好的过滤效果;但放在:
(tenant_id, status, create_time)
中,status是在已经限定租户之后继续缩小搜索范围,其实际价值可能完全不同。
所以不能通过:
COUNT(DISTINCT status) / COUNT(*)
然后规定“超过0.1才能建立索引”。
没有一个适用于所有MySQL表的统一选择性阈值。
索引是否有价值,最终仍然应该通过实际查询、数据分布和执行计划判断。
五、等值条件、范围条件和排序要一起考虑
例如:
SELECT id, amount
FROM orders
WHERE tenant_id = ?
AND status = ?
AND create_time >= ?
AND create_time < ?
ORDER BY create_time DESC
LIMIT 100;
一个很自然的候选索引是:
CREATE INDEX idx_orders
ON orders (tenant_id, status, create_time);
因为tenant_id和status属于等值过滤,create_time属于范围条件,同时查询还按照create_time排序。
这种结构有机会同时帮助过滤和排序。
但如果SQL变成:
WHERE tenant_id = ?
AND create_time >= ?
ORDER BY update_time DESC
再建立:
(tenant_id, create_time, update_time)
就不能简单宣称“这个索引既过滤create_time又完成update_time排序”。
因为create_time已经进入范围以后,后面的update_time并不自动保持整个扫描结果所需要的排序关系。
所以对于复杂分页查询,必须用EXPLAIN甚至EXPLAIN ANALYZE验证,而不能只看索引定义就下结论。
六、不要把覆盖索引理解成“给SELECT *加几个字段”
覆盖索引确实可以减少回表。
例如:
CREATE INDEX idx_user
ON users (tenant_id, status, create_time, user_id);
如果查询只需要索引中已经存在的字段:
SELECT tenant_id, status, create_time, user_id
FROM users
WHERE tenant_id = ?
AND status = ?;
在合适的执行计划下,可能直接从索引获得所需数据。
但如果查询:
SELECT *
FROM orders
WHERE tenant_id = ?
AND status = ?;
即使索引包含tenant_id、status、create_time、amount等几个字段,只要SELECT需要的其他列不在索引中,就不能把它称为覆盖索引。
更不能为了覆盖SELECT *而把整张表的字段全部塞进索引。
索引越宽,存储成本和写入维护成本越高。
七、不要给每个查询条件建立一个索引
假设orders表存在:
(tenant_id)
(status)
(create_time)
(user_id)
(order_no)
然后又不断增加:
(tenant_id,status)
(tenant_id,create_time)
(tenant_id,user_id)
(status,create_time)
(user_id,status)
最后数据库可能拥有十几个甚至几十个索引。
查询看起来很“有安全感”,INSERT和UPDATE却开始付账单。
InnoDB需要维护这些索引,索引还会占用Buffer Pool和磁盘空间。MySQL官方同样强调,应该根据查询需要设计索引,而不是无限增加索引。(dev.mysql.com)
因此,一个重要原则是:
一个联合索引如果已经能够有效服务多个核心查询,就没有必要为了某一条低频SQL立即增加新的索引。
八、不要把“3个索引以内”当成规则
原稿提出百万级表最好保持三个索引以内,这同样没有通用依据。
一张小型业务表可能只需要两个索引,一张复杂订单表可能需要更多索引。
真正需要考虑的是:
这个索引服务哪些SQL?
查询频率是多少?
扫描了多少行?
增加索引以后查询减少了多少成本?
INSERT、UPDATE、DELETE增加了多少成本?
索引占用了多少磁盘和内存?
有没有其他索引已经能够覆盖相同的访问路径?
因此,“索引数量”本身不是性能指标。
十个合理的索引可能比三个错误的索引更加健康。
九、两个索引字段相同但顺序不同,不一定是重复索引
例如:
INDEX idx1 (tenant_id, status)
INDEX idx2 (status, tenant_id)
不能简单称为“重复索引”。
因为:
(tenant_id, status)
和:
(status, tenant_id)
拥有不同的左侧前缀。
如果核心查询包括:
WHERE tenant_id = ?
第一个索引具有直接价值。
如果另一类核心查询是:
WHERE status = ?
第二个索引可能承担不同的访问路径。
当然,如果两者的实际SQL都同时对tenant_id和status使用等值条件,那么其中一个是否还有存在价值,就需要通过查询集合和执行计划判断。
十、OR并不代表索引一定失效
例如:
SELECT *
FROM orders
WHERE tenant_id = ?
OR status = 'paid';
不能简单认为OR查询一定不能使用索引。
MySQL优化器在特定条件下可以采用Index Merge等访问方式,也可能选择其他执行策略。
最终应该让EXPLAIN告诉你实际发生了什么,而不是看到OR就宣布索引死亡。
同样:
WHERE id = '123'
如果id是整数,也不能简单说“字符串参数导致索引失效”。MySQL存在类型转换机制,具体执行计划需要结合数据类型和表达式判断。
工程上更好的做法是让应用层参数类型与数据库字段保持一致,而不是依赖隐式转换。
十一、函数条件确实需要特别注意
例如:
WHERE YEAR(create_time) = 2026
如果create_time存在普通B+Tree索引,这种写法通常无法像直接范围查询一样充分利用create_time的连续范围。
更适合普通时间索引的写法通常是:
WHERE create_time >= '2026-01-01'
AND create_time < '2027-01-01'
如果业务确实需要针对表达式建立索引,MySQL也支持functional key parts,可以根据具体版本和场景考虑函数索引。(dev.mysql.com)
十二、SaaS中的“超级租户”是一个容易被忽视的问题
联合索引按照tenant_id设计以后,还要考虑租户之间的数据分布。
假设普通租户只有几万条订单,但某个大型租户拥有数亿条订单,那么:
tenant_id = A
和:
tenant_id = BIG_CUSTOMER
虽然使用完全相同的索引,却可能拥有完全不同的查询成本。
这就是典型的数据倾斜问题。
因此,SaaS系统不能只看平均租户规模,还应该监控最大的几个租户,也就是所谓的super-tenant。
当少数大型租户占据绝大多数数据以后,可能需要考虑数据归档、独立数据库、独立分片或者其他隔离策略。
索引可以解决访问路径问题,却不能解决无限增长的数据分布问题。
十三、不要把动态创建索引当成日常优化策略
原稿提出业务高峰期使用一套索引、低谷期再创建另一套索引,这不是通常意义上的动态索引策略。
索引是数据库结构的一部分,不应该为了每天的业务高峰和低谷频繁创建、删除。
如果确实需要测试新索引,可以在支持的MySQL版本中研究Invisible Index等机制,在不立即让优化器使用索引的情况下进行验证,再根据实际结果决定是否正式采用。
更重要的是提前评估DDL对生产系统的影响,并根据具体版本、存储引擎和表规模选择合适的变更方式。
十四、前缀索引不是“覆盖前100个字”
例如:
CREATE INDEX idx_notes
ON orders(notes(100));
它表示索引只存储notes字段前100个字符用于索引比较,并不意味着它可以“覆盖前100个字的查询结果”。
对于:
WHERE notes LIKE 'refund%'
前缀索引可能有帮助。
但:
WHERE notes LIKE '%refund%'
普通B+Tree前缀索引通常无法像前缀匹配一样直接定位。
如果业务需求实际上是全文搜索,就应该考虑FULLTEXT或专门的搜索系统,而不是不断增加notes字段的前缀长度。
十五、索引压缩不能用一个固定SQL解决
原稿中的:
WITH PADDING
也不能简单解释成“启用索引压缩,并节省30%空间”。
InnoDB不同表空间、页结构、压缩方式和具体配置之间存在差异。索引空间占用应该通过实际数据库统计和存储结构评估,而不是给出一个固定节省比例。
对于SaaS数据库,更实际的办法是监控表和索引大小,并定期检查哪些索引真正产生价值。
十六、SHOW INDEX不能直接告诉你“索引使用率”
很多开发者会执行:
SHOW INDEX FROM orders;
然后期待看到“这个索引使用了多少次”。
实际上,SHOW INDEX主要提供索引定义和统计信息,例如索引名称、列顺序、Cardinality等,并不是完整的运行时索引使用率统计工具。
要判断索引是否真正有价值,应该结合:
EXPLAIN
EXPLAIN ANALYZE
慢查询日志、Performance Schema、监控系统以及实际业务指标综合判断。
尤其需要区分:
“优化器选择过这个索引”
和:
“这个索引让整个业务系统变快了”。
这是两个不同的问题。
十七、SaaS联合索引最实用的设计流程
真正开始设计索引之前,可以先把核心SQL整理出来。
例如orders表可能存在:
1. tenant_id + status
2. tenant_id + status + create_time范围
3. tenant_id + user_id
4. order_no精确查询
5. create_time范围扫描
然后针对这些SQL分别记录:
查询频率、平均扫描行数、P95/P99延迟、返回数据量、排序方式、分页方式以及写入频率。
接下来设计候选索引,而不是看到一个字段就建立一个索引。
例如:
CREATE INDEX idx_orders_tenant_status_time
ON orders (tenant_id, status, create_time);
然后通过:
EXPLAIN
SELECT ...
观察优化器选择。
对于适合的测试场景,再使用:
EXPLAIN ANALYZE
SELECT ...
验证实际执行过程。
如果一个查询原来扫描数百万行,现在只扫描很小的范围,同时P95延迟下降,而INSERT和UPDATE成本没有出现不可接受的增长,那么这个索引才真正体现出价值。
十八、联合索引不是越复杂越好
一个常见错误是看到查询条件很多,就把所有字段全部塞进一个索引:
(tenant_id, status, user_id, create_time, update_time, amount, type, ...)
这种索引看起来非常“全面”,实际上可能变得又宽又重。
联合索引的目的不是把WHERE里的所有字段复制进去,而是构建高价值的访问路径。
一个好的索引通常能够服务一组重要查询,同时保持合理的存储和写入成本。
对于SaaS系统尤其如此,因为数据量越大、写入越频繁,错误索引的成本也会被放大。
MySQL联合索引设计真正应该遵循的原则,不是“tenant_id永远第一”,也不是“选择性最高永远第一”,而是从真实查询出发。先确定哪些SQL最重要,再分析哪些字段是稳定的等值条件、哪些属于范围条件、是否存在排序和分页需求,然后根据左侧前缀和实际数据分布设计候选索引。
最终再用EXPLAIN、EXPLAIN ANALYZE和生产监控验证。
对于SaaS数据库来说,联合索引解决的是“怎样更快找到需要的数据”,而不是“数据量大了以后怎样继续无限扩展”。当数据规模继续增长,还必须同时考虑归档、分区、读写分离、缓存、数据库拆分以及超级租户的数据倾斜。
真正成熟的索引设计,从来不是把字段一个个塞进CREATE INDEX,而是让数据库结构与业务访问模式形成长期稳定的配合。查询模式变了,索引也应该重新评估;数据分布变了,原来有效的索引也可能失去价值。数据库优化最怕的不是索引少,而是没有证据却不断增加索引。
MySQL 联合索引怎么设计?从 SaaS 查询条件理解字段顺序
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP