跳转至

Q-IM 项目记忆

更新日期:2026-09-28

当前阶段

  • writer 持久提交 + Redpanda Outbox/Fanout/dispatch 日志 + MailboxMaterializer 主链路与完整客户端已落地;全工作区 fmt/check/clippy/test、隔离 standalone Redis/Redpanda 真实集成、功能 smoke 15/15、Scylla RF3 三节点契约、Redis Cluster 负向启动与 writer SIGKILL 恢复门禁均已真实通过。目标硬件 180 秒稳态 + T-HEAVY、混沌和 orphan 独立审计尚未完成,当前不得沿用旧 TCP dispatch 的容量数字或声称可发布。
  • 历史 TCP dispatch 架构曾实测单节点持续容量 15,000 msg/s(180 s、P99 116 ms);该数字不适用于当前 Redpanda 日志驱动链路,禁止作为容量或发布结论。当前架构必须在目标硬件以显式审计速率重跑 180 s + T-HEAVY。
  • 历史 Redis/TCP 架构的 180 s 长尾教训仍有效,但数值不可复用:幂等键一消息一个,Redis 主/expires dict 随消息速率涨,2^20/2^21 翻倍 rehash 曾把 P99 推到 727 ms;预扩后同负载 116 ms。它证明短跑会掩盖时长依赖问题,也要求当前链路保留分段指标与 180 s 门禁,不证明当前容量。
  • 长尾显形在 writer→mailbox 内核缓冲,不在物化(2026-08-15 复核):rehash 期间物化健康(P99 49 ms),但 mailbox serve 读循环偶发停摆约 1 s,积压全滞留接收缓冲——无指标的隐形队列,write_all 不阻塞(缓冲远未满)故 writer 侧看着正常。实测 dispatch_transit P99 848 ms、>500 ms 有 8.6 万样本,其余每段 >100 ms 计数全 0;预扩 dict 后 >500 ms 归零、端到端 208 ms 达标(同一 loadgen,只改服务端状态)。
  • 历史教训:SEND_ACK 健康 ≠ 链路健康。旧实现的 ACK 不经过 writer→mailbox 段,曾导致 ACK 绿而投递长尾/丢失;新实现虽已把 ACK 收紧到 COMMITTED,端到端门禁仍必须独立观测 Outbox、Fanout、dispatch 物化和客户端拉取,禁止用 ACK 单点代替链路健康。
  • 可丢弃出站的消费端必须批量化:session 曾逐条 await 投影 HSET(吞吐≈1/RTT),15k 下 75% 投影被丢弃;改每连接单消费者 recv_many + 单次 pipeline 后归零。
  • 池化必须配显式准入(mailbox DISPATCH_INFLIGHT_LIMIT 信号量 + TCP 反压;writer 的 dispatch 满时等待不丢弃);热路径禁加 Redis 侧"保险"——Lua 版本比较曾把 15k 的 P99 推高 3 倍(教训见 projection.rs 头注释)。
  • "键控投递 + 单消费者"保证的是同一个消费者,不是有序(2026-08-19 实测修复):writer 对每请求 spawn、writer→mailbox 非键控 send、mailbox 对每条 dispatch 再 spawn,所以投影入队顺序 = dispatch 完成顺序 ≠ conversation_seq 顺序。当时会话头存在投影行里,末值合并写的是"后到"的那条,会话头倒退——用户看到"会话列表里最后一条消息是倒数第二条"。低速率下更容易中:消费者被逐条唤醒,乱序的两条分属两批,末值合并根本碰不到。
  • 会话头不额外写第二份:qsession 对本页会话经 qstore 两轮 Redis pipeline 读取 canonical msghistory:{commit}:... / msgrecord:{commit}:...,会话头取最新 MessageRecord;不恢复无人写入的旧 hist:{cid},也不把头塞回可能乱序的投影行或 writer 热路径。
  • 热路径每消息多一次 Redis 写就见顶(2026-08-19 同机 A/B):把 SET head:{cid} 加进 ALLOCATE_SCRIPT(想白拿 INCR cseq 的串行化顺序),SEND_ACK P50 6.3→10.0 ms、端到端 P99 239→443 ms,只增删这一行。与"每收件人一次 evalsha"是同一条教训的两种形态:单线程 Redis 近饱和时,ρ 的微增就是排队的剧增。
  • 投影行只留会话集合(convs:{user} zset,成员 = hex(cid),分值 = created_at):写用 ZADD ... GT——只增不减是 Redis 原生语义,零额外命令(它替代的就是原来那次写),乱序到达也不会让活跃度倒退,顺序要求不复存在而不是被防住。读路径 build_snapshot 仍 ZREVRANGE 0 -1 全量载入并对全部会话按定义式求未读,快照 5 分钟 TTL;重度用户的成本在快照重建而非分页(2026-09-25 按代码核对,此前“首页只取 N”的表述不成立)。
  • 顺序要求消失后,投影入队队满时可先等 50 ms 再丢(PROJECTION_ENQUEUE_WAIT),回退用非键控 send;此前键控是必需的所以只能立刻丢。仍保留超时丢弃:消息可达性不该依赖 SessionProjection 的健康度。
  • 连续水位有成本:现行 mailbox_seq 来自 dispatch partition offset,W 是持久化的 64-lane 连续物化状态,不再使用旧直写模型的“登记在途 + 完成销账”。当前成本来自 packed W 的读取、解码、CAS 与写回;早期“水位取最大值”版本虽测得更高吞吐,但会静默越过未完成区间,容量数字作废。
  • Redpanda 提交/分发日志、客户端 REBUILD 执行器和 Redis/Scylla 双后端已实现;ScyllaDB 在隔离三节点、RF=3、LOCAL_QUORUM、Tablets 禁用环境下通过真实 CQL/LWT/TTL/GC 契约门禁。PresenceDirectory、NotificationService、WebSocket 承载、帧压缩分片、E2EE 仍未实现。
  • 总体设计基线 docs/PLAN.md(v2,已合入评审 001 的 84 条、发布前终审 46 条、大群能力补漏 10 条)。
  • 评审记录 docs/REVIEW-001-plan-baseline.md;重大决策 docs/adr/。
  • 后续设计与实现必须先参考上述文件;核心决策变化时同步更新本文件和 docs/PLAN.md。

契约核心(不得被任何专题文档重新定义)

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

改上述内容:先改 docs/PLAN.md 或新增 ADR,再改实现。

已锁定架构决策

接入与连接

  • 客户端实时主链路 TCP/TLS 自定义二进制协议;浏览器用 WebSocket 承载同协议。
  • 端口固定 443,TLS 1.3 ALPN 协商 qim/1 与 http/1.1;回落序 ALPN → WSS 443 → 代理 CONNECT。
  • HTTP 只用于媒体上传下载、管理后台等非实时场景。
  • 用户经稳定虚拟桶固定到逻辑 MailboxShard 和 ConnectionShard;虚拟桶数建集群后不可变。
  • session_epoch 由 Auth/Session 服务分配,(tenant,user,device) 维度单调递增;ConnectionNode 只校验不生成。
  • 在线位置由 PresenceDirectory 承载,经 compacted topic 按 user_bucket 分区发布,推送路径 0 次同步远程调用。
  • 心跳自适应:起始 60 秒,前台上限 120 秒,后台上限 240 秒;服务端经 PONG 下发间隔与超时。
  • 发送侧独立超时:3 秒无 SEND_ACK 触发 probe,端到端不可用检测 ≤ 7 秒。
  • 帧头固定 20 字节大端;header_crc 只覆盖帧头,magic/crc 失配立即断连、禁止重新同步(docs/01 §2.1)。
  • 存储层 ID 映射:u128 用 blob 大端 16 字节,u64 用 bigint 且必须断言 bit63 恒零(否则跨 2^63 聚簇排序翻转)。
  • message_record 表级 TTL 必须为 0,保留期按 retention_class 逐行 USING TTL;否则 compliance_hold 被静默删除。
  • 所有 MessageStore 表禁止显式 DELETE,删除走 TTL 或加密擦除(TWCS 下范围墓碑不释放空间且击穿 tombstones_scanned==0 断言)。
  • 历史翻页必须有 history_max_empty_bucket_scan 上限——conversation_seq 的合法空洞会造成连续空桶扫描。
  • 载荷编码 Protobuf + 正文 bytes 透传;决定性理由是 §27.1.2 要求未知字段按 tag-length 跳过并保留原始字节。
  • prost 默认不保留未知字段:服务端不得对客户端上行结构做"解析后重写",上下行两套 message(docs/01 §3.3)。
  • 帧层 NEED_ACK 保留不用——帧层 ACK 会与游标+幂等重复,同拒绝 MQTT QoS 之理由。
  • 多路复用必须按 stream_write_chunk_bytes(64 KiB) 分片写出,否则 4 MiB 大帧阻塞 PONG 致心跳误断(docs/01 §5.2)。

序列与游标

  • 五种序列:message_id、conversation_seq、last_activity_id、mailbox_seq、room_seq。
  • message_id 与 last_activity_id 同为 u128 同源 HLC,同 ID 空间可比较。
  • conversation_seq 单会话严格递增但允许空洞(预留窗口、治理删除、可见性裁剪)。
  • mailbox_seq 为 (shard_epoch:16, log_offset:48) 复合 u64,跨 epoch 单调不回退。
  • materialized_watermark 按 lane 分组为向量;lane_id = blake3(tenant_id, user_id)[0] & (lane_count-1),独立哈希、不从 user_bucket 推导(§6.5.1),故分片分裂不改任何用户的 lane 归属。
  • mailbox_trim_watermark 是"空洞可安全跳过"规则的前置条件。
  • 设备游标只能由 MAILBOX_BATCH 连续推进,实时 PUSH_EVENTS 不得越位推进游标。
  • 每设备独立保存邮箱游标;用户级已读状态跨设备共享。

邮箱与分发

  • 每用户有独立邮箱键空间;邮箱只存消息引用,不复制群消息正文。
  • 同群消息在同一 MailboxShard 只分配一个 mailbox_seq;用户队列稀疏但按前缀精准查询。
  • 事件组硬上限:单个 (user_id, mailbox_seq) 至多 8 条、8 KiB,且每种 event_type 至多 1 条(当前 4 种类型下至多 4 条,8 为未来预留);批次切分只能在 mailbox_seq 边界。
  • fanout 重试永不丢弃已 COMMITTED 的消息;fanout_retry_max_window 只界定 UI 倒挂窗口,非丢弃阈值。
  • event_id 为确定性哈希,禁随机 UUID;所有参与物化的输入必须确定性。
  • 服务端登录时只在 AUTH_OK 告知存在离线数据,由客户端批量拉取,不逐条推送离线消息。
  • 群消息正文只存一份;10 万成员全量离线可达需 10 万条轻量邮箱引用。小会话(N × body_size ≤ 8 KiB)例外,正文内联进邮箱条目(ADR-0005)。
  • 分片归属唯一权威是 ShardRegistry;日志消费用 assign() 静态指派,禁消费者组自动 rebalance。
  • MailboxNode 接管起始位点 = min_j(W[j]) 的 offset + 1,不取消费者组已提交位点。
  • 邮箱裁剪两类:常规时间窗到期与超 max_mailbox_entries_per_user 的容量兜底;两者越过 落后设备游标时都必须显式 CURSOR_EXPIRED,区别是后者触发即 P2,前者不因正常到期告警。
  • 表情回应是第三类投递语义 ephemeral_aggregate:不进邮箱、不计未读、不改排序、不触发离线推送;持久的是聚合 MessageReactionSummary(§13.6)。
  • 成员数超 membership_event_broadcast_max_members(500)时,成员变更禁向其他成员广播,只在 PULL_MEMBERS 体现。
  • 10 万成员的成员列表必须分页拉取,@ 补全走服务端前缀搜索。
  • Telegram 超级群的 per-channel 游标 == 本文 mention_only 档,差别只在默认值与承诺(§10.2.4)。
  • mailbox_write_policy 默认 always;mention_only 降级档为预留能力,启用需 ADR-0003。
  • mention_only 准入判据是 α 判据(活跃比例),非单一人数阈值;big_group_lazy_threshold 是反推的等效值,待实测回填。
  • 读扩散档的历史缺口由 SESSION_LIST_BATCH.write_policy + delivered_conversation_seq 显式告知,是"邮箱层唯一锚点"的唯一例外。
  • 在线推送前必须先可靠物化邮箱引用,推送失败经邮箱恢复。
  • 加群不回填历史邮箱引用;以 (joined_at, left_at) 序号区间为边界(ADR-0021 已实现): 入群 joined_at = J−1、退群/移除 left_at = J、建群初始成员 joined_at = 0;区间外的 历史、未读与会话头一律不可见,退群者仍可读退群点之前。

会话列表

  • 采用 ConversationHead + UserConversationState + UserSessionProjection 三层模型。
  • UserConversationState 是会话集合的持久权威;UserSessionProjection 是可重建物化视图。
  • 普通消息不在发送链路中同步更新所有离线成员的持久会话投影。
  • 投影压缩器由邮箱写入流尾部驱动 + dirty_users bitmap,禁按用户主键全表扫描。
  • SESSION_DELTA 是幂等绝对值帧,版本源为 projection_mailbox_seq,禁增量语义。
  • 未读的权威定义式基于 read_conversation_seq 水位;unread_count 只是缓存,冲突以定义式为准。
  • 全局角标由 UserBadgeState 提供绝对值,与投影同 WriteBatch、同检查点。

其他

  • 普通群保留个人邮箱和按成员区间过滤的历史(ADR-0021)。聊天室只用房间序列、实时广播及短期回放。
  • 图片视频原文件进对象存储,IM 消息只存缩略图及对象引用。
  • 投递至少一次语义,客户端按 message_id/event_id 幂等去重。
  • MailboxStore 三级演进:一期 Redis(ADR-0007)→ 阶段一 ScyllaDB → 阶段二 自研 LSM(rust-rocksdb,ADR-0001 四条判据之一成立时)。
  • E2EE 适用单聊与 ≤1000 人群;超大群与聊天室不支持。删除依赖加密擦除。
  • 对照评审吸收(OpenIM,2026-08-15):①同设备重复登录顶号(KICKED{REPLACED} + 主动断开,一期 user/device 合一按 uid 顶);②发送前审核回调(HTTP 状态码协议 200/403,超时 moderation_sync_timeout_ms 按 QIM_WEBHOOK_FAIL_POLICY 走 fail-open/closed)与发送后异步通知(有界队列可丢有计数);③会话列表未读改单次 pipeline 求值(对照 GetMaxSeqs,mention_only 档的 delivered_seq 批量拉取复用);④message_record 主键即幂等的对照注记(docs/02 §2.1)。
  • E2EE 存储模型已裁决:单份正文密文 + E2EE_KEY_DIST 逐设备密钥分发(sender-key),不做逐设备正文副本。
  • 登录对账与幂等窗口(ADR-0008,2026-08-15):仅发送者本人的邮箱条目回带 client_message_id(§7.3 既有要求,用作对账锚点;其他收件人不带);断线重连后重发任何 local_pending 前必须先同步邮箱对账、未命中才重发;client_dedup_ttl_seconds 24 h → 2 h,与 client_pending_max_age(保持 24 h)解除恒等。理由:幂等键每消息一个,是一期 Redis 键数量主导项,窗口长度直接决定稳态键总量与 dict 翻倍规模;窗口只需覆盖"已提交但对账时尚不可见"的暴露期(水位停滞运营时限 1 h 的 2 倍)。存储条目编码在尾部追加 client_message_id:16(全零=无),旧条目免迁移。

