跳转至

Q-IM

现代化即时通信系统的架构设计与实现。支持单聊、群聊、聊天室、控制消息、 自定义消息、RTC 信令与多端同步。

当前阶段:可靠消息持久化主链路与完整客户端已落地,隔离功能 smoke 15/15、 Scylla RF3、Redis Cluster 负向启动和 writer SIGKILL 恢复门禁已通过; 目标硬件性能、混沌与 orphan 独立审计发布门禁仍待完成。 服务端语言 Rust(ADR-0006,倾向性决策,基准测试后终定)。


能力

单聊 / 普通群 / 聊天室 / 多设备同步
离线消息精确可达:拉一条个人邮箱队列即可恢复,不逐会话查询
消息正文只存一份,成员邮箱只存轻量引用
会话列表三层模型:公共头 + 用户主动状态 + 可重建异步投影
至少一次投递 + 幂等去重,不承诺 exactly-once
可选端到端加密(单聊与 ≤1000 人群)

一期形态(ADR-0007):群规模上限 1000 人,MailboxStore 用 Redis, MailboxNode 无主备。全部不可变项按 10 万人群档定死, 升级时只换存储实现,不改协议、不改客户端。

当前 Redis 部署边界:生产仅支持 Redis 7.0.0+ 的 standalone Redis,且必须启用 AOF 与 maxmemory-policy noeviction。启动会严格校验 INFO server 中唯一的三段式 redis_version >= 7.0.0;Redis Cluster 尚未实现,检测到 Cluster 端点时服务 必须拒绝启动,绝不以部分 hash tag、按槽分组 Lua 或客户端重试冒充支持。邮箱由 Redpanda 分发日志驱动物化,ConversationWriter 不再向 MailboxNode 走 TCP dispatch。


快速理解

三个阶段用可重放日志隔开,每段独立重试与恢复:

客户端 → ConnectionNode → ConversationWriter → 提交日志
                                                  ↓
                                          FanoutCoordinator
                                                  ↓
                                            分发日志(分区 1:1 分片)
                                                  ↓
                                            MailboxNode(个人邮箱物化)
                                                  ↓
                                          ConnectionNode → 在线设备
                                                  ↓
                                          NotificationService(离线)

完整架构与全链路时序见 docs/00-system-overview.md。


文档

docs/PLAN.md 是契约核心,以下五处不得被任何专题文档重新定义:

§5.1 实体与命名表
§6   标识、序列与游标
§7   核心数据模型
附录 A 协议帧与错误码总表
附录 B 默认参数表

改动契约核心必须先改 PLAN.md 或新增 ADR,再改专题文档与实现。 直接在专题文档中引入新序列、新帧、新错误码、新参数名,一律按缺陷处理。

文档 内容
分支模型 主干与两条存储线(Redis / ScyllaDB)的职责与同步规则
00 系统总览 架构图、服务清单、全链路时序
01 连接协议 opcode、帧头布局、编码、压缩、多路复用
02 消息模型与存储 ScyllaDB DDL、读写路径
03 邮箱与大群分发 MailboxStore、lane 调度、接管
04 会话列表 qsession durable 投影、会话权威、未读与分页
05 聊天室与控制消息 房间广播、回应、RTC
06 微服务与部署 拓扑、扩缩容、重分片
07 可靠性安全运维 fencing、鉴权、限流、告警
08 测试与容量 测试分层、压测、容量回填
09 推送与角标 APNs/FCM、频控、角标
10 保留删除合规 加密擦除、删除清单
11 客户端 SDK 通信层 SDK:分层、API 形态、端上契约不变量
评审 001 基线评审记录(84 条)
名词解释 / 术语×模块对照 / 工程黑话对照 / 消息流 配套速查

决策记录

ADR 决策
0001 MailboxStore 分阶段选型与切换判据
0002 接入协议:TCP/TLS + ALPN,WSS 回落,QUIC 二期
0003 大群投递策略与 mention_only 降级档
0004 mailbox_seq 复合序号与 epoch
0005 小会话正文内联与禁令收窄
0006 服务端 Rust + 日志 Redpanda
0007 一期简化形态与不可变项清单
0008 登录对账义务与幂等窗口收窄
0010 客户端 SDK:一份 Rust 核心 + 生成式绑定,本地库自带

