外观
ADR 009 消息保留配额与幂等
- 状态:提案,待总控决策
- 范围:密文信封、提交/拉取/ACK、过期清理、配额、幂等记录、恢复重复
- 不在本 ADR 冻结:E2EE 编码与密码套件、身份地址、客户端备份格式、开源许可
1. 背景
移动网络会超时、重试和乱序。服务端必须在不读取消息明文的前提下短期保管密文、隔离滥用、避免重复创建,并在 ACK 或 TTL 后清理。底层传输只能可靠地实现至少一次,用户可见去重依赖协议与存储共同约束。
1.1 来源 MUST
- 未送达密文默认保留 7 天;管理员可配置 1–30 天;发送端可请求更短 TTL。
- 已 ACK 信封立即进入删除流程,最长 1 小时清理。
- 所有写接口支持
Idempotency-Key,24 小时内返回一致结果。 - 拉取使用稳定游标;并发入队不得漏消息或无限重复。
- 队列按
recipient_device_id分区并限制待收数量和总字节;超限不得影响其他设备。 - ACK 与删除状态在事务中完成;重复 ACK 幂等。
1.2 已验证事实
当前没有数据库、API、Worker 或负载测试。默认值来自说明书,具体配额和实现策略尚未验证。
2. 选项
2.1 业务存储与工作队列
- A 仅 PostgreSQL 扫描:依赖少、恢复清楚,但延迟任务、重试调度与并发 Worker 操作成本较高。
- B PostgreSQL 事务 outbox + Redis 工作队列:数据库保留可靠意图,Redis 提供分发、延迟和并发消费;组件更多但故障边界清楚。
- C Redis 作为权威消息/任务存储:延迟低,但 Redis 丢失可能造成业务意图丢失,不满足恢复边界。
- D 专用 broker + 数据库状态:扩展性强,但首版增加新的运维系统。
已选架构:B。消息信封、ACK、幂等、outbox 和任务完成事实保存在 PostgreSQL;Redis 保存可由 outbox 重建的工作队列。C 被明确拒绝;D 留待规模证据出现后评估。
2.2 ACK 删除
- 同步物理删除:语义直观,但放大延迟和锁冲突,失败时难区分 ACK 是否提交。
- 事务标记 + 异步清理:ACK 快速、可重试;短时间保留已 ACK 密文。
建议:事务写入 acked_at/cleanup_after,Worker 最长 1 小时内物理删除。API 可安全重复 ACK,不因清理完成返回不同业务结果。
2.3 幂等结果
- 完整请求/响应副本:易重放但扩大密文和 token 副本。
- 请求指纹 + 最小安全响应:隐私更好,需要稳定规范化。
建议:只存作用域化 key 哈希、请求指纹、资源引用、状态码和最小响应。请求指纹的 canonical encoding 跟随 ADR-004,待总控决策。
3. 推荐语义
3.1 提交
在单事务中:
- 锁定/检查幂等记录。
- 校验 actor、接收设备、允许的
protocol_version、实际字节大小和 TTL。 - 原子检查单设备待收数量与总字节配额。
- 按
(recipient_device_id, message_id)建立唯一密文信封。 - 建立版本化 outbox event;dispatcher 在事务提交后发布到 Redis。
- 保存最小幂等结果并提交。
客户端超时后使用相同 Idempotency-Key 和完全相同请求重试,得到首次结果。相同 key 配不同请求返回 IDEMPOTENCY_KEY_REUSED。相同 message_id 配不同密文返回冲突并触发安全审计事件。
3.2 拉取
按 (accepted_at, internal_tiebreaker) 稳定排序,游标为版本化 opaque token。拉取不删除;重复拉取合法。客户端按 message_id 去重并仅在成功解密后 ACK。
3.3 ACK
批量最多 100 条,每条返回稳定结果。当前设备只能 ACK 自己队列的消息。不存在/已过期/已 ACK 不得造成其他有效 ACK 回滚。read receipt 是端到端加密控制消息,与服务端 delivered ACK 分离。
3.4 过期
expires_at = accepted_at + min(sender_requested_ttl, instance_max_ttl),同时满足实例最小/最大规则。到期信封不再返回;Worker 分批、限速清理。延长管理员 TTL 不复活已过期数据。
4. 配额模型
每设备至少限制:
- 单信封实际字节数。
- 待收信封数量。
- 待收密文总字节。
- 单 actor/IP/时间窗提交速率。
精确数值为待总控决策。建议用压测、成本模型和目标用户离线 7 天的分布确定,不凭空冻结。配额错误返回稳定码、是否可重试和安全的 retry_after,不泄露接收方活跃状态或精确剩余容量。
5. 并发与故障
- 配额计数与插入同事务,使用计数行锁或可证明无超配的数据库约束。
- 多 Worker 通过
FOR UPDATE SKIP LOCKED或等价机制领取过期/清理/outbox 任务。 - dispatcher 不在持有 PostgreSQL 行锁时调用 Redis;发布失败保留 outbox 状态并退避重试。
- Worker 在副作用成功但确认 Redis/数据库前崩溃可能重复执行;每个 handler 用稳定
event_id和业务唯一约束幂等。 - Redis 清空、故障转移或 namespace 迁移后,reconciler 从
pending/publishedoutbox 重建任务;不得把 Redis 持久化当作唯一恢复机制。 - Worker 在发送 wake 后崩溃可能重复 Push;Gateway 与客户端必须容忍,消息本身不重复创建。
- PostgreSQL 提交成功但 API 响应丢失由幂等记录恢复。
- Redis 丢失、Gateway/APNs 失败或 WSS 断开不影响数据库中的信封。
- PITR 可能使已 ACK 信封重现;客户端去重并重新 ACK,恢复流程重新启动清理。
6. 隐私后果
队列仍泄露接收设备、时间、大小和频率元数据。padding 可以降低部分大小泄漏,但 padding class 与编码属于协议线路,待总控决策。服务端、日志和指标不得保存密文全值、发送方可读身份、会话 ID 或内容预览。
已 ACK/过期删除是逻辑与业务保留承诺,不证明底层介质物理擦除;备份副本的保留必须独立披露。
7. 待总控决策
7.1 配额默认值
- 固定全局默认:简单,但不同部署容量差异大。
- 按实例配置并设安全上下界:灵活,但客户端错误处理和管理员 UX 更复杂。
- 动态配额:利用率高,但可能形成画像与不公平行为。
- 建议:实例可配置、产品给安全默认和上下界;首版不做用户画像驱动动态配额。
- 验证:500 msg/s 目标和 2 倍压测、并发超限、恶意小包/大包、设备间隔离。
7.2 游标格式
- 签名 JSON 易调试;加密二进制隐藏主键;两者都需版本和轮换。
- 建议:版本化不可伪造 opaque token,不暴露表主键;格式跟随 ADR-004。
- 验证:篡改、过期、重复、旧 key 轮换与乱序插入。
7.3 TTL 变更是否影响存量
- 仅新消息:可预测且不会突然删除。
- 同步存量:隐私策略收紧更快,但可能造成未预期丢失。
- 建议:默认仅新消息;管理员可发起明确、可审计的“提前清理存量”操作并预览影响。
- 验证:缩短/延长、并发拉取与 Worker 中断测试。
7.4 分区策略
起步普通表 + 索引最简单;按时间或 hash 分区便于规模化但迁移复杂。建议先以负载测试证据决定,不提前分区;schema 保留无停机迁移路径。
7.5 Redis 队列实现
- 选项:成熟 Go 队列库、Redis Streams 消费组、最小自建实现。
- 建议:优先成熟库,但必须完成许可证、维护状态、确认/租约、延迟任务、死信和可观测性评估;不自行发明通用分布式队列。
- 边界:选择实现不改变 PostgreSQL outbox 的权威性;job payload 不含密文全值、token、地址或联系人信息。
- 验证:Worker 在执行前/副作用后/确认前崩溃,Redis 重启或清空,网络分区、毒任务与滚动升级。
8. 验证与接受标准
- 客户端在提交成功后超时并重试,数据库只有一条信封且响应一致。
- 100 个并发提交不会突破单设备数量/字节配额,其他设备不受影响。
- 并发拉取、游标翻页和新消息插入无漏消息、无无限重复。
- 重复/混合 ACK 结果稳定,已 ACK 信封最长 1 小时内清理。
- Worker、API、PostgreSQL 重启和 Redis 清空、Gateway/APNs 故障不丢已确认信封或永久遗漏后台任务。
- TTL 边界、磁盘满、锁冲突、迁移中断和 PITR 恢复有集成测试。
- 日志、指标、trace、备份扫描无密文全值、正文、私钥或稳定用户标识。
- 未完成实现、压测和故障注入前,本 ADR 保持“提案”,不得标记“已接受/生产验证”。