未读与已读(2026-08-17)

  • 未读只有一个定义式:|{m | m.seq > read_conversation_seq ∧ m.sender_id ≠ 自己}|(§12.5.1)。服务端与客户端用同一式子——定义一分叉,"为什么这台显示 3 那台显示 5"就无从查。禁 latest - read 相减(conversation_seq 允许空洞,相减把治理删除算成未读,角标永远清不掉)。
  • 定义式有两个权威输入,缺一个就出假红点(2026-08-19 实测修复): ① sender_id ≠ 自己——漏了它,自己发的消息算成自己的未读;"发送即已读"只是让它在本地水位在手时不显形,水位一丢(换设备/清缓存/GUI 内存库重登)未读数就恰好等于自己上次发出去的条数。故 LocalStore::open(self_user_id):本地库要算未读就得知道自己是谁。 ② read_conversation_seq——user 级、只存在服务端(read:{user})。协议原先没有任何下行通道,客户端只能从 0 起算 base。现 SESSION_LIST_BATCH.sessions[] 回带该字段,且登录序列自动拉一次会话列表取回(不能等宿主开口)。合入只进不退;本地更高时反向回补 MARK_READ,两侧收敛。
  • 服务端会话页从 canonical history 有界读取定义式未读:精确窗口为 latest_seq - read_seq <= 200,解码后排除自己、Control、Recalled/Deleted;超过窗口返回饱和值 200 并在内部标记 unread_exact=false。当前 Session RPC 尚未公开该精确性位且仍固定 legacy tenant 0,均为后续兼容升级项;任何所需索引/记录缺失或损坏必须整页失败,不能伪装成零未读。
  • 同一批新消息,SDK 先发 ConversationsUpdated(已含这批)再发 MessagesAdded。上层在后者里再 +1 就是同一条数两遍(GUI 踩过)。未读增量归前一个事件全权负责,上层只覆盖。
  • 未读在读路径算,不在写路径算(projection.rs 头注释)。给 SESSION_DELTA 带 unread_count 就要在写路径按定义式求值,15k msg/s 下 +30,000 次存储往返/秒(约 +40% 存储负载),且落在刚定位过塌陷的那条路径。结论:不做。
  • 客户端本地算未读:本地消息 + 本地水位,同一定义式,零服务端往返。同步完成前是下界(本地是全集子集),只会少报不会多报。
  • 送达 ≠ 已读:后台设备、锁屏、最小化都会送达但不该清未读。判定"会话在前台"是 UI 职责,SDK 只提供 MarkRead 原语。例外是"发送即已读"——发送是主动行为,不标则服务端定义式把自己发的算成自己未读(实测踩到)。
  • 已读水位是 user 级、跨设备共享(read:{user},不带 device);邮箱游标才是 device 级。"别的设备拉离线消息会重新标未读"不成立。
  • READ_SYNC(0x0406)契约已定、故意不实现:一期 evict_device 按 uid 顶号(user/device 合一),同用户不可能两设备同时在线,此帧永远没有接收方。前置条件是多设备本身(连接注册改 (uid, device_id)、顶号语义改"顶同设备"),见 docs/11 §8.1。
  • 已读回执(谁读了)尚未设计。关键洞察:不需 per-message 存储——已读是水位,有前缀性质,"谁读了 seq=50" = {成员 | 水位 ≥ 50},存储量 O(成员数) 而非 O(消息数 × 成员数)。但大群必须"不推只拉"(1000 人群全员已读 = 10^6 帧,是 N²),隐私必须对称(关掉自己回执就不该能看别人),E2EE 下是元数据泄露。

术语约束

  • message_id:全局唯一、时间有序的消息标识(u128,HLC)。
  • conversation_seq:单会话严格递增的历史顺序,允许空洞。
  • last_activity_id:会话列表与跨会话时间轴排序键,与 message_id 同源同格式。
  • mailbox_seq:MailboxShard 的复合投递序号,个人队列允许空洞。
  • room_seq:聊天室短期顺序。
  • lane_id:MailboxShard 内的可见性隔离通道。
  • materialized_watermark[lane]:该 lane 已完整物化并可安全暴露给客户端的连续水位。
  • mailbox_trim_watermark:已物理删除的最大 mailbox_seq。
  • MailboxCursor:设备已应用完成的邮箱位置,≠ 网络已发送位置。
  • ConversationHead:每条消息只更新一次的会话公共头。
  • UserConversationState:置顶、静音、已读、隐藏和成员关系等用户主动状态。
  • UserSessionProjection:从用户邮箱派生并可重建的会话列表物化视图。
  • UserBadgeState:用户级全局未读聚合,供离线推送角标使用。
  • PresenceEntry:per-device 在线位置条目。

实体命名(禁止别名)

ConnectionNode、MailboxNode、ConversationWriter、FanoutCoordinator、HistoryArchiver、MessageStore、 GroupMembership、ShardRegistry、PresenceDirectory、SessionProjection、RoomWriter、 MediaService、NotificationService、ModerationService。

禁止出现:MessageNode、Mailbox Node(带空格)、Message Store(带空格)、connection_epoch。

禁止方案

  • 禁让用户扫描 MailboxShard 分发日志找自己的消息。
  • 禁登录时逐个会话拉离线消息(不含 §10.2 的大群读扩散降级档)。
  • 禁为超出 inline_body_budget_bytes(8 KiB 乘积预算)的会话复制完整正文;小会话正文内联见 ADR-0005。
  • 禁每条群消息同步更新所有成员的持久会话记录。
  • 禁把 HTTP 当 IM 实时通信和同步主链路。
  • 禁把图片视频等原始大文件直接写进消息正文存储。
  • 禁宣称 exactly-once;统一至少一次投递加幂等。
  • 禁用物理节点数量直接取模分配用户,必须经稳定虚拟桶。
  • 禁在早期邮箱任务未完成时越过所属 lane 的连续物化水位推进客户端游标。
  • 禁把客户端时间戳当任何增量同步的下界(晚到消息会永久丢失)。
  • 禁用 conversation_seq 或 mailbox_seq 的差值判定丢消息(两者对用户视角天然稀疏)。
  • 禁把分片级 materialized_watermark 与个人游标直接比较(会造成全网空拉)。
  • 禁 SESSION_DELTA 用增量语义。
  • 禁在物化路径用本地墙钟或随机 ID。

默认技术候选

  • Redis:一期 Redis 7.0.0+ standalone MailboxStore(按天分桶 zset,7 天固定绝对到期(ADR-0023,原 30 天);AOF + noeviction,Redis Cluster 不支持)。
  • MessageStore 一期为分层形态(ADR-0018):Redis 承载提交状态与 7 天热窗口历史,qim-archiver 异步批量归档到 ScyllaDB;ScyllaDB 是永久历史的权威。
  • ScyllaDB:消息历史与正文、会话公共状态、群和用户元数据、阶段一 MailboxStore。
  • Redpanda:提交日志、Outbox、分片分发日志、PresenceDirectory 的 compacted topic(已选定,非 Kafka)。
  • rust-rocksdb:MailboxNode 本地个人邮箱索引(阶段二,见 ADR-0001)。
  • S3/MinIO:媒体、检查点及冷归档。
  • RoaringBitmap(roaring-rs):群成员分片、在线成员集合、dirty_users;持久化必须用 portable 格式。

正式实施前必须完成基准测试,技术候选非不可替换的强制依赖。

文档 Todo

  • [x] 编写 README.md 与全部 11 份专题文档;建立 ADR 0001~0007。

  • [x] 写入总体设计计划 docs/PLAN.md。

  • [x] 完成基线评审 docs/REVIEW-001-plan-baseline.md 并将修订合入 v2。
  • [x] 定义契约核心:命名表、五种序列、数据模型主键、帧总表、默认参数表。
  • [x] 建立 ADR 0001~0007。
  • [x] 编写术语×模块对照 docs/glossary-modules.md(一词多义消歧:shard/partition/bucket/epoch/offset/consumer/watermark 各自归属哪个模块;按 crate 列出术语归属;术语层面的已知缺陷)。
  • [x] 编写名词解释 docs/glossary.md(系统实体、五种序列、游标与水位、数据模型、协议帧、错误码、安全加密、存储基础设施的分类解释)。
  • [x] 编写消息处理流程 docs/message-flow.md(群聊/单聊完整链路、lane 水位推进机制、离线同步、REBUILD、会话列表更新、聊天室、故障恢复、端到端延迟分解、四道阻塞防线)。
  • [x] 编写系统总览 docs/00-system-overview.md。
  • [x] 展开 TCP/WebSocket 二进制协议细节 docs/01-connection-protocol.md(opcode 表、20 字节帧头、Protobuf + 正文透传、zstd、分片写出多路复用)。
  • [x] 展开物理存储模型与 DDL docs/02-message-model-and-storage.md(6 张表 DDL、压缩策略、seq_bucket 分页、分组批量读、加密字段)。
  • [x] 展开大群分发与故障恢复 docs/03-mailbox-and-group-fanout.md。
  • [x] 展开会话列表算法 docs/04-session-list.md。
  • [x] 展开聊天室与控制消息 docs/05-chatroom-and-control-message.md。
  • [x] 定义部署、扩缩容、租约漂移接管与跨地域容灾 docs/06-microservices-and-deployment.md。
  • [x] 展开可靠性、安全与运维 docs/07-reliability-security-operations.md。
  • [x] 编写测试与容量计划 docs/08-test-and-capacity-plan.md(容量表待压测回填)。
  • [x] 编写离线推送与角标 docs/09-push-and-badge.md。
  • [x] 编写保留、删除与合规 docs/10-retention-deletion-compliance.md。

技术栈语言约束

  • 服务端语言已选 Rust(ADR-0006,倾向性决策,基准后终定),统一 tokio,禁混合语言部署;不会是 Java/JVM。
  • 提交与分发日志用 Redpanda(非 Apache Kafka),Kafka API 兼容故不锁死(ADR-0006)。
  • Redpanda 客户端使用 rust-rdkafka(librdkafka 的 C 绑定);ADR-0012 已取消 Fanout Kafka 事务与 transactional.id,防脑裂不再依赖 ProducerFenced,而由会话头条件写与数据面 fencing_epoch 校验承担。
  • 一期按简化形态交付(ADR-0007):群上限 1000 人、MailboxStore 用 Redis、MailboxNode 无主备、聊天室进程内环形缓冲。
  • 一期全部不可变项按目标档定死,尤其 lane_count = 64(千人群用不上,但今天不做以后做不了)。
  • MailboxStore 三级演进:一期 Redis → 阶段一 ScyllaDB → 阶段二 自研 LSM(rust-rocksdb)。
  • 设计文档中所有机制必须在 Rust 和 Go 两生态下均可实现,禁绑定单一语言生态的库为唯一方案(保留 Go 作 ADR-0006 复评基线)。
  • §23 的 Akka 对照只借模型不借实现,不引入 JVM。

开发与文档规则

  • 始终用简体中文写设计说明和代码注释。
  • Go 用 gofmt 和 golangci-lint;优先标准库和官方维护依赖。
  • Rust 用 rustfmt 和 clippy(-D warnings);异步运行时用 tokio;禁无充分理由的 unsafe。
  • TypeScript/JavaScript 禁动态 import、禁转 any,用 eslint 和 prettier。
  • Python 用 poetry 环境时经 poetry run 执行,并用 black、isort 和 flake8。
  • 新增依赖前说明选择原因、优点、缺点和替代方案。
  • 核心功能、故障恢复和高风险逻辑需测试;避免低价值重复测试。
  • 所有术语、字段和时序必须与 docs/PLAN.md 契约核心一致。
  • 文档中每个机制必须可实现、可验收;禁"建议加强""需要优化"这类无判据表述。
  • 凡给数值必须与附录 B 一致;待实测参数回填前不得用于容量结论或采购决策。

代码结构与纪律

  • Cargo workspace,服务端核心与端上共享 crate:qim-proto(契约核心代码化:常量/ID/路由/编译期断言 + .proto)、 qim-codec(帧编解码)、qim-store(MailboxStore 抽象 + Redis Lua)、 qim-common(内部 RPC + 幂等 + 群成员 + 限流 + TLS + obs/ 可观测)、 六个主链路服务端可执行体 qim-gateway / qim-writer / qim-fanout / qim-mailbox / qim-session / qim-archiver(ADR-0018), 以及 qimctl(运维控制台)与 qim-loadgen(验收 + 压测)。 端上:qim-sdk(一份 Rust 内核)+ 三个平行绑定 qim-sdk-ffi(C ABI) / qim-sdk-uniffi(Swift/Kotlin/Python)/ wasm-bindgen(未立项), 以及 qim-desktop(终端客户端)与 qim-gui(Tauri 2)。 绑定层不得反向影响核心(ADR-0010 §2):核心一行不为绑定而改。 qim-loadgen 不得自带状态机/游标/去重副本——依赖 qim-sdk。副本与 SDK 并存必漂移,漂移那侧永远不被压测执行到。 压测中 5% 连接由真实 SDK 驱动("SDK 回归泳道",--sdk-clients 可调,0 = 关闭作容量对照);其余留在帧层,因 PING 对照组与 ACK/PUSH 精确时刻是分段归因前提,SDK 事件流给不出该粒度。 泳道必须配对互发:SDK 对自己发的消息走对账,回声按 message_id 去重后静默丢弃,自聊时"是否走完全链路回到本端"在公开面不可观测。
  • 服务按职责分模块,main.rs 只做装配;单文件不超约 330 行。 契约落地点集中在少数文件,文件头注释写"为什么必须这样"而非"做了什么": gateway/backend(游标推进唯一入口)、gateway/push(不得推进游标)、 writer/send(trace_id 生成方)、mailbox/dispatch(一消息一序号 / 64 lane / 内联只执行不判定)、 mailbox/pull(唯一推进设备游标的路径)、session/unread(定义式未读)。
  • 契约常量只在 qim-proto/src/consts.rs 定义,其他 crate 一律引用,不得重复定义;新增常量必须能在 docs/PLAN.md 中按 snake_case 追溯(CI 门禁强制)。
  • 不可变项(VIRTUAL_BUCKET_COUNT、LANE_COUNT、MESSAGE_SEQ_BUCKET_WIDTH、epoch 位宽等)带 const _: () = assert!(...) 编译期断言,改错直接编译失败。
  • 序号用 newtype 封装(MailboxSeq/ConversationSeq/MessageId/LaneId),构造时强制校验 bit63 恒零——否则 CQL bigint 有符号排序在跨 2^63 时翻转。

可观测性纪律

  • trace_id 由 ConversationWriter 在提交时生成(§24.2 指定生成方),经 Dispatch → PushBatch/Projection 原样透传;各服务各自生成等于没有追踪。
  • 日志禁输出 access_token/游标签名/密钥/明文正文/预览;用 qim_common::obs::{secret_digest, body_shape, text_shape} 的脱敏表示。check-contract.sh 拦截违规写法。
  • 指标名一律 qim_ 前缀;标签禁含 user_id/message_id 等高基数字段。
  • 各服务暴露 /metrics(Prometheus 文本)与 /healthz(恒零指标非零返回 503,让编排系统摘掉该实例)。gateway/writer/mailbox/session 为 9100~9103,fanout 为 9106。
  • 恒零指标不允许有假阳性:语义是"停止发布"。并发路径上无法本地判定的不变量(如 mailbox_seq 单调)应移到启动自检,而非加会误报的运行期断言。自检判据比较 log_offset 而非复合序号——复合序号含 epoch 高位,空库时 compose(epoch=1, 0) 是 2^48 而非 0,按复合值判会把每次全新启动都误报。
  • 水位是连续覆盖点,非“见过的最大 offset”:现行同一 dispatch partition 按序处理;只有某 record 的全部 lane/chunk/entries 已 durable,且 expected_previous/CAS 证明没有越过前项时,才把对应 lane 的 W 推进到该 record 的 observed offset。物化失败不得推进 W(停在旧覆盖点是安全失败)。W = min(在途)-1 与“分配登记/完成销账”仅属于旧直写序号模型。
  • message_id 必须按 §6.2 位布局生成(含 region_id/writer_id)。缺 writer_id 时两 writer 实例同毫秒会生成相同 ID,客户端按它去重 → 两条消息只显示一条。writer_id 默认由 Redis 租约自动分配(启动占位 + 20s 续约,identity 模块),QIM_WRITER_ID 显式配置仅用于有稳定序号的环境(k8s StatefulSet)。
  • writer 多实例:gateway 配 QIM_WRITER_ADDRS(逗号分隔),按 conversation_id 会合哈希亲和路由,实例断线就近回退(reader EOF 即感知,不等写失败)。路由只是亲和性优化——一期 conversation_seq 分配与幂等都在 Redis 原子完成,任何 writer 处理任何会话都正确,回退无需补偿;阶段一换 ShardRegistry 会话租约时整体替换路由器(gateway/state.rs::rendezvous_pick),接口不变。
  • ./scripts/check-contract.sh 是 CI 契约断言:禁别名、禁 subscribe()、禁累加型未读更新、禁显式 DELETE、禁重复定义契约常量、契约常量必须可追溯、日志脱敏。
  • ./scripts/acceptance.sh 是九道门禁隔离验收(完整门禁认证 hybrid):格式/lint/契约 → 测试(含历史分层门禁)→ release + 六服务(含 qim-archiver)→ 功能(15 项)→ 180 秒实际到达率/SLO → T-HEAVY → Redis 最终工件 → 不变量证据 → MessageRecord↔dispatch orphan 独立审计。外层只允许 qim-it-* 隔离项目与精确 PID;orphan 的 durable 事实源未完成前,完整发布验收必须失败闭合。
  • ./scripts/perf-test.sh full 仅是上述权威门禁的兼容包装器,必须显式传入经审计目标速率且时长固定 180 秒;旧 steady/backlog/heavy 因会修改共享 Redis、全局杀进程或用代理指标冒充真实长尾而被显式拒绝。
  • T-HEAVY(--heavy)判的是另一条 P0 路径:5000 会话用户的会话列表首屏,P99 ≤ 500 ms(实测 6.1 ms)。稳态压测测的是消息投递,两者瓶颈不同,稳态绿不代表首屏绿。会话必须经真实写路径造(5000 对端各发一条),直接种 Redis 会把键布局抄进测试——布局一改测试照样绿而线上已读不出。client_message_id 必须每轮不同:固定值时非空库上会被幂等回放且不再 fanout,ACK 照收但消息进了上一轮的会话。
  • acceptance 的性能门禁前先等机器安静:它紧跟在 clippy+test+全量 release 构建之后,构建残留把端到端 P99 抬高一倍以上(同一份二进制:空闲 179~246 ms,构建刚结束 302~416 ms)。阈值不放宽,只让数字反映代码——会随机红的门禁最后一定被当成"本来就飘"而失效。
  • 提交前:cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace && ./scripts/check-contract.sh

