在多租户系统里,所有业务表通常都会带一个tenant_id字段用来做逻辑隔离。当单表数据量上升到千万甚至上亿级别时,如果索引结构不合理,绝大多数以租户为条件的查询都会变成全表扫描,数据库CPU和IO迅速被打满。针对tenant_id设计复合索引,是这类系统最基础也最关键的优化手段。

为什么tenant_id必须放在复合索引最左列
MySQL的复合索引遵循最左前缀原则。也就是说,一个建立在(tenant_id, created_at, status)上的索引,只有在查询条件里用到了tenant_id,或者用到tenant_id加created_at等左侧连续字段时,才能被有效利用。如果查询只带了created_at而没有tenant_id,这个复合索引就完全派不上用场。
在多租户场景中,几乎每一个查询都会带上tenant_id作为隔离条件。把tenant_id放在最左列,可以保证所有租户相关查询都能命中索引首层,快速缩小数据范围。反之,如果把tenant_id放在第二或第三列,那么只传tenant_id的查询将无法使用该索引,只能退化为全表扫描或依赖其他单列索引,性能差距在大数据量下可达几十倍。
最左前缀的执行计划验证
我们可以通过EXPLAIN来直观对比。假设有如下表和索引:
CREATE TABLE orders ( id BIGINT PRIMARY KEY, tenant_id INT NOT NULL, created_at DATETIME NOT NULL, status TINYINT NOT NULL, amount DECIMAL(10,2), INDEX idx_tenant (tenant_id, created_at, status) ) ENGINE=InnoDB;
执行下面两条语句并查看type列:
EXPLAIN SELECT * FROM orders WHERE tenant_id = 10 AND created_at > '2023-01-01'; EXPLAIN SELECT * FROM orders WHERE created_at > '2023-01-01';
第一条会显示range或ref,说明走了idx_tenant;第二条则大概率显示ALL,也就是全表扫描。这清楚地证明了tenant_id必须处于复合索引最左侧。
如何结合高频字段构建覆盖索引
仅仅把tenant_id放最左列还不够。如果查询经常需要过滤status或者按created_at排序,并且select的字段不多,我们可以把这些字段一起放进复合索引,形成覆盖索引,避免回表。
比如运营后台常见查询是:某租户下,某状态订单按时间倒序取前二十条。对应的复合索引应为(tenant_id, status, created_at)。这样MySQL在索引里就能完成过滤和排序,不需要再去主键索引取完整行,大幅降低IO。
覆盖索引示例代码
建表与查询示例如下:
CREATE TABLE orders ( id BIGINT PRIMARY KEY, tenant_id INT NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, amount DECIMAL(10,2), INDEX idx_cover (tenant_id, status, created_at) ) ENGINE=InnoDB; SELECT id, amount FROM orders WHERE tenant_id = 5 AND status = 1 ORDER BY created_at DESC LIMIT 20;
上述查询在EXPLAIN的Extra列会显示Using index,表示使用了覆盖索引。如果字段选择不当,比如把amount也频繁查询但没加入索引,就会看到Using where; Using index,仍可能触发随机回表。
索引下推对多租户查询的帮助
MySQL 5.6之后引入了索引下推(Index Condition Pushdown,简称ICP)。在复合索引(tenant_id, status, created_at)中,如果查询条件是tenant_id = ? AND status = ?,但status不在最左前缀的连续使用范围内(比如中间有范围查询打断),ICP允许存储引擎在遍历索引时直接过滤status,而不是把数据读到 server 层再判断。
虽然多租户查询通常tenant_id是等值条件,ICP收益看起来有限,但在复杂组合查询里,比如tenant_id等值、created_at范围、status等值,ICP能减少回表次数。可以通过EXPLAIN的Extra列观察是否出现Using index condition来确认。
查看ICP是否生效
EXPLAIN SELECT * FROM orders WHERE tenant_id = 3 AND created_at > '2023-06-01' AND status = 2;
若Extra中出现Using index condition,说明ICP已生效,存储引擎层就完成了status过滤,减少了无用回表。
超大租户下的特殊优化思路
有些系统中个别租户的数据量远超其他租户,比如平台自营账号。此时即便有tenant_id复合索引,该租户内部的查询仍要扫描巨量索引条目。可以考虑使用MySQL分区表,按tenant_id做LIST分区,或者将超大租户拆分到独立schema甚至独立实例。
分区表能让查询在确认tenant_id后只访问单个分区,避免跨分区扫描。但要注意分区键必须包含在主键或唯一索引里,否则会报错。下面是按tenant_id分区的简单示例:
CREATE TABLE orders ( id BIGINT NOT NULL, tenant_id INT NOT NULL, created_at DATETIME NOT NULL, status TINYINT NOT NULL, PRIMARY KEY (id, tenant_id), INDEX idx_t (tenant_id, created_at) ) ENGINE=InnoDB PARTITION BY LIST (tenant_id) ( PARTITION p0 VALUES IN (1,2,3), PARTITION p1 VALUES IN (4,5,6), PARTITION p_big VALUES IN (9999) );
这样租户9999的所有数据落在p_big分区,查询时优化器直接定位分区,索引结构压力也更小。
写入放大与索引数量的权衡
每多一个复合索引,写入时就要多维护一棵B+树,INSERT和UPDATE变慢,磁盘占用增加。针对tenant_id的复合索引一般一到两个就够了,不要给每张表建五六个组合。建议先梳理业务查询模式,把最高频的租户加时间、租户加状态两类查询分别建索引,其余低频查询容忍稍慢或使用联合条件。
此外,定期用pt-index-usage或数据库自带的performance_schema观察哪些索引长期不被使用,及时删除。多租户系统的索引优化不是一次性工作,而应随业务形态调整。
总结性实践清单
把上面内容浓缩成可落地的动作:
- 所有多租户表必须有tenant_id,且复合索引首列就是它
- 按真实查询顺序设计后续列,优先覆盖过滤与排序字段
- 用EXPLAIN验证type与Extra,确认走索引和覆盖
- 个别大租户用分区或独立schema隔离
- 控制索引总数,定期清理无用索引
只要围绕tenant_id把复合索引的顺序、覆盖字段和物理分布设计好,多租户MySQL系统就能在单实例上支撑相当可观的数据规模与并发。