01 总体架构与模块边界

01 总体架构与模块边界:把「状态所有权」变成编译期约束

项目地址 :github.com/zhao-ru-jie...
系列第 1 篇。本篇只回答两件事:系统按什么原则被切开 ,以及切开之后靠什么机制保证它不会长回去 。 与 12 深度剖析 的分工:那一篇讲「为什么这些机制能保证可靠性」, 这一篇讲「这些机制为什么只能长在这些模块里」。


1. 先定义切分原则:谁写,谁拥有

绝大多数项目的模块划分沿技术分层展开(controller / service / dao / client), 结果是一个业务改动会横穿四层,而「谁是这个状态的唯一写者」这个问题从来没被回答过。

本项目的切分判据只有一句:

一个状态由「唯一有权写它的进程」拥有;不写它的模块,只能通过端口读。

把这句话套到系统里所有状态上,模块边界就自动出现了:

状态 唯一写方 读方 若归属错了会怎样
本机连接表(userId → deviceId → session) 网关 --- 若 IM 服务试图维护它,需要订阅每条连接的生死,等于把长连接层搬进业务层
在线路由 im:gateway:user:{u}(SET) 网关 IM 服务 若由 IM 服务写,它必须知道「连接建在哪」,而它只能靠网关上报,多一跳 RTT 且必然漂移
离线队列 / 补发游标 / 群锚点 IM 服务 IM 服务 若网关也判离线,两个进程会对「该不该落离线」给出不同答案
消息体、群、成员、关系链 存储插件 双方 若网关直连存储,业务规则会渗到接入层
设备台账 im:user:{u}:device:state IM 服务(网关上报事实) 接口层 若网关自己聚合,它只能看到本机设备------跨实例事实必须由中心方维护
客户端水位 im:user:{u}:wm:{d} IM 服务 IM 服务 若网关写,进程重启即丢,撑不起 delivered 语义

三条推论可以直接复用:

  1. 「本机可见」的状态归接入层(连接表、背压水位、心跳计时);
  2. 「跨实例事实」归业务层(设备台账、在线路由的语义判定);
  3. 「历史真相」归存储(消息、群、关系链),永远不放进可淘汰的缓存。

第 2 条最容易被写错。设备管理是个典型陷阱:网关天然知道「这台机器上有哪些设备」, 于是很容易顺手把「这个账号有几台设备」也实现掉------但它只看得见自己。 DeviceService 的类注释把这个边界写死了:网关只上报事实(ReportDevice),不做聚合。


2. 模块与依赖:三条硬约束

vbscript 复制代码
im-app           ──> im-gateway ──┐
                 ──> im-server ───┼──> im-proto
                 ──> im-rpc ──────┘
​
im-gateway-boot  ──> im-gateway / im-rpc
im-server-boot   ──> im-server / im-rpc / im-storage-*
im-storage-jdbc    ──> im-server ──> im-proto
im-storage-mongodb ──> im-server ──> im-proto
graph TD proto["im-proto 契约"] gw["im-gateway 接入"] srv["im-server 业务"] rpc["im-rpc gRPC 适配"] jdbc["im-storage-jdbc"] mongo["im-storage-mongodb"] app["im-app 单体装配"] gwboot["im-gateway-boot"] srvboot["im-server-boot"] gw --> proto srv --> proto rpc --> proto jdbc --> srv mongo --> srv app --> gw app --> srv app --> rpc gwboot --> gw gwboot --> rpc srvboot --> srv srvboot --> jdbc srvboot --> mongo
# 约束 守住什么 破坏后的具体后果
C1 im-gateway 与 im-server 互不依赖,共享物只有 im-proto 接入与业务的演进解耦 网关为了推消息会去引用 MessageService,单体形态「能跑」,拆分时发现推不出去
C2 业务模块不依赖 im-rpc 业务代码里看不到传输 改一次传输层超时策略要动 30 个业务类;本地直调与远程调用无法互换
C3 im-storage-* 单向依赖 im-server 的端口 换介质不动业务 业务出现 @Repository,第二个存储实现无处安放

这三条不是靠 Code Review 守住的------写反了编译不过。C2 的实现细节值得单独看:

java 复制代码
// im-server 里只有这个端口,没有任何 gRPC 类型
public interface PushPort {
    PushResult push(PushTask task);
}
​
// im-app(单体)注入本地实现;im-server-boot 注入 RemoteGatewayPushPort
// 因此「本地直调」与「跨机 gRPC」是同一条业务路径的两个实现

2.1 端口清单与「可空依赖」的处理

端口 方向 单体实现 拆分实现
MessageIngress 网关 → IM MessageService(本地直调) gRPC im.ingress.v1.MessageIngress
PushPort IM → 网关 GatewayPushPort RemoteGatewayPushPort
PresencePort IM ↔ 网关 本地 gRPC im.presence.v1.*
DeviceControlPort IM → 网关 本地 gRPC(踢下线/查连接)
VendorPushPort IM → 厂商中转 默认不装配 HTTP