分支

本仓库采用「一条主干 + 两条存储线」,见 BRANCHES.md。 功能与契约只在 main 开发,单向合并进 store/redis 与 store/scylla。

客户端缺陷修复(2026-08-16)

PC 端验收发现并修复两处 qim-sdk/qim-desktop 缺陷:

  1. /quit 与顶号退出后进程挂起:qim-desktop 的 stdin 读取任务用 tokio::io::stdin()(内部 blocking 线程池),main 返回时 Runtime drop join 该线程 → 进程永不退出(4 次复现)。修复:退出路径显式 std::process::exit(0)(CLI 工具退出即终止)。
  2. 断线期间发送不立即落 local_pending(违反 docs/11 §4.1 "send_message 立即落库并返回"):断线时命令只在 SDK 内存队列排队,进程退出即丢,且 /pending 与 UI 不一致。修复:run() 退避期间用 select! 同时消费 cmd_rx,SendMessage 先落库并回 Pending 事件,帧由重连后对账按原 id 重发(ADR-0008 幂等收敛);Disconnect 立即终止事件循环。新增 2 条回归测试(offline_send_tests)。

端到端验证:/quit 进程正常退出;断线发送 /pending 立即可见、重连后 [已送达] 成功;顶号后旧连接正常退出。全 workspace fmt/clippy/test/check-contract 全绿。

客户端跨平台与 Windows 打包(2026-08-16)

  • qim-desktop 是纯 Rust 终端客户端(tokio + clap + rusqlite bundled + rustls),无平台特定代码,Linux / macOS / Windows 三平台均可编译运行,交互命令一致。
  • Windows 交叉编译链路(Linux 侧,无需 Windows 机器):zig 0.16 + cargo-zigbuild,已固化到 scripts/build-windows.sh。产物 target/x86_64-pc-windows-gnu/release/qim-desktop.exe(PE32+,~7.5 MB,单文件无系统依赖)。发布包 qim-desktop-windows.zip(2026-08-19 重打:zig 在 /tmp 会被定期清理,每次重新下载 0.16.0 解压到 /tmp/zig/zig;README-Windows.md 不在 git 里,从旧 zip 提取后 zip -j 平铺打包,勿带路径)。
  • 交叉编译三个坑(都踩过):
  • 全局 ~/.cargo/config.toml 的 rustc-wrapper=sccache 会包装 zig 的 C 编译 → 覆盖 CARGO_BUILD_RUSTC_WRAPPER= 为空;
  • zig 0.16 不认 x86_64-pc-windows-gnu(UnknownOperatingSystem),必须用 x86_64-windows-gnu;cargo-zigbuild 自动处理;
  • --target=windows-gnu 的 rustc 传 GNU ld 风格 PE 选项(--dynamicbase 等),zig cc/ld.lld(GNU 驱动) 都不认——只有 cargo-zigbuild 的链接参数转换能过。
  • 跨机器访问:gateway 默认监听 127.0.0.1,客户端连别的机器需 QIM_GATEWAY_LISTEN/PUSH、QIM_TOKEN_LISTEN 设 0.0.0.0(开发环境明文,内网可用)。macOS 同理可直接 cargo build --release -p qim-desktop(本机构建,交叉编译需 macOS SDK)。
  • 仓库根旧 qim-server-linux-x86_64.tar.gz 是 2026-08-19 TCP dispatch 历史产物,缺 qim-fanout,不得作为当前发布包。新打包流程必须包含五个主链路服务与 qimctl,并经隔离八阶段门禁;禁止再用 FLUSHALL、全局 pkill 或共享 6379 做发布准备。

GUI 客户端(Tauri 2,2026-08-16/17)

新增 crates/qim-gui:Tauri 2 + React/TS + Vite + qim-sdk 图形界面客户端,功能:会话列表(单聊+群)、聊天窗口、群成员管理、单聊/群消息收发。

  • 布局:左栏会话列表(含加群框)、中栏聊天(消息+输入)、右栏群成员面板。
  • 登录必须来自用户显式动作,宿主构造只打开稳定 SQLite 与启动 driver,不得在组件 mount 时自动联网。SDK Online 事件再触发会话初始化(拉列表+打开单聊)——不能在 login 返回后立即拉(SDK 未就绪会丢首帧应答,实测踩到)。
  • SDK 线程安全增强:Handle 的 cmd_tx/event_rx 改用 futures::lock::Mutex 并让 send/next_event 收 &self——事件泵长期阻塞 next_event 不阻塞命令。原 &mut self 时 GUI 事件泵独占 Handle,命令死锁(pump_events 持锁跨 await)。注意:std::sync::MutexGuard 跨 await 不 Send(tauri command 要求),futures Mutex 满足且 wasm 可编译(ADR-0010 可移植约束)。
  • SDK 群能力:Command::PullMembers + Event::MembersUpdated(wire/state/api/client 四层),qimctl 新增 membership create(管理面建群),qim-desktop 新增 /members、/gmsg 验证命令。端到端验证:拉群成员、群消息收发、单聊收发全通。
  • 构建:Linux 需 libwebkit2gtk-4.1-dev 等(已装);tauri dev(开发,vite :1420)与 tauri build --no-bundle(release 二进制)均可。
  • 无头验证:Xvfb :99 + openbox(WM,否则 WebKitGTK 不接收输入)+ xdotool XTEST(--window 坐标鼠标无效,用根坐标)+ import 截图 + rapidocr OCR。
  • 交付截图:qim-gui-main.png(单聊主界面)、qim-gui-group.png(群成员)。

GUI 能力补齐(2026-08-18)

crates/qim-gui 补齐 SDK 公开面:聊天室、消息类型透传、终态/错误事件、发送闭环。

  • 聊天室(桥接层 room_join/room_leave/send_room_message,复用 SEND_MESSAGE,会话 id 用 room_conversation_id(room_id) 推导):房间面板(左栏加入/退出/列表)、房间模式视图({t:"room"})、回放截断警示条(replay_truncated)、乐观发言行。房间消息只进前端内存态 rooms,绝不触 convs/unread/mark_read(§14)。
  • 消息类型透传:UiMessage 增 kind/custom_type,发送侧 kind 下拉(文本/自定义)+ 子类型输入;渲染侧 Custom 显示 自定义·子类型 蓝标徽记、Media/Control 徽标。
  • 终态与错误事件:Kicked 顶号横幅(含重新登录按钮)、Failed、SyncProgress、LocalStoreReset 均已呈现。
  • 发送闭环(重点 bug):client_message_id 是"发送中"行的身份(幂等键),去重键为 pendingClientMessageId ?? messageId——按 seq 去重会互相挤掉。实测 Committed 事件可能先于 invoke("send_message") 返回到达前端(事件路径快于命令响应):此时注册表查不到 cmid,乐观行永不删除,出现"已确认行 + 幽灵发送中行"并存。修复:consumedCmidRef 先记录先到的终态,sendMsg 拿到返回值后据此跳过乐观行(Committed 拉历史补正式行、Failed 插失败行),聊天室路径同处理。
  • 重登修复:Kicked 时 loggedIn 仍是 true,useEffect([loggedIn]) 不重跑致横幅不清。setKicked(null)/initialized.current=false 移入 doLogin 开头,不依赖 loggedIn 翻转。
  • 端到端验证(Xvfb + xdotool + OCR):单聊互发、自定义消息(桌面端显示 <Custom:demo.card>)、聊天室双向广播、房间视图未读徽标递增/打开清零、顶号横幅 + 重登恢复,全通。交付截图 docs/gui-final.png。

消息持久化与完整客户端重构(2026-08-23,核心链路完成)

完整审计与测试计划见:

  • docs/designs/testplan_20260823_message_persistence_client.md
  • docs/designs/arch_20260823_message_persistence_client.md
  • docs/designs/contracts/(实现前接口冻结)

初始审计确认的发布阻断事实(本轮均已有对应修复与回归门禁):

  1. writer 曾在 MessageRecord 与可靠 fanout 前返回 SEND_ACK,dedup 又已进入最终态;崩溃或历史写失败后,同一 cmid 重试只回 ACK,不会补历史/分发。
  2. writer→mailbox 的进程内 TCP 队列曾被当成可靠 Outbox;mailbox 物化失败只剩序号,没有 payload/重放源,会永久卡连续水位。
  3. Redis 邮箱曾未全局按 mailbox_seq 归并跨日扫描,且把完整复合序号作为 ZSET double;epoch>=32 时失去精度,损坏记录还会被静默过滤。
  4. Scylla 分支的旧分配器用普通 INSERT 初始化,存在跨实例覆盖 CAS 结果和序号复用;段租约未发号会在重启后形成永久水位空洞,生产一致性仍是 RF=1/ONE。
  5. 旧 CREATE_GROUP 可把任意认证用户加入已有群;PULL_MEMBERS 也缺成员准入,属于历史泄漏/消息注入漏洞。
  6. GUI 与 UniFFI 曾在构造 driver 后漏发 Command::Connect;GUI/CLI 默认内存 SQLite,游标 lane/epoch 未持久化,CURSOR_EXPIRED 的 REBUILD 生产链路未接通;C FFI 批量事件只导出首项。

