先画一张“万能分享表”再补索引,常会漏掉实际最昂贵的访问:按用户和时间翻页。键、索引和游标应从查询与更新方式反推;如果列表在持续插入期间出现重复,补一台数据库机器也不会修正页面语义。

沿用前两篇的分享元数据场景:需求与撤销约束决定谁能看;容量估算给出怎么算峰值。本篇只回答“用户按时间查看自己的分享列表”,实验使用真实 SQLite,而不是用 Python 排序冒充数据库查询。

API 的三种访问,写入只有一个归属

教学范围有三条请求:POST /shares 创建分享,GET /shares/{share_id} 按标识取单条,GET /users/{owner_id}/shares?limit=20&after=... 按创建时间倒序取列表。已登录的创建者可以 DELETE /shares/{share_id} 撤销;只有本人可以列出自己的分享,列表允许展示已撤销状态但不展示给读者。排除按目标 URL 搜索、全站热度排名及跨用户浏览。对外读取仍按第 01 篇的强撤销契约判断。

读者用链接查单条,创建者按时间浏览列表,两个路径的定位方式不同。候选表 shares(share_id 主键, owner_id, created_at, target, state, version) 和 requests(owner_id, idempotency_key, share_id):创建时在一个原子提交边界上写分享与同键映射;读单条靠主键;撤销由所有者权限检查后更新状态;列表需要 (owner_id, created_at DESC, share_id DESC)。若以后新增“按 URL 找全部分享”,再补访问模式与索引,不能先为想象的需求付每次写入的维护代价。

列表可设 limit 上限 100,默认 20;游标包含最后一条的 (created_at, share_id) 和页面过滤条件,服务端绑定已授权的 owner。公开给客户端的游标不应允许改 owner 后访问别人的记录;可以签名或服务端存储游标会话,选择取决于过期/撤销要求。GET 的语义不能因一次列表读取顺手修改资源,HTTP 安全方法的规范边界见 RFC 9110 §9.2.1。这里的签名与授权只是待验证设计,SQLite 小实验没有实现 HTTP 层。

先算读写,再给列表一个响应预算

独立教学假设:每天创建 20 万条,列表请求 400 万次/日,列表请求与写入请求比 20:1;每次列表请求至多返回 20 条,不能据此反推每份分享各被读 20 次。峰值为平均的 8 倍,则列表峰值约 4000000 × 8 / 86400 ≈ 370.37 requests/s。假设每项序列化 1 KiB,20 项的响应有效载荷在峰值约 370.37 × 20 KiB ≈ 7.23 MiB/s ≈ 60.68 Mbit/s,未含协议头、缓存、TLS 和移动端重试。

如果单改上限从每页 20 条变成 100 条且请求速率不降,最大响应有效载荷按线性估算增至约 36.17 MiB/s ≈ 303.4 Mbit/s,数据库也需返回五倍行数;实际请求速率可能因为每页更大而减少,不能两头都按最坏值同时宣称是观测数据。列表候选 SLO 为合法查询的服务端 p99 小于 200 ms;平均响应时间不能代替 p99,本篇未做延迟压测。

容量也要写完整:每条元数据 800 B、索引预算 200 B,365 天三份按 200000 × (800+200) × 365 × 3 = 219 GB ≈ 203.96 GiB;每次创建一条 160 B 的日志,7 天一份 200000 × 160 × 7 = 0.224 GB,总约 219.224 GB(204.17 GiB)。假设稳态价格为 0.02 货币单位/(GB·月),仅按这份容量估价约 4.38 货币单位/月;是刻意简化的练习单价,不含请求、对象正文、备份、实际多索引和带宽成本。

翻页要先指定顺序与锚点

只以 created_at 排序不充分:同一时间戳多条记录会使下一页边界模糊,故追加唯一的 share_id 作为决胜键。OFFSET 2 跳过的是第二次查询当前排序结果的前两行,不是第一次查询的前两行;若第一页与第二页之间插入较新的记录,老第二名可能重新出现在下一页。SQLite 的 ORDER BY、LIMIT、OFFSET 定义见 SQLite SELECT 文档,行值 (created_at, id) 的比较形式见 SQLite Row Values。

