2、整体架构设计:微服务拆分原则、服务边界划分、数据一致性策略
好,咱们进入正题。这一章聊的是整个改造的骨架——架构设计。说白了,就是决定把ThingsBoard这个大胖子,切成多少块,每块干什么,块与块之间怎么说话。
我见过不少团队,一上来就照着网上的“最佳实践”咔咔拆,结果拆完发现,服务之间互相调来调去,延迟比原来还高。嗯,这里要注意,微服务拆分不是越细越好,而是恰到好处。
2.1 微服务拆分原则:我的三个“铁律”
我个人习惯,在动手拆之前,先定三条原则。这三条原则帮我避过不少坑。
原则一:高内聚,低耦合
同一个业务变更,尽量只改一个服务。如果改一个功能要同时动三个服务,那说明拆错了。
原则二:以数据为中心,而非以代码为中心
先看数据怎么流动,再看代码怎么组织。数据边界决定了服务边界。
原则三:拆分粒度与团队规模匹配
你想想看,一个5人团队拆出20个服务,每人维护4个,光部署就累死。我曾经见过一个项目,3个人维护12个服务,最后连日志都找不到在哪。
2.2 服务边界划分:ThingsBoard怎么切?
ThingsBoard的核心业务,我把它归纳为四大块:设备接入、数据处理、规则引擎、可视化。每一块都可以独立演进。
具体怎么切?我建议这样分:
| 服务名称 | 核心职责 | 数据存储 |
|---|---|---|
| Transport Service | 设备连接、协议适配(MQTT/CoAP/HTTP) | 无状态,数据直接发往Kafka |
| Rule Engine Service | 消息处理、规则链执行、告警生成 | Redis + PostgreSQL |
| Core Service | 设备管理、租户管理、用户权限 | PostgreSQL(主库) |
| Data Service | 时序数据存储、查询、聚合 | Cassandra / TimescaleDB |
| Web UI Service | 前端静态资源、API网关 | 无状态,缓存Redis |
这里有个关键点:Transport Service 和 Rule Engine Service 之间,绝对不能直接RPC调用。为什么?因为设备流量是突发的,RPC会直接把规则引擎打挂。我在项目中遇到过,高峰期设备上报量暴增,规则引擎直接OOM,整个系统瘫痪了半小时。
我的建议:Transport Service 把数据扔到Kafka,Rule Engine Service 从Kafka拉。这样削峰填谷,两边都稳。
2.3 数据一致性策略:CAP 的取舍
拆成微服务后,最头疼的问题来了——数据一致性。以前单体应用,一个事务搞定。现在数据散落在各个服务里,怎么保证不丢、不乱?
我个人的经验是:别追求强一致性,接受最终一致性。你想想看,设备上报的温度数据,晚几秒入库,用户根本感觉不到。但要是因为强一致性导致系统不可用,那才是大问题。
2.3.1 业务数据:用 Saga 模式
比如创建设备时,需要同时创建设备信息、分配租户、初始化告警规则。这三个操作分布在 Core Service 和 Rule Engine Service 里。
我建议用编排型 Saga:
// Saga 编排器伪代码
public class DeviceCreateSaga {
@Autowired
private CoreServiceClient coreClient;
@Autowired
private RuleEngineClient ruleClient;
public void createDevice(Device device) {
try {
// 步骤1:创建设备信息
coreClient.createDevice(device);
// 步骤2:初始化告警规则
ruleClient.initAlarmRules(device.getId());
// 步骤3:分配租户资源
coreClient.assignTenant(device.getId(), device.getTenantId());
} catch (Exception e) {
// 补偿操作
coreClient.rollbackDevice(device.getId());
ruleClient.removeAlarmRules(device.getId());
throw new SagaException("设备创建失败,已回滚", e);
}
}
}
注意:Saga 的补偿操作必须是幂等的。我曾经因为补偿逻辑没做幂等,导致回滚时重复删除了两次告警规则,数据直接乱了。血的教训。
2.3.2 时序数据:用“先写后读”策略
设备上报的时序数据,对一致性要求最低。我采用本地消息表 + 定时同步的策略:
- Transport Service 收到数据后,先写入本地消息表(状态为“待发送”)
- 异步发送到Kafka,成功后更新状态为“已发送”
- Data Service 消费Kafka消息,写入时序数据库
- 定时任务扫描“待发送”超过30秒的数据,重新发送
这样做的好处是:数据绝对不会丢。就算Kafka挂了,数据还在本地消息表里,恢复后自动补发。
2.3.3 缓存与数据库:用“双删”策略
Core Service 里经常要查设备信息,我用了Redis做缓存。但缓存和数据库之间怎么保持一致?
我推荐延迟双删:
public void updateDevice(Device device) {
// 第一步:删除缓存
redisTemplate.delete("device:" + device.getId());
// 第二步:更新数据库
deviceRepository.save(device);
// 第三步:延迟1秒后再删一次
executor.schedule(() -> {
redisTemplate.delete("device:" + device.getId());
}, 1, TimeUnit.SECONDS);
}
为什么要删两次?因为在高并发下,第一次删除后,可能有其他线程把旧数据又写回缓存。第二次删除就是兜底。嗯,这个方案不完美,但够用。
2.4 避坑指南:我踩过的三个坑
坑一:服务间通信全用HTTP
我曾经把所有服务间的调用都写成REST API,结果延迟高得离谱。后来改成gRPC,延迟降了80%。记住:内部服务用gRPC,外部接口用REST。
坑二:每个服务一个数据库
一开始我严格按照“每个服务独享数据库”的原则,结果发现跨服务查询时,只能靠API聚合,性能极差。后来我把读多写少的数据做了冗余,比如把设备名称冗余到告警服务里,查询直接走本地库,快多了。
坑三:忽略分布式事务的边界
有一次我把一个用户注册流程拆到了5个服务里,结果Saga补偿逻辑写了200行,还经常出bug。后来我反思:如果一次操作涉及超过3个服务,就该考虑是不是拆得太碎了。
2.5 小结
这一章的内容,说白了就是三句话:
- 拆分原则:高内聚低耦合,以数据为中心,匹配团队规模
- 服务边界:Transport、Rule Engine、Core、Data、Web UI 五大块
- 一致性策略:业务用Saga,时序用本地消息表,缓存用延迟双删
下一章,咱们聊聊具体的服务通信和API网关设计。到时候我会分享一个我亲手搭的网关架构,保证让你少走弯路。