冻结的架构决策:

  • SEND_ACK 只由 MessageCommitter 的 COMMITTED 结果生成;提交顺序为 RESERVED → MessageRecord → Redpanda Outbox → COMMITTED,任一中间态由请求重试或恢复器续做。
  • 新增 qim-commit-log 深模块与 qim-fanout 服务;使用 rust-rdkafka producer(acks=all;writer 的 Outbox 生产者幂等,fanout 的 dispatch 生产者自 ADR-0017 起默认关闭幂等,QIM_FANOUT_PRODUCER_IDEMPOTENCE=true 可改回)与显式消费者位点。Fanout 必须先取得整批 dispatch 的 durable delivery,再同步提交 source offset;允许崩溃后重复一批,由确定性 dispatch_id + payload_digest 收敛。ADR-0012 已取代旧的 consume-transform-produce 事务选择;librdkafka/CMake 构建链与真实 broker 测试成本仍存在。
  • MailboxNode 以 dispatch 日志 partition/offset 构造稳定 mailbox_seq,启动从 min(W)+1 重放;DispatchProgress、W 与 W_floor 是接管真相,消费者位点只作性能提示。
  • Redis 与 ScyllaDB 只实现共同 MessageStore/MailboxStore 契约。Redis AOF 缺口必须由分发日志重放兜底;Scylla 生产门禁必须 RF=3 + LOCAL_QUORUM,并用 LWT 条件初始化。
  • 客户端宿主默认使用平台稳定数据目录;只有测试显式选择内存库。GUI 不再 mount 自动连接默认账号,建群必须等待服务端终态。
  • 客户端 CreateGroup 只创建不存在的群,服务端绑定创建者并强制加入 actor;受信管理面成员变更使用独立语义,所有群读写在 writer 侧重新鉴权。
  • 群消息在 conversation_seq 分配后、Outbox 前固化不可变 membership_version;Fanout 只能按精确版本读取快照,禁止在重放时改用当前成员。每目标 MailboxShard 恰好一条 GroupDispatch,分块只属于物化进度;dispatch_id = blake3("qim.group-dispatch.v1\0" || message_id_be16 || shard_be4)[0..16]。
  • CommitIntent 在 RESERVED 时必须持久包含完整 canonical MessageRecord;恢复器不得依赖客户端再次上传 payload。request_id 只用于当前连接的帧关联,不进入持久提交事实,重复请求用当前 request_id + 原提交坐标生成 ACK。
  • wire group_id 经 strip_kind 后用 group_conversation_id 归一;唯一性与授权均使用 (tenant_id, canonical ConversationId),不得同时维护 raw/canonical 两套群键。
  • HLC 不再仅靠进程内 last_ms:writer 启动时以 (region_id, writer_id) 从 Redis 原子领取严格越过历史高水位的 60s 毫秒窗口,运行中提前 20s 续领;只有持久窗口成功后才安装,窗口耗尽即拒绝发号。这样固定 writer_id、崩溃重启与时钟回拨不会复用旧 message_id;代价是 Redis 长故障时 writer 会在安全窗口耗尽后停止提交。
  • 集成门禁只使用 compose.integration.yml 的隔离项目、端口与卷:默认 Redis 16379、Redpanda Kafka 19092、Scylla CQL 29042,项目名必须以 qim-it- 开头;清理仅允许 docker compose down --volumes 命中该项目,禁止 FLUSHALL、全局 prune 或按进程名杀宿主服务。性能包装器必须读取实际到达率并先通过到达率门禁,不能只检查延迟。
  • 隔离 wrapper 未显式设置端口/offset 时,以项目名/PID 的确定性起点扫描 0..500,一次选择 Redis core/mailbox、Redpanda Kafka/admin、Scylla、gateway/writer/mailbox/session、gateway push/dev-token 与五个 metrics 的 16 个互异 loopback 端口;显式端口在 up 前逐个拒绝占用。该检查存在无锁 TOCTOU 窗口,Docker 绑定冲突必须安全失败,禁止声称绝对并发无冲突。up.sh 的独立 wait 命令必须携带完整端口、split、topic/retention 与 shard 配置。QIM_INTEGRATION_MAILBOX_REDIS_SPLIT=0 让业务 mailbox 复用 core,=1 使用第二实例;QIM_TEST_MAILBOX_REDIS_ADDR 始终指向第二实例,shared 清理工件标为 unused 而不当业务指标。QIM_LOADGEN_TOKEN_ADDR 是 loadgen 可选 host:port 覆盖,未设仍兼容 <gateway-host>:8000,wrapper 必须把它精确导出为 QIM_TOKEN_LISTEN。QIM_MAILBOX_SHARD_COUNT(默认 64)与 QIM_SESSION_DISPATCH_PARTITION_COUNT 必须相等、为 1..=65536 的二次幂,wait 按该数创建并回读 dispatch topic。
  • PULL_HISTORY 必须透传并执行 OLDER/NEWER、limit、max_bytes;默认/硬字节页上限为 512 KiB,第一条即使超过请求预算也返回以保证 anchor 可前进。历史记录解码损坏时整页失败,禁止 filter_map 静默缺行;wire 保真 message_type/custom_type/created_at/body_included。内部持久 body_included 使用 optional 尾部字段,旧记录缺失按 true 解释,避免升级后把既有正文误判为治理删除。群历史按 gateway 注入的 actor_user_id 重新鉴权,不信任客户端目标字段。
  • SEND_MESSAGE.client_message_id 与 conversation_id 在 gateway 必须严格为非零 16 字节;非法值在进入 writer/dedup 前拒绝,避免零值或畸形 ID 污染幂等域。custom_type 超过 64 字节返回 PAYLOAD_TOO_LARGE,禁止静默清空后继续提交。
  • writer 已切换为 DurableMessageCommitter:发送入口先构造规范 AuthenticatedSend,再由 load_commit_for_request 同时核对 (tenant,sender,cmid) 与 device/conversation/content/mention/reply 等稳定身份;合法 ACK 丢失重试在新成员关系/审核策略前续做首次 intent,不同消息复用同 cmid 必须拒绝。新请求依次推进 RESERVED → RECORDED → OUTBOXED → COMMITTED,只有 CommittedMessage 能生成 SEND_ACK。Outbox 发布成功而状态标记失败允许产生同 payload 的重复日志记录,邮箱必须以持久 dispatch_id + payload_digest 去重。
  • writer readiness 必须同时真实访问 MessageStore 与 OutboxPublisher::readiness,后者校验 broker、topic 和固定分区;仅成功创建 lazy producer 或对 max_items=0 做本地空扫描都不能开放监听。
  • MessageStore::history_page 自身必须在任何 key 查询或空结果前执行 HistoryAuthorizer:它返回可见区间 HistoryVisibility{after, before}(Direct 全区间;Group 取成员区间,从未入群拒绝),各后端按区间收紧锚点并裁剪页(authz.rs 两个共用函数),分层后端只授权一次。调用方预鉴权只作纵深防御(writer 预检“曾有区间”而非“当前在群”,否则退群者读不到退群前历史),授权依赖不可用时整页失败,禁止返回空页伪装成功。
  • Redis MessageStore 生产实例必须使用 AOF、appendfsync=everysec|always 与 maxmemory-policy=noeviction;appendfsync=no、配置读取无权限或未知值均失败闭合。提交 hash、conversation record 或历史索引被逐键淘汰会破坏原子提交事实。put_record 对同值的局部 record/index 缺失可幂等修复,异值或不可重建损坏必须整页/提交失败,不能用空历史伪装成功。
  • Kafka enable.idempotence 只覆盖同一 producer 的协议重试,不提供应用级 publish 去重。Outbox 使用稳定 message_id key 且允许“发布成功、状态标记失败”后产生重复记录;Fanout 的确定性 dispatch_id 与 MailboxStore 的 dispatch_id + payload_digest 持久收敛才是逻辑唯一性边界,文档和指标不得宣称跨崩溃 exactly-once。
  • Fanout 不使用 Kafka 事务(ADR-0012)。旧的“每条 Outbox 一次事务”短压中仅约 100 msg/s,已被淘汰。当前 QIM_FANOUT_BATCH_SIZE 默认 512(DEFAULT_FANOUT_BATCH_SIZE)、合法 1..=1024;同批 source 逐条按冻结受众确定性规划,全部 dispatch 先 enqueue、再等待所有 durable delivery,最后同步提交批末实际 source offset。任一规划、投递或位点提交失败都不推进调用方位置,整批重放由稳定 dispatch_id 收敛。
  • QIM_MAILBOX_SHARD_COUNT 是 writer、fanout、mailbox 三者共享的启动契约:默认 64,必须为 1..=VIRTUAL_BUCKET_COUNT 的二次幂;writer 冻结群 target shards、fanout 路由和 mailbox 静态消费/收件人校验必须使用同一已验证值,禁止任一服务硬编码默认值后继续启动。
  • writer 单条 gateway 连接最多并发处理 QIM_WRITER_REQUEST_INFLIGHT_LIMIT 个请求(默认 1024、上限 8192;2026-09-25 前为固定 256),达到上限停止读 socket 并由 TCP 反压。这道闸门是一条隐形队列:触顶后请求在 gateway 后端 mpsc(4096)与内核缓冲里排队,writer 的 qim_send_ack_latency_us 从取得许可才计时,看不到这段。180 s 门禁 10k msg/s 下客户端 ACK P99 404~711 ms 而 writer 直方图 P99 ≤ 50 ms,差值全在这里;改 1024 后 P99 24.9 ms。现有 qim_writer_request_permit_wait_us、qim_writer_requests_inflight、gateway 侧 qim_gateway_send_round_trip_us(SEND 收帧到 ACK 回写)、qim_gateway_backend_queue_depth 与恒零的 qim_gateway_backend_dropped_total(此前后端队列满时 try_send 静默丢弃)把它显形。SEND_ACK 与请求级 Error 通过有界响应队列可靠 await send,队满不得静默丢弃;写侧断开会通知读侧关闭并使等待响应的任务失败。只有明确尽力而为且有丢弃指标的 after-send webhook 可继续 try_send。
  • MessageType 的 raw 数值必须逐字节遵守 proto:UNKNOWN=0、TEXT=1、MEDIA=2、CUSTOM=3、CONTROL=4;未来未知 u32 禁止降级或截成 u8。历史、Outbox、GroupDispatch、MailboxEntry、SDK 全链路都保留 raw 值。
  • GroupDispatch 必须携带 canonical sender 与 client_message_id;MailboxMaterializer 只对 recipient == sender.user_id 写入 cmid 对账锚点。v1 dispatch 缺这两个字段,无法安全物化,升级时显式拒绝而非猜值。
  • 日志重复记录的 observed_log_offset 不属于 dispatch 身份:同 dispatch_id + payload_digest 在不同 offset 出现时,条目复用首次 canonical_log_offset/mailbox_seq,但 64 lane 的连续水位仍推进到本次 observed offset;异 digest 必须停止服务。每 lane 的 expected chunk manifest 在 begin_dispatch 时持久化,advance 不能信任调用方临时传入的空列表。
  • MailboxNode 已取消 writer Dispatch TCP 热路径,只服务 PullMailbox;启动对每个配置 shard 的 64 lane 执行 W/W_floor 自检,以 min(W)+1 静态 seek read_committed dispatch 分区。物化按 (lane, chunk) 全量 append 后再标完成,空 lane 同样推进 observed offset;durable 完成后 Push/Projection 才允许 best-effort。QIM_MAILBOX_SHARD_COUNT 在 mailbox 与 writer/fanout 同规则校验并贯穿静态分区、路由与 manifest,禁止生产路径暗含 64。
  • live dispatch 物化按 shard 建立固定容量 128 的 FIFO worker:同 Kafka partition 严格串行、不同 shard 并行;队满由 await send 把反压传回中心 consumer。任一 worker 失败立即标记 MailboxDirty、停止中心消费并取消其他 worker,未完成或已预读记录依靠未推进水位重放。启动 catch-up 仍在 ready 前完整完成,不能先监听再后台追赶。
  • RdkafkaDispatchLogConsumer 禁用 auto commit/offset store、校验 partition=target_shard,并拒绝损坏、越界与缺失分区。64 分区 readiness 对每个 low/high 仍逐一校验,但并发阻塞查询,避免逐分区 timeout 线性放大;隔离 Redpanda 门禁计时要求 readiness <10 秒。Outbox producer readiness 以 metadata 验证 topic/fixed partition,不以 lazy producer 伪绿。
  • librdkafka 静态 assign 后、空 topic 尚未 poll 时,position() 可能短暂返回本地 Offset::Invalid;这是“尚未初始化”,不是 broker 位点越界。DispatchPartitionPosition::Pending 必须让 qsession 保持 checkpoint 不动并重试,其他负 offset/OffsetOutOfRange 仍失败闭合。fanout 启动期间的 CoordinatorLoadInProgress、CoordinatorNotAvailable、NotCoordinator 只在配置的 operation timeout 内有界重试;授权、真实 retention/position 错误不得吞掉。
  • qsession 独立静态消费 dispatch 并把 Redis checkpoint 绑定 cluster_id + topic_id + partition_count;写完该 partition record 的全部 recipients 后才 CAS 推进,启动追到初始高水位后才监听。缺失/全零 topic UUID、同名重建或位点越界全部 fail closed。当前最低 broker 基线为支持 KIP-516 非零 topic UUID 的 Redpanda v26.2.2;v24.3.6 不可用于 durable projection。
  • MARK_READ 成功后必须失效该用户的 qsession 内存快照;否则后续 PULL_SESSION_LIST 会在最长 5 分钟内继续返回旧 unread/read_seq。单实例已由成功后 invalidate 修复;多 qsession 实例仍要求粘性路由或跨实例版本/失效广播,未满足时不得宣称跨实例读后立即可见。
  • Outbox 消费只能在首次定位或 source 不连续时 seek;每次成功 poll 后必须更新本地 positioned_after。曾因空 poll 重复 seek 导致第二条饥饿,又因每条 backlog 重复 seek 将 fanout 限制到约 2 条/s;回归用真实 Redpanda 在 5 秒预算内连续消费、确认 dispatch durable 后提交 24 条 backlog 的 source offset(整组实测 1.31 秒),完整功能 smoke 15/15 通过。
  • Redis mailbox 水位使用 packed-v1:wm_layout + wm_epoch + 1216B wm_blob 固定保存 64 lane 的 W/W_floor/W_recomputed;layout/epoch/长度/presence/三元组或旧新字段混入均失败闭合。无 epoch 证据的 legacy 192-field 水位禁止运行期在线迁移;升级必须 fence 旧 owner 后离线按显式 epoch 转换。epoch bump 时旧 packed W 读作 stale 空状态,同 ID 的旧 epoch DispatchProgress 原子轮转为新 receipt,旧 epoch GC 也必须先通过当前 W 状态校验。
  • MailboxMaterializer 的用户写入使用 buffer_unordered(32) 有界并发;接管时一次 lane_watermarks(shard) 同时推导 seek 起点并初始化 startup/live 共用的 W cache,生产 record 不再重复读 64 lane。canonical manifest 的最高 (lane,chunk) 是唯一 fixed final;Redis finish_dispatch 用一条 Lua 原子提交 final completion 与 64 lane packed W,典型 Fresh 单 chunk 从 5 个脚本降为 begin + append + finish 3 个。默认/Scylla 路径先从持久 manifest 验证 canonical final,再允许逐 lane 推进;失败可留下安全前缀,但 qmailbox 必须整条失败、置 dirty、缓存不更新,重启以持久 W 和 AlreadyAdvanced 收敛。
  • live dispatch 仅用全局 permit 限制 durable materialize;完成 W 后的 Push/Projection 都是易失优化。Projection 只允许 try_send_keyed,满则计 projection_dropped_total,不得等待并阻塞同 shard FIFO;qsession 的独立 durable dispatch consumer 承担最终恢复。分段指标固定为每 shard broker→enqueue、FIFO queue wait/depth,以及全局 permit wait/materialize;depth 用 RAII 保证失败/取消/shutdown 后归零。
  • standalone Redis 增加机会型 try_materialize_fresh_single_recipient:仅 Fresh、唯一收件人/entry/canonical final chunk、可完整证明的空或同日 V4 热态,才在一次 Lua 内原子写 DP、邮箱事件组、V4 proof/identity、完成索引与 64 lane packed W。Ok(None) 保证 7 个关联 key 的内容/TTL 零写并走通用链;Err 必须停止 shard,网络结果未知时禁止 fallback。Existing、legacy、跨日/过期、复杂 manifest 均不冒充命中。该脚本是 standalone 专用跨 tag 事务,不代表 Redis Cluster 支持。
  • 2026-08-24 同口径 split Redis、100 客户端、1000 msg/s、10 秒 A/B 中,Fresh fused 把 Mailbox EVALSHA 从 32939 降到 11839(约 3.14→1.13 次/dispatch)、Mailbox CPU 从 10.32s 降到 8.82s、materialize 从 15.47ms 降到 11.83ms、queue 从 545.04ms 降到 43.84ms,E2E P99 从 3052.5ms 降到 732.0ms,ACK P99 保持 9.5ms;工件 X8ItRX/BqdY36。128 shard 单次仅到 711.1ms 且 P50/P95、queue、materialize、permit、Redis CPU/Lua 时间均恶化,不采纳。inflight=20 单次到 692.6ms,但 materialize +21.15%、Redis/Lua 成本上升且热点更集中,未采纳;现行 DISPATCH_INFLIGHT_LIMIT_DEFAULT 为 64、上限 256(2026-09-25 按代码核对)。三组均 10000/10000、frame/SDK 100%、failure=0,但仍未达 300ms,短压不能替代 180 秒门禁。
  • 当前 1000 msg/s×10s 冷库短压并非“完全无 rehash”:约 79ms 高频采样捕获 4 个短暂 DB dict rehash 点,最大 64KiB。决定性预扩 A/B 先写 40000 个 TTL dummy key并驱动到连续2.77s、40/40样本 rehash=0,随后负载132点仍全0,但 E2E P99 仍879.9ms(冷库744.0ms)、queue>500ms 仍6.07%(冷库4.26%)。因此 rehash 不是当前700~900ms长尾的必要条件,也无证据支持其为主因;历史15k/s、180s在2^20/2^21翻倍造成尖刺的结论仍有效,但不可套用当前链路。工件 /tmp/qim-rehash-artifacts.oEgLqI、/tmp/qim-rehash-ab-artifacts.sGvwAw。
  • MessageStore 提交辅助状态保留 2 小时:Redis 后端在进入 COMMITTED 时给 commit hash 设绝对过期(committed_at_ms + 2 h,PEXPIREAT),不再登记 msgcommitted 索引,writer 的 GC(每分钟 256 条)只清升级前遗留;R/D/O 中间态永不过期,由恢复器续做。此前完全依赖该 GC,持续写入超过约 4 条/秒即无界累积(2026-09-26 修复)。仅 Ephemeral 历史自动按 24 小时清理,Default、TenantCustom、ComplianceHold 不猜固定 TTL,必须由显式租户/合规策略驱动。当前租户策略映射、冷归档与归档回读尚未实现,是发布阻断。
  • 完成的 DispatchProgress 由 mailbox 每分钟每 shard 最多清 256 条;GC 安全证明必须同时满足所有 lane 的 W/W_floor 已越过该 dispatch 的最大 observed offset,且最后一次重复记录被观察后已超过 replay safety window。首次完成时间或 canonical offset 不能替代这两个条件,否则旧 DP 删除后重放会重新分配 mailbox seq。
  • MailboxGcConfig::default() 以 u64::MAX 禁用 DispatchProgress GC;只有部署方显式注入经审计的日志 replay safety window 才允许删除。Redis 邮箱元数据固定 retention_format_version=4:活动日索引、事件组日桶身份和 expired_gap_boundary 共同证明自然 TTL 缺口;V1~V3、身份索引缺失、跨日同序号或损坏 proof 均失败闭合并要求迁移/日志重放。Scylla 使用同等 V4 active-ledger/generation 语义(bucket 证明按 epoch 分行、以写时间戳单调写入),dispatch 身份由 TTL 回收(ADR-0022),禁止静默跨过已物理过期的游标。
  • 生产 Redis 仅支持 7.0.0+ standalone:启动必须先从 INFO server 精确证明唯一、严格三段式 redis_version >= 7.0.0,再验证 cluster_enabled=0、AOF 已开启、appendfsync=everysec|always、maxmemory-policy=noeviction。QIM_REDIS_ALLOW_UNSAFE_DEV=1 只允许字面 loopback/localhost/Unix socket 跳过 AOF/fsync/noeviction,绝不绕过版本或 standalone;Redis Cluster 需要独立解决跨槽 GroupMembership、全局 {commit} 热槽和 Cluster 路由/迁移后才可评估。
  • 内部 RPC 的生产边界为 TLS 1.3 双向认证 + 服务端 CA/SNI 校验 + 客户端叶证书 DER SHA-256 指纹角色白名单。仅 loopback 监听可显式使用明文 Local 开发模式;非 loopback 缺完整 mTLS 直接拒绝启动。方法矩阵固定为:Writer/Gateway=SendMessage|PullHistory|PullMembers|CreateGroup,Writer/Admin=GroupAdd,Mailbox/Gateway=PullMailbox,GatewayPush/Mailbox=PushBatch,Session/Mailbox=Projection,Session/Gateway=SessionListReq|MarkRead;qimctl GroupAdd 即使 loopback 也强制 Admin mTLS。
  • 客户端宿主存储身份采用“规范化 endpoint + 显式 tenant_id + user_id”的稳定路径,禁止 token 入路径。当前单租户 legacy tenant 为 0;C/UniFFI 新版本 API 显式传 tenant,旧 ABI 固定 0。C ABI 事件结构只能版本化扩展,旧 QimEvent 布局与 free 必须永久保留;跨 FFI 字节所有权用 boxed slice/明确 allocator,禁止用假定 capacity == len 的 Vec::from_raw_parts。
  • Tauri/React 跨 JS 边界的 user/group/member/room/tenant 等业务 ID 一律用规范十进制字符串(内部按 u64/u128 严格解析),会话 ID 保持 32 位十六进制字符串;禁止经过 JS number,回归必须覆盖 2^53+1、u64::MAX 与 u128::MAX。
  • SDK 接收 mailbox/push 时,进程内去重与 pending 解除都只能在 SQLite 原子落库成功后提交;失败重连必须让同批重放重新进入持久化,不能因内存 DedupSet 或提前 ResolvePending 把消息/待发送事实吞掉。每设备 client_message_id 由 SQLite BEGIN IMMEDIATE 原子预留持久 ordinal 号段,再以完整 device_id + ordinal 的域分隔 BLAKE3 映射为 u128;外部伪造、重复消费、号段耗尽与时钟回拨均不得复用或回绕。
  • PUSH_EVENTS 只可低延迟落库,绝不推进设备 cursor;收到 PUSH 后由状态机立即触发合并/单飞 PULL_MAILBOX,在飞期间只置 dirty,末页 SQLite 提交并推进 cursor 后再决定是否续拉,PONG 仅作同一入口的兜底。性能 SDK 泳道必须按本轮 nonce + peer sender + ordinal 集合逐 pair 核验互投,收尾等待本端 committed/pending 闭合并在 pair 层比较对端 applied;不能以首次 500ms 静默或 applied == 自己 emitted 伪造完成。

