系统设计 10:消息与异步处理——确认、重领和背压
把“分享创建后通知关注者”改成异步,创建接口会更少等待通知渠道,但不意味着“消息只会处理一次”。消费者拿到消息后、发送通知前后都可能失效:没确认的消息应如何重领,已发出的副作用能否重复,积压能涨到多少,都要写进设计。
接在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。这些是云端原始记录;当前本地环境没有 Redis Server/CLI,本地复跑为 NOT_RUN。
1 | |
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、谁重领、重领后已发出的外部动作怎么办”,并写出有界积压的处理策略。
参考资料
- Redis
XREADGROUP、XACK、XPENDING、XAUTOCLAIM官方命令文档:仅说明实际使用的消费组投递、确认和重领语义。 - 云端 Ubuntu 环境的 Redis 7.0.15 原始实验、配置及限制:
examples/system-design/evidence/10/stream.md。




