KiPlant 智慧工厂数字孪生平台——微服务架构搭建与多租户慢SQL治理

_

一、项目背景与当时的痛点

1. 业务背景

KiPlant 是一个面向制造业的 SaaS 化数字孪生平台,核心功能是把工厂的产线设备、生产计划、工单执行、质量检测等数据实时汇聚,通过数字孪生体可视化呈现,帮助工厂管理层做 OEE(设备综合效率)分析和生产决策。客户是中小型制造企业,一个平台实例要同时承载几十到上百家工厂租户的数据。

我加入时平台已经有一个单体版本在跑,大约 30 多家租户。核心矛盾已经暴露出来了。

2. 具体痛点

痛点一:多租户慢SQL严重拖慢全局响应。

单体架构下所有租户共享一个数据库实例,用 tenant_id 字段做逻辑隔离。问题是当时的表设计和索引策略非常粗糙——生产工单表 work_order 已经有 800 多万行,设备采集数据表 device_data 更是超过 5000 万行。有几个大租户(设备多、产线多)的数据量占了总量的 60% 以上,他们的报表查询(比如"某产线过去 30 天每日 OEE 趋势")经常触发全表扫描,一条 SQL 跑 8-15 秒,直接把 MySQL 的 CPU 打到 90%+,导致其他小租户的简单查询也变慢,连工单列表页都要等 3-5 秒。

痛点二:核心接口 QPS 上不去。

工厂设备通过 MQTT 网关上报采集数据,高峰期(白班开工后)大约 2000-3000 条/秒的设备数据写入。再加上前端页面查询(工单列表、设备状态、OEE 看板),整体 QPS 大约 3000-5000。单体应用扛到 3000 QPS 就开始频繁超时,但业务目标是支撑 100+ 租户、1W+ QPS。

痛点三:单体耦合导致迭代效率极低。

所有功能堆在一个 SpringBoot 应用里,生产计划模块改个接口,要和设备管理、质量检测一起打包部署,每次发版全量停服 5-10 分钟。制造业客户对停服非常敏感——产线是 7×24 跑的,停服意味着设备数据丢失。

3. 我的角色

作为架构师主导整个平台从单体到微服务的重构,包括服务拆分、多租户数据隔离方案设计、慢 SQL 治理、以及整套 Spring Cloud Alibaba 技术栈的选型和落地。


二、多租户慢SQL治理

1. 典型慢SQL案例与诊断

最痛的一条 SQL 是 OEE 日报表查询:

-- 原始SQL:查某租户某产线过去30天每日OEE
SELECT DATE(collect_time) as day,
       AVG(availability) as avg_availability,
       AVG(performance) as avg_performance,
       AVG(quality) as avg_quality
FROM device_data
WHERE tenant_id = 'tenant_1024'
  AND production_line_id = 'line_A3'
  AND collect_time BETWEEN '2023-11-01' AND '2023-11-30'
GROUP BY DATE(collect_time)
ORDER BY day;

EXPLAIN 分析结果:

type: ALL
rows: 52000000
Extra: Using where; Using temporary; Using filesort

全表扫描 5000 万行。原因很清楚——device_data 表只有一个主键索引(自增 ID),没有按 tenant_id + production_line_id + collect_time 建联合索引。更糟的是主键是自增 bigint,数据按插入顺序物理存储,同一租户的数据散落在整个 B+ 树里,即使加了索引,回表开销也很大。

2. 索引优化

第一步是加联合索引:

ALTER TABLE device_data 
ADD INDEX idx_tenant_line_time (tenant_id, production_line_id, collect_time);

加完之后 EXPLAIN 变成了:

type: range
rows: 86000
Extra: Using index condition; Using temporary; Using filesort

从 5200 万行扫描降到 8.6 万行,查询时间从 12 秒降到 1.8 秒。但 1.8 秒对于前端看板来说还是太慢,特别是 OEE 页面要同时加载 5-8 条产线的数据。

3. 雪花ID替换自增ID + 分表

进一步优化分两步走。

第一步:主键从自增ID改为雪花ID。 原来的自增 ID 没有业务含义,改成雪花 ID 后在 ID 生成中嵌入了 tenant_id 的低 10 位作为 worker ID 的一部分。这本身不直接提升查询性能,但为后续分表做准备——分表路由可以直接从主键里提取租户信息,不用额外查 tenant_id 字段。

