在凯士比数字工厂(KiPlant)项目中,随着租户数量增长,核心查询接口 RT 持续恶化。本文记录如何通过 explain 定位问题、为什么自增 ID 在多租户场景下会"反噬"性能、以及雪花 ID 改造的完整方案。
一、问题发现
1.1 背景
KiPlant 是一个多租户 SaaS 平台,所有租户的数据存储在同一套 MySQL 集群中,通过 tenant_id 字段做逻辑隔离。平台上线初期只有 3-5 家工厂客户,系统运行平稳。但当租户增长到 15 家、核心业务表数据量突破千万级之后,Grafana 上的慢查询告警开始频繁触发。
1.2 故障表现
最先暴露问题的是设备工单列表查询接口。运维人员在管理后台按条件筛选工单(按时间范围 + 状态 + 设备类型),P99 响应时间从最初的 100ms 逐步恶化到 3-5 秒,某些租户甚至超过 10 秒直接超时。
Druid 监控面板上,慢 SQL 日志中反复出现同一类查询:
SELECT * FROM work_order
WHERE tenant_id = 1023
AND status = 'PENDING'
AND device_type = 'PUMP'
AND create_time BETWEEN '2024-01-01' AND '2024-03-31'
ORDER BY id DESC
LIMIT 20;看上去查询条件很明确,也有对应的联合索引,为什么还是慢?
二、explain 深度分析
2.1 第一次 explain:索引看着对,但执行计划不对
对上述 SQL 执行 EXPLAIN:
EXPLAIN SELECT * FROM work_order
WHERE tenant_id = 1023 AND status = 'PENDING'
AND device_type = 'PUMP'
AND create_time BETWEEN '2024-01-01' AND '2024-03-31'
ORDER BY id DESC LIMIT 20;结果:
关键发现:type 列是 index,key 列是 PRIMARY。这意味着 MySQL 没有使用我们精心设计的联合索引 idx_tenant_status_type_time(tenant_id, status, device_type, create_time),而是选择了主键索引做全索引扫描。rows 列显示预估扫描了 24 万行。
2.2 为什么优化器"弃用"了联合索引?
这里要理解 MySQL 优化器的决策逻辑。这条 SQL 有一个 ORDER BY id DESC LIMIT 20,优化器面临两个选择:
方案A:用联合索引。 通过 idx_tenant_status_type_time 快速定位符合 WHERE 条件的记录,但这些记录在联合索引中不是按 id 排序的,所以还需要额外的排序操作(filesort)才能完成 ORDER BY id DESC。
方案B:用主键索引。 主键 id 是自增的,按主键倒序扫描天然就满足 ORDER BY id DESC 的要求,不需要额外排序。优化器"赌"从主键末尾往前扫,很快就能找到满足 WHERE 条件的 20 条记录。
在数据量小的时候,方案B确实很快——从尾部开始扫,很快就能凑够 20 条符合条件的记录。但随着数据量增长,问题出现了。
2.3 自增 ID 在多租户场景下的"毒性"
自增 ID 的特点是:所有租户的数据按插入时间全局混合排列。
假设 work_order 表中有 1000 万条记录,其中租户 1023 只有 5 万条(占 0.5%)。当 MySQL 沿着主键倒序扫描时,每扫 200 条记录才能遇到 1 条属于租户 1023 的记录。要找到 20 条符合所有 WHERE 条件的记录,实际需要扫描几万甚至几十万条无关记录。
数据在主键索引(B+ 树叶子节点)中的分布大致如下:
主键ID(自增): ... 9999997 9999998 9999999 10000000
tenant_id: ... 1005 1023 1008 1023
↑ ↑
租户1023的数据稀疏地散布在整棵树中租户数量越多,单个租户的数据在主键索引中越稀疏,主键倒序扫描需要跳过的无关记录越多,查询就越慢。 这就是为什么上线初期 3-5 个租户时没问题,15 个租户之后急剧恶化。
2.4 用 FORCE INDEX 验证猜想
为了验证分析的正确性,我强制指定联合索引:
EXPLAIN SELECT * FROM work_order FORCE INDEX(idx_tenant_status_type_time)
WHERE tenant_id = 1023 AND status = 'PENDING'
AND device_type = 'PUMP'
AND create_time BETWEEN '2024-01-01' AND '2024-03-31'
ORDER BY id DESC LIMIT 20;扫描行数从 24 万降到 312,虽然多了一个 filesort,但 312 条记录的排序代价几乎可以忽略。实测 RT 从 5 秒降到 15ms。
猜想得到验证:优化器的选择是错误的,它低估了主键扫描需要跳过的数据量。
三、根治方案:雪花 ID 改造
3.1 为什么不直接加 FORCE INDEX?
FORCE INDEX 只是一个临时方案,有三个问题:第一,侵入业务代码,后续 DBA 调整索引后可能导致 hint 失效;第二,只解决了这一条 SQL,类似的查询模式还有几十条;第三,没有解决根因——自增 ID 导致的数据分布问题。
根治方案是将主键从自增 ID 改为雪花 ID,从根源上改变数据在主键索引中的分布特征。
3.2 雪花 ID 如何解决问题
标准雪花 ID 的结构是 时间戳(41位) + 机器ID(10位) + 序列号(12位),生成的 ID 本质上是按时间递增的。单纯使用标准雪花 ID 并不能直接解决多租户数据分布的问题。
关键改造在于:我对雪花 ID 的结构做了定制,将 tenant_id 编码到 ID 的高位中。
自定义雪花ID结构(64位):
┌─────────┬───────────┬──────────┬──────────┬──────────┐
│ 符号位 │ 租户标识 │ 时间戳 │ 机器ID │ 序列号 │
│ 1 bit │ 10 bits │ 31 bits │ 10 bits │ 12 bits │
└─────────┴───────────┴──────────┴──────────┴──────────┘将 tenant_id 的哈希值(取低 10 位)放在高位,效果是:同一个租户的数据,其主键 ID 天然聚集在 B+ 树的相邻区域。
改造后的主键索引数据分布:
主键ID: [租户A的区域 ... ] [租户B的区域 ... ] [租户C的区域 ... ]
↑ 租户A的数据连续排列,主键倒序扫描时不需要跳过其他租户的数据这样一来,即使优化器仍然选择主键索引倒序扫描,它也只需要在租户 A 的连续区域内扫描,命中率从 0.5% 提升到接近 100%。
3.3 雪花 ID 生成器实现
public class TenantSnowflakeIdGenerator {
private static final long TENANT_BITS = 10L;
private static final long TIMESTAMP_BITS = 31L;
private static final long MACHINE_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;
private static final long EPOCH = 1640995200000L; // 2022-01-01
private final long machineId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public TenantSnowflakeIdGenerator(long machineId) {
this.machineId = machineId & ((1L << MACHINE_BITS) - 1);
}
public synchronized long nextId(long tenantId) {
long timestamp = System.currentTimeMillis();
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
timestamp = waitNextMillis(timestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
// 租户标识取低10位
long tenantBits = tenantId & ((1L << TENANT_BITS) - 1);
// 时间戳取秒级(31位可用68年)
long timeBits = ((timestamp - EPOCH) / 1000) & ((1L << TIMESTAMP_BITS) - 1);
return (tenantBits << (TIMESTAMP_BITS + MACHINE_BITS + SEQUENCE_BITS))
| (timeBits << (MACHINE_BITS + SEQUENCE_BITS))
| (machineId << SEQUENCE_BITS)
| sequence;
}
private long waitNextMillis(long currentTimestamp) {
while (currentTimestamp <= lastTimestamp) {
currentTimestamp = System.currentTimeMillis();
}
return currentTimestamp;
}
}3.4 数据迁移方案
改造主键是一个高风险操作,不能停机迁移。我设计了一套双写+灰度切换的迁移方案:
第一阶段:双写。 新增一个 new_id 字段(雪花 ID),所有写入操作同时写入 id(自增)和 new_id。历史数据通过后台任务批量回填 new_id。
第二阶段:灰度读。 读接口增加开关,部分请求使用 new_id 作为排序和分页依据,验证查询性能和结果正确性。
第三阶段:切换主键。 确认无误后,通过 ALTER TABLE 将主键从 id 切换到 new_id(在业务低谷期执行,使用 pt-online-schema-change 工具避免锁表)。
第四阶段:清理。 删除旧的 id 字段,new_id 重命名为 id。
整个迁移过程历时两周,期间业务零中断。
四、优化效果
4.1 查询性能对比
以原始的慢 SQL 为基准测试:
改造后不需要任何 FORCE INDEX 提示,优化器自动选择正确的执行计划,因为主键扫描本身也变快了(数据连续,不需要跳过无关租户)。
4.2 联合索引优化
在雪花 ID 改造的同时,我也重新审视了联合索引的设计。原来的索引是:
-- 旧索引:四列联合索引
CREATE INDEX idx_tenant_status_type_time
ON work_order(tenant_id, status, device_type, create_time);由于雪花 ID 高位已经包含了 tenant_id 信息,主键索引天然按租户聚集,tenant_id 在联合索引中的区分度相对下降。我将索引调整为:
-- 新索引:利用主键天然的租户聚集,联合索引聚焦业务筛选字段
CREATE INDEX idx_status_type_time
ON work_order(status, device_type, create_time);
-- 保留一个租户维度的索引,用于纯按租户查询的场景
CREATE INDEX idx_tenant_time
ON work_order(tenant_id, create_time);索引数量从 5 个减少到 3 个,写入性能也有约 8% 的提升(少维护了两棵索引树)。
五、分库分表的思考
5.1 当前是否需要分库分表?
做完雪花 ID 改造后,团队内部讨论过是否需要进一步做分库分表。我的判断是当前阶段不需要,原因如下:
单表数据量在千万级,MySQL 在合理索引下处理千万级数据没有问题,真正的瓶颈线通常在亿级。雪花 ID 改造后查询性能已经满足 SLA 要求。分库分表引入的复杂度(跨库 JOIN、分布式事务、数据迁移)远大于收益。
5.2 如果要分,怎么分?
如果未来数据量增长到亿级(50+ 租户),分库分表的方案已经提前设计好了:
分片键选择 tenant_id。 理由是绝大多数查询都带有 tenant_id 条件,按 tenant_id 分片可以让查询落到单一分片上,避免跨片查询。同时 SaaS 的天然特性是租户间数据完全独立,不存在跨租户 JOIN 的需求。
分片策略用一致性哈希。 而不是简单的取模。好处是新增分片时只需要迁移少量数据,不需要全量重分布。
雪花 ID 天然兼容分片。 因为 tenant_id 已经编码在 ID 的高位中,通过 ID 就能定位到对应的分片,甚至不需要查询条件中额外带 tenant_id。这是当初设计定制雪花 ID 时就埋好的伏笔。
六、经验总结
不要盲目相信优化器。 MySQL 的 CBO(基于代价的优化器)在大多数场景下是可靠的,但在数据分布不均匀、多表联合、复杂排序等场景下,它的代价估算可能严重偏差。养成对核心查询跑 explain 的习惯。
自增 ID 不是万能的。 自增 ID 在单租户场景下是最佳选择(写入顺序性好、B+ 树页分裂少),但在多租户 SaaS 场景下,它会导致不同租户的数据在物理存储上交织混合,降低单租户查询的局部性。
改造主键的风险管控。 主键改造是数据库级别的"心脏手术",一定要有灰度切换方案。双写 → 灰度读 → 切主键 → 清理的四步法,虽然周期长,但保证了零停机和可回滚。
如果这篇文章对你有帮助,欢迎访问我的博客 robinzhu.top 获取更多实战分享。