系统设计 13:文本分享:到期拒绝与物理删除是两个时刻
文本分享服务允许用户存一段日志并发给别人。正文放进对象存储之后,数据库删除一行并不代表文本已经消失:缓存、搜索索引、对象版本和备份都可能保留副本。可验证的设计要先给出“何时不再允许读取”,再给出“哪些副本何时清除”,而不是用一个删除按钮替代全部承诺。
场景借用 Pastebin 题型,只讨论纯文本、公开或认证私密分享、有效期与撤销。协同编辑、富文本和全站推荐不在范围内。第 12 篇短链保存的是目标地址,这一篇必须负责正文的存储、交付和生命周期。
不变量沿着访问权限定义
候选接口为 POST /pastes、GET /pastes/{id} 和 DELETE /pastes/{id}。创建时提交 UTF-8 文本、可见性及有效期,服务端校验编码、实际字节长度和最大 1 MiB 限额。返回值包含 ID、到期时间和状态;私密分享只允许所有者及授权主体读取,不把“没有列在目录中”当作私密。
权威元数据是 pastes(id, owner, visibility, body_key, expires_at, revoked_at, version)。正文对象键包含内容版本,避免对相同对象键就地覆盖导致旧缓存与新元数据混用。授权关系单独存为 (paste_id, principal),检索索引只放可公开检索的摘要。未列出分享、公开可搜索分享和认证私密分享应是三个不同选项。
读取不变量可以写成:在权威授权判断时,主体有权、未撤销且 now < expires_at 才能返回正文。到期边界使用严格小于,到期瞬间应拒绝。这里不承诺能收回已经发到读者设备上的内容;对于在撤销前已通过检查、仍在传输中的响应,还需要单独约定是否中断。将这一边界说清,比笼统承诺“实时删除”更可信。
正文服务发送 text/plain; charset=utf-8,不把用户上传内容作为 HTML 解释,且限制下载大小。附件下载可使用适当的内容处置头。日志片段常含密钥,因此创建页的默认可见性、过期时间和泄漏提醒属于产品约束;服务端不能通过一个猜不到的短 ID 假定正文已经获得访问保护。
小文本是否值得拆成两套存储
教学假设每天新增 20 万份,平均 20 KiB,读取 400 万次/日,读写比 20:1,峰值系数 6。写平均约 2.31 requests/s,读平均约 46.30 requests/s,峰值读取约 277.78 requests/s。没有缓存时正文带宽约 277.78 × 20480 = 5.69 MB/s。
正文保留 30 天,逻辑量 200000 × 20480 × 30 = 122.88 GB,三份为 368.64 GB;每条元数据按 600 B,三份只需 10.8 GB。正文平均值若变成 200 KiB,正文容量和交付流量都增十倍,元数据基本不变。这个敏感性差异才是分离正文与元数据的理由,而不是认为所有文本从第一天起都必须放对象存储。
对于每天几千份、上限几十 KiB 的内部工具,正文与元数据放同一数据库能得到简单的事务删除与备份,值得作为初版。拆分后获得更便宜的大对象存储和独立吞吐,却引入对象成功、元数据失败的双写窗口,以及权限控制和回收流程。若拆分收益尚未覆盖运维成本,可以保留数据库正文列并监测实际增长。
读取缓存命中率 90% 时,正文源站流量约为 0.57 MB/s;若私密读取每次都查授权,权威库仍有约 278 次/秒检查。热点公开文本与私密文本可以有不同缓存策略,不能把公开内容的高命中率用于估算所有权限查询。到期任务也不能按平均 2.31 条/秒设计:批量导入采用相同到期时间,可能形成某一分钟的集中清理。
逻辑拒绝先于派生副本清理
最小架构保留一个权威元数据库。读取先校验元数据,再从正文缓存或对象存储取指定版本。撤销在同一个数据库事务中更新状态、增加版本并写清理任务;事务提交后返回撤销成功,清理任务异步删除索引、缓存和正文对象。通知丢失时由扫描器按撤销状态补齐。
flowchart LR
C[创建者] --> A[文本API]
A --> D[(权限与到期元数据)]
A --> O[(版本化正文对象)]
R[读者] --> G[鉴权与生命周期门禁]
G --> D
G --> B[正文缓存]
B --> O
D --> Q[清理任务]
Q --> B
Q --> I[检索索引]
Q --> O
缓存可以保存正文,但不能自行决定生命周期。私密链接直接暴露长寿命对象 URL 会绕过门禁;短期签名 URL 只能提供有界过期,不能天然满足立即撤销。若选择签名 URL,就把最大剩余有效期纳入撤销 SLO;若要求撤销后新请求立即拒绝,正文需经过代理或具有实时授权能力的交付层。
清理任务幂等地按 (paste_id, version) 处理。删除不存在的对象仍视为成功,旧版本的清理不能误删新版本正文。对象不可变键能简化这一点。清理器在执行前重新核对当前引用,以防用户重建分享后 ID 或对象键被复用。重试达到阈值进入待处理队列,同时记录最老未清理对象年龄;仅统计队列长度无法说明是否长期泄漏。
sequenceDiagram
participant U as 所有者
participant D as 权威库
participant R as 读取门禁
participant W as 清理器
U->>D: 撤销并写清理任务
D-->>U: 提交成功
R->>D: 新读取检查状态
D-->>R: 已撤销 拒绝正文
Note over D,W: 缓存和对象此时仍可能存在
W->>D: 领取指定版本清理任务
W->>W: 删除缓存 索引 正文
W->>D: 标记物理清理完成
到期的依据与备份的边界
到期判断使用服务端受控时钟,不信任客户端时间。多个节点存在时钟偏差时,应明确允许的误差;严格有效期场景可以让权威数据库给出判断,代价是额外读取和数据库时钟的依赖。后台定时清理不承担在线拒绝功能,即使清理器停机一天,在线接口也必须按到期字段拒绝。
备份通常不能立即按单条记录物理改写。候选契约可以分别写在线读取立即拒绝、活动对象在二十四小时内删除、备份在规定保留期结束后自然淘汰;这些是需产品和运维确认的设计参数,不是本地实验已经证明的时间保证。恢复备份时先回放删除账本,再开放读取,避免旧备份把已撤销内容重新公开。
监控至少区分逻辑撤销延迟、活动存储清理延迟、索引墓碑传播延迟,以及恢复后漏应用墓碑的数量。安全事件排查保留必要审计字段,但不在访问日志里再次复制完整私密正文,否则主存储清理完成也没有消除新副本。
最小实验验证什么
实验 examples/system-design/labs/13/paste.py 使用 SQLite 元数据和真实临时文件,缓存及索引用字典和集合表示。到期记录 expires=100 在时间 99 可读,在 100 被拒绝;错误主体在 99 也被拒绝。另一条未到期记录撤销后,正文文件和旧索引仍存在,但权限门禁已经拒绝读取。随后执行幂等清理,核对正文、缓存与索引均消失。
1 | |
默认退出 0,负例绕过门禁读取旧缓存,检测后退出 2。输出和运行环境见 examples/system-design/evidence/13/。模型使用显式整数时间,没有测量真实跨机时钟偏差;没有对象云服务、签名 URL、备份或加密密钥销毁实验。物理文件删除的通过也不代表底层磁盘字节不可恢复。
45 分钟练习可先用五分钟写权限与到期边界,八分钟估算正文和元数据,再用十二分钟画读写及清理路径,十五分钟追问签名 URL、停机清理器和备份恢复,最后检查用户看到的状态。被追问“直接把 TTL 设在缓存上够不够”时,应指出缓存未命中仍可能读到存储正文,权限与生命周期要由事实源决定。
参考资料
- RFC 9111:HTTP Caching:本文只借用缓存存储与验证语义,业务撤销仍需自定义权威门禁。
- SQLite Transaction:本地元数据提交的事务边界;跨对象存储提交不由此保证。