更重要的是雪花 ID 带时间戳,天然按时间有序,配合 InnoDB 的聚簇索引特性,同一时间段的数据物理上连续存储,范围查询的磁盘 IO 大幅减少。

第二步:device_data 按 tenant_id 做水平分表。 采用 ShardingSphere-JDBC 做分片,策略是按 tenant_id hash 取模分 16 张表:

# ShardingSphere配置
rules:
  sharding:
    tables:
      device_data:
        actual-data-nodes: ds_0.device_data_${0..15}
        table-strategy:
          standard:
            sharding-column: tenant_id
            sharding-algorithm-name: tenant-hash-mod
    sharding-algorithms:
      tenant-hash-mod:
        type: HASH_MOD
        props:
          sharding-count: 16

分表后每张表从 5000 万降到约 300 万行。同一租户的数据集中在一张分表里,配合联合索引,OEE 查询从 1.8 秒降到 200-300ms。

work_order 表也做了类似的分表处理(按 tenant_id hash 分 8 张表),工单列表查询从 3 秒降到 150ms 以内。

4. 查询带路由键的强制约束

分表后有一个必须严格遵守的规则:所有查询必须带 tenant_id 作为路由键,否则 ShardingSphere 会对所有分表做全扫描然后合并结果,性能反而更差。

我们在 MyBatis 拦截器层面加了强制校验:

@Intercepts({@Signature(type = Executor.class, method = "query", 
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class TenantShardingInterceptor implements Interceptor {
​
    // 需要分表的表名集合
    private static final Set<String> SHARDED_TABLES = 
        Set.of("device_data", "work_order", "production_plan");
​
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        BoundSql boundSql = ms.getBoundSql(invocation.getArgs()[1]);
        String sql = boundSql.getSql().toLowerCase();
        
        for (String table : SHARDED_TABLES) {
            if (sql.contains(table) && !sql.contains("tenant_id")) {
                throw new IllegalStateException(
                    "Query on sharded table [" + table + "] must include tenant_id. " +
                    "SQL: " + sql.substring(0, Math.min(sql.length(), 200)));
            }
        }
        return invocation.proceed();
    }
}

这个拦截器在开发阶段就能拦住"忘带 tenant_id"的 SQL,避免上线后出现慢查询。


三、微服务拆分与服务治理

1. 核心服务拆分

从单体拆成了 8 个微服务:

服务

职责

数据库

kiplant-gateway

API网关,路由+鉴权+限流

kiplant-auth

认证授权,JWT Token签发,租户身份识别

auth_db

kiplant-device

设备管理、设备注册、MQTT数据接收与解析

device_db

kiplant-production

生产计划、工单管理、排产调度

production_db

kiplant-quality

质量检测、SPC分析、不良品追溯

quality_db

kiplant-twin

数字孪生体管理、3D模型关联、实时状态渲染数据

twin_db

kiplant-analytics

OEE分析、报表聚合、数据看板

analytics_db(只读副本)

kiplant-tenant

租户管理、套餐配额、计费

tenant_db

拆分原则:按业务域拆分,每个服务独立数据库(Database per Service),服务间通过 Feign + RocketMQ 通信。设备数据写入(高频,2000-3000 TPS)和报表查询(重计算)分离到不同服务,避免互相影响。

2. Spring Cloud Alibaba 组件选用

Nacos: 注册中心 + 配置中心。每个微服务注册到 Nacos,配置(包括数据库连接、分表规则、租户配额等)集中管理。多租户的差异化配置(比如大客户的独享数据库连接池)通过 Nacos 的 namespace 隔离。

Sentinel: 限流 + 熔断。在 Gateway 层做全局限流(按租户维度限流,防止单个租户打爆整个平台);在服务间调用做熔断(比如 analytics 服务调 device 服务拉取数据,device 服务响应慢时 analytics 自动熔断降级返回缓存数据)。

// 租户级限流规则——从Nacos动态加载
@Component
public class TenantFlowRuleManager {
    
    @PostConstruct
    public void init() {
        // 默认每个租户100 QPS,大客户可单独配置
        FlowRule defaultRule = new FlowRule();
        defaultRule.setResource("api_gateway");
        defaultRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        defaultRule.setCount(100);
        
        // 按租户ID做热点参数限流
        ParamFlowRule tenantRule = new ParamFlowRule();
        tenantRule.setResource("api_gateway");
        tenantRule.setParamIdx(0);  // 第0个参数是tenantId
        tenantRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        tenantRule.setCount(100);
        // 大客户例外
        tenantRule.setParamFlowItemList(List.of(
            new ParamFlowItem("tenant_vip_001", 500, String.class.getName())
        ));
        
        ParamFlowRuleManager.loadRules(List.of(tenantRule));
    }
}

Gateway(Spring Cloud Gateway): 统一入口,做请求路由、JWT 校验、租户上下文注入。每个请求经过 Gateway 时从 JWT 中解析出 tenant_id,放入请求头 X-Tenant-Id,下游所有服务通过 ThreadLocal 透传。

RocketMQ: 服务间异步通信。设备数据从 MQTT 网关收到后,kiplant-device 做基础解析和存储,然后发 MQ 消息通知 kiplant-analytics 做实时 OEE 计算、通知 kiplant-twin 更新孪生体状态。解耦后 device 服务只需要保证写入性能,不用等 analytics 的重计算。

3. 多租户隔离落地

数据隔离——共享数据库+逻辑隔离为主,大租户物理隔离为辅:

中小租户共享同一套分表(通过 tenant_id 逻辑隔离),ShardingSphere 保证路由正确。超大租户(设备数超过 500 台、日数据量超过 100 万条的)提供独立数据库实例选项,在 kiplant-tenant 服务里配置该租户的独立数据源,运行时动态切换。

动态数据源切换的实现:

@Component
public class TenantDataSourceRouter extends AbstractRoutingDataSource {
​
    @Override
    protected Object determineCurrentLookupKey() {
        String tenantId = TenantContext.getCurrentTenantId();
        // 查租户配置:是否有独立数据源
        TenantConfig config = tenantConfigCache.get(tenantId);
        if (config != null && config.hasDedicatedDs()) {
            return "ds_dedicated_" + tenantId;
        }
        return "ds_shared";  // 默认共享数据源
    }
}

配置隔离——Nacos namespace + 租户配额:

每个租户有自己的配额配置(最大设备数、最大存储量、API 调用频率限制),存在 tenant_quota 表里,通过 Nacos 配置中心动态推送到各服务。超过配额时 Gateway 层直接拒绝请求。

租户上下文透传——ThreadLocal + 请求头 + MQ Header:

// Gateway过滤器:注入租户上下文
@Component
public class TenantContextFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String tenantId = extractTenantFromJwt(exchange);
        ServerHttpRequest request = exchange.getRequest().mutate()
            .header("X-Tenant-Id", tenantId)
            .build();
        return chain.filter(exchange.mutate().request(request).build());
    }
}

