在凯士比数字工厂(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;

结果:

id

select_type

type

key

rows

Extra

1

SIMPLE

index

PRIMARY

245830

Using where

关键发现:type 列是 indexkey 列是 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;

id

select_type

type

key

rows

Extra

1

SIMPLE

range

idx_tenant_status_type_time

312

Using index condition; Using filesort

扫描行数从 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 为基准测试:

场景

自增 ID + 主键扫描

自增 ID + FORCE INDEX

雪花 ID(改造后)

扫描行数

~245,000

~312

~280

P99 RT

3-5 秒

15ms

12ms

是否需要 hint

改造后不需要任何 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 获取更多实战分享。


本站由 困困鱼 使用 Stellar 创建。