系统架构总览
IoT DC3 把"采集—归一—分析—执行—反馈"的闭环落成一套分层、多租户的微服务架构:北向只暴露一个网关入口,四个中心服务各管一段链路,南向由协议驱动接入现场设备。这页先用一张分层图建立全局心智模型,再逐一讲清四个关键设计各解决什么问题、如何协作,最后导向每条链路的深度子页。
你在这里:已读过 平台定位 与 核心概念,现在把闭环拆成可落地的分层结构。下一步可深入任一平面(数据 / 命令 / 鉴权 / 领域模型)。
产品架构全景
下面这张全景图把六个分层、四个中心服务的端口、消息总线交换机与可选运维栈一次铺开——先建立"一图看全"的整体印象,再往下看每条链路的逻辑细化。图会随站点明暗主题自适应。
三层结构:接入、平台、存储与消息
平台不是一个大单体,而是按职责切开的一组服务。从调用方视角看进去,请求只有一个入口——网关 dc3-gateway(HTTP 8000),它是唯一对外的 HTTP 端口;其余中心服务的 HTTP/gRPC 端口都只在内部网络可达。网关把请求路由到四个中心服务,它们彼此之间不走 HTTP,而是通过 gRPC facade 跨进程协作。
南向是另一套节奏:现场设备由协议驱动(dc3-driver-*,共 28 个)接入,驱动与数据中心之间不直接调用,而是经 RabbitMQ 异步收发——位号值往北上行、命令往南下行。所有持久化最终落到 PostgreSQL,其中时序数据(位号值历史)由 TimescaleDB 超表承载。
这张图的"虚线"是 gRPC facade 调用、"双向实线"是 RabbitMQ 异步收发——两种连接方式的差别,正是下面四个设计要解释的重点。各服务的端口、启动顺序与健康检查见 服务与拓扑。
单体与分布式两种部署形态
上图是默认的分布式形态(四个中心独立进程)。平台也支持把中心合并为单一进程(dc3-center-single)跑在一台机器上——这只是部署拓扑的选择,不改变业务链路。切换由 DC3_FACADE_MODE 决定,详见 Facade 模式。
gRPC facade:中心之间如何互相调用
四个中心服务需要频繁互相取数——比如数据中心下发命令前要向管理中心确认设备、位号是否存在且启用。如果让业务代码直接拼 HTTP URL 去调对方,服务边界会被传输细节污染,单体/分布式两种部署也无法共用同一套代码。
IoT DC3 的解法是 facade 接口:跨服务调用统一面向 dc3-common-facade-api 里的契约接口编程,业务代码只认接口、不认传输。运行时由 DC3_FACADE_MODE 决定接口背后的实现:
grpc(分布式默认)—— 实现来自dc3-common-facade-grpc,调用走 gRPC 跨进程到目标中心;local(单进程)—— 实现来自dc3-common-facade-local-*,调用在进程内直接方法调用,不过网络。
也就是说,"分布式还是单体"是一个部署开关,而非两套代码。同一份业务逻辑,换 DC3_FACADE_MODE 即可在两种形态间切换。
facade 模式的默认值要看清
分布式各中心默认 grpc:管理中心 application.yml 声明 dc3.facade.mode: ${DC3_FACADE_MODE:grpc},dc3/env/dev.env 也设 DC3_FACADE_MODE=grpc。鉴权中心基础 application.yml 里有一行 dc3.facade.mode: local,那是单进程场景的本地覆盖,不代表分布式默认是 local——以环境变量与 Manager 的声明为准。完整辨析见 Facade 模式。
RabbitMQ 异步解耦:驱动与数据中心为什么不直连
位号值是高频、突发的——一个 Modbus 驱动一轮采集可能瞬间产出成百上千条值。如果驱动同步调用数据中心写库,任一环节变慢都会反压到采集线程,导致驱动掉线、数据丢点。命令下行同理:HTTP 请求不该一直阻塞等设备把寄存器写完。
所以驱动与数据中心之间隔着一层 RabbitMQ,把"产生"和"消费"在时间上解开:
- 上行(数据):驱动把采集结果发到 topic 交换机
dc3.e.value,路由键dc3.r.value.point.{驱动服务名},落到持久队列dc3.q.value.point(7 天 TTL,配死信交换机dc3.e.point_value_dead)。数据中心的PointValueReceiver异步消费、批量或即时落库。 - 下行(命令):命令经交换机
dc3.e.point_command投递到对应驱动队列(30 秒 TTL + 死信),驱动执行后把结果回发到dc3.e.point_command_result(60 秒 TTL),由数据中心的结果接收器回收。
这样位号写入永不阻塞采集,命令下发也立即返回 commandId 供轮询。消息采用持久投递 + 手动 ack + publisher confirm,失败按重投/死信处理。完整的交换机、队列与回执链路见 数据平面 与 命令平面。
写命令失败不回显伪造值
写命令只有当驱动的 write() 返回 Boolean.TRUE 才算成功;失败时结果 responseValue=null,不会回填任何"看起来成功"的值。这是有意为之,避免假成功误导上层。命令 PointCommandDTO.expireAt 默认 now + 10s,超时由驱动在消费时判定为 EXPIRED。
多租户隔离:tenantId 如何贯穿每一层
平台从设计上就是多租户的——隔离不是事后补的过滤器,而是贯穿请求全链路的地基。一次请求进来后,tenantId 会沿着"网关 → 中心服务 → gRPC 调用 → 数据库查询 → 缓存键"一路传递,任何一层都不允许跨租户取数。
落地上有几个强制点:
- 查询层:MyBatis-Plus 的租户行处理器对所有
TenantOwned实体自动追加WHERE tenant_id = ?,覆盖四个中心的全库查询。 - 接口层:
BaseController.requireTenant()对跨租户的单条 ID 查询返回 404(而非泄露数据),filterTenant()在批量结果里剔除非本租户记录。 - 跨服务:gRPC facade 调用在契约支持时携带租户 ID,缓存键也带租户上下文。
这意味着写新查询、新 gRPC 调用、新缓存键时,都必须保留租户作用域——这是硬要求,不是可选优化。隔离怎么一层层落实、与 RBAC 如何配合,见 鉴权 · 租户 · RBAC。
HMAC 网关签名:后端如何信任"调用方是谁"
网关在鉴权后,会把解析出的身份(租户、登录名、principal)打成 X-Auth-Principal JSON 头,转发给后端中心服务。后端据此做权限判定。问题是:后端凭什么相信这个头不是伪造的?毕竟绕过网关直接打内网端口也能构造一个假 principal 头。
解法是 HMAC-SHA256 签名。网关用密钥 AUTH_HMAC_SECRET(配置键 dc3.auth.hmac.secret)对 principal 内容签名,把签名放进 X-Auth-Sign 头;后端用同一密钥验签,验不过就拒绝。只有持有密钥的网关能签出有效请求,伪造的 principal 头会在后端被挡下。
生产环境密钥必须改,否则启动即失败
AUTH_HMAC_SECRET 出厂默认值是 io.github.pnoker.dc3,仅供开发。当 Spring profile 含 pre 或 pro 时,若密钥为空或仍等于该默认值,服务会在启动时抛 IllegalStateException fail-fast,拒绝带着弱密钥上生产。DC3_SECURITY_KEY(登录 Token 签名)同理必须换成环境专属的强随机值。
一致性与可扩展性
四个中心服务本身无状态——会话、令牌denylist、最新值等热数据放在 Caffeine 缓存或数据库里,请求不黏在某个实例上。因此每个中心都可以水平扩展:在网关后面多挂几个同类实例即可分担负载,无需共享内存。
数据中心的吞吐瓶颈在消费侧,而消费并发是可调的:PointValueReceiver 用高吞吐监听容器消费 dc3.q.value.point,按入站速率在"即时落库"与"PointValueJob 批量落库"之间切换;批量阈值由 POINT_BATCH_SPEED(默认 100 条)/ POINT_BATCH_INTERVAL(默认 5 ms)控制,谁先满足谁先刷盘。面对采集洪峰,先由 RabbitMQ 削峰,再靠并发消费与批量写入消化。
强一致与最终一致并存
租户隔离、权限判定、命令状态机这些在请求路径上的环节是强一致的(同步校验、即时拒绝);而位号值的上行落库是经 MQ 的最终一致——值进了队列即视为可靠交付,落库与告警评估异步完成。理解这条边界,有助于排查"命令已回执但历史查询还差一拍"之类的时序问题。