大型SaaS表同时按租户时间和状态查询 联合索引应该怎样设计
大型SaaS系统里的订单、账单、审计日志和任务表,经常同时出现tenant_id、create_time和status三个查询条件。看起来最自然的做法,就是建立一个(tenant_id, create_time, status)联合索引,但真正进入生产环境以后,很多工程师会发现:索引明明建了,查询依然扫描大量记录,甚至优化器有时选择另一个索引。
原因在于,MySQL联合索引并不是把三个字段简单地“叠加”起来。InnoDB中的B+Tree索引按照字段顺序组织键值,查询条件在不同位置出现等值、范围、排序和分页操作时,能够利用索引的程度并不一样。因此,设计大型SaaS表的联合索引,第一步永远不是问“这三个字段哪个选择性最高”,而是先把真实SQL找出来。
假设最常见的查询是:
SELECT *
FROM orders
WHERE tenant_id = 't001'
AND create_time >= '2026-01-01'
AND create_time < '2026-02-01'
AND status = 'completed';
如果建立:
INDEX idx_tenant_time_status (tenant_id, create_time, status)
这个索引当然可以发挥作用。MySQL可以首先定位tenant_id='t001'的索引范围,然后继续按照create_time缩小扫描范围。但当create_time已经成为范围条件以后,status位于其后,它通常不能像位于范围字段之前的等值条件那样继续把B+Tree的搜索范围精确切开。
这就是联合索引设计中非常重要的一个概念:索引字段的位置决定了优化器能够怎样利用它。通常情况下,等值条件适合放在范围条件之前,而范围字段之后的列是否仍然有价值,要结合具体查询、索引下推以及返回数据量判断,不能简单说“第三个字段完全没用”。
如果SaaS系统最常见的查询是“某租户在某个时间范围内的所有订单”,那么:
INDEX idx_tenant_time (tenant_id, create_time)
往往已经是非常重要的基础索引。
如果另一类高频查询是:
SELECT *
FROM orders
WHERE tenant_id = 't001'
AND status = 'completed';
那么:
INDEX idx_tenant_status (tenant_id, status)
可能比单纯依靠tenant_id索引更合适。
如果第三类查询同时要求租户、状态和时间,例如:
SELECT order_id, amount, create_time
FROM orders
WHERE tenant_id = 't001'
AND status = 'completed'
AND create_time >= '2026-01-01'
AND create_time < '2026-02-01';
那么:
INDEX idx_tenant_status_time
(tenant_id, status, create_time)
就值得认真测试。
这里与前面的(tenant_id, create_time, status)存在一个重要区别:在第二种设计中,tenant_id和status都是等值条件,create_time最后才执行范围过滤。因此,B+Tree可以先定位到特定租户和特定状态的索引区域,再在这个范围内执行时间范围扫描。对于这种查询模式,它可能比(tenant_id, create_time, status)更符合索引访问路径。
所以,并不存在一个适用于所有SaaS系统的“tenant_id、时间、状态固定顺序”。真正的顺序应该由查询模式决定。
对于多租户系统,tenant_id经常适合出现在联合索引中,但理由也不能简单写成“tenant_id是安全隔离字段,所以必须是第一列”。tenant_id主要是查询访问路径的一部分,而真正的租户隔离还必须由应用层、ORM、Repository、后台任务、缓存和消息处理等多个环节保证。一个数据库索引本身不能提供完整的多租户安全隔离。
同时还要考虑租户规模差异。如果一个SaaS平台拥有大量小租户,却有几个超级租户占据了绝大多数数据,那么即使建立了tenant_id联合索引,大租户仍然可能产生非常大的扫描范围。这种情况下,需要进一步考虑分页方式、数据归档、分区、读副本甚至分片,而不是不断增加索引。
状态字段也不能简单因为只有几个值就判断“没必要建立索引”。低基数并不等于索引永远没有价值。假设某个租户的数据有数百万行,而某个查询只需要其中少量状态记录,那么status仍然可能有帮助。反过来,如果某个状态占据绝大多数记录,单独使用status索引的过滤能力可能非常有限。
因此,判断status是否值得进入联合索引,应该观察实际数据分布和查询结果集,而不是使用“状态只有四种,所以选择性低”这样的固定规则。
例如:
SHOW INDEX FROM orders;
可以查看索引统计信息,再结合:
EXPLAIN
SELECT ...
或者MySQL支持的:
EXPLAIN ANALYZE
SELECT ...
观察实际执行情况。
尤其要注意EXPLAIN中的key、possible_keys、rows、filtered以及实际执行耗时。rows是优化器估算的扫描行数,并不是一个精确的实际扫描数量;EXPLAIN ANALYZE则可以帮助比较估算与实际执行情况。
对于大型SaaS表,覆盖索引也经常被误解。
假设查询是:
SELECT order_id, amount, user_id, create_time
FROM orders
WHERE tenant_id = 't001'
AND status = 'completed'
AND create_time >= '2026-01-01'
AND create_time < '2026-02-01';
可以考虑设计包含查询所需列的索引,例如:
INDEX idx_cover
(tenant_id, status, create_time, order_id, amount, user_id)
如果查询能够完全从二级索引获得所需数据,就可能减少回表访问。
但这并不意味着应该把所有SELECT *字段都加入索引。大型订单表可能拥有几十个甚至上百个字段,把整行数据复制进二级索引会明显增加索引体积和写入维护成本。InnoDB的二级索引本身还包含主键值,因此索引越宽,对存储、缓存和INSERT/UPDATE/DELETE的影响越明显。
很多情况下,更合理的做法是只让索引覆盖真正高频、字段数量有限的查询,而不是为了消灭回表,把整个表复制一份到索引里面。
分页也是大型SaaS系统必须考虑的问题。
如果查询使用:
ORDER BY create_time DESC
LIMIT 50 OFFSET 500000;
即使索引设计正确,大OFFSET仍然可能需要扫描和跳过大量记录。
对于不断向下翻页的后台订单列表,更适合考虑Keyset Pagination,例如利用上一页最后一条记录的create_time和order_id继续向后查找。这样可以减少随着页码增加而不断扩大扫描范围的问题。
如果排序条件是:
ORDER BY create_time DESC, order_id DESC
那么索引设计就需要同时考虑WHERE条件和ORDER BY,而不能只根据过滤字段决定顺序。例如:
INDEX idx_tenant_status_time_id
(tenant_id, status, create_time, order_id)
可能更符合“租户+状态过滤,再按时间和ID进行稳定分页”的查询模式,但最终仍然应该通过实际SQL和EXPLAIN ANALYZE验证。
对于时间范围非常大的历史订单表,还可以考虑分区。但分区不是联合索引的替代品。按时间进行分区可能帮助管理生命周期、归档旧数据和减少部分查询需要访问的分区数量,但它不会自动解决tenant_id和status的查询性能问题。分区键、查询条件、唯一键约束以及维护方式都需要结合表结构设计。
同样,不应该因为“create_time经常查询”就一定建立一个单独的:
INDEX(create_time)
如果主要查询实际上都是:
WHERE tenant_id = ?
AND create_time BETWEEN ? AND ?
那么单独的create_time索引是否有价值,就要通过实际查询和数据分布判断。额外索引意味着更多存储空间和写入维护成本,尤其是订单、日志等高写入表。
也没有必要把“索引合并”当成联合索引设计的主要目标。MySQL可以在某些情况下使用Index Merge,但如果一个高频查询可以通过合理设计的联合索引直接完成有效访问路径,通常应该优先测试联合索引,而不是先建立多个单列索引,再期待优化器把它们合并。
对于一个大型SaaS订单表,更现实的索引设计可能是围绕不同查询场景建立少量明确的索引,例如:
PRIMARY KEY (order_id)
INDEX idx_tenant_time
(tenant_id, create_time)
INDEX idx_tenant_status_time
(tenant_id, status, create_time)
但这只是一个起点,不是固定模板。如果实际业务中“租户+状态”的查询极少,而“租户+时间+排序”的查询占绝大多数,那么第二个索引可能完全没有必要。反过来,如果后台大量按照租户和状态筛选订单,再按照更新时间排序,那么索引可能需要围绕那个真实SQL重新设计。
还应该注意tenant_id的数据类型。如果数据库中的tenant_id实际上是固定长度的UUID,却使用较大的VARCHAR字段,并且大量联合索引都把它放在第一列,那么索引体积会明显增加。对于超大型SaaS系统,租户标识的数据类型本身也值得从存储空间、索引大小和应用兼容性角度考虑。
最终,联合索引设计应该形成一个闭环:先收集真实SQL,再分析WHERE、ORDER BY、GROUP BY和LIMIT,然后设计候选索引,使用EXPLAIN和EXPLAIN ANALYZE验证,再结合慢查询日志、CPU、磁盘I/O、锁等待和写入吞吐观察生产环境表现。索引上线以后还需要观察它到底有没有被有效使用,而不是建完就认为任务完成。
大型SaaS数据库真正需要避免的不是“索引太少”或者“索引太多”这样简单的结论,而是让索引结构脱离真实业务查询。tenant_id、status和create_time三个字段看起来只是三个条件,但一旦加入范围查询、排序和分页,字段顺序就会直接改变MySQL能够扫描的索引范围。真正好的联合索引,不是把所有查询字段全部塞进去,而是用尽可能合理的索引顺序,让最常见、最重要的查询以最低的扫描成本完成,同时把索引本身对写入和存储造成的负担控制在合理范围内。
一个大型 SaaS 表同时按租户、时间和状态查询,联合索引应该怎样设计
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP