跳到主要内容

面试题:设计短链接系统——短码怎么生成才不碰撞、跳转用 301 还是 302、读写比 100:1 的存储与缓存怎么选?

· 阅读需 9 分钟

这是「每日一题」专栏的第十八篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇讲了 分布式限流怎么做,今天按轮换回到系统设计方向,兑现昨天的预告:短链接系统。8 月 12 日已经写过一版 基础篇,今天这版是进阶篇——聚焦预告点名的三个硬核考点:短码怎么生成才不碰撞、跳转到底用 301 还是 302、读写比悬殊的存储与缓存怎么选,顺便把基础篇里「跳转一律用 302」的简化结论升级成完整决策框架。

一、题目与三个考点​

需求老规矩先澄清,但这里只列影响架构的几条:

  • 写入 1 亿条/天,读取 100 亿次/天,读写比约 100:1,读峰值约 35 万 QPS;
  • 短码 7 位 Base62(62 的 7 次方 ≈ 3.5 万亿组合,容量估算详见基础篇);
  • 短链永久有效,跳转延迟目标低于 100ms。

三个考点正好对应三层:算法层(短码生成不碰撞)、协议层(301/302 的取舍)、存储层(读写比悬殊的选型)。下面逐层拆。

二、短码生成:不碰撞的三个流派​

「不碰撞」是硬需求——短码就是主键,撞了等于数据丢失。三个流派各有取舍:

流派一:随机短码 + 唯一索引重试​

每次生成 7 位随机 Base62 串,靠数据库唯一索引兜底:

import random, string

ALPHABET = string.digits + string.ascii_letters # 62 个字符

def random_code(length=7):
return ''.join(random.choice(ALPHABET) for _ in range(length))

def create_short_url(url, db):
for _ in range(5): # 最多重试 5 次
code = random_code()
try:
db.insert(code, url) # 唯一索引冲突直接抛异常
return code
except UniqueViolation:
continue # 撞了换一个重试
raise TooManyCollisions

两个关键细节:

  1. 不先 SELECT 再 INSERT。先查再插有 TOCTOU 竞态——两个请求同时查都说不存在,然后都插入,还是撞。正确姿势是直接 INSERT、依赖唯一索引捕获冲突异常重试,让「检测 + 插入」由数据库原子完成。
  2. 重试次数要封顶。7 位码空间 3.5 万亿,亿级数据下碰撞概率远低于 1%(生日悖论),5 次重试绰绰有余;但上限必须有,防止极端情况死循环。

流派二:发号器 + Base62(工业界主流)​

不碰概率,从源头消灭碰撞:先取一个全局唯一 ID,再编码成短码。

# 伪代码:号段式发号器
# Redis INCR / DB 号段 / Snowflake 变体,取到全局唯一 ID
code = base62_encode(get_next_id()) # base62 实现见基础篇

# 7 位 Base62 空间约 3.5 万亿,号段分配下绰绰有余
# 示例:ID=100 → "1C",ID=62^7 - 1 → "ZZZZZZZ"

优势:零碰撞重试,写入路径干净,高并发下没有异常风暴;代价是短码可被顺序推算(ID 递增,业务量泄漏)。缓解手段:编码后对字符做一次固定置换(乱序表),或号段内加随机步长。

流派三:预生成池​

后台线程批量生成短码、查重后灌入 Redis 队列,写入时 RPOP 直接取——O(1) 出码、零等待。适合写入有瞬时峰值的场景(比如活动期的建链洪峰)。代价是池子要维护水位监控,池子空了会退化回碰撞重试。

三流派对比​

流派碰撞处理写入代价幂等性复杂度
随机码 + 唯一索引重试概率低 + 重试兜底异常路径重试不幂等低
发号器 + Base62源头消灭一次取号不幂等中
预生成池预查重O(1) 出码不幂等中高

加分点:答完三流派后主动补一句「同一 URL 重复创建要幂等」——对长 URL 做哈希,命中直接返回已有短码,避免垃圾数据。这是区分背题和真懂的分水岭。

三、301 还是 302:先反问产品需求​

这是预告点名的考点,也是基础篇被简化掉的地方。先把语义讲死:

  • 301 永久重定向:浏览器、代理、CDN 都会缓存这个响应,下次访问直接跳目标地址,不再回源;
  • 302 临时重定向:每次访问都要回源问一次短链服务。

所以这不是技术判断题,是产品判断题:

产品诉求选择原因
要点击统计、要动态分流302每次回源才能记账、才能按设备/地区/时间动态决定目标
纯工具型、极致省成本301热点短链被浏览器和 CDN 缓存,服务器压力骤降,跳转更快
营销链接要统计 + 长尾要省成本混合统计类走 302,普通链接走 301 + CDN 日志离线分析

两个进阶细节:

  1. 301 也能做统计——短链域名接入 CDN,CDN 访问日志本身就是全量点击数据,离线分析即可,不需要每次回源。统计需求不是 302 的充分条件。
  2. 过期短链的语义:永久链接指向的资源失效时,专业做法是返回 410 Gone(明确的「资源已删除」,搜索引擎会摘除索引),而不是 302 到一个错误页。

四、读写比 100:1:存储层与缓存层怎么选​

1. 存储层:这是 KV 语义,不是关系语义​

短链映射是教科书级的 KV 负载:code → url,无 join、无范围查询、单点读写。基础篇给的 MySQL 分片方案能用,但生产上更常见的答案其实是 KV 存储(DynamoDB / Cassandra / 自研分片 KV):

  • 按 code 哈希分区,读放大极小(一次点查);
  • 天然水平扩展,容量从 TB 级到 PB 级无感;
  • 只追加写入,无二级索引维护成本。

选 MySQL 也不丢分,但要说清楚「为什么关系型在这里是杀鸡用牛刀」——面试官想听的是你分得清负载类型。

一致性:用户刚创建的短链必须立即可访问。MySQL 主从有同步延迟,读从库可能读到不存在——所以写路径要写穿透:写主库的同时写/删缓存,读路径优先命中缓存,天然规避从库延迟。

2. 缓存层:Cache-Aside + 三个经典坑​

读多写少、100:1 的读写比,缓存是必选项,模式就是 Cache-Aside(读未命中回填、写时先写库再删缓存)。但短链场景有三个必须主动讲的坑:

坑一:缓存击穿——热点短码回源风暴。一条被大 V 转发的短链,缓存过期瞬间可能有几十万 QPS 同时回源打 DB。解法:单 key 重建互斥锁——Redis SETNX 拿锁,拿到锁的请求查库回填,其余请求短暂等待或返回旧值(逻辑过期方案)。

坑二:缓存穿透——不存在的短码。攻击者拿随机码批量探测,全部未命中、全部打 DB。解法:布隆过滤器前置(内存判存在性,不存在直接拒绝),或对空结果做短 TTL 缓存。

坑三:缓存雪崩——批量过期。大量 key 同一时刻过期(比如同时批量创建的营销短链),回源流量叠加成峰。解法:TTL 加随机抖动(基础 TTL 加减 10%~30% 的随机量)。

3. 多级缓存:短链是最适合 CDN 缓存的负载之一​

短链映射只读稳定、无个性化,301 响应本身就是可缓存的静态资源。完整链路是四层:

浏览器/客户端
│ 命中即跳转,零回源
CDN 边缘(301 响应缓存,热点流量直接扛住)
│
网关本地缓存(Caffeine/进程内,微秒级)
│
Redis 集群(全局共享,毫秒级)
│
KV 存储(兜底,最终一致)

CDN 层是真正的杀器:把 301 响应按短码缓存到边缘节点,热点短链的流量根本到不了后端。这也是「301 + CDN 日志统计」组合能成立的原因——缓存和统计双赢。

五、面试追问合集​

追问回答要点
碰撞重试会不会打爆数据库?碰撞概率极低,重试是异常路径不是常态;发号器流派直接消灭
301 被浏览器缓存了还怎么统计?CDN 访问日志全量离线分析;或对统计类链接单独走 302
缓存全挂了怎么办?降级直连 KV 存储 + 限流保护(fail-open + 本地兜底,见 限流篇)
自定义短码(用户指定 alias)怎么处理?唯一索引校验 + 保留字黑名单 + 长度限制
短码空间用完了怎么办?7 位 3.5 万亿远够;真用完就加长到 8 位,老码兼容
怎么防滥用刷接口?令牌桶限流 + 恶意域名检测 + 创建配额

六、总结​

短链接这道题,从基础篇到进阶篇串起来就是三层答案:

  1. 算法层:短码生成三流派——随机码 + 唯一索引重试(简单可靠)、发号器 + Base62(工业界主流、零碰撞)、预生成池(峰值吞吐);核心是「不先查再插,直接依赖唯一索引」;
  2. 协议层:301/302 是产品判断题——要统计走 302,要省成本走 301 + CDN 缓存 + 日志分析;
  3. 存储层:KV 语义选 KV 存储,Cache-Aside 兜底,防击穿/穿透/雪崩三件套,CDN 边缘缓存是终极大杀器。

能答出「为什么是 KV 而不是 MySQL」「301 缓存了怎么统计」「缓存挂了怎么降级」这三个追问,这道题就是满分。


明日预告:AI/LLM 方向——「投机解码(Speculative Decoding):草稿模型怎么选、为什么能提速 2~3 倍、和 KV Cache 怎么配合?」