// 下游服务:Feign拦截器透传
@Component
public class TenantFeignInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        template.header("X-Tenant-Id", TenantContext.getCurrentTenantId());
    }
}

// MQ消息:Header携带
public void sendDeviceDataEvent(DeviceDataEvent event) {
    Message<DeviceDataEvent> msg = MessageBuilder.withPayload(event)
        .setHeader("tenantId", TenantContext.getCurrentTenantId())
        .build();
    rocketMQTemplate.asyncSend("device-data-topic", msg, callback);
}

四、上线压测、监控与生产数据

1. 压测方案与结果

用 JMeter + Prometheus + Grafana 做全链路压测。模拟场景:100 个租户并发,其中 5 个大租户各有 200 台设备上报数据(1000 台设备 × 每秒 2 条 = 2000 TPS 写入),同时模拟前端 8000 QPS 的读请求(工单列表、OEE 看板、设备状态)。

指标

单体架构(重构前)

微服务架构(重构后)

设备数据写入 TPS

1200(再高就超时)

5000+(device服务3节点)

OEE报表查询 p99

12s

280ms

工单列表查询 p99

4.5s

120ms

整体 QPS 上限

3000

12000+

MySQL CPU(高峰)

90%+

35%-45%

发版停服时间

5-10 分钟

0(滚动更新)

1W+ QPS 的验证是在压测环境里 JMeter 分布式集群(4 台压力机)打出来的,持续 30 分钟,系统稳定无报错。生产环境实际峰值大约 6000-8000 QPS(还没到 100+ 租户的目标规模),但压测结果证明架构能支撑。

2. Prometheus 核心监控指标

