跳转至

06 微服务与部署

状态:可实施 更新日期:2026-09-01 上游契约:docs/PLAN.md §5.1、§5.5、§17、§19.2、§27.2.4 实施决策:ADR-0006(Rust + Redpanda)、ADR-0007(一期简化形态)、 ADR-0012(Fanout 无 Kafka 事务)

0. 文档边界

本文定义:服务拆分与部署拓扑、扩缩容、租约漂移接管、跨地域、重分片操作手册。

本文不得重新定义:§5.1 的实体命名(只能使用该表写法,禁止别名)、 服务职责边界(§17.1)、fencing 机制(§19.2)、§27.2.4 的不可变项清单。


1. 部署单元

1.1 二进制拆分

ADR-0006 规定单一语言、禁止混合部署。按有状态性与扩缩容维度拆分:

二进制 承载实体 状态 扩缩容维度
qim-gateway ConnectionNode 有状态不进检查点(连接) 连接数
qim-writer ConversationWriter + MessageCommitter 会话租约 + 序号窗口 + HLC + 可恢复提交 会话写入速率
qim-fanout FanoutCoordinator Outbox → dispatch:全部输出 durable 后同步提交 source checkpoint;允许重放重复,不使用 Kafka 事务 Outbox 吞吐
qim-mailbox MailboxNode 一期无本地权威数据(ADR-0007) 邮箱写入速率
qim-session SessionProjection 视图可重建;Redis 持久 dispatch checkpoint,追平后才 ready 会话列表读 QPS
qim-room RoomWriter 进程内环形缓冲,可丢 房间数 × 在线数
qim-notify NotificationService 私有 token 表 推送量
qim-registry ShardRegistry + PresenceDirectory 聚合层 强一致小集群 极低
qim-media MediaService 无状态 上传下载量
qim-admin ModerationService 无状态 低

当前 lane 覆盖:qim-mailbox 已使用 64-lane 映射和 packed 持久格式,但 finish_dispatch 在整条 record 的全部 chunk durable 后统一推进 64 lane。下文按 lane 独立停滞/接管的操作手册是目标形态,相关 M-1/用例 26.3.6 当前仍应失败。

当前 qim-writer 与 qim-fanout 以 Redpanda 日志边界解耦:

ConversationWriter / MessageCommitter
  -> MessageRecord + Redpanda Outbox -> SEND_ACK
  -> FanoutCoordinator(read_committed)
  -> Redpanda 分发日志 -> MailboxNode 物化邮箱

MailboxNode 不接受 writer→mailbox TCP dispatch;在线 PushBatch 仅在持久物化后尽力发送。

1.2 一期拓扑(ADR-0007 起步档)

qim-gateway    2 + 2(冗余)      20 万连接/节点
qim-writer     4
qim-fanout     按 Outbox 吞吐与分区数伸缩
qim-mailbox    16                 每节点 4 个 MailboxShard(共 64 分片)
qim-session    4
qim-room       2
qim-notify     2
qim-registry   3(etcd 3 节点 + 无状态聚合层)
外部依赖       Redis 7.0.0+ standalone(AOF + noeviction)/ Redpanda v26.2.2+ / S3;
               writer/mailbox 可显式选择 ScyllaDB,qsession checkpoint 仍使用 Redis

qim-session 独立静态消费 dispatch 日志并按 partition 保存下一 offset;checkpoint 同时绑定 cluster_id + topic_id + partition_count。Broker 必须提供 KIP-516 非零 topic UUID;同名 topic 重建、身份缺失/全零或位点越界均拒绝就绪。Redpanda v24.3.6 不满足 该能力,当前隔离基线为 v26.2.2。

Redis Cluster 不属于当前部署拓扑。配置为 Cluster 端点时,依赖 Redis 的服务必须在 readiness 前拒绝启动;原因及未来独立立项条件见 docs/03 §1.1。


2. 分片归属与租约

2.1 唯一权威(§5.3.7)

ShardRegistry(etcd)
    mailbox_shard_id -> { owner_node, shard_epoch, lease_expire_at }
    connection_shard_id -> { owner_node, lease_expire_at }
         │
         ├──▶ ConnectionNode 订阅式本地缓存(读路径解析落点,稳态零远程点查)
         └──▶ MailboxNode 先取租约,再 assign() 消费对应日志分区

必须用 assign() 静态指派,禁用消费者组自动 rebalance。 两套成员机制会分裂归属:Kafka 认为节点 A 拥有分区 7、注册中心认为 B 服务分片 7 的读, B 就会用未追平的 W[lane] 回答 PULL_MAILBOX,触发 §9.3 规则 3 的假空洞 → 丢消息。