可选端口一律用 ObjectProvider<T> 注入,而不是 @Autowired(required=false) + null 判断散落在业务里。区别在于:ObjectProvider 把「可能不存在」表达在类型上, 业务方法只有一处 getIfAvailable(),而且单测里可以显式不提供:

java 复制代码
public DeviceService(DeviceStore store, ClientFrames frames,
                     ObjectProvider<PushPort> pushPortProvider,
                     ObjectProvider<DeviceControlPort> controlProvider, ...) { ... }

VendorPushPort 的缺失是正常状态(没用厂商推送的部署),流程必须能继续; 把它做成必需依赖会让「没有中转服务」的部署起不来。这类「可选依赖必须真的可选」 在第 02 篇的自动配置一节还有更典型的例子。


3. 两种运行时形态:为什么值得多维护一个入口

3.1 单体装配(im-app)

yaml 复制代码
WS 8081 / TCP 9092
本地直调
本地推送
客户端
Netty 双通道
网关逻辑
IM 服务
Tomcat 8080
Redis
MongoDB / MySQL

单体入口的关键不是「省一次网络调用」,而是它把上行通道做成了可切换的三档 (im.ingress.transport):

档位 行为 用途
local(默认) 本地直调 MessageService 联调、回归、行为基线
shadow 本地为准 + 远程异步双跑比对 切流前验证两条通路语义等价
grpc 完全走 gRPC 单体内也能压测远程通道

3.2 shadow 到底验证什么(以及不验证什么)

ShadowMessageIngress 的类注释写得很克制,值得完整引用其判断:

  • 验证 :序列化是否正确、幂等键是否原样传递、seq 是否一致、幂等回放是否返回同一个 serverMsgId、错误码是否可比;
  • 不验证 :落库差异。两条路径共享同一 Redis/DB,shadow 侧必然命中幂等回放 (duplicate=true)------这是预期行为,不是差异;
  • 绝不做 :pullPending。拉取有消费语义,双跑会把离线消息消费两次。

三条工程约束体现了它的定位(旁路,不是并联主路):

scss 复制代码
public SubmitMessageResponse submit(SubmitMessageRequest request) {
    primaryCalls.increment();
    // 主路径异常直接向上抛:shadow 不得吞掉主路径故障
    SubmitMessageResponse primaryResult = primary.submit(request);
    if (sampled()) {
        executor.execute(() -> compareOne(request, primaryResult));   // 虚拟线程异步,失败只记指标
    }
    return primaryResult;
}

GET /shadow/stats 给出 matchRate 与 shadowErrors。判读口径也要写清: matchRate 长期 100% 且 shadowErrors 为 0 才能说「两条通路等价」; 如果 shadow 侧大量 duplicate 但 serverMsgId/seq 一致,那恰恰是幂等在两条通路上都生效的证据。

3.3 拆分部署(im-gateway-boot + im-server-boot)

bash 复制代码
WS/TCP
WS/TCP
gRPC SubmitMessage
gRPC SubmitMessage
gRPC DownstreamPush
客户端
网关 #1
网关 #2
IM 服务集群
IM 服务
Redis:路由/离线/发号
MongoDB / MySQL

拆分的收益是扩缩容维度分离:网关按连接数(空闲 CPU ≈ 0),IM 服务按 CPU/吞吐。 代价是多一次 RPC、两套清单、以及「两类进程分别挂掉时行为不同」需要单独设计(第 10 篇)。


4. 一条消息的完整链路(含每跳的时间预算)

以「网关 1 上的 alice 发给网关 2 上的 bob」为例:

arduino 复制代码
"bob(GW2)""网关2""Redis""IM 服务""网关1""alice(GW1)""bob(GW2)""网关2""Redis""IM 服务""网关1""alice(GW1)""msg.send{clientMsgId,to,body}"1"解帧 → 每连接串行队列(虚拟线程)"2"SubmitMessage(deadline ≤300ms 单体 / 10s 跨机)"3"Lua:幂等判定 + INCR 取 seq(1 次 RTT)"4"seq=7"5"落库(结构化记录,write-ahead)"6"写幂等 {serverMsgId|seq}(24h)"7"更新 conv meta + 成员会话列表"8"ack{serverMsgId, seq=7}"9"msg.ack"10"读 im:gateway:user:{bob} → {GW2}"11"DownstreamPush.Push(frame)"12"msg.push{seq=7}"13

把每一跳的超时与所有者列出来,才能回答「慢在哪」:

跳 超时/预算 超时后的行为 所有者
网关内部队列 无(背压水位 1024 帧/连接) 暂停读取,不丢帧 网关
网关 → IM 单体 300ms / 跨机 10s 回 error 503(可重试),连接保持 网关
Redis 命令 1s(命令超时) 触发熔断,走兜底 RedisDegrade
落库 由数据源决定 受理失败 503(未落库 ⇒ 未 ack) 存储插件
IM → 网关推送 800ms(im.push.timeout-ms) 记 failed → 落离线 IM 服务
网关写 socket 由 isWritable() 判断 记 failed → 落离线 网关