# 按租户维度的关键指标
- api_request_duration_seconds{tenant_id, service, endpoint}  # 接口延迟
- api_request_total{tenant_id, service, status_code}           # 请求计数
- db_query_duration_seconds{tenant_id, table}                  # DB查询耗时
- device_data_write_tps{tenant_id}                             # 设备数据写入速率
- tenant_quota_usage_ratio{tenant_id, resource_type}           # 配额使用率

# 服务级别
- jvm_memory_used_bytes{service}
- hikari_connections_active{service, pool}  # 连接池活跃数
- sentinel_flow_block_total{service}        # 限流拦截次数
- rocketmq_consumer_lag{group, topic}       # 消费积压

告警规则:单租户 p99 > 2s 告警(说明该租户可能有慢 SQL 或数据量暴增);MySQL CPU > 70% 告警;消费积压 > 10000 告警。

3. 架构复用到 KiCloud

KiPlant 的这套微服务底座后来直接复用到了 KiCloud 项目(云端扩展版)。复用的核心组件:租户上下文透传机制、ShardingSphere 分表配置模板、Sentinel 租户级限流规则、Nacos 多环境配置体系。KiCloud 在此基础上扩展了多云部署能力(阿里云 + 本地私有化),但底层的服务拆分和数据隔离方案基本沿用。

4. 踩过的最大坑

坑:跨服务查询的分布式JOIN性能灾难

重构后最大的坑出在 analytics 服务做 OEE 报表时。OEE 计算需要关联 device_data(设备采集数据,在 device_db)、work_order(工单数据,在 production_db)、quality_record(质检数据,在 quality_db)三张来自不同服务不同数据库的表。单体时代一个 SQL JOIN 就搞定了,拆分后这个 JOIN 没法直接做。

初始方案是在 analytics 服务里用 Feign 分别调三个服务拉数据再在内存里做关联,但当数据量大时(30 天的数据可能有几十万条),内存占用暴涨、Feign 调用超时频繁,p99 延迟从单体时代的 12 秒进一步恶化到了 20 秒——重构反而变慢了,这是最让人崩溃的时刻。

解决方案:宽表预聚合 + 异步物化。

不再在查询时做实时关联,而是在数据写入时就通过 RocketMQ 事件驱动,把需要关联的数据异步聚合到 analytics_db 的一张宽表 oee_daily_summary 里:

CREATE TABLE oee_daily_summary (
    id BIGINT PRIMARY KEY,
    tenant_id VARCHAR(64) NOT NULL,
    production_line_id VARCHAR(64),
    stat_date DATE,
    availability DECIMAL(5,4),
    performance DECIMAL(5,4),
    quality_rate DECIMAL(5,4),
    oee DECIMAL(5,4),
    total_output INT,
    defect_count INT,
    planned_run_minutes INT,
    actual_run_minutes INT,
    updated_at DATETIME,
    INDEX idx_tenant_date (tenant_id, stat_date)
);

kiplant-device 写入设备数据后发 MQ,kiplant-analytics 消费消息后实时增量更新 oee_daily_summary。前端查 OEE 报表直接查这张宽表,单表查询,200ms 内返回。

代价是数据有几秒的延迟(MQ 传递 + 聚合计算),但对于 OEE 报表这种"看趋势"的场景,几秒延迟完全可接受。


五、OEE 提升 15% 指标的关联

这个 15% 不是说平台性能提升让 OEE 提高了,而是平台能力的提升让工厂管理层能实时看到 OEE 数据,从而做出优化决策,最终提高了 OEE。具体关联:

重构前 OEE 报表要 12 秒才能加载,很多车间主管懒得等,干脆不看。重构后 200-300ms 加载完成,还支持实时刷新(数字孪生大屏每 5 秒自动更新),管理层开始真正依赖 OEE 数据做决策——比如发现某条产线的"性能率"(实际节拍 vs 理想节拍)持续偏低,排查后发现是某台设备的送料速度没调到最优参数,调整后该产线 OEE 从 62% 提升到了 71%。

平台上线后跟踪了 3 个月,使用 OEE 看板做日常管理的工厂平均 OEE 从 58% 提升到了 66.7%,提升约 15 个百分点。这个数据是产品经理从 10 家典型客户的月度 OEE 统计里算出来的平均值,写进了对外的客户案例报告。

KiCloud SaaS 可用性保障——线程池隔离、限流降级与 MTTR 闭环机制 2025-01-15
APS 生产计划、三维工厂孪生体与设备 TPM 闭环管理 2025-02-16

评论区