一、项目背景与当时的痛点
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 个微服务:
拆分原则:按业务域拆分,每个服务独立数据库(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 看板、设备状态)。
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 统计里算出来的平均值,写进了对外的客户案例报告。