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 时序数据:用“先写后读”策略

设备上报的时序数据,对一致性要求最低。我采用本地消息表 + 定时同步的策略:

  1. Transport Service 收到数据后,先写入本地消息表(状态为“待发送”)
  2. 异步发送到Kafka,成功后更新状态为“已发送”
  3. Data Service 消费Kafka消息,写入时序数据库
  4. 定时任务扫描“待发送”超过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网关设计。到时候我会分享一个我亲手搭的网关架构,保证让你少走弯路。