游标把第一页最后的 (created_at,id) 带进第二次查询:

1
2
3
4
5
6
SELECT id, created_at
FROM shares
WHERE owner_id = :owner
AND (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;

SQLite 3.45.1 的固定输入里,第一页为 [6, 5](两者时间戳均为 500);新插入较新的 7 后,OFFSET 2 得 [5, 4],重复 5,游标 (500,5) 得 [4,3];若游标是 (500,6),下一页 [5,4],不会因时间戳并列跳过 5。具体版本、SQL 和原始输出见仓库 examples/system-design/evidence/03/pagination.md。实验初次还因把 (id,created_at) 错绑定为 (created_at,id) 得到空页,断言失败;修正参数顺序后才通过,这也是接口序列化要测的边界。

该版 SQLite 的 EXPLAIN QUERY PLAN 输出为 SEARCH shares USING COVERING INDEX shares_by_owner_time (owner_id=? AND created_at<?)。这证明该固定查询、该数据与该版本的优化器选择使用了索引,不保证其他数据库、索引统计变化或带 target 等非覆盖列时仍是覆盖扫描,更不证明 p99 延迟。索引优化方法可参照 SQLite Query Optimizer Overview。

游标也不是时间快照:第一页之后插入游标之前的较新记录,本次向后翻页不会补到它;插入更旧的历史记录可能被看到;删除会使某项永远缺席。如果产品要求“本次浏览绝不漏掉截点前任何版本”,应明确快照语义、保留有效快照及其成本,不能拿普通游标替代。

最小架构与失败时间线

初版用 HTTP 服务、一个权威关系数据库就够了。写入与幂等映射一起提交后才回“创建成功”;列表先鉴权,按 (owner_id, created_at DESC, share_id DESC) 索引读;获取下一页时消费上页最后的游标。列表只返回条目摘要和撤销状态,接收方按主键读取仍检查 state。浏览埋点可以异步记录,但不能作为权限或分页边界的事实源。

flowchart LR
    U[创建者] -->|POST 创建 / GET 带游标列表| A[HTTP 服务]
    A -->|创建时写分享和请求映射;列表按 owner 和游标读取| D[(权威数据库)]
    D -->|提交确认 / 有序 id 与下一游标| A
    A -->|创建确认 / 最多 limit 个条目| U
    A -.->|尽力异步写浏览指标| L[诊断记录器]
sequenceDiagram
    participant C as 创建者
    participant A as HTTP 服务
    participant D as 数据库
    C->>A: GET limit=2(第一页)
    A->>D: ORDER BY created_at,id DESC LIMIT 2
    D-->>A: [6,5],最后游标 (500,5)
    A-->>C: [6,5] 与游标
    Note over C,D: 其他请求提交了较新记录 7
    C->>A: GET 下一页,带游标
    A->>D: WHERE (created_at,id) < (500,5)
    D-->>A: [4,3]
    A-->>C: [4,3],没有重复 5
    Note over C,A: 若改为 OFFSET 2:得 [5,4];客户端必须去重或重刷

“OFFSET 简单,不需要签名游标”是第一种选择:适合静态、很短的管理列表,或者产品明确容忍翻页时重复和跳项。另一种是键集游标:能界定已经看过的位置,在实时插入列表上避免这个特定重复,但必须管理排序变更、过滤器绑定及被删除的末项。若排序字段会被编辑,应使用不可变的排序时间或明确版本化游标;否则更新本身可能移动条目,单纯把 OFFSET 换成游标也不够。恢复与迁移时先保留旧分页 API,双读比较同一数据集的覆盖和重复率,再决定什么时候停止旧方式。

最小验证确实调用了 SQLite 3.45.1:默认命令退出 0,打开 --strict-offset 把重复页当错误、退出 2;未测数据库并发事务、鉴权、游标签名和延迟。面试练习可用 5 分钟明确列表访问模式、8 分钟核算速率和接口、12 分钟画读写路径、15 分钟追问插入/删除/并列时间戳、5 分钟检查游标承诺;仅作练习安排。可迁移规则是:按实际过滤、排序和分页方式设计键与索引,写出插入发生于哪两次读取之间,才能知道“翻页稳定”具体指什么。

参考资料