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 状态—— 这一点同时排除了"长尾来自压测客户端测量结构"的假说。
排障纪律(两条,都有教训):
- 服务端最后一段必须有指标。gateway 每连接出站队列的入队等待与
write_all耗时(qim_conn_write_wait_us/qim_conn_write_syscall_us) 曾是唯一盲区;补上后实测 583 万样本最大值 <10 ms,才有资格说 "帧已及时离开服务端"。缺这段时,长尾只能靠排除法甩给客户端。 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 过滤)