跳转至

07 可靠性、安全与运维

状态:可实施;以下 fencing 的实现缺口为发布阻断 更新日期:2026-09-01 上游契约:docs/PLAN.md §8、§19、§20、§24 实施决策:ADR-0006(Rust + Redpanda,受 ADR-0012 覆盖)、ADR-0007(一期简化形态)

0. 文档边界

本文定义:fencing 实现、检查点参数、鉴权与令牌生命周期、限流实现、 运维手册与告警响应。

本文不得重新定义:错误码含义(附录 A.6)、一致性边界(§19.1)、 到达层级(§11.2)、SLO 目标值(§2.4)。


1. fencing 实现(§19.2)

1.1 两个校验点,缺一不可

前置约束  Writer 自我 fencing:续租超过 self_fence_deadline 后停止写入;
          它只缩小故障窗口,不能替代下列两个校验点。

校验点 1  控制面:ConversationHead 的条件更新
          IF fencing_epoch < ? OR (fencing_epoch = ? AND head_version < ?)

校验点 2  分发数据面:GroupDispatch.fencing_epoch 的消费侧单调过滤
          MailboxNode 按 (tenant, conversation) 维护已见最大 fencing_epoch
          小于该值的记录整条丢弃,计入 stale_epoch_dispatch_dropped_total

当前发布阻断:目标 GroupDispatch 必须携带并逐字节编解码 fencing_epoch,但当前 qim_common::commit::GroupDispatch 没有该字段,MailboxNode 亦未实现对应过滤。此处保留目标 要求并将缺口暴露为发布阻断;不得删除校验点 2、把它降级为内存 best-effort,或用 broker producer fencing 替代。

1.2 Fanout 日志语义与实现漂移

ADR-0012 已取消 FanoutCoordinator 的 Kafka 事务。日志只承担可重放、幂等生产及 durable delivery; 它不承担 stale-owner fencing。正确顺序是:

产出本批全部 GroupDispatch,并等待全部 durable delivery(acks=all)
    -> 才持久化/确认该批 Outbox source 进度

第 1 步成功、第 2 步失败 -> 重放;由 dispatch_id + MailboxStore 持久去重收敛。
禁止 source 进度先于 dispatch durable 被确认,否则会永久跳过消息。

transactional_id、InitProducerId 和 ProducerFenced 均不是本设计的 fencing 前提;不得把 fencing_epoch 编入任何 broker producer 标识来试图代替 GroupDispatch 过滤。

当前命名/配置漂移:RdkafkaCommitLog::publish_dispatches_and_ack_batch 已落实“全部 dispatch durable 后才 Sync commit source”的 ADR-0012 顺序。代码中的 TransactionalFanout / FanoutBatchTransaction / metrics 是历史名称;QIM_FANOUT_TRANSACTIONAL_ID 虽未参与该语义仍被 强制读取。发布前必须移除无用 env 门禁并清理名称,但不得把它们误判为仍在运行 Kafka transaction; 本节的故障注入证明必须持续保留。

1.3 参数与不等式

shard_lease_ttl             15 s
lease_renew_interval         5 s   失败后转 1 s 快速重试直至 self_fence_deadline
writer_self_fence_deadline  12 s
writer_failover_wait        25 s

必须成立:writer_failover_wait > shard_lease_ttl > writer_self_fence_deadline

接管必须等租约自然过期,不能靠心跳判定。SIGSTOP、cgroup CPU 限流、 VM 挂起都会造成秒级不响应而进程仍活着;Rust 无 GC 不改变这一点。


2. 检查点与恢复(§19.3)

2.1 一期的检查点内容(ADR-0007 简化)