代码结构

crates/
  qim-proto        契约核心的代码化:常量、ID 类型、路由、编译期断言、.proto
  qim-codec        帧编解码(docs/01),TCP 与 TLS 共用
  qim-store        MessageStore / MailboxStore 抽象与 Redis 实现(docs/03),含 Lua 脚本
  qim-common       内部 RPC、幂等、群成员、限流、TLS、可观测(obs/)
  qim-gateway      ConnectionNode
  qim-writer       ConversationWriter + MessageCommitter
  qim-commit-log   Redpanda Outbox / 分发日志深模块
  qim-fanout       FanoutCoordinator
  qim-archiver     HistoryArchiver(Outbox → ScyllaDB 异步批量归档,ADR-0018)
  qim-mailbox      MailboxNode
  qim-session      SessionProjection
  qimctl           运维控制台(docs/06 §8),危险操作需二人复核
  qim-loadgen      验收与压测客户端(docs/08 §7),完整客户端状态机

服务按职责分模块,main.rs 只做装配。契约落地点集中在少数几个文件, 改动前先读它们的文件头注释——那里写着"为什么必须这样",而不只是"做了什么":

qim-gateway/  config state auth wire client frames backend push room
              ├ client   连接生命周期:空闲判死、有界背压、限流
              ├ backend  后端应答路由;**游标推进的唯一入口**(§6.8)
              └ push     在线推送展开;**不得推进游标**

qim-writer/   config hlc ctx net send history members
              ├ send     发送链路,且是 trace_id 的生成方(§24.2)
              └ hlc      message_id 来源,时钟回拨下仍保单调

qim-mailbox/  config ctx net dispatch pull selfcheck
              ├ dispatch  物化:一消息一序号、64-lane 格式;当前按 record 统一推进
              ├ pull      读邮箱:唯一允许推进设备游标的路径
              └ selfcheck 启动自检:序号计数器重置检测

qim-session/  config ctx projection unread
              ├ projection 投影:成员只来自真实收件人列表,不得推导
              └ unread     定义式未读 + 只进不退的已读水位

qim-common/obs/  trace metrics invariant redact endpoint

可观测性

三者职责分开,混在一起会同时毁掉三样东西(指标基数爆炸、追踪查不到、告警被淹没):

指标      回答"系统整体怎么样"      /metrics(Prometheus 文本)
追踪      回答"这一条消息经历了什么"  trace_id 贯穿五段
恒零指标  回答"要不要立刻停止发布"    /healthz 非零即 503

trace_id 贯穿

trace_id 由 ConversationWriter 在提交时生成(§24.2 指定生成方), 随后原样透传,一次 grep 即可串起全链路:

$ grep -h 18cbba6f68072ac10031304200000000 /tmp/qim-*.log
qim_writer::send:        已提交,转 fanout  trace_id=18cb… conversation_seq=1 body=<17 bytes> inline=true
qim_mailbox::dispatch:   已物化            trace_id=18cb… entries=2 elapsed_us=1832
qim_session::projection: 投影已更新         trace_id=18cb… recipients=2

生成方唯一是关键:各服务各自生成会得到五个互不相关的 ID,等于没有追踪。

脱敏

§24.2 禁止 access_token、游标签名、密钥、明文正文、预览进入日志。 但只立规矩不给替代品,实现者就会绕过它,因此提供了替代表示:

secret_digest(s)   h:3f2a1b8c/len=64   可判定"是否同一个 token",不可逆
body_shape(b)      <17 bytes>          只记形状
text_shape(s)      <12 chars>          预览同理

check-contract.sh 会拦截把这些字段直接写进 tracing! 的写法(已做负向验证)。

端点

