如何为MySQL中的多租户系统设计兼顾隔离与性能的复合索引

作者:袖梨 2026-07-12
tenant_id必须作为复合索引最左列,否则索引失效;正确写法为CREATE INDEX idx_orders_tenant_status_created ON orders (tenant_id, status, created_at);需结合查询模式精简字段数,避免冗余。

tenant_id 必须是复合索引最左列,否则等于没建

如果你在多租户表里建了 CREATE INDEX idx_orders_status_created ON orders (status, created_at),但查询总带 WHERE tenant_id = ? AND status = 'paid',那这个索引几乎不会被用到。MySQL 的 B+Tree 索引严格遵循最左前缀匹配,tenant_id 不在最左,优化器就无法跳过其他租户的数据页,只能全表扫描。

真正有效的写法是:

CREATE INDEX idx_orders_tenant_status_created ON orders (tenant_id, status, created_at);
  • tenant_id 是高频、高选择性、且从不缺失的等值条件,必须打头
  • 后续字段按查询频率和选择性排序:比如 statuscreated_at 更常用于等值过滤,就放前面
  • 如果还有 ORDER BY created_at,把 created_at 放最后能避免文件排序

聚合查询慢?先看 tenant_id 是否在索引里打头

执行 SELECT COUNT(*) FROM orders WHERE tenant_id = 123 AND status = 'shipped' GROUP BY product_id 却跑得慢,大概率不是 GROUP BY 拖累的,而是索引没让 MySQL 快速定位到租户 123 的全部行。

此时哪怕加了 WHERE tenant_id = 123,若索引是 (status, tenant_id)(created_at),优化器仍可能放弃索引走全表扫描。

  • 必须用 (tenant_id, status, product_id) 这类以 tenant_id 开头的复合索引
  • 如果 GROUP BY 字段也参与过滤(如 WHERE tenant_id = ? AND product_id IN (...)),把 product_id 放第二位可进一步剪枝
  • 分区表场景下,RANGE PARTITION BY tenant_id 能替代索引做物理剪枝,但要求查询必须精确命中单个 tenant_id

别信视图或存储过程里硬写的 WHERE tenant_id

MySQL 不支持行级安全策略(RLS),所以 CREATE VIEW v_orders AS SELECT * FROM orders WHERE tenant_id = @current_tenant 是危险的——@current_tenant 是会话变量,连接池复用时极易残留旧值,导致查出其他租户数据。

真正可靠的租户隔离只发生在两层:

  • 应用层:ORM 拦截所有 SQL,自动注入 AND tenant_id = ?,且对 COUNTDISTINCT、窗口函数等特殊语法做额外校验
  • 数据库层:索引强制让带 tenant_id 的查询快,不带的查询慢到不可接受(倒逼开发不敢漏)

如果发现某条慢查没带 tenant_id,优先检查是不是 ORM 拦截漏了,而不是急着加索引。

复合索引字段数别贪多,tenant_id + 2~3 个高频字段够用

有人建 (tenant_id, status, type, channel, region, created_at) 这种六字段索引,结果写入变慢、空间暴涨,而实际查询很少同时过滤这么多条件。

更务实的做法是聚焦真实查询模式:

  • 查订单列表:常用 tenant_id + status + created_at
  • 查统计报表:常用 tenant_id + status + product_id
  • 查用户行为:常用 tenant_id + user_id + event_type

每个核心查询路径配一个精简索引,比堆一个“全能索引”更省资源、更易维护。别忘了定期用 EXPLAIN 验证索引是否真被命中——尤其注意 key_lenrows 值是否合理。

最容易被忽略的是:索引生效的前提,是应用代码里每一条 SQL 都老老实实带上 tenant_id 参数。再好的索引,也救不了漏过滤的查询。

相关文章

精彩推荐