一期(Redis 7.0.0+ standalone,AOF + noeviction)与阶段一(ScyllaDB):权威数据在节点外
    检查点只需 W[lane] + W_floor[lane] + DispatchProgress 快照
    不做邮箱索引检查点

    邮箱由 Redpanda 分发日志物化:日桶保留 30 天;DispatchProgress 默认不 GC,
    只有部署方显式注入经审计的日志 replay safety window,并证明全部 lane 的
    W/W_floor 越过最大 observed offset 且最后一次重复记录已超过该窗口后才删除。
    writer→mailbox TCP dispatch 不属于当前链路。

阶段二(自研 LSM):才需要完整索引检查点到对象存储
    内容 = 邮箱索引 + 投影 + UserBadgeState + shard_epoch + log_offset
           + 各 lane 水位 + EpochBoundary / ShardSplitBoundary

2.2 恢复时间不等式

T_recover = 检查点加载时间 + 重放追平时间
          = checkpoint_load_time_budget + checkpoint_interval × 分片事件速率 / replay_rate

log_retention_days >= (checkpoint_interval + T_recover) × 2

×2 覆盖:重放期间日志仍在追加、恢复本身失败并需重来一次

目标:checkpoint_interval 15 min,shard_rto_target 10 min
      由 §26 用例 26.6.3 的实测保证,不是推导结果

2.3 备份与时间点恢复

全量快照 + 增量 -> 独立 bucket(WORM)
RPO <= 15 min
每季度恢复演练,演练结果回填 checkpoint_load_time_budget(附录 B.7)

3. 鉴权与令牌

3.1 三种令牌

令牌 签发方 生命周期 校验点
access_token Auth/Session 1 h 每次 AUTH
route_token(REDIRECT) Auth/Session ≤ 60 s,单次使用 重定向落点
游标令牌(MailboxCursor) ConnectionNode 随分片归属 每次 PULL_MAILBOX

3.2 游标令牌:签名部分与明文部分(§6.8)

令牌承载不可伪造部分(HMAC 签名):
    mailbox_shard_id、lane_id、shard_epoch、user_id、device_id、签发时间

last_applied_mailbox_seq 在帧中**明文传输**:
    服务端校验 acked_seq <= 该用户 lane 当前 W[lane]
    越界返回 ERROR{CURSOR_INVALID}

这样客户端可以本地推进游标而不必每次换发令牌,同时无法越权读取其他分片。

3.3 长连接内的令牌续期(§15.3)

连接可能活 7 天而 access_token 只有 1 h:
    客户端在过期前用 refresh_token 换新,经控制帧刷新,**不断连**
    刷新失败 -> ERROR{TOKEN_EXPIRED} -> 客户端静默重连

吊销(远程登出 / 封禁 / 设备丢失):
    吊销事件经 PresenceDirectory 的 compacted topic 传播
    ConnectionNode 收到后立即发 KICKED{token_revoked} 并关闭连接
    同步删除该设备的 device_token 与 DevicePreKeyBundle

4. 限流实现(§8.2、§20.4)

4.1 三层准入

L1  发送者维度   per_sender_in_conversation_rate(1 msg/3s)
                 ConnectionNode 本地令牌桶 + ConversationWriter 复核
L2  会话维度     per_conversation_msg_rate(按 N 分档 20/5/2 msg/s)
L3  租户维度     tenant_fanout_quota(entry/s 令牌桶)

超限 -> ERROR{RATE_LIMITED} 或 FANOUT_QUOTA_EXCEEDED
**行为是发送侧拒绝或排队,绝不静默丢邮箱引用**

4.2 执行位置的确定性

用户与会话都已固定到分片(§5.2),因此每用户 / 每会话令牌桶可以
**在单节点内存中维护**,不需要跨节点一致的分布式限流器,
也不需要把 Redis 放进每条消息路径(§3 禁令 9)。

每租户 fanout 配额是全局量:中心配额分片下发 + 本地令牌桶,
中心每 quota_refill_interval 按节点在途负载重新分配份额。

4.3 其他限流维度(§20.4)