服务 指标端口
gateway / writer / mailbox / session 9100 / 9101 / 9102 / 9103
fanout 9106
$ curl -s localhost:9101/metrics | grep -v '^#'
qim_messages_total 46
qim_duplicate_send_total 1
qim_fanout_entries_total 94
qim_send_ack_latency_us_bucket{le="1000"} 41
...
$ curl -s -o /dev/null -w '%{http_code}' localhost:9101/healthz
200        # 恒零指标非零时返回 503,让编排系统摘掉该实例

QIM_LOG_FORMAT=json 切换为 JSON 日志(生产采集用),默认文本(本地开发用)。


开发

just check          # fmt + clippy + test + 契约断言(提交前必须全绿)
QIM_LOAD_RATE="${AUDITED_QIM_LOAD_RATE:?先设置经审计目标}" just acceptance
                    # 隔离 Redis+Redpanda:契约/lint → 测试 → release → E2E → 180s → T-HEAVY → 不变量 → orphan 独立审计
just bench          # 基准(防退化,不产出容量结论)
just dev-certs      # 生成本地 TLS 证书链(CA + 叶证书)

just 未安装时,justfile 里每条命令的正文都可直接复制执行。

当前完整 just acceptance 会在最终 MessageRecord ↔ dispatch 独立审计步骤失败闭合: durable CommitAuditFact、持久 FanoutAuditReceipt 与独立 coverage cursor 尚未实现; receipt 必须位于“dispatch 全部 durable 后、source offset 提交前”的顺序边界。 这不是测试偶发失败;在证据源落地前不得移除该阻断。功能回归请使用 just smoke, 但 smoke 不构成发布结论。

运行(当前生产形态,依赖 standalone Redis + Redpanda)

当前 Redis 仅接受 7.0.0+ standalone 端点(QIM_REDIS_ADDR,默认 127.0.0.1:6379), 必须启用 AOF、appendfsync=everysec|always 和 noeviction;版本不足、版本信息不可证明或 Cluster 端点都会被启动校验拒绝。SEND_ACK 的可靠边界是 MessageRecord 与 Redpanda Outbox,MailboxNode 从 Redpanda 分发日志物化 30 天日桶邮箱。 DispatchProgress 只有在部署方显式配置经审计的 replay safety window 后才允许 GC;默认禁用, 而不是猜一个保留期或接收 writer 的 TCP dispatch。客户端提交/去重窗口为 2 小时。当前只有 Ephemeral 历史自动按 24 小时到期;Default、 TenantCustom、ComplianceHold 的租户策略映射、冷归档与归档回读尚未实现, 不能把目标设计中的默认保留期当作当前承诺。

# 前置:本机 Redis 7.0.0+ standalone(必须 AOF + noeviction;Redis Cluster 会被拒绝)
redis-server --appendonly yes --appendfsync everysec --maxmemory-policy noeviction
# 还需支持 KIP-516 非零 topic UUID 的 Redpanda/Kafka(隔离基线 Redpanda v26.2.2);
# topic 必须预建,dispatch 分区数与 mailbox shard 数一致。v24.3.6 会 fail closed。
export QIM_REDPANDA_BROKERS=127.0.0.1:19092
export QIM_OUTBOX_TOPIC=qim.outbox
export QIM_OUTBOX_PARTITION=0
export QIM_OUTBOX_RECOVERY_WINDOW_MS=86400000
export QIM_DISPATCH_TOPIC=qim.dispatch
export QIM_DISPATCH_RECOVERY_WINDOW_MS=3000000
export QIM_FANOUT_GROUP_ID=qim-fanout-local
# ADR-0012 已无 Kafka transaction;当前代码仍因历史配置漂移强制读取该变量,值不承载事务语义。
export QIM_FANOUT_TRANSACTIONAL_ID=qim-fanout-legacy-unused
export QIM_MAILBOX_SHARD_COUNT=64