实施 Todo(未完成前禁止声称可发布):

  • [x] 完成 contracts 接口冻结与共享编码向量。
  • [x] 完成日志驱动 MailboxMaterializer、Redis MessageStore、writer 提交状态机、可靠 Outbox 与 FanoutCoordinator;目标 crate、真实 Redpanda readiness/去事务 durable-delivery/backlog 与隔离功能 smoke 15/15 已通过。
  • [x] 修复 Redis 精确序号/跨桶归并/裁剪、readiness 与安全 GC;Memory/Redis 共享过期游标、重复 dispatch 与保留策略故障向量,隔离 Redis 58 项通过。
  • [x] 修复群创建、成员读取和群历史 initial-cohort 过渡授权;后加/退群重加/旧元数据失败闭合。
  • [x] 群成员 (joined_at, left_at) 可见区间(ADR-0021):群键随提交桶同实例,成员变更为占序号的 CONTROL 消息、与序号分配同一 Lua;群发送同 Lua 校验冻结版本;历史/未读/会话头按区间裁剪;LEAVE_GROUP(0x0A04)、管理面 GroupRemove、qimctl membership add|remove、SDK/C/UniFFI/桌面退群;e2e 第 16 项“退群边界”。
  • [x] 完成 SDK 磁盘重开、完整游标身份、REBUILD、ACK/PONG deadline、历史分页、正文/未知类型保真与 tracked 成员操作终态。
  • [x] 修复 GUI/CLI/C FFI/UniFFI 的显式连接、稳定默认路径、分页事件、C V1 ABI/释放所有权、V2 严格建群关联与 JS 大整数精度。
  • [x] 完成分支审计并把 Redis/Scylla 双后端接入当前主线;Scylla 纯 Rust 契约、三节点 RF3/LOCAL_QUORUM/Tablets 禁用真实门禁均通过,无静默 Redis 回退。
  • [x] 在隔离 standalone Redis + Redpanda 环境运行真实集成、全工作区测试与五服务 E2E 15/15;资源均由 qim-it-* 项目和精确 PID 清理。
  • [ ] 在目标硬件以显式审计速率运行完整 180 秒 + T-HEAVY,并完成日志中断/进程崩溃等混沌门禁;未提供目标速率时不得擅自运行或复用旧容量结论。
  • [ ] 实现 Default/TenantCustom 租户保留策略、冷归档与归档回读;当前仅 Ephemeral 自动 24h 到期。
  • [ ] 实现 durable CommitAuditFact、FanoutAuditReceipt 与独立 coverage cursor;receipt 必须遵守“dispatch 全部 durable 后、source offset 提交前”的顺序,完整 acceptance 在 orphan 审计步骤必须继续失败闭合。
  • [x] GroupDispatch.fencing_epoch:ADR-0020(负责人选方案 B)定为一期形态不适用——writer 不持序号窗口、序号由 Redis Lua 原子分配;回归测试 两个_writer_并发提交同一会话_序号不重复且连续(200 条恰为 1..=200)。引入本地序号窗口/会话租约/writer 条件更新 ConversationHead 前必须先实现。
  • [ ] 实现目标 lane 独立可见性推进;当前 finish_dispatch 等整条 record 完成后统一推进 64 lane,空 lane 也不提前越过,M-1/用例 26.3.6 必须保持失败。
  • [x] UserConversationState 会话集合兜底(ADR-0021 决策 4):ScyllaDB user_conversation_state 首次进入集合时登记(投影 Lua 写 Redis 待登记队列、后台批量落表);checkpoint 越窗/投影全失递增投影纪元、按用户惰性重建。缺口:越窗期间首次出现且之后无消息的会话无法恢复。
  • [x] 在隔离三节点 Scylla 环境运行 RF3 真实门禁,覆盖 LWT、历史、邮箱 V4 retention;2026-09-28 起邮箱部分改为 ADR-0022 v2 门禁(分片前沿、写时间戳单调写、按页收尾、epoch 回退拒绝、身份 TTL),原 ghost-row/分阶段 GC 用例随 v1 表删除。

最终校验记录(2026-08-24):

  • cargo fmt --all -- --check、cargo check --workspace --tests、workspace clippy -D warnings、check-contract.sh、脚本 bash -n 与 git diff --check 通过。
  • 隔离 qim-it-* standalone Redis + Redpanda 下 cargo test --workspace --no-fail-fast 全通过;不提供 QIM_TEST_REDIS_ADDR 时 Redis 测试按设计拒绝共享 6379,不能把该安全失败误报成代码回归。
  • 五服务 release 功能 smoke 15/15 通过,含 canonical 未读 3 → 0 与 ACK 丢失重连对账;真实 Redpanda 的 64 分区 readiness 和 24 条 durable-delivery/backlog 两项 ignored 门禁 2/2 通过,耗时 1.31 秒。
  • packed W/cache/final、Fresh fused 与独立复核修复后的定向门禁:qcommon lib 86/86、qstore lib 52/52、qmailbox 50/50、隔离 Redis mailbox 38/38、真实 Redis 版本门禁 1/1,strict clippy/fmt/contracts/diff 全通过。1000 msg/s 短压实际提交与覆盖率通过,融合后最佳 E2E P99 仍为 692.6ms,未达 300ms;性能 Todo 仍未完成。
  • 所有隔离容器、卷、网络与本工作区服务 PID 已清理。已运行三节点 Scylla RF3、三主节点 Redis Cluster 负向门禁和 writer SIGKILL 恢复门禁;未运行完整 180 秒/T-HEAVY 与混沌发布门禁,短时性能运行仅作瓶颈诊断,不得冒充容量结论。

2026-09-01 文档一致性修补

  • ADR-0012 是 Fanout 提交语义的现行决策:无 Kafka transaction/transactional.id;任何仍以 ProducerFenced 作为当前第三道防线的 PLAN、ADR 或运维文字均属漂移。
  • 重客户端直写仍是未实现、未实测的候选,ACK 原子性、稳定 cmid 映射、群任务恢复、会话权威重建、fencing 与同步游标尚未闭环;不得再写成“修补后只剩两条分歧”。
  • UserConversationState 兜底和 GroupDispatch.fencing_epoch 均保留为目标正确性要求(2026-09-26:前者会话集合子集已由 ADR-0021 实现,后者由 ADR-0020 定为一期不适用);禁止通过删文档要求来伪装一致。
  • 64-lane packed 存储格式不等于 lane 隔离已实现:当前是 record 级统一推进;目标独立推进 仍是发布门禁,文档必须显式区分数据格式与行为语义。
  • 邮箱时间窗到期不等待设备游标;落后游标必须由 expired_gap_boundary / effective_trim 显式返回 CURSOR_EXPIRED。旧“安全裁剪不越游标、用户无感”表述已作废;条目上限裁剪 与时间窗裁剪的区别是前者触发 P2,而不是只有前者会越游标。

架构复评与热路径四项优化(2026-09-25)