per_ip_connect_rate        L4 / ConnectionNode,直接拒握手不返回应用层帧(避免放大)
unauth_connection_timeout  10 s,slowloris 防护
takeover_admit_rate        5%/s,接管分批放行
per_user_reaction_rate     5/s,与消息速率独立计量
history_pull_rate          每用户历史拉取速率

5. 多租户隔离(§20.5)

隔离档位
    共享集群     逻辑隔离,tenant_id 贯穿全部主键与缓存键
    独立分片     物理隔离到指定 MailboxShard 与 ConnectionShard(超大租户)
    独立集群     私有化部署

噪声邻居防护
    §5.2 的均匀打散 + 分片级连续水位曾构成"单租户慢任务阻塞同分片其他租户"的
    级联路径 —— §6.5.1 的 lane 向量水位把阻塞域从整分片缩小到 1/64
    超大租户应启用"独立分片"档位彻底隔离

缓存键必须包含 tenant_id(防跨租户命中)
数据驻留:region 绑定,跨境传输受 §21.5 约束

6. 可观测性

6.1 恒零指标(非零即 P1)

mailbox_seq_regression_count        mailbox_seq 回退
cursor_advanced_by_push_total       游标被 PUSH_EVENTS 越位推进(违反 §6.8)
event_id_nondeterminism_total       重放/主备物化结果比对不一致
孤儿 dispatch 计数                   有 MessageRecord 无 dispatch,或反之
covered_through_seq 跨越未物化区间   客户端上报

这五个指标是正确性的最后防线。它们非零不是"性能退化", 是"某条不变量已被破坏",必须停止发布并定位。

6.2 关键 SLI 与告警

指标 目标 告警
online_ready_latency_p99{backlog} 无积压 ≤ 2 s;1 万条 ≤ 10 s 超目标 5 min → P2;超 2× → P1
send_ack_latency_p99 ≤ 150 ms(同区) > 500 ms → P1
lane_watermark_stall_ms{shard,lane} < lane_stall_alert(30 s) > 30 s → P2;> 120 s → 自动接管
projection_lag_seq p99 < 5 min > 保留窗口 1/3 → P1
unread_drift_ratio ≈ 0 > 0.1% → P3
fanout_entries_per_sec{tenant,shard} ≤ 预算 50% > 70% → P2;> 90% → P1
mailbox_store_shadow_mismatch 0 > 0 → P1 且阻断切换
device_token_digest_lag < 1 min > 5 min → P3
conversation_active_member_ratio{size_bucket} 无目标值 无告警(是 §10.2.3 判据的输入量)

6.3 追踪标识

贯穿提交、分发、邮箱、推送日志:

trace_id, tenant_id, message_id/event_id, conversation_id, dispatch_id,
mailbox_shard, lane_id, mailbox_seq, connection_shard,
connection_node_id, mailbox_node_id, shard_epoch, membership_version, device_id

日志与追踪禁止输出:access_token、refresh_token、游标签名、 DEK 与任何密钥、明文消息正文。


7. 告警响应手册

7.1 mailbox_seq_regression_count > 0(P1)

含义:某分片的 mailbox_seq 出现回退 —— 复合序号的单调性被破坏
可能原因:shard_epoch 未在日志重建/跨集群切换时递增
立即动作:
  1. 停止该分片的一切写入(qimctl shard drain)
  2. 比对 EpochBoundary 与实际 log_offset
  3. **不要**试图"修正"已发放的游标 —— 递增 shard_epoch 后走 CURSOR_REBASED
禁止:降低水位或重置游标,那会静默丢消息

7.2 lane_watermark_stall_ms > lane_stall_failover(P2→自动接管)

含义:某 lane 的子任务卡住,该 lane 用户的可见性冻结
排查顺序:
  1. DispatchProgress 中该 lane 是否有未完成块
  2. 该块对应的 dispatch 是否在慢速重试队列
  3. MailboxStore 是否有写入错误(standalone Redis AOF/内存策略异常 / Scylla 超时)
