后端系统工程 34:可撤销短链与访问统计
短链服务的主路径只有一次查表和一个 3xx 响应,难点在三个容易被忽略的边界:两个创建者同时申请同一个别名时谁的目标留下;所有者撤销一条短链之后,旧目标还会被返回多久;统计库变慢或不可用时,跳转是否跟着变慢。实现是一个 Java 21 + PostgreSQL 16 的最小服务,三个问题各自对应一组可执行的断言。
需求与范围
操作只有五种:创建随机码短链、创建自定义别名、撤销、按码访问、记录点击。过期时间在创建时写定,之后不改;目标地址创建后不可修改,需要改目标就撤销旧码再建新码。
业务不变量:
- I1 码唯一且不覆盖。一个码最多对应一个目标;任何被告知“创建成功”的调用方,其提交的目标就是库中该码的目标。
- I2 撤销有界生效。撤销事务提交后,返回旧目标的 302 只会在一个可计算的窗口内出现,窗口上界由缓存参数决定,并且可以测量。
- I3 统计不阻塞跳转。统计库连接失败、表锁等待或写入超时,不改变重定向的状态码,请求线程也不等待统计侧的连接或锁。
- I4 点击至多计一次。同一次点击因重试被写入多次时,统计表里只有一行。点击可以因队列满而丢弃,但丢弃数必须被计数。
明确排除:账号与权限、多实例部署、CDN 与浏览器缓存的实际行为、目标网站内容审查、按访客去重的独立访客统计、限流。
教学规模假设
以下数字是教学假设,用于选参数,不来自任何测量。随机码为 8 位,字母表 57 个字符(去掉 0、O、1、l、I 五个易混字符),取值空间 57⁸ ≈ 1.1×10¹⁴。假设库中已有 10⁸ 条短链,一次随机插入撞上已有码的概率约为 10⁸ / 1.1×10¹⁴ ≈ 9×10⁻⁷,连续 5 次都撞上的概率可以忽略,所以重试上限定为 5 次,超过即报错,不做无界重试。读写比假设远大于 100:1,跳转路径需要缓存;撤销假设很少发生(每秒个位数),因此允许撤销之间串行化。撤销生效窗口的目标:轮询正常时接近一个轮询周期,轮询失效时不超过缓存条目 TTL。
接口与数据模型
表 34-1:接口与错误语义
| 操作 | 成功结果 | 拒绝或失败 |
|---|---|---|
createRandom(owner, target, ttl) |
新码与冲突重试次数 | InvalidTarget;连续 5 次冲突抛 code space exhausted |
createAlias(owner, alias, target) |
别名本身 | AliasTaken(SQLSTATE 23505);InvalidTarget |
revoke(code) |
新版本号 | 已撤销或不存在时返回 -1,不产生变更记录 |
GET /{code}、HEAD /{code} |
302 + Location |
404 未知码;410 已撤销或已过期;503 主库查询异常(未做实验) |
| 其他方法 | 无 | 405 |
所有响应都带 Cache-Control: no-store。数据分两个库:主库存短链与变更日志,统计库只有点击表。分库是为了让 S2、S3 能单独让统计侧失效,而不碰主路径的连接。
1 | |
点击表 click(click_id uuid PRIMARY KEY, code, clicked_at, ingested_at) 在统计库中。click_id 在处理重定向请求时生成,之后所有重试都携带同一个值。
状态只存 ACTIVE 与 REVOKED 两种。过期不落成状态,而是每次访问时用 expires_at 与当前时间比较得出。这一区分决定了缓存能否出错:过期时间创建后不变,缓存里的副本永远正确(过期判断用重定向进程的本地时钟,expires_at 由数据库 now() 写入,两个时钟的偏差会直接加到过期边界上;本实验是单机);撤销改变的是可变状态,缓存副本会落后于库。
图 34-1:短链状态与访问结果(只画实验覆盖的转移)
stateDiagram-v2
[*] --> ACTIVE: INSERT 成功(主键无冲突)
[*] --> 拒绝: 23505 别名已占用 / 目标校验失败
ACTIVE --> REVOKED: revoke,前置条件 state='ACTIVE',version+1 并写 link_change
REVOKED --> REVOKED: 再次 revoke,影响 0 行,返回 -1
ACTIVE --> ACTIVE: GET 且未过期,返回 302
ACTIVE --> ACTIVE: GET 且 now >= expires_at,返回 410
REVOKED --> REVOKED: GET 返回 410(缓存条目被淘汰或超过 TTL 之后)
选哪个重定向状态码
RFC 9110 第 15.4 节定义了 3xx。301 与 308 表示目标资源“已被分配新的永久 URI”,302 与 307 表示“暂时位于另一个 URI,客户端应继续使用原 URI”。对短链更关键的是缓存语义:第 15.1 节列出可启发式缓存的状态码,包含 301、308、404、410,不含 302、307。RFC 9111 第 4.2.2 节允许缓存在响应没有显式过期时间时自行估算新鲜期,规范只给出约束而不规定算法;第 3 节规定,没有 max-age、Expires 等显式信息的响应,只有状态码属于可启发式缓存时才可以被存储。
推论是:返回 301 或 308 且不带缓存指令,浏览器或中间缓存可以在服务端毫不知情的情况下长期复用旧跳转,服务端撤销对这些副本无效。302 默认不可启发式缓存,但只要响应里带了 max-age,同样会被存下来。404、410 也可启发式缓存,若一个未知码先被缓存为 404,之后再被创建,客户端会继续看到 404。
表 34-2:重定向状态码比较
| 状态码 | 语义(RFC 9110) | 默认可启发式缓存 | 撤销后旧副本 | 方法改写 |
|---|---|---|---|---|
| 301 | 永久移动 | 是 | 可能长期残留 | 允许 POST 改 GET |
| 302 | 暂时位于别处 | 否 | 无显式指令时不残留 | 允许 POST 改 GET |
| 307 | 暂时,保持方法 | 否 | 同 302 | 禁止改写 |
| 308 | 永久,保持方法 | 是 | 可能长期残留 | 禁止改写 |
本实现选 302,并在所有响应上发送 no-store。RFC 9111 第 5.2.2.5 节规定 no-store 对私有缓存和共享缓存都适用,缓存不得存储该响应;同一节也写明它对恶意或被攻破的缓存不构成可靠保证。因此 I2 的成立条件包括“客户端与中间节点遵守 HTTP 缓存规范”。R1 只断言服务端发出了 no-store,没有验证任何浏览器或 CDN 的行为。服务只接受 GET 与 HEAD,POST 返回 405 并按 RFC 9110 第 15.5.6 节带 Allow: GET, HEAD(R1 断言了这个头),避免 302 对 POST 的方法改写问题。
创建:唯一性来自哪一步检查
唯一性只由 link.code 主键保证。应用层不先查是否存在,因为查询与插入之间没有任何东西阻止另一个事务插入同一个码。别名创建直接插入,冲突时由数据库返回 23505:
1 | |
随机码用 ON CONFLICT (code) DO NOTHING,并以受影响行数判断成败:
1 | |
I1 的保证来自两处:主键约束在插入时检查冲突;调用方只在 n == 1 时才把码返回给用户。若把 n 的检查去掉,冲突时调用方会拿到一个属于别人的码。若把 DO NOTHING 换成 DO UPDATE,冲突时会改写别人的目标。
负向对照 NC1 构造的就是后一种(对照组使用结构相同的 link_naive 表)。16 个线程各自先查询 promo-q4 是否存在,在屏障处等齐,然后都执行 INSERT ... ON CONFLICT (code) DO UPDATE SET target = EXCLUDED.target。PostgreSQL 16 的 INSERT 文档说明 ON CONFLICT DO UPDATE 保证原子的“插入或更新”结果,这一点没有被违反,每条语句都成功了;错的是语义。事件序列如下:
图 34-2:NC1 先查后写加覆盖式 upsert 的事件序列
1 | |
输出原文:
1 | |
同样的 16 线程换成 createAlias(A1),结果是 1 个成功、15 个收到 AliasTaken,库中目标等于唯一成功者提交的目标。A2 用脚本化的码序列让前两次插入分别撞上已有的 Abc23456 与 promo-q4,第三次成功,冲突重试次数为 2,已有行的目标不变;码序列固定为同一个值时,第 5 次冲突后抛出 code space exhausted。A3 用 16 线程各创建 50 个随机码,返回 800 个互不相同的码,库中 800 行。
I1 的失败边界:主键必须是唯一性的唯一来源。如果为了分库而把码拆到多个没有全局唯一约束的库里,或者别名比较不区分大小写而约束区分,这条保证就失效。
重定向主路径与撤销窗口
主路径只依赖主库的 link 表,经过一个进程内缓存。条目记录目标、状态、版本、过期时刻和装入时间;条目年龄小于 TTL 时直接使用,否则回源。撤销通过两条途径进入缓存:
- 撤销事务把版本加一,并在同一事务里向
link_change追加一行。重定向服务的轮询线程按seq递增读取变更,淘汰版本更旧的缓存条目。 - 轮询失效时,条目 TTL 是兜底上界:最迟一个 TTL 之后必然回源,读到
REVOKED。
于是撤销生效窗口 W 满足:轮询正常时 W ≈ 轮询周期 + 一次查询;轮询停止时 W ≤ TTL。另外有一类请求无法被任何缓存机制拦截:撤销提交之前已经读到 ACTIVE 的在途请求,它们仍会返回 302。W 的定义因此是“提交之后才开始的请求还可能拿到 302 的时间范围”。
图 34-3:撤销经轮询淘汰缓存的时序(R2 的参数)
sequenceDiagram
participant O as 所有者
participant DB as 主库 link / link_change
participant P as 轮询线程(每 100ms)
participant C as 进程内缓存(TTL 2000ms)
participant U as 访问者
U->>C: GET 码(条目 v1 ACTIVE)
C-->>U: 302
O->>DB: UPDATE state=REVOKED, version=2;INSERT link_change(seq, v2);COMMIT
U->>C: GET 码(条目仍是 v1)
C-->>U: 302(窗口内的旧值)
P->>DB: SELECT ... WHERE seq > lastSeq
DB-->>P: (seq, 码, v2)
P->>C: computeIfPresent:v1 < v2,淘汰
U->>C: GET 码(未命中)
C->>DB: 回源读到 REVOKED
C-->>U: 410
R2 与 R3 用真实 HTTP 测量这个窗口:先访问一次把条目装进缓存,提交撤销,然后每隔约 5ms 发起一次请求,直到第一次拿到 410。每个 302 响应都带一个观测用的 X-Entry-Age-Ms 头,记录所依据条目的年龄。断言有两条:撤销后每个 302 依据的条目年龄都小于 TTL;首次 410 距提交的时间不超过 TTL 加最大采样间隔加 100ms 余量。
1 | |
这些毫秒数是本机单次观察值,只说明窗口落在模型给出的范围内,不能当作性能结论。R2 中窗口小于一个 100ms 的轮询周期,具体值取决于提交时刻落在轮询周期的哪个位置,多次运行会不同;R3 关闭轮询后窗口接近 TTL,撤销后 302 所依据条目的最大年龄为 1483ms,小于 1500ms。I2 的观察位置是 X-Entry-Age-Ms 与首次 410 的时间,失败边界是下面两个反例。
两个让撤销漏掉的交错
回源读在撤销之前,填充在淘汰之后
请求 A 未命中缓存,回源读到 v1 ACTIVE;此时撤销提交,轮询线程读到 v2,去缓存里淘汰,但缓存里还没有这个码,淘汰什么也没做;随后 A 把 v1 写进缓存。这条旧条目没有任何后续变更来淘汰它,只能等 TTL 过期。
R4 与 NC3 用测试钩子构造这个交错:钩子在回源读到行之后、写入缓存之前阻塞请求 A,主线程提交撤销并等到轮询线程的“已见版本”变为 2,再放行 A。修复是让轮询线程先登记每个码的已见版本,回源结果版本更旧时不进缓存:
1 | |
轮询一侧先执行 seenVersion.merge(code, v, Math::max),再对同一个键调用 computeIfPresent。ConcurrentHashMap 的 Javadoc 写明 compute 整个调用原子执行,同一个键上的这两个操作互斥。若回源一侧先完成 compute,旧条目已在缓存里,随后的淘汰把它删掉;若轮询先登记版本,回源一侧的比较会拒绝旧读。两种次序都不会留下旧条目。
1 | |
请求 A 本身返回 302,这属于前面说的在途请求,守卫只保证它的旧读不会延续到后续请求。
小序号晚提交,轮询游标越过它
link_change.seq 来自序列,序列值在插入时分配,与提交顺序无关。两个撤销并发时,A 先拿到 seq=7 但迟迟不提交,B 拿到 seq=8 先提交;轮询读到 8,把游标推到 8;A 随后提交,seq=7 小于游标,永远不会被读到。
NC4 用 beforeCommit 钩子让撤销 A 写完变更记录后停住,再让撤销 B 完成:
1 | |
修复是让变更记录的写入串行:撤销事务开头先取一个事务级咨询锁,持有到提交。
1 | |
后一个撤销在前一个提交之后才能执行插入、拿到序列值,所以任何能看到较大序号的快照也一定能看到较小序号。成立条件:所有写入 link_change 的事务都先取这把锁(本章只有撤销写这张表),且序列 CACHE 为 1(bigserial 的默认值);CACHE 大于 1 时各会话预取号段,串行提交也不能保证序号递增。R5 中 B 在 A 提交前确实在锁上等待,seq(X)=5 < seq(Y)=6,两个码都返回 410。代价是全部撤销串行执行,这依赖“撤销很少”的规模假设;撤销频繁时需要换成按时间回扫的轮询,或改用数据库的逻辑复制流,本篇没有实现这两种方案。
去掉这两处修复时,两个反例都不违反 TTL 上界,只是把窗口从一个轮询周期拉长到一个 TTL。若 TTL 被设得很长甚至无限,它们就会让撤销完全失效。
统计:故障留在主路径之外
处理请求时,服务先完成存在、状态、过期三项检查,然后生成一个点击记录,用不阻塞的 offer 放进有界队列,队列满则丢弃并计数,最后返回 302:
1 | |
后台写入器每批最多取 100 条,在一个事务里执行 INSERT ... ON CONFLICT (click_id) DO NOTHING;失败时保留整个批次,稍后重试;连接设置 lock_timeout = 200ms,锁等待不会无限挂住写入器。PostgreSQL 16 文档对 lock_timeout 的说明是:语句等待锁超过该时长即被中止,每次加锁尝试分别计时。
I3 的保证来自 offer 不等待这一步,以及写入器与请求线程不共享连接。I4 的保证来自 click_id 主键和 DO NOTHING:批次提交成功但确认丢失、写入器重发整批时,已提交的行被跳过。
表 34-3:统计侧故障用例
| 用例 | 注入方式 | 跳转结果 | 统计结果 |
|---|---|---|---|
| S1 | 无 | 20 次 302 | 20 行,丢弃 0 |
| S2 | 统计库地址指向不可达端口,队列容量 8 | 30 次 302 | 失败批次 7,丢弃 21;恢复地址后补写 9 行,等于入队数 |
| S3 | 另一会话持有 click 表的 ACCESS EXCLUSIVE 锁 |
20 次 302(客户端超时 1000ms) | 写入器 2 次因 55P03 失败;释放锁后 20 行 |
| S4 | 第一个批次提交后模拟确认丢失 | 10 次 302 | 批次尝试 6、失败 1,插入 10 行,主键去重 1 行 |
| NC2 | 统计同步写入,表被锁 | 3 次请求全部客户端超时 | 不适用 |
ACCESS EXCLUSIVE 与所有锁模式冲突,INSERT 需要的 ROW EXCLUSIVE 也在其中(PostgreSQL 16 显式锁定一章)。NC2 把统计写入放回请求线程,且该连接没有 lock_timeout,于是 3 次请求全部在客户端 1000ms 超时:
1 | |
S4 的去重行数等于确认丢失那一批的大小。10 次请求是串行发出的,写入器取第一批时队列里只有第一次点击,所以本次运行去重 1 行,其余 9 次点击作为后续批次正常插入。批次如何切分取决于调度,断言只要求“重试批次中有行被去重”和“最终 10 行”。
统计的失败边界写在契约里:点击可以丢,丢的数量在 dropped 计数器上;队列在进程内存中,进程崩溃时未入库的点击全部丢失,本篇没有做崩溃实验。I4 只说明同一个 click_id 不会计两次,不说明同一访客的重复点击会被合并。
开放重定向
RFC 6749 第 10.15 节把开放重定向器定义为:用参数指定跳转位置、且不做任何校验就把用户代理重定向过去的端点,并指出它可被用于钓鱼,借用可信域名的外观。短链服务按定义就是重定向器,差别只在“参数”换成了预先登记的码。能在语法层面做的校验有四项,V1 覆盖了每一项:
- 只接受
http与https,拒绝javascript:、data:、ftp:,拒绝没有协议的相对地址与协议相对地址//host/x。 - 拒绝 userinfo。RFC 9110 第 4.2.4 节建议(SHOULD)接收方把来自不可信来源的 http(s) URI 中的 userinfo 视为错误,因为它常被用来掩盖真实主机;RFC 3986 第 7.6 节给出了
@前伪装主机名的例子。 - 拒绝空白与控制字符。目标会原样进入
Location头,回车换行进入响应头就是头部注入。 - 要求非空主机名,拒绝
https:///nohost。
语法校验管不了目标站点本身是否恶意。对已登记的恶意目标,可用的补救手段就是撤销,这也是 I2 需要一个可计算上界的原因。另有一处跨站泄露:RFC 9110 第 10.2.2 节规定,Location 不带片段时,用户代理沿用原请求 URI 的片段,第 17.11 节提醒这可能把一个站点的片段带到另一个站点。本实现不处理片段继承,列为限制。
恢复流程
- 轮询连接断开:轮询线程捕获
SQLException,睡眠一个周期后重连,游标lastSeq保留在内存中,重连后从原位置继续。断开期间撤销窗口退化为 TTL。R3 通过关闭轮询模拟了这一退化情形;断线重连与游标续读的代码路径没有做实验。 - 重定向进程重启:缓存为空,游标从
max(seq)开始。启动前的撤销已经反映在link行里,回源即可读到,不需要回放。 - 撤销事务失败:变更记录与状态更新在同一事务,回滚后两者都不存在,缓存保持旧值是正确的。重复撤销影响 0 行,返回 -1,不追加变更记录。
- 统计库不可用:写入器保留当前批次并按 100ms 间隔重试,队列满后丢弃并计数;恢复后补写已入队部分(S2)。
- 统计提交结果未知:整批重发,
click_id去重(S4)。核对口径是统计库count(*)等于写入器的rowsInserted,且等于入队数。
证据与限制
run.sh 一条命令完成启动集群(端口 55434)、重建 shortlink34 与 shortlink34_stats 两个库、编译、运行全部用例并停集群。最后一次运行退出码为 0,汇总行:
1 | |
表 34-4:实验清单
| 编号 | 类别 | 内容 | 状态 |
|---|---|---|---|
| V1 | 模型回放(纯函数) | 目标地址校验 | PASS |
| A1、A2、A3 | 真实组件 | 别名并发、随机码冲突重试、并发随机创建 | PASS |
| NC1 | 真实组件,负向对照 | 先查后写加覆盖式 upsert | PASS(暴露覆盖) |
| R1 | 真实组件 + 回环 HTTP | 302/404/410/405 与 no-store |
PASS |
| R2、R3 | 真实组件 + 回环 HTTP | 撤销窗口,轮询开启与关闭 | PASS |
| R4、NC3 | 真实组件 + 确定性钩子 | 回源旧读与版本守卫 | PASS |
| R5、NC4 | 真实组件 + 确定性钩子 | 变更日志序号与提交顺序 | PASS |
| S1–S4、NC2 | 真实组件 + 回环 HTTP | 统计故障隔离、丢弃计数、去重 | PASS |
限制逐条列在 evidence/34/LIMITS.md,要点如下。服务端与客户端在同一个 JVM 内经回环地址通信,没有浏览器、代理或 CDN,no-store 是否被遵守未验证。重定向服务只有一个实例,多实例时每个实例各自轮询,窗口取各实例中最慢的一个,本篇没有测。主库故障时主路径返回 503 或阻塞在连接池上,没有做主库故障实验。统计队列在内存中,进程崩溃会丢失未入库点击,没有做 kill -9 实验。所有毫秒数都是本机单次观察值。
本批导航
- 上一篇:33 通知渠道的结果未知
- 下一篇:35 文件上传与异步处理
- 本批索引:仓库
batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md - 运行入口:
batch-3/examples/shortlink/run.sh - 证据目录:
batch-3/evidence/34/ - 本篇工程与证据快照:backend-systems-34.tar.gz(SHA-256 见同目录
backend-systems-34.tar.gz.sha256)。正文中的examples/、evidence/、SOURCES.md路径都相对仓库的batch-3/目录,与快照包内布局一致。