按代码逐段核对热路径后落地四项改动,均不改 wire 契约、序号语义或失败闭合姿态:

  • writer:reserve 与 record 合并为一个 Lua(reserve_and_record.lua,MessageStore::reserve_and_record 默认两步实现、Redis 覆盖为单次往返)。RESERVED 时 intent 已含完整 canonical MessageRecord,RECORDED 只是同一份记录再写历史;分开只多一次往返与一次 Lua 调用, 且落在提交侧封顶的那个实例。Fresh 直接落 D,Existing 原样返回,旧 R 提交仍由 PutRecord 续做;脚本所有校验在 INCR 之前,非法输入不消耗序号。fault-injection 下 harness 指定 after-reserve 时仍走两步,保留 RESERVED 中间态的 SIGKILL 向量。
  • mailbox:通用物化扁平化。此前按 lane→chunk 嵌套串行,群收件人散在最多 64 个 lane、每 chunk 常只 1 人,buffer_unordered(32) 无并发可用,串行往返 ≈ 2×chunk 数。 现为 begin → 全部 (lane,chunk,user) 并发 append → 全部非 final chunk 并发标完成 → finish;任一 append 失败即零 completion、零 finish、不推 W。隔离 Redis 结构对照 (16 收件人/16 lane、debug 构建、各 100 条 × 2 轮):嵌套串行 30.0/30.3 ms/dispatch, 扁平化 14.5/12.9 ms/dispatch;下限是 Redis 单线程 Lua 时间,省的是往返不是 CPU。
  • fanout:(tenant, group, membership_version) 快照进程内 FIFO 缓存(ADR-0016 决策 1, 4096 条 / 200 万成员上限)。版本不可变故命中恒正确;member_count/target_shards 仍逐次核对。群 record 从每条 3 次 Redis 往返降为 0。
  • qsession:脏会话增量刷新。此前每条投影 invalidate 整份快照,重度用户每条消息 换来一次 5000 会话全量重建 + 全部会话定义式未读。现只把该会话记脏(MARK_READ 同), 新一次列表首页对脏集合 HMGET read + 一次 conversation_summaries 重算并换 revision; 分页续读停留在冻结快照;刷新失败把脏集合放回,绝不返回部分刷新。未读仍只在读路径 按定义式求值,不累加。指标 qim_session_snapshot_rebuilt_total/refreshed_total。
  • A/B(同机隔离 4+4 Redis、10,000 msg/s × 60 s、1200 连接、各一次,非容量结论), 对照 2026-08-26 基线二进制:SEND_ACK P50 11.7→9.8 ms、P99 48.6→27.3 ms;端到端 P99 141.8→118.3 ms、max 474→293 ms;投递 100%、failure 0;core Redis evalsha 3.13→2.14 次/消息、总命令 39.4→32.6 次/消息,CPU 191→185 s(Lua 内部工作量不变)。 单聊压测走 mailbox 融合路径,mailbox 与 fanout 缓存在该负载下不显形。
  • 复评结论与未做项:Outbox 单分区 + 单 fanout 顺序规划是结构性单点(ADR-0016 决策 2 提议按提交桶分区,实施前须目标硬件 180 秒证明 fanout 是瓶颈);shard % 实例数、 单 QIM_GATEWAY_PUSH_ADDR 是扩展上限(GroupMembership 固定实例 0 已由 ADR-0021 消除);core Redis 每条 消息 33 次命令中 Lua 占其 CPU 约一半,下一杠杆仍是 Lua 内部(ADR-0015)。
  • 本轮修正三处记忆漂移:fanout 批默认 512(非 128)、mailbox 全局 permit 默认 64(非 16)、 qsession 列表重建仍全量 ZREVRANGE 0 -1(非“首页只取 N”)。
  • 顺带发现并修复一个既有的静默丢消息缺陷(基线二进制同样复现,非本轮引入):Redis range_scan 对零游标(raw 0,“尚未应用任何条目”)也用 ZRANGEBYSCORE (0 排他下界, 把 log_offset = 0——每个 dispatch 分区的第一条 record——排除在外。旧 TCP 直写模型 序号从 1 起,排他下界无害;日志驱动模型后 offset 0 是合法序号,于是每个分区首条消息 对离线拉取永久不可见;在线用户靠 PUSH 拿到,压测投递 100% 不显形,只有隔离 e2e 第 3/4 项(离线暂存后登录拉取)与 writer SIGKILL 门禁的邮箱核验能抓到。修复:零游标下界 -inf, 只有真正应用过 (epoch, 0) 的游标才排除它;回归用例 零游标必须包含_offset_0_的条目_应用过_offset_0_的游标才排除。教训:压测的投递率证明的是 PUSH 路径,离线拉取路径只有 e2e 与 SIGKILL 门禁在证明;两者必须每轮都跑。
  • 180 s 门禁首轮(2026-09-25,同机 14 核/28 线程、4+4 Redis、Redpanda smp=4/4G、10,000 msg/s):到达率 100% 但 SEND_ACK P99 404 ms、端到端 P99 958 ms;60 s 短压看不到。归因过程:① 每 5 s 采样 Redis 键数/fork/bgsave 与 writer 直方图——core 键数 175 s 时 94 万未到 2^20,rehash 排除;② compose Redis --save 60 1 + 默认 AOF rewrite 门槛令 8 实例 180 s 内 fork 78 次(各 27~38 ms 停顿、子进程 6~12 s),关掉(save "" + auto-aof-rewrite-min-size 8gb)后 P99 仍 711 ms,fork 不是主因;③ writer 直方图 P99 ≤ 50 ms 而客户端 P99 700 ms,差值只能在 gateway→writer 之间——单连接在途上限 256 触顶停读。改 1024 后同条件 180 s:SEND_ACK P50 10.7 / P95 18.8 / P99 24.9 / max 57 ms,端到端 P99 111.8 / max 173 ms,到达率 100%,gateway 往返 1.8M 次中仅 18 次 >50 ms。钉核(Redis/Redpanda/服务/loadgen 各占物理核)在本机反而把 P99 推到 2.8~8 s,不采用;Redpanda smp=4 只用到 1.3 核,不是瓶颈。
  • 180 s + T-HEAVY 已在本机通过一次,但存在未定位的间歇性启动瞬态(2026-09-25,run.sh perf-180 redis --target-rate 10000,4+4 Redis、Redpanda smp=4/4G、save ""、AOF rewrite 门槛 8gb、e2e 预热后):通过轮 SEND_ACK P50 10.1 / P95 16.5 / P99 22.0 / max 64 ms,端到端 P99 111.9 / max 164 ms,到达率 100%,SDK 泳道不变量通过;T-HEAVY 5000 会话首屏 P99 1.0 ms。同配置 6 轮中 2 轮通过、4 轮在压测开始后第 10~15 s 出现一次 1~3 s 的整体停摆(该 5 s 段内 1.3 万~3.3 万条 ACK >50 ms,之后每段为 0),P99 因此落到 122~1833 ms。已排除:Redis fork/bgsave(关闭后仍现)、dict rehash(键数未到 2^20)、Kafka publish(分段 0 慢)、AOF 延迟 fsync(0)、THP(madvise);分段直方图显示 resume/reserve 只占慢请求的 1/4,其余时间不落在任何 await 段内。已加 qim_runtime_lag_us(每服务 10 ms 探针)与 writer 全部分段直方图,探针轮恰好未复现。在定位并消除该瞬态前,不得把本机 180 s 通过当作稳定结论;目标硬件门禁仍待跑。
  • 间歇性瞬态已定位:宿主盘 /data(SOYO MA NVME 1TB)整盘停顿,不是代码(2026-09-25 晚,重启后用 bpftrace 追踪 D 状态内核栈 + iostat 复现 2/2):负载开始的第 1~6 s,ext4 journal 提交线程 jbd2/nvme0n1p1 卡 1.5 s,8 个 Redis 的 bio_aof fsync 线程卡 1.5~1.9 s(folio_wait_writeback / jbd2_log_wait_commit),随后 4 个 core Redis 主线程在 AOF write() 里同时卡 2.08~2.14 s(ext4_da_write_begin → ext4_block_write_begin → __wait_on_buffer),同盘上无关的 immich postgres 也卡 1.2~2.5 s;同一秒 w_await 284 ms、%util 52% 而只写 88 kB/s——请求已下发但盘不完成。所有 Redis 与 Redpanda 共用这一个文件系统与 journal,按实例分片隔离不了。数据改放三星盘(nvme1n1,SAMSUNG MZVL21T0HCLR,挂在 /)后同配置 180 s:到达率 9,999.9/s(门禁 ≥ 9,900)、SEND_ACK P50 9.8 / P95 15.6 / P99 20.4 / max 50.7 ms、端到端 P99 110.7 / max 170 ms、SDK 泳道不变量通过、T-HEAVY 首屏 P99 2.0 ms,Redis aof-write 最大 8 ms;同时段 SOYO 盘在仅 25 kB/s 写入下 jbd2 提交仍反复卡 1~3 s,确认是该盘自身问题。集成脚本新增可选 QIM_INTEGRATION_DATA_ROOT(非根绝对目录):设置后本项目全部命名卷绑定到 $QIM_INTEGRATION_DATA_ROOT/$QIM_INTEGRATION_PROJECT/<卷名>,down.sh 用本项目 Redis 镜像的无网络一次性容器删除该目录;不迁移 Docker 全局 data-root,未设置时行为不变。本机性能门禁应使用 QIM_INTEGRATION_DATA_ROOT=/app/qim-it-data(2026-09-26 起 Docker data-root 已整体迁到三星盘,见下条,此变量不再必需)。只搬数据卷还不够:三星盘第三轮 SEND_ACK 正常(P99 20.3 ms)但端到端 P99 2,607 ms——Redpanda 的 reactor-2 线程在缺页读回自身代码/库页时卡 2.4 s(filemap_fault → folio_wait_bit_common),因为镜像层与容器根文件系统仍在 SOYO 上;fanout 的 Outbox 滞后因此 1~2.5 s,约 2.4 万条(= 10k/s × 2.4 s),同时 dockerd 在 /data 上 fsync 卡 3.4 s。2026-09-26 00:19 把整个 Docker data-root 从 /data/container/docker 迁到 /var/lib/docker-data(三星盘,rsync -aHAXS 57 GB、dry-run 零差异,/etc/docker/daemon.json.bak-20260925 与旧目录保留作回滚;Docker 停机 23:29–00:19)。immich_postgres 与 schools-pg 用 /data/... 绑定挂载,仍在 SOYO 上。同一改动修了 wrapper 端口判空:本机 net.ipv4.ip_local_port_range 为 1024–65535,上一轮的出站连接与 TIME_WAIT 套接字会占住 7000~30000 段本地端口,原先只用 connect 探测监听者判不出,连续跑门禁时 Docker 绑定频繁 address already in use;现以 ss -Htan "( sport = :端口 )" 任何状态的本地占用都视为不可用(TOCTOU 仍在,但不再被这类占用触发)。生产含义:每个 Redis 实例(及 Redpanda)的 AOF/日志必须落在各自独立、写延迟有保证的盘或文件系统上,共享一个 ext4 journal 时一次慢提交会同时冻结所有实例。
  • 本机 180 s + T-HEAVY 门禁稳定通过(2026-09-26,Docker data-root 在三星盘、qsession 缓存与 e2e 修复后,连续 3/3):到达率 9,999.93 / 9,999.99 / 9,999.98 msg/s(门禁 ≥ 9,900);SEND_ACK P99 19.5 / 17.0 / 16.4 ms(max ≤ 47 ms);端到端 P99 107.9 / 103.4 / 104.2 ms(max ≤ 178 ms);T-HEAVY 5000 会话首屏 P99 0.8 / 0.8 / 0.7 ms;e2e 15/15、SDK 泳道不变量全通过;Redis aof-write 最大 16 ms;qsession 零告警。配置:4+4 Redis(save ""、AOF rewrite 门槛 8gb)、Redpanda smp=4/4G、1200 连接、不钉核。此为本开发机(E5-2680 v4 14C/28T、62 GB)结论,仍不是目标硬件容量结论;混沌门禁与 orphan 审计仍未完成。Redis 在此负载下每实例约 77~82% 忙,mailbox 侧 evalsha 占其 CPU 78%,线性外推约 12k msg/s 打满——下一档容量靠加实例或削减 Lua 工作量。
  • 15k msg/s × 180 s 瓶颈依次显形(2026-09-26,本机 8+8 Redis、1200 连接): ① 默认配置:SEND_ACK P99 107 ms 达标,但到达率 98.9%(< 99%)、端到端 P50 8.2 s / P99 14.6 s。根因是 fanout 串行批周期:Outbox 单分区、单进程,每批“读 → 规划 → 写 dispatch 并等 Redpanda 持久确认 → 同步提交位点”,其中等确认占 32.8/34.5 ms;平均批 511 顶满上限 512,吞吐上限 ≈ 512 / 34.5 ms ≈ 14.8k/s,而 fanout 只用 0.3 核——受时延约束而非 CPU。② QIM_FANOUT_BATCH_SIZE=1024 后 Outbox 滞后全部 < 250 ms(平均批 606),积压下移到 mailbox:每 shard FIFO 串行物化,store 调用 3.2 ms 而 Redis 执行 0.2 ms,热 shard 超载(最热 5 个 shard 各积 1.6~2.2 万秒队列等待),端到端 P99 15.4 s;整机负载均值 43.65 / 28 线程、空闲 7.5%,store 调用的 3 ms 主要是进程排队等 CPU 与经 127.0.0.1 发布端口的用户态 docker-proxy 转发。③ 再让服务直连 Redis 容器 IP(本机测试环境专有开销,生产不存在):连续 3/3 通过:到达率 14,929.6 / 14,936.0 / 14,937.9 msg/s(门禁 ≥ 14,850);SEND_ACK P99 124.3 / 52.6 / 71.2 ms;端到端 P99 250.4 / 163.2 / 186.7 ms(max 848 / 293 / 698 ms);首屏 P99 0.7 / 0.9 / 0.8 ms;e2e 15/15、SDK 泳道全通过;整机 87~88% 忙,余量薄。fanout 批周期的真正根因在 Kafka 生产者客户端,不在 Redpanda(同日拆分计时后确认):每批 30.5 ms = 等 dispatch 确认 27.4 ms + 同步提交位点 1.8 ms + 读取规划约 1.4 ms;而 Redpanda 自报 produce 请求平均只处理 0.25 ms(99.2% < 2 ms)、只用约 1 核,Redpanda 从 4 核/4G 加到 8 核/8G 后确认等待 29.3 ms 无改善。librdkafka(2.12.1)对每个分区单独发 produce 请求,一批约 520 条 dispatch 分布在 64 个 dispatch 分区即约 64 个请求(broker 侧 produce 请求约 2,048/s ≈ 30 批/s × 64 + writer 的 Outbox 请求,互相印证)。曾推断“幂等使每连接最多 5 在途 → 约 13 轮往返”是主因,已被实测否定:按 ADR-0017 关闭 dispatch 生产者幂等后,每批等确认只从 27.4 降到 25.3 ms(−8%)。更符合数据但尚未证实的模型:64 个请求全走同一条连接、被依次处理(约 64 × 0.25 ms),叠加 librdkafka 默认 linger.ms=5 与本机 CPU 饱和下的各次唤醒排队。决定性验证是把生产者按分区拆成多条连接,或把 fanout 生产者 linger.ms 调到 0。加 Redpanda 资源已证无效,加 dispatch 分区只会让每批请求更多。qim-commit-log 已新增 qim_fanout_dispatch_ack_wait_us、qim_fanout_offset_commit_us、qim_fanout_dispatches_per_batch。结论:本机 15k 已在 CPU 边缘;架构上的下一瓶颈依次是 fanout 批周期(ADR-0016 决策 2 的分区/流水化是根治,调大批上限只是把上限约推到 2 倍)与 mailbox 每 shard 串行物化。
  • 15k 时 CPU 按服务归因(2026-09-26,pidstat + perf 全机采样,二者一致):整机约 25 个逻辑线程忙;Redis×16 占 10.7 核(42%,自身 7.4 / musl 1.4 / 内核 2.0)、writer 4.7、mailbox 4.5、gateway 1.5(七成在内核收发)、压测客户端 1.0、Redpanda 0.9、session 0.5、fanout 0.35、docker-proxy 0.27。最大的纯浪费是 Lua 脚本 SHA-1:24 处调用点每次现场 redis::Script::new(源码),redis-rs 构造时对整段源码现算 SHA-1,mailbox 每条 dispatch 哈希 21.6 KB 融合脚本(约 324 MB/s),占 mailbox 1.5 核(三分之一)、writer 0.5 核,且在 shard 串行物化的关键路径上;仓库已有的 ScriptCache 从未被这些调用点使用。修复:LuaScript::script() 按变体 OnceLock 缓存(穷尽 match,新增变体忘登记即编译失败),6 处内联脚本常量同样缓存;另把热路径拼键的逐字节 format!("{b:02x}") 换成查表编码 qim_store::hexfmt(约 0.4 核)。同配置 15k×90 s 复测:mailbox 4.53 → 3.14 核(−31%)、writer 4.67 → 4.07、整机空闲 +0.9 核;SEND_ACK P50/P99 23.9/60.0 → 13.4/31.6 ms,端到端 P50/P99 109.8/183.9 → 84.5/125.7 ms;fanout 每批等确认 24.5 → 19.0 ms(说明其中有一部分是 CPU 争用下的唤醒排队)。Redis 实例数的推算依据(同一轮):每条消息耗 Redis CPU 约 0.71 ms(core 0.34 + mailbox 0.37),15k/s 合计约 10.6 核;Redis 单线程、单实例上限 1 核且利用率超过约 0.75 后排队剧增,故至少需约 14 个实例,而 64 shard / 256 提交桶要求实例数为 2 的幂,8+8 是 15k 下的最小合法配置(10k 时 4+4,每实例约 0.8)。单实例负载:core 0.54~0.88、mailbox 0.58~0.73;最热的 0.88 是承载非分片数据(群成员、会话投影、已读、writer 租约等)的默认 core 实例,会最先饱和。要减少实例数只能降低每条消息的 Redis 工作量(Lua 与命令约 69%、musl 13%、内核收发 18%),不是换配置。剩余可优化项:writer/mailbox 的 malloc/free/memcpy 合计约 2 核;各服务内核态约 6.5 核,主要是回环 TCP 与 Docker 桥接的 netfilter(nft_do_chain 等约占全机 3.4%,测试环境专有)。
  • 冷启动瞬态的一个已知成分:writer 的幂等 producer 在首次 produce 前才完成 GETPID,全新 Redpanda 上会遇到 Not coordinator: retrying;服务刚就绪即打 10k msg/s 时头 10 s 内约 1.4 万条请求 ACK > 50 ms。acceptance.sh 的门禁 4(e2e)天然在门禁 5 前预热了 producer,单独跑稳态时必须保持同序;根治要让 writer readiness 覆盖 producer 就绪(不得用假记录预热 Outbox)。预热后仍出现的第 10~15 s 停摆是另一件事。
  • 一期 Redis 持久化参数需要显式决策:compose 的 --save 60 1 与默认 auto-aof-rewrite-min-size 64mb 在 10k msg/s、1.3 GB 数据集下 180 s 内每实例 fork 11~12 次(RDB 3 + AOF rewrite 8~9,各 27~38 ms 停顿、子进程 6~12 s);AOF 为主时应 save "" 并把 rewrite 门槛抬到数据集量级,待写入 PLAN 附录 B。
  • 同轮 T-HEAVY 两次抓到 qsession 脏刷新丢标记(4987/5000、4888/5000):① 全量重建“读 Redis → 安装”窗口内到达的标记因“没有活快照”被丢;② mailbox 低延迟投影通道仍整份 invalidate,把在飞刷新之后到达的标记一并删掉,刷新结束又把旧基底装回;③ 两次并发全量重建,后装入者覆盖先装入者及其脏集合。第一版修复只堵了①。最终改为“标记序号 + 覆盖序号”:mark_dirty 记全局递增序号;begin_rebuild 在读 Redis 前取 start、begin_refresh 取脏集合时取 upto,只把自己真正读到的范围记为 covered;标记只在被已安装快照的 covered 涵盖后删除;取脏集合不删标记(失败无需回填);刷新只替换仍是原基底的快照,基底已换或已失效时绝不回装;覆盖不少于本次的快照已装入时重建让位。低延迟通道改为只打标记。7 项单测覆盖上述竞态。同日 e2e 第 6 项(群进会话列表)在约 25 次预热中失败 1 次:B 收到群 PUSH 后只读一次列表,而会话投影异步、尽力而为,可能晚于 PUSH(B 此前从未拉过列表,走的是首次全量重建,与缓存改动无关);与第 4 项同理改为 10 秒有界重拉,仍要求群最终出现。随后 e2e 又抓到增量刷新自身的 bug:读已读水位用了 redis-rs hget(key, &fields),单字段时实际发 HGET,字段缺失返回 nil 被转成空数组、长度不符,单会话刷新整次失败且客户端收不到应答(旧实现只在 MARK_READ 后刷新、水位必存在,故未暴露);改为显式 HMGET。另注:qsession 列表处理失败时只记 WARN、不回 Error 帧,客户端只能等超时——属既有行为,待补。
  • scripts/integration/writer-kill9.sh 此前写死 7000/7001/7003 等端口,只在 wrapper 恰好选到 offset 0 时通过;现改为读取 wrapper 导出的 QIM_*_HOST_PORT,邮箱核验窗口 5 s → 30 s (门禁判恢复正确性而非时延),失败时先抓五服务 /metrics 再停进程。

  • 单 Redis + 4 核小主机实测(2026-09-26,本机 cpuset 模拟,60 s 短压,非容量结论):五服务与全部容器钉在 4 个物理核(QIM_AB_BOX_CPUS=0-3),core 与 mailbox 共用 1 个 Redis(QIM_AB_SPLIT=0 QIM_AB_SHARDS=1),Redpanda smp=1/2G,服务直连 Redis。1000/1500/2000 msg/s 通过(2000 时 ACK P99 72 ms、E2E P99 156 ms),2500 勉强通过(ACK P99 142.6 ms),3000 崩溃(提交 2895/s、时延约 7 s);拐点时 4 核只用 2.3~2.4 核,封顶的是 Redis 单线程(0.87~0.91 核)。负载越高每条消息 Redis 成本越低(1000 时 607 µs,2500 时 356 µs,批量摊薄系统调用)。模拟云上常见的 4 vCPU(2 物理核 + 超线程,0,1,14,15):evalsha 单次 70 → 93~105 µs,1500 通过、2000 崩溃,比独占物理核少约 25~35%。

  • Redis 内存按键拆分(同轮 1500 msg/s,每条消息约 4.9 KB):msgcommit(提交辅助 hash,COMMITTED 后仍保留完整 canonical MessageRecord)1865 B、dp(DispatchProgress)1119 B、邮箱条目+seq 桶约 410 B(30 天)、msgrecord+msghistory 约 360 B(Default 保留无 TTL)、cmidown 208 B(2 h TTL)、msgcommitted 索引约 166 B、dpcomplete 约 54 B。发现一处缺陷(已修复,见“历史落库与后端拆分”):writer 提交辅助状态 GC 每 60 s 全局只清 256 条(约 4.3 条/s),任何持续速率高于此都会让 msgcommit 无界累积;DispatchProgress GC 默认禁用,同样无界。即使 GC 跟上,2 h 窗口也要约 16 MB/(msg/s)。结论:8 GB 主机的持久上限由内存而非 CPU 决定,现行代码约 85 万条消息即写满(2000 msg/s 下约 7 分钟,noeviction 后拒写)。

历史落库与分层存储(2026-09-26,ADR-0018)

  • 决策(项目负责人):历史消息必须落数据库,不能永久留在 Redis/AOF;但提交热路径不同步写库——近期历史本来就由热层读取,只有超出热窗口的旧消息才需要归档层。形态为 tiered:提交状态、ClientDedup、序号分配与 history_hot_window(7 天)内的近期历史在 Redis(融合 Lua 不变),新服务 qim-archiver 以独立消费组读 Outbox(完整 canonical MessageRecord、acks=all 多副本,是比 Redis AOF 更可靠的重放源),批量普通 INSERT 写 Scylla(先记录后索引,无 LWT,同值幂等;批内同序号异内容拒绝),全部确认后才同步提交位点,再按“批前取 T0 与 Outbox 高水位 H0,位点 ≥ H0 后推进到 T0”发布归档水位 msgarchive:watermark(默认 core 实例,只进不退)。writer 每 5 s 批量裁剪:只删 committed_at ≤ min(now − 热窗口, 水位 − Outbox 恢复窗口 − 60 s) 的记录,并抬高每会话热层下界 msghistfloor;归档器停摆时只涨不删。读(TieredMessageStore):seq ≤ 下界 读 Scylla、以上读 Redis,下界为 0 时与纯 Redis 完全相同;定义式未读只在“已读点低于下界且窗口 ≤ 200”时才碰 Scylla。
  • 后端取值:QIM_MESSAGE_STORE_BACKEND(writer/session)= tiered(默认,一期生产)/ redis(无归档,仅开发)/ scylla(提交路径同步写 Scylla,仅对照);QIM_MAILBOX_STORE_BACKEND(mailbox)= redis(默认)/ scylla;旧全局 QIM_STORE_BACKEND 出现即拒绝启动。集成脚本 gate hybrid = tiered + Redis 邮箱,wrapper 导出各 Scylla 节点的容器地址(驱动沿用联系点端口拼接 system.peers,127.0.0.1:<宿主端口> 会让发现到的节点全部连接被拒)。Scylla 节点数(2026-09-27,负责人决定测试环境用单节点):QIM_INTEGRATION_SCYLLA_NODES 取 1 或 3;gate hybrid(含 acceptance.sh 完整门禁与性能 harness)默认 1 节点 RF=1,并导出 QIM_SCYLLA_ALLOW_SINGLE_NODE_DEV=1——ScyllaConfig 只在该开关下放行 RF=1(不放行 RF=2),生产不得设置;gate scylla 证明 RF3/LOCAL_QUORUM/LWT 跨节点语义,固定 3 节点、拒绝覆盖。acceptance.sh 完整门禁已改为 perf-180 hybrid,门禁 2 追加 tiered_message_store --ignored、门禁 3 启动 qim-archiver(metrics 9107)。
  • 实测(本机,Scylla 三节点各 1 核 1G,1000 msg/s × 60 s):e2e 15/15;SEND_ACK P50/P99 4.9/7.7 ms、端到端 P99 70 ms;Scylla 归档 60,048 行 = 压测 60,000 + e2e 48,归档失败 0、积压 0、水位滞后约 1 s;Scylla 每节点 0.14 核(138 µs/条),archiver 0.11 核。对照同步写 Scylla(scylla 后端)在 200 msg/s 即 ACK P50/P99 184/252 ms:每条约 6.5 次串行 LWT,LWT 三个 Paxos 阶段都等副本刷盘,本机三星 PM9A1 消费级盘 4 KiB 同步写 6.5 ms(QIM_SCYLLA_UNSAFE_BYPASS_FSYNC=1 模拟服务器盘后 LWT 31.5→3.1 ms,仅归因用),且每条消息每节点约 33 次写、23 次读、3.7~4.7 ms CPU。
  • 10k msg/s × 180 s(本机,hybrid,与此前纯 Redis 通过轮同条件:4+4 Redis 直连、save ""、AOF rewrite 门槛 8gb、Redpanda 4/4G、Scylla 三节点各 1 核):到达率 9,999.96/s 通过;SEND_ACK P99 16.8 ms、端到端 P99 107.8 / max 226 ms(纯 Redis 同条件 103~108 ms,分层无可测的时延代价);T-HEAVY 首屏 P99 0.8 ms;e2e 15/15;Scylla 归档 1,800,040 行与 archiver 计数一致,失败 0、积压 0、水位滞后 479 ms;Scylla 每节点 0.78 核、archiver 0.69 核。同日 acceptance.sh(已改为 perf-180 hybrid)门禁 1~4 全过(642 测试、e2e 15/15)、门禁 5 到达率与 ACK 通过但端到端 P99 366 ms 超标——该脚本走 docker-proxy 访问 Redis 且沿用 compose 默认持久化(core 180 s 内 fork 12 次),与上面同条件对照的唯一差异即此;acceptance 此前从未以分片 4+4 跑过(邮箱地址校验只认单实例,已修)。
  • 提交辅助状态内存泄漏已修:此前 COMMITTED hash 无 TTL、靠 writer 每分钟清 256 条(约 4.3 条/s),持续写入超过即无界累积(4c8g 单 Redis 实测每条约 4.9 KB 驻留)。现进入 COMMITTED 时 PEXPIREAT committed_at + 2h,不再登记 msgcommitted;writer 各类回收每轮取尽(批 1024、每轮 2 s 预算)。
  • 提交事实持久化界限(2026-09-26 修复):writer 连接的 Redis(默认 core 与全部 MessageStore 分片)启动探测强制 appendfsync always 且 no-appendfsync-on-rewrite no(RedisDurability::CommitFacts,assert_redis_runtime_durable),不满足拒绝启动;邮箱/fanout/session/archiver 仍接受 everysec。否则宿主崩溃丢约 1 秒会让 msgcseq/HLC 回退、复用已归档序号并静默覆盖归档。未采用“检测回退后按 Outbox 重新播种”:崩溃前预留、崩溃后才发布的记录扫描时可能不可见,竞态面过大。compose 的 redis/redis-core-* 已改 always,集成预检按实例区分。always 的代价取决于盘:Redis 每轮事件循环回复前统一 fsync,单连接每轮最多读 16 KB(提交脚本 1~2 KB/条),本机消费级盘单次刷盘 13~17 ms——单连接时 10k msg/s 提交塌到 1,873/s、ACK P50 17.7 s;writer 改为每实例连接池(QIM_MESSAGE_STORE_CONNECTIONS,默认 8)后 5k msg/s 全量提交但 ACK P50/P99 129/184 ms 仍超 SLO。生产必须带掉电保护的服务器 SSD(与 ScyllaDB 同一要求)。用 tmpfs 承载全部数据卷模拟“刷盘近乎免费”的服务器盘(仅归因,非持久性结论),同条件 hybrid 10k×180 s:到达率 9,999.93/s、SEND_ACK P50/P99 11.5/25.1 ms、端到端 P99 114.6 ms、T-HEAVY 0.7 ms、e2e 15/15、孤儿审计 1,800,035 提交/1,887,082 dispatch 四类计数全 0、归档 1,800,035 行;对照 everysec 真实盘 ACK P99 16.8 ms,always 代价约 +8 ms。默认 core 实例 1.01 核已达单线程上限,10k 生产应按 8 core 实例配。
  • 历史保留期(2026-09-26,ADR-0019,负责人决定“保留 1 个月、可配置”):default 保留 30 天(DEFAULT_HISTORY_RETENTION_DAYS,QIM_DEFAULT_HISTORY_RETENTION_DAYS 1..=3650),qim_store::RetentionPolicy 统一算到期(created_at + 保留期);writer 在 COMMITTED 时登记 Redis 热层到期 GC,qim-archiver 按剩余期限写 Scylla 逐行 TTL,两进程读同一变量;ephemeral_24h 固定 24 h,compliance_hold 永不到期,tenant_custom 失败闭合为不到期;库内缺省不到期(保留期必须由装配方注入)。一期不做对象存储冷归档与归档回读。真实门禁:Redis 到期删除/合规保留不删、Scylla TTL≈30 天/合规无 TTL。
  • 热窗口裁剪与到期清理的索引缺陷(2026-09-27 修复):裁剪在第 7 天删记录却留下 msghistorygc 条目,第 30 天到期清理遇到“记录不存在”即判损坏、整批中止并永久卡住(每条约 157 B 只增不减)。现裁剪同时移除该条目;到期清理对“序号不高于热层下界”的遗留条目只清索引(返回 2),下界之上缺失仍失败闭合。见 ADR-0019 修订。
  • orphan 独立审计已实现(2026-09-26):qim-commit-log::audit::CoverageAudit + qimctl audit dispatch——先等提交恢复索引排空(RedisMessageStore::pending_commit_count)与 fanout 追平 Outbox 高水位,再以 Outbox(提交事实:ACK 前必已 durable、带完整记录与冻结受众)推导应有 (message_id, target_shard)(单聊按收件人路由、群聊取冻结分片),与 dispatch 日志(展开回执)逐条比对;有提交无 dispatch、有 dispatch 无提交、坐标/发送者不一致、分区错位均须为 0。7 项单测覆盖正负例;hybrid 5k×60 s 实测 300,048 提交、315,095/315,095 dispatch、四类计数全 0。acceptance.sh 门禁 9 已改为执行该审计。生产常驻增量审计(持久覆盖游标)未实现。
  • 15k msg/s × 180 s(2026-09-27,本机、tmpfs 模拟服务器盘、8+8 Redis 直连、fanout 批 1024,含 ADR-0021,各一次):纯 Redis 形态通过——提交 14,969/s、SEND_ACK P50/P99 19.3/53.1 ms、端到端 P50/P99 93.6/146.4 ms、首屏 0.7 ms、e2e 16/16、孤儿审计 0,整机 86% 忙。分层(生产形态)不通过——提交 14,849/s(门禁 14,850)、SEND_ACK P99 620.8 ms、端到端 P99 2.68 s;正确性无损(孤儿审计 0、归档 2,672,959 行全部、failure 0,SDK 泳道“缺 30 条”是端到端 max 7.4 s 超过 5.1 s 收尾等待的迟到)。归因:整机 28 线程 90% 忙(Scylla×3 与 archiver 多约 2.9 核),writer 内部 99.5% < 50 ms、许可等待 99.6% < 1 ms,但 gateway 往返 ≤ 50 ms 从 99% 降到 17%、后端队列平均深度 15 → 40——时间耗在进程间排队等 CPU,不在某个组件。结论只说明单台 14 核开发机容纳不下 15k 的全部组件;生产 Scylla 应独立部署,目标硬件门禁仍待跑。
  • 改单节点 Scylla 后分层形态 15k 通过(2026-09-27,同上配置,Scylla 1 节点 RF=1):提交 14,927/s(门禁 ≥ 14,850)、SEND_ACK P50/P99 27.5/67.2 ms、端到端 P50/P99 111.0/170.8 ms(max 284 ms)、首屏 0.9 ms、e2e 16/16、SDK 泳道不变量通过、孤儿审计 0、归档 2,686,958 行、失败 0、积压 0;整机 88% 忙(此前三节点 90%),Scylla 单节点 0.87 核、archiver 0.45 核。对照同日纯 Redis 形态 ACK/端到端 P99 53.1/146.4 ms,分层在本机 15k 下多约 14/24 ms。
  • ScyllaDB MailboxStore 重做为 v2(2026-09-28,ADR-0022,负责人“全都做;ScyllaDB 能不能按页写”):原实现 20 msg/s 只投递约 2 msg/s(每条 dispatch 约 4,070 次 LWT,advance_watermark 逐 lane 且每次对 64 个 manifest 重做 ensure_source);去冗余(A)后约 130 LWT、4 核上限约 110 msg/s。单次 LWT 约 220 µs Scylla CPU,Paxos 状态写强制同步刷盘——“每次落盘”的来源就是 LWT。v2:①分片前沿取代逐 lane W,mailbox_shard_frontier(shard, epoch DESC, frontier_offset) 取最高 epoch 行,64 lane 同值,lane 独立推进(M-1)拒绝;②单调列以 USING TIMESTAMP 写、热路径零 LWT:前沿与保留期 bucket 证明(mailbox_retention_bucket_v2,按 epoch 分行)写时间戳 = log offset,dispatch 身份 mailbox_dispatch((shard, epoch, dispatch_id)) 写时间戳 = 2^48−1−offset(最早 observation 胜)。时间戳只能用 48 位 offset:Scylla 默认拒绝超过“当前 + 3 天”的时间戳,含 epoch 高位的 mailbox_seq 在 epoch ≥ 7 即越界(首版这样写,契约门禁当场报错);③身份行 TTL 31 天、gc_completed_dispatches 恒 0,chunk 完成只记在进程内在途表,重启后整条重放;qim-mailbox 以 Scylla 启动时校验 QIM_OUTBOX_RETENTION_MS + QIM_DISPATCH_RETENTION_MS < 31 天;④按页推进:MailboxStore::finish_page_limit()(默认 1,Scylla 64)+ finish_dispatch_page,worker 取到一条后把同 FIFO 已排队的 record 一并取出(不等凑页),并发写完条目后一次写前沿;QIM_MAILBOX_FINISH_PAGE_MAX 只能调低。实测(单节点 4 核、tmpfs、4+4 Redis、各 60 s 一次):200 msg/s 投递 100%、端到端 P99 111 ms、Scylla 0.2 核;2,000 msg/s P99 73.7 ms、0.7 核;10,000 msg/s 投递 100%、ACK/端到端 P99 26.6/131.4 ms、Scylla 2.6 核(含归档)、平均页 4.46、e2e 16/16;同条件页上限 1 端到端 P99 904.9 ms、Scylla 3.4 核;Redis 邮箱对照 23.0/110.5 ms。每条消息 Scylla 插入 4.10(归档占 2)、更新 1.30、读 1.46、LWT 0。生产承载邮箱 keyspace 的集群必须 commitlog_sync: batch(默认 periodic 10 s 不抗多节点同时掉电)。v1 的 dispatch_progress/dispatch_chunk/lane_dispatch/lane_watermark/dispatch_observation/completed_dispatch 表与分阶段 GC 已删除(无生产数据,不迁移)。另:本机 fs.aio-max-nr 65536 不足以同时运行 Redpanda 4 核与 Scylla 4 核,已改为 1048576 并持久化到 /etc/sysctl.d/60-qim-aio.conf。
  • Redis DispatchProgress 稀疏 manifest(2026-09-28):DP hash 的 manifest 字段原为 64 lane 全量 v1 编码(≥ 324 B),任一字段值超过 hash-max-listpack-value(64 B)就把整条 hash 从 listpack 转成 hashtable。现写 DPM\x02(只含非空 lane,lane 1 字节 + LEB128 count/chunk_id,单 lane 单 chunk 7 字节,拒绝非最短编码),读兼容 v1;begin_dispatch.lua 对已存 v1 从 ARGV 重建 v1 字节比较 identity,快路径 O(1) 校验 v2 形态。实测(hybrid、1+1 Redis、2k msg/s×60 s):每条 DP 2,173 → 472 B,邮箱 Redis 总内存 238 → 113 MB(同 12 万条,−52%)。升级后不可回滚到旧二进制(旧代码不认 v2)。
  • Redis 提交辅助状态压缩与到期索引裁撤(2026-09-28,负责人“做内存优化”):① 提交 hash 进入 COMMITTED 时在同一 Lua 内读出身份与序号、DEL 后重建为只含回放 ACK 的坐标(identity 摘要、conversation_seq、outbox、state、committed_at_ms、history_gc_member 与 16 字节的 message_id/last_activity_id/trace_id/conversation_id),删掉各含一份完整记录的 immutable/intent/record 与 recovery_member;必须重建而非 HDEL——hash 编码只会从 listpack 单向升级为 hashtable,删字段不会退回。② 请求身份此前含完整正文,现存 AuthenticatedSend::reserve_identity_digest(QIMD + 域分隔 BLAKE3,36 B);三个预留脚本读到升级前的完整身份(QIMC)返回 L:seq,由 Rust 逐字节比较。③ 接口:ReserveResult::Committed(CommittedMessage) 与 LoadedCommit::{InFlight, Committed},writer 状态机对已提交的直接回放 ACK;Memory/Scylla 不压缩存储、只做类型映射。④ 分层写入方(with_trim → with_trim_window)对保留期 ≥ 热窗口 + 1 天的消息不登记 msghistorygc(它们必先被热层裁剪),见 ADR-0019 修订 2。实测(100 B 正文)单条提交 hash 2,640 B(hashtable)→ listpack;hybrid 1+1 Redis、2k msg/s×60 s:msgcommit 1,917 → 552 B/条,core Redis 345.5 → 151.9 MB,连同 DP 稀疏化两台 Redis 合计 583.6 → 265.1 MB(每条 4.86 → 2.21 KB,−55%);10k msg/s×60 s(4+4、tmpfs)Redis 总内存 2,270.6 → 1,348.1 MiB,ACK/端到端 P99 24.4/109.0 ms(对照 23.0/110.5)无回归。剩余大头:msgrecord 235 B 与 msghottrim 约 106 B(7 天热窗口)、cmidown 208 B(2 h)、DP 472 B(7 天,DISPATCH_PROGRESS_RETENTION_MILLIS 是附录 B 契约常量,缩短需改 PLAN)。fs.aio-max-nr = 1048576 已持久化到 /etc/sysctl.d/60-qim-aio.conf。
  • 离线消息 7 天、历史 30 天(2026-09-28,ADR-0023,负责人决定):MAILBOX_RETENTION_DAYS 30 → 7(契约常量,PLAN 附录 B.3;qim-store、Scylla 账本、融合 Lua 一律引用它,融合脚本改为由 Rust 传入天数);日桶到期 (D + 7 + 1) × 86400 s,至少 7 整天。离线消息定义:新客户端登录后至少能拉回 7 天的消息,更早的走 PULL_HISTORY。实现为零游标(last_applied_mailbox_seq 原值 0)不判过期,读回保留窗口内尚存的全部条目(显式 trim 之后);非零游标越过过期边界仍 CURSOR_EXPIRED → REBUILD(PLAN §9.6.1 已改写)。升级时旧 30 天日桶会与新到期时刻冲突失败闭合,需清空邮箱数据。
  • DispatchProgress 回收上限缺陷已修(2026-09-28):原每分钟每 shard 至多 256 个(64 shard 约 273 个/s),群扇出下平均 2~4 千 dispatch/s 只增不减;现 qim-mailbox::dispatch_gc 每 10 s 一轮、每 shard 连续取批(1024)直到不满或 5 s 预算用完。保留期 7 天(DISPATCH_PROGRESS_RETENTION_MILLIS)未改。
  • 群扇出压测(2026-09-28,loadgen --group-size,harness QIM_AB_GROUP_SIZE):帧层连接按群大小分组、首成员建群、全员发群消息;发送时刻打在内联正文里,每个收件人记一个端到端样本,门禁按“应投人次”判覆盖。一期峰值口径 3,800 msg/s × 22.6 人 ≈ 8.2 万 entry/s(本机、tmpfs、各 60 s 一次):提交侧达标(ACK P99 21~24 ms,fanout 每批 1.3k dispatch、等确认 13~15 ms、Outbox 滞后约 18~20 ms,跟得上);投递不达标——Redis 邮箱 8+8:2.04 万 entry/s(应投的 25%)、端到端 P50 25.8 s;Scylla v2(8 核):3.86 万 entry/s(47%)、P50 16.4 s;页内并发 8 → 32 反而降到 3.32 万。共同根因是 dispatch 放大:64 shard 下 22 人群每条消息约 18.7 个 dispatch、每个仅 1.15 条条目,按 dispatch 计的固定成本主导——Redis 每 dispatch 约 2.3 ms 串行往返(每 shard 上限约 430 个/s,合计约 2.7 万/s)且约 310 µs Redis CPU(峰值需约 22 核);Scylla 每页 63.6 ms(平均页 47.8),mailbox 进程 6.3 核 + Scylla 6.1 核。500 msg/s(1 万 entry/s)时群路径端到端 P99 已 228 ms(单聊 1 万 msg/s 约 110 ms)。
  • 仍未完成(发布阻断):② 升级前已在 Redis、不在 Outbox 保留期内的历史需一次性回填 Scylla 后才能裁剪;③ scylla 后端(非生产)的 message_commit/commit_recovery 表级 TTL 对所有状态生效,卡住超 2 h 的提交会消失,另有 UPDATE 半行 TTL 问题;④(已解决)本机 fs.aio-max-nr 已持久化为 1048576。
  • Scylla MessageStore 的 invariant() 此前丢弃错误原因;现先记 ERROR 日志。ScyllaConfig::from_env() 为共享解析入口。

群成员可见区间与会话集合权威(2026-09-26,ADR-0021)

  • 群键与会话提交事实同桶:grp/grpmeta/grpsnap/grpmember:{bNNN}:…,{bNNN} 由 qim_common::bucket::commit_bucket(tenant, group_cid) 决定;writer/fanout/session/qimctl 一律按 qim_common::bucket::commit_instance_addrs(QIM_REDIS_ADDR + QIM_MESSAGE_STORE_ADDRS,同序)装配 GroupMembership::new_sharded,Redis/分层后端直接复用 RedisMessageStore::instance_connections()。上线前必须清空旧格式群键(无生产数据,不迁移)。
  • 分配之后冻结的原子化:reserve_and_record.lua/reserve_commit.lua 对 GroupSnapshot 受众多带群 meta 键与冻结版本,在 INCR 前比较,不符返回 V→MessageStoreError::MembershipChanged(不消耗序号);writer 发送路径重新冻结、有界重试 3 次,仍冲突回 RATE_LIMITED。既有提交(ACK 丢失重试)在版本校验之前返回,不受成员变化影响。
  • 成员变更 = 占序号的 CONTROL 消息(custom_type = qim.membership.v1,正文 op|actor|n|users):membership_change.lua 一次完成版本校验 → INCR 得 J → 改成员集合/区间/版本 V+1 快照 → 记录提交(RECORDED),之后与普通消息同一状态机。规划在 writer(membership.rs)按当前版本过滤出真实对象,空则 NoOp;cmid 由 (tenant, group, op, actor, V, users) 确定性派生。受众为显式 Direct:变更后成员 ≤ 500 时广播(含变更对象),否则只给变更对象。管理面操作的记录发送者是群 owner、正文 actor=0。对照用 scylla MessageStore 后端返回 Unsupported。
  • 读路径:HistoryAuthorizer 返回区间;qsession::visibility 对群会话 base = max(read, joined_at),已离开者经 MessageStore::visible_conversation_summary(走 history_page,共用授权与裁剪)求有上界的头与未读;缺区间的旧群会话隐藏并计 qim_session_group_interval_missing_total(恒零)。PULL_MEMBERS 仅当前成员、回带真实 joined_at_conversation_seq(Rpc tag 76 member_joined_at)。
  • UserConversationState 一期只存会话集合(kind, first_seen_at),不复制成员区间(权威在 grpmember,且大群成员事件正文不内联进 dispatch,qsession 读不到对象)。写入:投影 Lua ZSCORE 为空时 RPUSH session_projection:ucs_pending,后台任务批量写表后按原样逐项出队。checkpoint 缺失且低水位非零、或越过低水位 → INCR session_projection:epoch、从低水位继续;越过高水位或身份不符仍失败闭合。列表全量重建前 ensure_user_rebuilt 比较 session_projection:user_epoch。 两条投影路径必须共用登记 Lua(conversation_state::project_entries,2026-09-27 修复):mailbox 低延迟通道此前直接 ZADD GT,通常先到,durable 侧看到成员已存在就不登记,180 s 实测 250 万条投影只登记 15 个会话;单测直接调 project_recipients 覆盖不到这个到达顺序,只有真实负载的登记计数暴露了它。也不能让低延迟通道只更新已有成员:持续 10k 负载下 durable 投影远远落后,集合新鲜度靠低延迟通道(实测 T-HEAVY 30 秒只见 730/5000)。
  • 顺带修复的既有缺陷:Redis 邮箱条目解码器读完非空 custom_type 后没有推进偏移,任何带 custom_type 的条目都被判“message_type v2 尾部字段截断或版本非法”、拉取即 MAILBOX_DIRTY。自定义消息此前未走过 Redis 邮箱拉取路径所以一直没暴露;退群 e2e(控制消息恒带 custom_type)抓到。教训:编解码往返测试必须覆盖每个可选字段的非空形态。
  • 性能代价(2026-09-27 实测,hybrid、tmpfs、4+4、10k × 180 s,各一次):SEND_ACK P99 25.1 → 30.0 ms、端到端 P99 114.6 → 122.2 ms,门禁全过(T-HEAVY 0.9 ms、e2e 16/16、孤儿审计 0)。来源是登记 Lua(约 6 µs/次,原 ZADD 0.9 µs)落在已 1.0 核封顶的默认 core 实例;可削减方向是 durable 登记与 checkpoint CAS 合并一次调用,或把 qsession 键移出默认实例。
  • 验收映射见 ADR-0021“实现与验收映射”;GUI 未加退群入口。

邮箱热路径性能与容量(2026-08-26,ADR-0014)

  • mailbox / core Redis 实例数是容量参数,不是调优旋钮:单线程 Redis 打满即 ρ≈1,服务时间 237µs 的 evalsha 客户端观测到 4,388µs(18 倍排队)。 QIM_MAILBOX_REDIS_SHARDS / QIM_MESSAGE_STORE_SHARDS 值域均为 1..8 (compose 内置 8 个实例;64 shard、256 提交桶必须被实例数整除)。 实测 10,000 msg/s:4+4 端到端 P99 5,208ms → 8+4 降至 113.2ms、8+8 在 12,000 msg/s 为 P99 343.6ms,提交封顶 4 核约 9,650/s、8 核约 12,120/s。 均为 60 秒短压,不是容量结论;发布仍需目标硬件 180 秒 + T-HEAVY + 混沌。
  • 中心 dispatch consumer 队满只暂停该 Kafka 分区(pause_shard/resume_shard): 此前队满在中心循环 await,一个热点 shard 停掉全部 64 分区,实测消费者 99% 墙钟卡在 enqueue。修复是隔离而非扩容:过载时 P50 2,940ms→110ms 但 P99 5,208ms→15,275ms、聚合吞吐 −1.9%;不过载时暂停 0 次完全惰性。 饱和度看 qim_mailbox_dispatch_partition_paused_total。
  • qim_mailbox_dispatch_broker_to_enqueue_us 名不副实:计时起点是消费者 已读到 record 之后,不含 broker 滞后;分区暂停落地后其 sum/墙钟会超过 100% (实测 186%),不可再用作消费者饱和度。
  • 单线程热路径的成本归因必须直接 perf 采样,不能由聚合指标做减法: 同一问题上四次推断被实测推翻(详见 ADR-0014)。cmdstat 不归属 Lua↔C 桥接 与写效果传播;perf 采样不区分是否在关键路径(RDB bgsave 在 fork 子进程、 另一个核,占 5.58% 采样但关闭后零收益);微基准的键分布必须与生产一致 (每次写新键量到的是建键成本,生产是更新既有键)。
  • packed watermark v2(ADR-0015):wm_blob 长度自身编码「是否全同」—— 19 字节 = 64 lane 均等于该条记录,1216 字节 = 逐 lane,合法长度只有这两个。 uniform_blob 由「两次 1197 字节 string.sub 比较」退化为 O(1) 长度判定, 全同时写回 19 字节而非 string.rep 出 1216 字节。实测 10,000 msg/s、4 实例: evalsha 225.35→186.51µs(−17.2%)、Redis CPU 99.7%→85.8%、 端到端 P99 10,657→131.8ms、投递 99.3%→100%。 五个 Lua 脚本读兼容 v1,三个写入点一律 v2;旧代码遇 v2 在 layout 与长度 两处独立失败闭合,故同 shard 内不支持滚动升级,必须先 fence 旧 owner。 实施时修掉三处压缩引入的越界:热路径与复数版 advance 的慢分支 (19 字节 blob 上逐 lane 索引越界)、gc_completed_dispatch.lua 逐 lane 读 会取到 nil 而让 GC 永远拒绝(失败闭合但错误)。
  • 容量阶梯(packed-v2 后,同机 60 秒短压,非容量结论): 4+4 实例可撑 10,000 msg/s(P99 131.8ms、CPU 85.8%);4+8 在 12,000 时 mailbox CPU 99.6% 饱和;8+8 在 15,000 时 committed 12,989/s 由提交侧封顶, mailbox CPU 仅 67.3%——约束已从邮箱侧转移到 MessageStore 侧。
  • perf 符号化方式:redis:7.4.1-alpine 与 redis:7.4.1 build-id 一致, 把未 strip 的 Debian 版二进制加入 perf buildid-cache 即可。 实测 Redis CPU 构成:Lua 解释器 34.5%(其中 luaS_newlstr 6.59%)、 libc+内核+未分类 40.6%、Redis 数据结构 9.6%、jemalloc 8.8%。 下一个杠杆是 luaS_newlstr,由 uniform_blob 对 1216 字节 blob 的两次 string.sub 驱动;压缩 packed W 布局需独立 ADR 与离线迁移路径。

客户端绑定兼容修复(2026-08-23)

  • C FFI 的 QimEvent、qim_next_event 与 qim_event_free 是永久 V1 ABI;分页、成员关联、历史锚点和 raw message_type 只经 QimEventV2 / qim_next_event_v2 / qim_event_v2_free 发布。V1/V2 读取同一事件流,单个客户端不得交替消费后期待事件重放。
  • FFI 字节缓冲一律由 Box<[u8]> 转移与按切片长度回收,禁止以 Vec::from_raw_parts(ptr, len, len) 假定 capacity。回归测试锁定 V1 布局、V1/V2 幂等释放和大容量 Vec 的所有权转移。
  • C 构造 V2 显式接收 tenant/user/device;旧构造保持符号与用户=设备的 legacy 语义、tenant=0。构造只打开本地库并启动 driver,网络连接必须由 qim_connect 显式发起;内存库 C 入口仅在 cfg(test) 编译。
  • UniFFI 保持 V1 Record/枚举字段冻结,新增字段使用 QimConfigV2、QimConversationV2、QimMessageV2、QimEventV2 和 V2 查询方法;V1 表面由可构造/穷尽匹配测试守门,生产绑定不导出内存库选择。
  • 桌面/Tauri 默认库路径必须含规范化 endpoint(地址、CA、SNI、安全模式)+ 显式 tenant + user,tenant 路径段为 32 位十六进制;当前单租户 legacy tenant 固定 0。Tauri tenant 跨 JS 边界必须传字符串并解析为 u128,禁止 JS number 精度损失。
  • MembersUpdated 必须携带并消费 operation + request_id;建群 UI 只能接受 CreateGroup 操作的终态,不能把同 group 的 PullMembers 页误报为建群成功。
  • endpoint 路径段使用 blake3("qim.endpoint-scope.v1\\0" || canonical_endpoint)[0..16] 的 32 位小写十六进制,而非完整 endpoint 十六进制:BLAKE3 已是工作区依赖、跨平台稳定且避免超过常见 255 字节目录段;代价是路径不可读,排障时仅记录该脱敏 scope。C/UniFFI 暴露同一 V1 scope 计算 API,便于其宿主构造一致路径。
  • send_tracked(TrackedCommand) 在 driver 成功写帧后返回实际 wire request_id;desktop/Tauri 和 C/UniFFI V2 的建群都保存该值,成功 MembersUpdated 与失败 MemberOperationFailed 均只接受 request_id + operation + group_id 三元组。普通 Failed 不得结束成员操作;V1 C/UniFFI 表面冻结,成员专用失败仅降级为既有通用失败,严格关联必须使用 V2。
  • [x] 客户端宿主/绑定 P0:冻结 C V1 事件 ABI、用 V2 承载新增字段,修复 boxed-slice 释放、显式 tenant/user 构造、禁止生产内存库、endpoint/tenant/user SQLite 隔离、严格建群终态和 UniFFI V1 快照门禁。