这张表解释了一个反直觉的设计:网关到 IM 的超时(300ms~10s)远大于推送超时(800ms) 。 因为上行是同步等待(用户在看屏幕),下行是异步补偿(有离线队列兜底)------ 谁有兜底,谁的超时可以更短。


5. 边界最容易破的三处(都是「看起来顺手」)

破口 顺手写法 后果 正确位置
把业务下沉到网关 网关直接判「对方在不在线」「群成员够不够」 网关需要读群成员表与关系链,接入层开始依赖业务存储;拆分后网关必须直连 DB 准入在 IM 服务(MessageAcl),网关只做认证与转发
上行绕过 IM 服务 网关直接写消息库 + 发号 两处写库 = 两套幂等;网关重启会丢「已 ack 未落库」 一切受理走 MessageIngress
把存储端口暴露给网关 网关为「历史接口」直接注入 MessageRepository 存储实现变成两个模块的共同依赖,C3 失效 历史接口在 IM 服务(Tomcat 侧)

第三条还有个具体教训:网关的 Tomcat(8080)确实提供 /admin/*、/actuator/* 与测试页, 但它不提供任何业务接口。一旦为了让「连接侧少一次 RPC」而在网关上开业务接口, C1 会在几周内名存实亡。


6. 反模式:四种「模块划分看起来对、实际会烂掉」的写法

  1. 共享 DTO 包 :把请求/响应对象放公共模块,结果公共模块变成「什么都能往里塞」。 契约只应有 proto + 端口接口 + MessageEnvelope 这类稳定结构。
  2. 公共 util 模块 :任何两个模块想共享工具类时,先问「它操作的是谁的状态」; RedisKeys 之所以能放在 im-proto,是因为它只做字符串构造,不含读写逻辑。
  3. 按技术层切包 :controller/service/repository 在每个模块里重复,导致「网关的 service」 与「IM 的 service」听起来一样、职责完全不同;本项目按能力 切包(message/、conversation/、 relation/、presence/、device/)。
  4. 逻辑删除跨模块共享 :markDeleted 是用户级状态(我删了这条), 与撤回(所有人都看不见)是两件事,前者归 IM 服务 + 存储,后者还要进读路径脱敏(第 05 篇)。

7. 验证:边界是被断言住的

用例 断言什么
ImServerApplicationScanTest jdbc / mongodb 的包都在扫描白名单里,且不含已删除模块的包名(防止「类在但没被扫到」= 启动时缺 Bean)
StorageWiringTest 未选中存储介质时不创建 DataSource/MongoClient/MongoTemplate;每个端口恰好一个 Bean
GrpcChannelFactoryHealthTest 通道健康检查开关的三种行为,包括「对端没有健康服务时不摘除」
/admin/status 连接的传输分布(websocket/tcp)与背压暂停数------它是单机排障的第一站

第 2 条值得强调:@ConditionalOnProperty 漏写一个不会导致启动失败,只会导致「两个实现同时存在」 或「一个都没有」。这类错误在运行时表现为「消息写进了错误的介质」,因此必须用装配断言而不是人工检查。


8. 小结:三条可迁移的原则

原则 具体做法 判据
状态所有权决定边界 每个状态问「谁唯一有权写」 答不出唯一写方 ⇒ 边界没切干净
依赖方向即约束 写反了编译不过 需要靠文档/评审维护的约束,三个月后一定失效
双形态保留行为基线 单体 + 拆分,行为一致 单体正常、拆分异常 ⇒ 问题必在传输层,排查范围砍一半

下一篇 :02 技术选型与取舍 ------ 每个组件为什么是它, 被否掉的方案差在哪一步,以及这些选择如何反过来决定了本篇的边界。

相关推荐
贾公子28 分钟前
从单条内容到账号维度——AIGC 风险评分的工程实现
后端·python
一条泥憨鱼1 小时前
【消息队列】ACK确认了什么:消息队列可靠投递的完整链路
java·后端·kafka·消息队列·rabbitmq
孟陬2 小时前
Foobar 技术周刊#160:大模型让大规模形式化软件验证成为现实丨AI写C++代码之后会发生什么?丨我们真的需要alpha() 函数吗丨重试,有时会让故障更
前端·后端·typescript
BingoGo2 小时前
PHP 标准库的价值远不止少写几行代码
后端·php
JaguarJack3 小时前
PHP 标准库的价值远不止少写几行代码
后端·php·服务端
鶴哥只手遮天3 小时前
《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路
redis·后端·开源
用户400315043353 小时前
《从 skynet.timeout 到回调执行:Skynet 分层时间轮源码剖析》
后端
一条Go3 小时前
四个进程,一把锁:Alioth 的告警评测为什么不需要消息队列
后端·设计模式·架构
用户1996143559963 小时前
插件是怎么被安全装进系统的
后端