自动动作:lane_stall_failover(120s) 触发租约漂移接管
人工兜底:若接管后仍停滞,说明是数据问题而非节点问题,转 7.1 流程

7.3 projection_lag_seq 逼近 dispatch recovery window(P1)

含义:qsession durable checkpoint 滞后逼近 dispatch 日志恢复窗口——再滞后将无法靠日志重放投影
立即动作:
  1. 扩容 qsession 消费/Redis 写入能力并定位慢 partition
  2. 暂停尽力低延迟 Projection 补充,把资源让给 durable dispatch catch-up
  3. 若仍不收敛,延长 dispatch retention(治标)并准备 UserConversationState 权威重建
禁止:checkpoint 落到 broker low watermark 之前后仍以残缺 Redis 投影对外 ready

7.4 mailbox_store_shadow_mismatch > 0(P1)

含义:两套 MailboxStore 实现对空洞、边界或幂等的理解不一致
立即动作:阻断切换、回切到原实现、保留差异样本
这是**实现缺陷**而非环境问题,必须定位到具体条目差异

8. 混沌场景清单(§26.8)

节点 kill -9(gateway / writer / mailbox 各一轮)
SIGSTOP 30 秒后 SIGCONT(模拟运行时停顿、cgroup 限流、VM 挂起)
网络分区(region 间、节点与 etcd 间、节点与 Redpanda 间)
磁盘满(Redis / Scylla)
时钟回拨(超过 clock_regression_reject_ms 与未超过各一次)
日志分区不可用 10 min
登录风暴(百万连接 60 秒内重连)
大群消息风暴 / 聊天室洪峰
Redis Cluster 三节点配置启动拒绝(相关服务不得 ready)

每个场景的通过判据统一为:§6.1 的五个恒零指标保持为零, 且持久消息丢失数 == 0(以邮箱层三元组集合比对)。


9. 验收

R-1【发布阻断】fencing 两个校验点
  SIGSTOP 旧 Writer 30 s 后恢复:ConversationHead 条件写 100% 被拒。
  新 epoch dispatch 已消费后注入旧 epoch dispatch:MailboxNode 100% 拒收、
  新增 UserMailboxEntry == 0、stale_epoch_dispatch_dropped_total 增量 == 注入数。
  GroupDispatch 字段缺失、编解码未保留 epoch、或关闭任一校验点时,本用例必须失败。

R-2【发布阻断】Fanout 无事务提交顺序
  断言 RdkafkaCommitLog 先完成全部 dispatch durable,再 Sync commit source offset;
  启动/运行不得要求 QIM_FANOUT_TRANSACTIONAL_ID(无该 env 时正常通过 readiness 与 fanout)。
  在“全部 dispatch durable、source checkpoint 前”与“部分 dispatch 失败”两点分别 kill -9:
  前者恢复后允许重复但不得缺失;后者 source checkpoint 前进次数 == 0。

R-3 令牌续期不断连
  连接保持 2 小时(access_token 1 h):刷新成功且断连次数 == 0

R-4 吊销即时生效
  远程登出:目标设备在 1 分钟内收到 KICKED{token_revoked} 并断开
  该设备后续 AUTH 一律失败

R-5 限流不静默丢消息
  注入超 tenant_fanout_quota 的负载:
  返回 FANOUT_QUOTA_EXCEEDED 的次数 > 0
  静默丢弃邮箱引用次数 == 0

R-6 恢复演练
  从检查点 + 日志恢复单分片:RTO <= shard_rto_target(10 min)
  恢复后五个恒零指标为零,邮箱三元组集合与故障前一致

10. 待办

[ ] quota_refill_interval 的默认值与中心配额分配算法
[ ] history_pull_rate 的默认值(本文引用,附录 B 尚未收录)
[ ] APNs JWT 与 FCM OAuth 凭证轮换流程
[ ] qimctl 危险操作的二人复核实现