cargo run -p qim-session &   # :7003 会话投影(独立消费 dispatch,Redis 持久 checkpoint)
cargo run -p qim-mailbox &   # :7002 邮箱物化(Redis 持久化)
cargo run -p qim-fanout &    # Outbox → 固化成员快照 → dispatch 日志
# tiered(默认)需要三节点 Scylla 与 QIM_SCYLLA_HOSTS/KEYSPACE/DC/RF;纯开发可设 QIM_MESSAGE_STORE_BACKEND=redis 并省略 archiver
export QIM_ARCHIVER_GROUP_ID=qim-archiver-local
cargo run -p qim-archiver &  # :9107 metrics;Outbox → ScyllaDB 批量归档 → 归档水位
cargo run -p qim-writer  &   # :7001 消息提交 + Redis 热层历史 + 可靠 Outbox
cargo run -p qim-gateway &   # :7000 接入 + :7010 推送 + :8000 token 签发

# 功能 smoke:15 项,每项都是断言(失败即非零退出)
cargo run --release -p qim-loadgen -- --e2e
# 完整发布门禁:先停止上方手动服务;速率必须来自目标环境审计
QIM_LOAD_RATE="${AUDITED_QIM_LOAD_RATE:?先设置经审计目标}" ./scripts/acceptance.sh
# 交互式客户端
cargo run -p qim-loadgen -- --interactive --user 1

环境变量(可配置)