2.2 租约参数与防脑裂

shard_lease_ttl            15 s   租约时长
lease_renew_interval        5 s   续期间隔(失败后转 1 s 快速重试)
writer_self_fence_deadline 12 s   自我隔离阈值:到期未续上即主动停写
writer_failover_wait       25 s   接管等待,**必须 > shard_lease_ttl**

不等式:writer_failover_wait > shard_lease_ttl > writer_self_fence_deadline
        接管方等待租约自然过期,而不是心跳超时就抢 —— 这是 §19.2 的核心

为什么等租约而不是等心跳:failure detector 判定失联不等于对方已停止 (§23.7 的 Akka 教训)。进程被 SIGSTOP、cgroup 限流、VM 挂起都会造成秒级不响应 而进程仍活着。Rust 无 GC 不改变这一点,禁止以"我们没有 JVM 级 GC" 为由缩短 writer_failover_wait。


3. 接管操作手册

3.1 MailboxNode 漂移接管(ADR-0007 默认形态)

算法见 docs/03 §4.1。运维侧动作:

自动触发   租约超时 / lane_stall_failover(120s) 停滞
手动触发   qimctl shard drain <shard_id>   (优雅:先停消费、推完水位、再释放租约)

观察点
    mailbox_seq_regression_count      恒为 0,非 0 立即 P1 停止操作
    lane_watermark_stall_ms{shard,lane}
    mailbox_cursor_rebase_count       漂移形态下会有正常增长,阈值按形态取值

回滚   接管失败(W_floor 校验不过)-> 该 lane 保持 SHARD_MOVED
       **不得强行降低水位对外服务** —— 那是静默丢消息

3.2 ConnectionNode 接管(§5.3.4)

N+2 接管候选
放行速率 takeover_admit_rate(5%/s),超额回 ERROR{RATE_LIMITED, retry_after_ms}
AUTH_OK.sync_delay_hint_ms(0~30s 随机) 削平拉取尖峰

验收:单节点故障后 5 分钟内接管节点 CPU 与 AUTH P99 <= 稳态 2 倍
      无用户超过 60 秒无法登录

4. 扩缩容

4.1 可在线伸缩的

qim-gateway    直接加减节点,连接自然重分布(客户端重连 + REDIRECT)
qim-session    无状态,直接伸缩
qim-notify     无状态,直接伸缩
qim-mailbox    加节点后由 ShardRegistry 重新分配分片租约(分片数不变,只换归属)

4.2 需要走流程的:分片分裂(§5.5)

逻辑分片数只能通过分裂增加,且上界为 min(virtual_bucket_count, connection_shard_count)(§5.2 推论 1)。

qimctl shard split <old_shard> --into 2

 1. ShardRegistry 递增 shard_epoch,写 ShardSplitBoundary
    { old_shard, old_epoch, split_at_seq, new_shard, new_epoch, bucket_range }
 2. 新分片 assign() 新日志分区,开始消费
 3. 双写窗口:同一 dispatch 在旧分片与新分片各得一个 mailbox_seq
    不冲突的原因:新分片 epoch 严格更大,复合序号仍单调(§5.5.3)
 4. 新分片追平 -> 切读
 5. 旧分片停写、保留至 log_retention_days 后回收
 6. 客户端游标按边界表**换发签名令牌**(CURSOR_REBASED),不是数值映射

lane_id 跨分裂稳定:它由 blake3(tenant_id, user_id) 导出,不含分片数(§6.5.1)
                    分裂不改变任何用户的 lane 归属,无需补偿

4.3 永不可变(§27.2.4)

virtual_bucket_count(65536)、lane_count(64)、message_seq_bucket_width(4096)
bucket_hash / lane_id 的哈希族(blake3)
event_id / dispatch_id 的哈希输入元组
event_ordinal 的 event_type 优先级映射
全部分区键设计
mailbox_seq / room_seq 位布局与 epoch 上界

lane_count = 64 即使一期用不上也必须设死(ADR-0007):它已签入游标令牌, 设 1 则升级到大群档时必须重建集群并换发全部游标。


5. 跨地域

5.1 两个 region 概念(§5.3.3,禁止混用)

用户 home_region    该用户的 MailboxShard 与 ConnectionShard 在哪个 region
会话 Home Region    该会话的 conversation_seq 由哪个 region 的 ConversationWriter 分配

