把“分享创建后通知关注者”改成异步,创建接口会更少等待通知渠道,但不意味着“消息只会处理一次”。消费者拿到消息后、发送通知前后都可能失效:没确认的消息应如何重领,已发出的副作用能否重复,积压能涨到多少,都要写进设计。

接在ID、幂等键与唯一约束之后,本篇以分享创建的非关键通知作异步任务。权威分享和幂等映射仍在数据库提交;通知最终送达可以延迟,分享授权与撤销不可依赖消息的消费进度。本文不设计邮件服务的真实送达承诺、跨地域队列或支付类强一致事务。

写清哪些结果可以晚到

POST /shares 用幂等键确认分享只创建一次;成功响应只承诺数据库中的分享和请求映射已提交,不承诺关注者收到通知。后续事件可建模为 ShareCreated(event_id, share_id, owner_id, version),通知处理按 (event_id, recipient) 记录处理结果和重试次数;正文、权限信息应由权威库按需读取,不能让一条旧消息重新发布已撤销内容。若以后业务要求“通知不可丢”,必须验证数据库提交与事件发布间的崩溃窗口,引入可核对的 Outbox 或等价事务边界;本篇的 Redis 实验没有证明事务与 XADD 原子提交。

教学目标定为合法分享创建 p99 小于 300 ms、通知入队后 99% 在 2 秒内完成,积压出现时拒绝或降级非关键通知而不影响分享写入。这里只有固定输入的消息行为验证,无百分位或渠道送达压测,不能宣称目标已达成。

独立算量输入:每天 20 万次分享创建、400 万次分享读取,读写比 20:1;峰值取日均的八倍,约 18.52 次创建/秒和 370.37 次读/秒。每份分享向三位关注者生成三项投递工作,则每天 60 万项投递、在相同峰值比例下约 55.56 项/秒。若每条入队事件 200 B,峰值新增事件有效载荷约 18.52 × 200 ≈ 3704 B/s,不含投递工作、确认和网络开销。每份分享元数据与索引 1000 B,保留 365 天三份约 219 GB;假定事件 200 B/条、保留 7 天两份约 0.56 GB;读写诊断日志每天 420 万条、每条 200 B、保留 7 天一份约 5.88 GB,合计约 225.44 GB。按练习单价 0.02 货币单位/(GB·月),仅这些容量约 4.51 货币单位/月,未计缓存、内存、持久化实际开销与通知渠道成本。若每份分享平均不是 3 人而是 30 人,投递侧的练习峰值变为约 555.6 项/秒;分享写峰不变,瓶颈可能转移到消费者和渠道。

先定义确认边界,再让消费者并行

最小设计把权威写入和通知工作分开。直接在创建请求内同步发送通知比较简单,但外部渠道故障会把尾延迟带回分享接口,也无法因为数据库事务回滚就收回已发出的通知;异步队列允许延后与限流,却要管理重复、积压及写库与队列的双写窗口。实例候选使用 Redis Streams 的消费组;Redis XREADGROUP 会把已投递但未确认的条目放入消费组待确认列表,业务成功后用 XACK 去除待确认状态。若处理器在副作用后、确认前崩溃,重领处理仍可能再次触发同一通知;消息 ID 只能辅助去重,不能替代渠道副作用的幂等确认。

flowchart LR
    C[创建者] -->|POST 幂等键| A[分享应用]
    A -->|事务提交分享和请求映射| D[(权威数据库)]
    A -->|提交后发通知事件,双写窗口待处理| Q[(Redis Stream)]
    Q -->|XREADGROUP 投递| W[通知消费者]
    W -->|按 event_id + recipient 去重| D
    W -->|发送成功后 XACK| Q
    W -->|失败保留 pending 并记录重试| Q
    A -->|只确认分享结果,不承诺通知完成| C

对积压不能仅看流长度:已分发但没 ACK 的 pending 与尚未投递的新事件是两块不同工作。需要同时观测入队速率、完成速率、最旧未确认项的等待、失败次数与渠道错误;若积压超过预设的工作量/年龄阈值,先对非关键通知限流或暂停新投递,不能用无限消费者重试冲垮权威数据库。本文没有运行吞吐压测,这些阈值待真实负载下确定。

一条未确认事件怎么回来

此前云端 Ubuntu 环境从 deb 包只解包、不安装 Redis 7.0.15,由脚本 examples/system-design/labs/10/stream.sh 在私有 Unix socket 上启动。TCP 端口为 0,关闭快照和 AOF,用完停止并删临时目录。第一个事件给 consumer-a 后待确认数为 1,XACK 后为 0。第二个事件同样读到却不 ACK;另一个消费者 consumer-b 只读新消息时不会自动收到这条旧 pending。调用 XPENDING 可以看到积压,实验再用 XAUTOCLAIM 手动把它转给 consumer-b,最后 ACK 清零。该次正常命令退出 0,--expect-auto-redelivery 故意假设新消费者自动收到未确认旧消息,以 2 退出。云端记录见 examples/system-design/evidence/10/stream.md;本次 macOS 从官方 Redis 7.0.15 源码编译后执行下述恢复实验,来源哈希、配置与原始输出另见 local-recovery.md。

