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 语义 |
三条推论可以直接复用:
- 「本机可见」的状态归接入层(连接表、背压水位、心跳计时);
- 「跨实例事实」归业务层(设备台账、在线路由的语义判定);
- 「历史真相」归存储(消息、群、关系链),永远不放进可淘汰的缓存。
第 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
| # | 约束 | 守住什么 | 破坏后的具体后果 |
|---|---|---|---|
| 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. 反模式:四种「模块划分看起来对、实际会烂掉」的写法
- 共享 DTO 包 :把请求/响应对象放公共模块,结果公共模块变成「什么都能往里塞」。 契约只应有 proto + 端口接口 +
MessageEnvelope这类稳定结构。 - 公共 util 模块 :任何两个模块想共享工具类时,先问「它操作的是谁的状态」;
RedisKeys之所以能放在im-proto,是因为它只做字符串构造,不含读写逻辑。 - 按技术层切包 :
controller/service/repository在每个模块里重复,导致「网关的 service」 与「IM 的 service」听起来一样、职责完全不同;本项目按能力 切包(message/、conversation/、relation/、presence/、device/)。 - 逻辑删除跨模块共享 :
markDeleted是用户级状态(我删了这条), 与撤回(所有人都看不见)是两件事,前者归 IM 服务 + 存储,后者还要进读路径脱敏(第 05 篇)。
7. 验证:边界是被断言住的
| 用例 | 断言什么 |
|---|---|
ImServerApplicationScanTest |
jdbc / mongodb 的包都在扫描白名单里,且不含已删除模块的包名(防止「类在但没被扫到」= 启动时缺 Bean) |
StorageWiringTest |
未选中存储介质时不创建 DataSource/MongoClient/MongoTemplate;每个端口恰好一个 Bean |
GrpcChannelFactoryHealthTest |
通道健康检查开关的三种行为,包括「对端没有健康服务时不摘除」 |
/admin/status |
连接的传输分布(websocket/tcp)与背压暂停数------它是单机排障的第一站 |
第 2 条值得强调:@ConditionalOnProperty 漏写一个不会导致启动失败,只会导致「两个实现同时存在」 或「一个都没有」。这类错误在运行时表现为「消息写进了错误的介质」,因此必须用装配断言而不是人工检查。
8. 小结:三条可迁移的原则
| 原则 | 具体做法 | 判据 |
|---|---|---|
| 状态所有权决定边界 | 每个状态问「谁唯一有权写」 | 答不出唯一写方 ⇒ 边界没切干净 |
| 依赖方向即约束 | 写反了编译不过 | 需要靠文档/评审维护的约束,三个月后一定失效 |
| 双形态保留行为基线 | 单体 + 拆分,行为一致 | 单体正常、拆分异常 ⇒ 问题必在传输层,排查范围砍一半 |
下一篇 :02 技术选型与取舍 ------ 每个组件为什么是它, 被否掉的方案差在哪一步,以及这些选择如何反过来决定了本篇的边界。