变量 默认值 说明
QIM_REDIS_ADDR 127.0.0.1:6379 Redis 7.0.0+ standalone 地址(AOF + appendfsync=everysec|always + noeviction;版本不可证明或 Cluster 均拒绝启动)
QIM_REDIS_ALLOW_UNSAFE_DEV 0 仅字面 loopback/localhost/Unix socket 开发端点可设 1,跳过 AOF/appendfsync/noeviction 检查;不绕过 Redis 7.0.0+ 与 standalone 校验
QIM_REDPANDA_BROKERS mailbox 本地默认;writer/fanout 必填 Redpanda broker 列表
QIM_OUTBOX_TOPIC / QIM_OUTBOX_PARTITION 必填 / 0 writer 提交 Outbox topic 与固定 partition
QIM_OUTBOX_RECOVERY_WINDOW_MS 必填 writer/fanout 可恢复停机窗口;broker 实际 retention.ms 必须不小于该值
QIM_DISPATCH_TOPIC mailbox 默认 qim.dispatch;fanout 必填 分发日志 topic;分区数必须覆盖全部 MailboxShard
QIM_DISPATCH_RECOVERY_WINDOW_MS 必填 mailbox/qsession 恢复窗口;broker 实际 retention.ms 必须不小于该值
QIM_FANOUT_GROUP_ID 必填 Fanout 的 Outbox source checkpoint 消费组
QIM_FANOUT_TRANSACTIONAL_ID 当前实现暂时必填,计划移除 ADR-0012 后不参与 Kafka transaction、fencing 或正确性;只是 RdkafkaCommitLogConfig 的历史配置残留,发布前应取消强制读取
QIM_MAILBOX_SHARD_COUNT 64 writer/fanout/mailbox 共享;必须是 1..=VIRTUAL_BUCKET_COUNT 的 2 次幂
QIM_MESSAGE_STORE_BACKEND tiered writer/session 的 MessageStore(历史、正文、提交状态与 ClientDedup)后端,两服务必须一致。tiered(ADR-0018):提交与近期历史在 Redis,qim-archiver 异步归档到 ScyllaDB;redis:无归档,历史永久留在 Redis,仅限开发与测试;scylla:提交路径同步写 Scylla,仅作对照
QIM_MAILBOX_STORE_BACKEND redis mailbox 的 MailboxStore(离线邮箱)后端;仅接受 redis 或 scylla。一期生产形态为 MessageStore=tiered + MailboxStore=redis(集成脚本 gate hybrid)
QIM_MESSAGE_STORE_CONNECTIONS 8 writer:每个 MessageStore Redis 实例的提交热路径连接数(1..=64)。appendfsync always 下组提交覆盖的命令数与连接数成正比
QIM_DEFAULT_HISTORY_RETENTION_DAYS 30 writer 与 qim-archiver:retention_class=default 的历史保留天数(1..=3650,ADR-0019);两进程必须同一值
QIM_HISTORY_HOT_WINDOW_MS 604800000(7 天) writer:近期历史留在 Redis 的时长;超过且已被归档水位覆盖才裁剪。下限 1 小时
QIM_ARCHIVER_GROUP_ID 必填(archiver) qim-archiver 的 Outbox 消费组,必须与 fanout 不同
QIM_ARCHIVER_BATCH_MAX / QIM_ARCHIVER_METRICS 512 / 127.0.0.1:9107 qim-archiver 每批最多归档条数(1..=4096)与指标端点
QIM_STORE_BACKEND 已废弃 旧的全局开关;writer/session/mailbox 见到即拒绝启动,不做兼容映射
QIM_SCYLLA_HOSTS / QIM_SCYLLA_KEYSPACE / QIM_SCYLLA_DC / QIM_SCYLLA_RF Scylla 模式必填 Scylla contact points、预建 keyspace、本地 DC 与 RF;生产要求 RF≥3、LOCAL_QUORUM、Tablets 禁用
QIM_AUTH_SECRET 开发默认值 token 签名密钥,生产必须改为随机值(未配置时启动会 WARN)
QIM_TLS_CERT / QIM_TLS_KEY 无 服务端证书;两者必须同时配置,只配一个会启动报错而非降级明文
QIM_TLS_CA / QIM_TLS_SNI 无 客户端信任根与 SNI(qim-loadgen 用)
QIM_INTERNAL_TLS_CERT / QIM_INTERNAL_TLS_KEY loopback 可省略 内部 RPC 本进程叶证书与私钥;非 loopback 监听必须配置
QIM_INTERNAL_TLS_CLIENT_CA loopback 可省略 内部 RPC 服务端用于校验客户端证书的 CA
QIM_INTERNAL_TLS_SERVER_CA loopback 可省略 内部 RPC 客户端只信任的服务端 CA,不读取系统根
QIM_INTERNAL_TLS_SERVER_NAME[_WRITER\|_MAILBOX\|_SESSION\|_GATEWAY] 无 内部 RPC 目标 SNI;mTLS 客户端必填通用值或对应服务值
QIM_INTERNAL_GATEWAY_CERT_SHA256 / QIM_INTERNAL_MAILBOX_CERT_SHA256 / QIM_INTERNAL_ADMIN_CERT_SHA256 无 内部叶证书 DER SHA-256 角色白名单;可逗号分隔多个指纹
QIM_WRITER_ADDRS QIM_WRITER_ADDR 的值 writer 实例列表(逗号分隔);按 conversation_id 会合哈希亲和路由,断线就近回退
QIM_WRITER_ID 自动 message_id 实例位段;默认 Redis 租约自动分配,StatefulSet 可显式指定
QIM_WEBHOOK_BEFORE_SEND_URL 无 发送前审核回调(http://,状态码协议:2xx 放行 / 403 拒绝)
QIM_WEBHOOK_AFTER_SEND_URL 无 发送后异步通知(尽力而为,有界队列)
QIM_WEBHOOK_FAIL_POLICY open 审核不可用时 open 放行 / closed 拒绝
QIM_SHARD_EPOCH 1 分片纪元;接入 ShardRegistry 前由此注入
QIM_GATEWAY_LISTEN / QIM_GATEWAY_PUSH / QIM_TOKEN_LISTEN 127.0.0.1:7000/7010/8000 gateway 监听
QIM_WRITER_LISTEN / QIM_MAILBOX_LISTEN / QIM_SESSION_LISTEN 127.0.0.1:7001/7002/7003 各服务监听

内部证书指纹按叶证书 DER 计算:

openssl x509 -in cert.pem -outform DER | openssl dgst -sha256 -hex

仅当内部监听全部是 loopback 时允许明文开发模式;任一非 loopback 内部监听缺少完整 mTLS 配置都会拒绝启动。qimctl 的成员管理调用即使连接 loopback 也强制使用 Admin mTLS。

历史容量观测(旧 TCP dispatch 架构,特定硬件基线)

下表来自旧的 writer→mailbox TCP dispatch 架构,在 28 核 / 31 GB / 单 Redis 主机上进行的 30 秒观测。它只用于保留瓶颈定位历史,不是当前 Redpanda 日志驱动实现的发布门禁、容量结论或 SLO 背书;当前发布必须以隔离环境中的实际到达率、故障恢复和相应阶段的压测门禁为准。

目标速率 到达率 PONG P99 SEND_ACK P99 端到端 P99 SLO(150 / 300 ms)
15,000 msg/s 100% ~2 ms 22.5 ms 64.9 ms 达标
18,000 msg/s 100% ~2 ms 34.7 ms 493.9 ms 端到端超标
20,000 msg/s 100% ~2 ms 84.5 ms 4181 ms 超标(Redis 饱和)

服务间出站已池化(RpcSenderPool:4 连接轮转 + recv_many 合并写出)。 此前的单条 TCP 出站是实测容量拐点:18k 时写链路健康(SEND_ACK P99 31 ms) 而端到端 P99 740 ms——排队全部堆在 dispatch→物化→推送的串行通道上。 池化后 15k 的端到端 P99 从 116 ms 降到 64.9 ms,18k 从 740 ms 降到 494 ms。 18k 起的剩余瓶颈重新回到单线程 Redis(实测 100% 饱和)。这是旧架构的 特定硬件观测,不构成 Redis Cluster 已支持的依据;当前扩容优先评估 ScyllaDB。

配套的两条纪律(都有实测教训): 1. 池化必须配显式准入。旧单连接曾意外充当准入阀;打散它之后上游全速 灌入,无界并发任务在 Redis 前排队,18k 的 P99 一度恶化到 5.9 s。 旧 MailboxNode 的 DISPATCH_INFLIGHT_LIMIT(512) 信号量曾满则停读 socket, 经 TCP 反压逐级传导;旧 writer dispatch 满时等待而非丢弃 (已 ACK 的消息不可丢),20k 下到达率因此保持 100%。 2. 热路径的"保险"可能自己成为拐点。投影单调性曾用 Lua 比较版本号 (每收件人一次 evalsha ≈ 16 µs 替代 1 µs 的 HSET),在 93% 饱和的 Redis 上把 15k 的 P99 推到 323 ms。现改为结构保证零开销: 投影按 conversation_id 键控投递(同会话恒走同一连接)+ session 连接内串行处理,到达顺序即写入顺序。 3. 可丢弃出站的消费端必须批量化。session 曾对投影逐条 await 一次 HSET——每连接吞吐被钉在 ≈1/RTT,15k 下 75% 投影在 mailbox 出站池 被丢弃(231 万条,会话列表静默滞后)。改为每连接单消费者 recv_many 批量取 + 单次 pipeline 写出(顺序保证不变)后丢弃归零, 平均一次往返摊 13 个事件。

历史:180 秒稳态与 Redis 字典翻倍(旧架构时长依赖长尾)

本节记录的是上述旧 TCP dispatch 实现及该硬件基线的故障定位,不能用作当前日志驱动实现的 发布门禁或容量数字。

30 秒达标的配置在 180 秒稳态下曾测出端到端 P99 727 ms(更早为 1664 ms), 且长尾时间锚定:不论 1200 还是 1000 个客户端,尖刺都出现在 t≈70 s 与 t≈140 s,幅度逐次翻倍。用分段直方图定位:长尾 100% 在 qim_dispatch_transit_us(writer 打点→mailbox 读到),而入池等待 (qim_dispatch_enqueue_wait_us)、准入信号量(qim_dispatch_admission_wait_us) 全程无阻塞。

机制:幂等键每消息一个(dedup:*),主 keyspace dict 与 expires dict 以 15k keys/s 增长,在 2^20(t≈70 s)、2^21(t≈140 s)处翻倍扩容并增量 rehash——期间每个操作摸新旧两张表,Redis 有效吞吐下降,mailbox 的并发物化 任务占满运行时,serve 读循环偶发停摆约 1 s(qim_mailbox_read_gap_us

100 ms 仅 8 个样本 = 8 次停摆),到达的 dispatch 随之积压。

积压的位置是内核接收缓冲,不是出站队列(2026-08-15 复核修正): qim_rpc_pool_dwell_us{peer="mailbox"}(打点→write_all 返回)P99 仅 0.2 ms、>100 ms 为 0,而同期 transit P99 848 ms。writer 早已把帧写进 socket, 数据是在 TCP 缓冲里等 mailbox 来读——write_all 不阻塞(缓冲远未填满), 所以 writer 侧看一切正常。这段隐形队列会绕过准入反压设计,是下一个目标。

决定性实验:预置 440 万带 TTL 的种子键把两张 dict 预扩到 2^23 桶 (180 s 内不可能再翻倍),同负载重跑——尖刺全部消失, 180 s 稳态 P99 116.4 ms 达标(到达率 100%,恒零指标全零)。 复核轮同样成立:transit >500 ms 从 85,926 个样本降到 0, 端到端 P99 843.9 ms → 208.6 ms 达标。同一压测客户端、只改服务端 Redis 状态—— 这一点同时排除了"长尾来自压测客户端测量结构"的假说。

排障纪律(两条,都有教训):

  1. 服务端最后一段必须有指标。gateway 每连接出站队列的入队等待与 write_all 耗时(qim_conn_write_wait_us / qim_conn_write_syscall_us) 曾是唯一盲区;补上后实测 583 万样本最大值 <10 ms,才有资格说 "帧已及时离开服务端"。缺这段时,长尾只能靠排除法甩给客户端。
  2. SEND_ACK 健康不代表链路健康。ACK 在发送链路第 3 步返回、dispatch 在第 6 步转发,ACK 不经过 writer→mailbox 段。"ACK 与推送同路径、 一快一慢 ⇒ 服务端无责"的推论前提就是错的。

对生产的含义:长期运行的 Redis 键数在 TTL 窗口内达到平台期后不再翻倍, 但冷启动 / 清库后的前几分钟必然连撞两次扩容;上线预热(预扩 dict) 可消除,阶段一 ScyllaDB(主键即幂等,无全局 dict)从结构上消除。 同时幂等窗口的键数与内存按目标速率是亿级(ADR-0008 已把窗口从 24 h 收窄到 2 h,键数降一个数量级,但仍是键数量的主导项)——幂等表的量级 必须进容量表(docs/08 §4 回填项),单 Redis 不可承载。

单节点持续容量取 15,000 msg/s(180 s 稳态、预热后 P99 116 ms)。 容量结论必须以 SLO 为界,不能以"还能跑"为界——超出 SLO 后吞吐仍上得去、 消息也不丢,但 P99 会跳升一个数量级。短跑会高估容量 (15 秒达标 → 30 秒退化;30 秒达标 → 180 秒撞上 dict 翻倍再退化), 稳态结论一律以 180 秒为准。

瓶颈归因

PING→PONG 只经 ConnectionNode、不触达任何后端,因此它是纯接入层的对照组。 实测 PONG P99 只有 1–3 ms,而 SEND_ACK P99 是它的 20 倍——时延不在接入层。

再往下:各服务进程 CPU 都远未打满(gateway 197%、writer 188%、mailbox 243%,机器 28 核), 而 Redis 单线程占满 93% 的一个核,是全系统唯一饱和的资源。 SEND_ACK 自身只需 1 次 Redis 操作,但它要和 fanout 的写入一起排在同一条单线程队列后面。

据此做了两处优化(详见对应源码注释):

append_batch.lua   9 次 redis.call → 5 次
                   移除从未被读取的 DispatchProgress 位图(含 Lua 字符串拼接)
                   2 次 HGET 合并为 1 次 HMGET(日桶范围每用户每天最多变一次)

幂等 + 序号分配     2 次串行 Redis 往返 → 1 次
                   Lua 在 Redis 中整体原子执行,"先查重后分配"无竞态,
                   顺带使重发不再白白消耗一个 conversation_seq

效果(15,000 msg/s):SEND_ACK P50 15.8 → 5.7 ms,P99 44.3 → 23.9 ms。

当时的旧直写实现为连续水位加回了两次 Redis 往返(登记在途 + 销账), 因此该历史版本的 SEND_ACK P99 为 25.4 ms、端到端 P99 为 116.0 ms。 为把销账挪出时延路径,推送被提前到销账之前(推送不依赖水位,也不推进游标), 水位改为读时计算(PULL_MAILBOX 的 QPS 比 fanout 低几个数量级)—— 这两处使端到端 P99 从 368 ms 回落到 116 ms。

扩容路径是当前 standalone Redis → 优先 ScyllaDB。Redis Cluster 不在既定升级路径中: 只有完成独立的提交状态机、数据迁移和集成验证工作后才可重新评估;局部 hash tag 并不构成支持。

上表数值只用于说明当前实现的瓶颈位置,不是附录 B 的容量常数—— 后者仍待按 docs/08 §4 的流程在目标硬件上回填。

硬性规则

简体中文编写设计说明与代码注释
rustfmt + clippy -D warnings;禁止无充分理由的 unsafe
新增依赖前说明选择原因、优点、缺点与替代方案
所有术语、字段、时序必须与 PLAN.md 契约核心一致
凡给出数值必须与附录 B 一致;待实测参数回填前不得用于容量结论

五项不变量证据

前三项服务端计数由 smoke/full 的观测门禁检查;cursor_advanced_by_push_total 由 SDK 压测泳道检查。orphan_dispatch_total 尚无独立事实源,完整 acceptance 在最终审计阶段会主动失败闭合,不能把任一进程内零值当作发布证据:

mailbox_seq_regression_count      分片序号计数器被重置但数据仍在(启动自检)
cursor_advanced_by_push_total     游标被 PUSH_EVENTS 越位推进
event_id_nondeterminism_total     重放/主备物化结果不一致
orphan_dispatch_total             有 MessageRecord 无 dispatch,或反之
covered_through_gap_total         covered_through_seq 跨越未物化区间

这些断言不允许有假阳性。 曾经把"本次分配值 > 上次观察值"作为 mailbox_seq_regression 的判据,结果因 dispatch 并发处理稳定误报—— Redis INCR 分配出的 100 与 101 到达本地断言的顺序本就可以颠倒。 运行期该不变量由 INCR 的原子性保证,可精确检测的失效只有 "计数器被重置但数据仍在",因此改为启动时一次性自检。


状态

[x] 总体设计定稿(PLAN.md,三轮评审 140 条修订闭环)
[x] 11 份专题文档 + ADR-0001~0015
[x] writer 持久提交 + Redpanda Outbox/Fanout/dispatch + MailboxMaterializer
[x] standalone Redis MessageStore/MailboxStore、30 天日桶、显式 trim 与 replay-safe DP GC
[x] SDK/CLI/GUI/C/UniFFI 的磁盘恢复、分页、严格建群终态与大整数身份保真
[x] 全工作区 test/check/clippy;隔离 Redis 真实集成与 Redpanda 无事务 durable-delivery/readiness 门禁
[x] 隔离 Redis + Redpanda 五服务功能 smoke 15/15(含 ACK 丢失重启对账)
[x] 三主节点 Redis Cluster 负向门禁:服务在持久写入与业务监听前拒绝启动
[x] `scripts/acceptance.sh` 只使用 `qim-it-*` 隔离项目与精确 PID,不再 FLUSHALL/pkill

[ ] 在目标硬件以显式 `QIM_LOAD_RATE` 跑完整 180 秒 + T-HEAVY 发布门禁并回填新架构容量
[x] ScyllaDB 三节点 RF3/LOCAL_QUORUM/Tablets 禁用真实契约门禁
[ ] 未实现实体:PresenceDirectory / ShardRegistry / NotificationService /
    MediaService / ModerationService / RoomWriter 持久化
[ ] 未实现能力:WebSocket 承载、帧压缩与分片、E2EE、加密擦除
[ ] 容量参数按 docs/08 §4 在目标硬件回填
[ ] ADR-0006/0012 复评条件验证(acks=all durable delivery、source checkpoint 顺序、GroupDispatch epoch 过滤)