MySQL多租户系统复合索引设计:兼顾隔离与性能
时间:2026-07-20 07:02
多租户MySQL索引设计中,tenant_id必须作为复合索引最左列,否则无法实现隔离。聚合查询慢需检查索引是否以tenant_id开头。视图或存储过程硬写tenant_id存在安全风险,应靠应用层自动注入与索引倒逼。复合索引字段数不宜贪多,每个查询路径配精干索引,并定期用EXPLAIN验证。
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 是查询里的高频且高选择性的等值条件,几乎从不缺席,所以必须放在第一位打头阵。
* 后续的字段排序,得看它们的查询频率和选择性。比如,
status 比
created_at 更常用于等值过滤,那就让它排在前面。
* 如果查询里还有
ORDER BY created_at,把
created_at 放在复合索引的最后一位,还能顺带帮 MySQL 省去文件排序的麻烦。
聚合查询慢?先看 tenant_id 是否在索引里打头
排查慢查询时,经常会碰到类似的情况:执行一个
SELECT COUNT(*) FROM orders WHERE tenant_id = 123 AND status = 'shipped' GROUP BY product_id 的统计,慢得不行。这时候,第一反应不该是怪
GROUP BY 操作本身,而是要回过头检查:索引有没有让 MySQL 先精准定位到租户 123 的所有数据行?
如果索引是
(status, tenant_id) 或者干脆只是
(created_at),就算
WHERE 条件里写了
tenant_id = 123,优化器在很多情况下依然会选择放弃索引,走上全表扫描这条“不归路”。
所以,实战中的解法很明确:
* 必须创建以
tenant_id 开头的复合索引,比如
(tenant_id, status, product_id)。
* 如果
GROUP BY 的字段也在过滤条件里(像
WHERE tenant_id = ? AND product_id IN (...)),那把
product_id 直接放到第二位,能进一步帮索引“剪枝”,效率更高。
* 另外,分区表也是一种思路。如果用
RANGE PARTITION BY tenant_id 来做物理分区,它能起到类似物理剪枝的效果。但前提是,业务查询必须能精确地命中单个
tenant_id。
别信视图或存储过程里硬写的 WHERE tenant_id
MySQL 不像 PostgreSQL 那样内建了行级安全策略(RLS)。所以,像
CREATE VIEW v_orders AS SELECT * FROM orders WHERE tenant_id = @current_tenant 这种写法,存在很大的安全隐患。
这里的
@current_tenant 是一个会话变量。生产环境有连接池,连接是复用的。一旦上一个请求没清干净变量,下一个请求很可能就拿到了不该看到的其他租户的数据。这种风险太隐蔽,出事概率却不低。
真正能兜底的租户隔离,其实就靠两层:
* **应用层**:ORM 框架拦截所有 SQL,自动帮你注入
AND tenant_id = ?。而且,对于
COUNT、
DISTINCT、窗口函数这些特殊语法,还得额外做校验,确保它们也没漏掉这个条件。
* **数据库层**:靠索引来“倒逼”。设计上让那些带了
tenant_id 的查询飞起来,而那些没带的查询变得巨慢无比。这样,开发人员一旦发现慢查询,第一个反应就是去查是不是应用层漏了
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_len 和
rows 这些关键指标。
最后,必须提醒一句:再完美的索引,也得靠应用代码里每一条 SQL 都老老实实地带上
tenant_id 参数才能生效。一个漏网之鱼的查询,就能让精心设计的索引体系功亏一篑。