1
2
REDIS_SERVER=/path/to/redis-server REDIS_CLI=/path/to/redis-cli bash examples/system-design/labs/10/stream.sh
REDIS_SERVER=/path/to/redis-server REDIS_CLI=/path/to/redis-cli bash examples/system-design/labs/10/stream.sh --expect-auto-redelivery
sequenceDiagram
    participant P as 发布者
    participant Q as Redis Stream
    participant A as consumer-a
    participant B as consumer-b
    P->>Q: XADD 第二个事件
    A->>Q: XREADGROUP 取到事件
    Q-->>A: 记录为 pending
    Note over A,Q: A 在 XACK 前停止,副作用是否执行未知
    B->>Q: XREADGROUP 用 > 只读新消息
    Q-->>B: 收不到 A 的旧 pending
    B->>Q: XAUTOCLAIM 按空闲阈值重领
    Q-->>B: 转交同一事件
    B->>Q: 去重处理后 XACK
    Note over P,B: 若 A 已发送通知,B 必须按业务键避免重复副作用

实验为复现设 XAUTOCLAIM 最小空闲时间 0 毫秒,生产中这样做可能让 A 尚在处理时 B 就重领,形成并发副作用;必须按处理时长设租约、限制重试和死信处理。Redis Streams 在这个配置下关闭了持久化,不能说消息重启不丢;也没验证消费者断网、写库与 XADD 双写或网络超时。恢复时先判断事件 ID 对应的业务去重记录,再重领 pending,最后审计积压是否收敛。面试不要只说“加队列削峰”,应问“什么时候 ACK、谁重领、重领后已发出的外部动作怎么办”,并写出有界积压的处理策略。

提交副作用后退出,重领是否还会重复

labs/10/recovery.py 把副作用缩小为 SQLite 中的一条本地通知记录。消费者 A 先提交记录,再以退出码 17 结束子进程,没有执行 XACK。这时 Redis 中真实的 pending 数仍为 1。消费者 B 重领同一消息,以 (event, recipient) 唯一约束写入通知记录,冲突时保留已有结果,再确认消息。恢复后本地通知记录只有一条,pending 清零。去掉唯一约束的 --unsafe-effect 反例得到两条记录,违反不变量并退出 2。

这项检查证明的是“这张 SQLite 表中的逻辑通知只创建一次”。它没有向邮件或短信渠道发送请求,不能证明外部通知只送达一次。若渠道支持幂等键,应把同一个业务键传过去;若渠道受理后响应丢失且不能查单,任务只能进入结果不确定状态,继续盲目重试可能重复发送。把去重记录先写成“成功”、再调用渠道,则会在两者之间产生丢通知窗口;正文图中的“去重”必须与可确认的业务结果绑定,不能只是一个预先设置的标志。

同一实验再入队 100 条消息,用 XREADGROUP 批量读取后检查 pending 为 100。应用按这一阈值拒绝新增可选通知,断言 Redis 流长度没有增加;随后逐条提交 SQLite 副作用,检查累计有 101 条不同事件记录,再用一条 XACK 批量确认这 100 条消息,pending 归零。本次恢复段实测约 458.978 ms,其中 ACK 命令约 0.190 ms;这是一轮本机测量,不是吞吐或 p99。脚本用反向列表模拟完成顺序,没有运行并行工作线程;消费组投递顺序不能推出处理完成顺序。需要按分享版本更新同一对象时,应串行处理该对象,或让业务存储拒绝低版本覆盖高版本。生产积压预算仍须根据到达率、处理率和允许等待时间测定。

1
2
python3 examples/system-design/labs/10/recovery.py --server /path/to/redis-server
python3 examples/system-design/labs/10/recovery.py --server /path/to/redis-server --unsafe-effect

排查消息故障时,先用同一业务键关联数据库结果、消息 pending 和渠道结果。数据库结果存在而 pending 未清除,重领应重用既有结果;pending 清除却无业务结果,需检查确认是否过早;业务结果存在但渠道状态未知,应进入查询或人工核对路径。增加消费者只能解决吞吐不足,不能补出缺失的确认边界。

参考资料

  • Redis XREADGROUP、XACK、XPENDING、XAUTOCLAIM 官方命令文档:仅说明实际使用的消费组投递、确认和重领语义。
  • 云端 Ubuntu 环境的 Redis 7.0.15 原始实验、配置及限制:examples/system-design/evidence/10/stream.md。