两者可以不同:跨国群的会话 Home Region 只有一个,成员邮箱分布在各自 home region
用户邮箱固定在用户 home region -> 会话 Region 切换**不影响** mailbox_seq 与设备游标
跨 region 分发通过日志镜像完成

变更用户 home_region 等价于一次跨 region 重分片,走 §5.5 流程。

5.2 Home Region 切换(§19.2.3)

 1. 探测失联
 2. **等待租约自然过期**(不是心跳超时就抢)
 3. 新 Writer 递增 fencing_epoch 取得租约
 4. 从持久化的已预留上界恢复 conversation_seq(预留窗口内的空洞是合法的,§6.3)
 5. 接受写入

切换期间客户端表现:ERROR{REGION_FAILOVER, retry_after_ms}
                    客户端本地排队并保持 pending 气泡,
                    **不得提示"发送失败"直到超过总重试预算**

5.3 灾难恢复等级(§19.4)

标准租户   跨区域异步复制,RPO > 0(明确非零)
高级租户   同步复制或双地域提交,RPO = 0,接受更高写延迟与成本

6. 内部通信与鉴权(§17.3)

内部 RPC 一律 mTLS 双向鉴权 + 服务身份白名单
禁止任何内部服务以"内网可信"为由跳过鉴权

关键白名单:
    device_token 本体      仅 NotificationService 可读
    device_token_digest    MailboxNode 可读(只有摘要,无凭证本体)
    DEK 解封              仅持有对应 key_scope 的服务
    游标签名密钥           仅 ConnectionNode 与 Auth/Session

7. 配置与灰度

不可变项(§4.3)      集群初始化时写入 ShardRegistry,运行期只读,改动需重建集群
租户策略              热更新,经配置通道下发(保留期、E2EE 开关、静音策略等)
mailbox_write_policy  **禁止运维直接改配置切换**,必须走 ADR-0003(改变产品承诺)
MailboxStore 切换     单分片灰度 + 影子读比对,mailbox_store_shadow_mismatch 非零即阻断
Redis 部署模式        仅 7.0.0+ standalone(AOF + noeviction);版本不可证明或 Cluster 配置必须启动拒绝,禁止灰度绕过
协议版本              双版本共存期 >= 2 个客户端强制升级周期(§27.1.1)

8. 运维命令基线

qimctl shard list [--region R]
qimctl shard drain <shard_id>              优雅释放租约
qimctl shard split <shard_id> --into N     §4.2 流程
qimctl mailbox-store switch <shard_id> --to redis|scylla|native  §18.1.2 灰度
qimctl watermark show <shard_id>           打印 W[lane] 与 W_floor[lane]
qimctl cursor inspect <user> <device>      解码游标令牌(脱敏)
qimctl membership recompact <group_id>     槽位重编号(停写窗口,§7.9)

recompact 是唯一需要停写窗口的操作:槽位重编号会作废所有旧版本 Bitmap, 必须在窗口内完成并强制递增 membership_version。


9. 验收

D-1【发布阻断】归属单一权威
  人为制造"消费者组认为 A 拥有分区 7、注册中心认为 B 服务分片 7"的分裂:
  实现必须不可能进入该状态(assign() 由租约推导)
  静态检查:代码中不出现 subscribe() 调用

D-2 分裂后游标可换发
  执行 shard split:全部在线客户端收到 CURSOR_REBASED 并恢复
  消息缺失数 == 0,重复由 message_id 幂等收敛
  lane_id 在分裂前后不变(断言)

D-3 防脑裂不等式
  以 SIGSTOP 暂停旧 Writer 30 秒后 SIGCONT:
  旧 Writer 恢复后的写入 100% 被两个校验点拒绝
  切换期间产生的双写条数 == 0

D-4 接管不降水位
  构造 W_floor 校验失败:该 lane 返回 SHARD_MOVED
  向客户端下发低于 W_floor 的水位次数 == 0

D-5 网关接管
  单 qim-gateway 故障:5 分钟内接管节点 CPU 与 AUTH P99 <= 稳态 2 倍
  无用户超过 60 秒无法登录

D-6【发布阻断】Redis Cluster 启动拒绝
  将 Redis 地址指向三节点 Cluster:相关服务非零退出且 /healthz 不得返回就绪;
  不得以 hash tag 或客户端重试继续服务。

10. 待办

[x] qimctl 的实现与权限模型(危险操作需二人复核,`crates/qimctl/`)
[ ] etcd 容量规划(分片数 × region 数的 watch 连接数)
[ ] 日志镜像的跨 region 拓扑与带宽预算