OpenAI 短链接面试题的完整解答:容量估算与 key 长度、key 生成方案对比、计数器范围、可猜测性、存储、全局重定向路径、301 与 302、热点 key 缓存、脱离热路径的点击分析,以及滥用防控。
由 The Forward Deployed 编辑团队撰写 已审阅
属于 OpenAI 系统设计题库。该题目具有该轮面试的代表性。分析与解答为本站原创。
问题描述
用户提交一个长 URL,然后得到类似
https://sho.rt/aZ3k9Qx
的短链接。任何人打开它都会被重定向到原始 URL。链接所有者可以查看点击次数。想法很简单,而高级别的面试轮次考察的是深度:大规模下的 key 生成、快速且低成本的全局重定向路径、绝不拖慢重定向的分析功能,以及滥用防控。
澄清问题
- 规模? 练习场景:每天 1 亿条新链接,读写比为 100:1。
- Key 长度? 尽可能短。根据数据推导出来。
- 自定义别名? 支持,可选。
- 过期时间? 每条链接可选。
- 分析功能? 按天、国家、来源统计点击次数,延迟一分钟可以接受。
- 链接创建后能否修改? 目标地址不变;链接可以被禁用。
- 私有链接? 有些链接指向私有文档,因此 key 不应可被猜出。
短链接服务难在哪里
小规模下没有什么是难的。大规模下,有三件事很难。
Key 生成必须以每秒数千次的速度、跨多台服务器生成数十亿个唯一且短的 key,既不能有中心化瓶颈,也不能出现冲突。
重定向路径承载了几乎全部流量,来自世界各地。它每增加一毫秒,就会加到每一次点击上;而它上面的每一次数据库读取,在每秒 10 万次读取的规模下都要花钱。
而且公开的短链接服务会招来滥用:钓鱼和恶意软件链接藏在短 URL 背后,扫描器会枚举 key 来寻找私有内容。
因此核心矛盾是读取延迟和成本与业务所需的簿记之间的权衡:分析、滥用检查和链接管理都必须在不触碰重定向热路径的情况下完成。
flowchart LR U([用户点击短链接]):::user --> EDGE[边缘 / CDN 区域]:::svc EDGE --> C[(区域缓存)]:::store C -->|未命中| KV[(键值存储副本)]:::store EDGE -->|302 Location| U EDGE -.点击事件.-> LOG[[点击流]]:::svc classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思路。 把重定向做成靠近用户的缓存查找。其他一切,包括分析和滥用检查,都在这条路径之外进行。
关键概念
Base-62 编码
数字、小写和大写字母共提供 62 个符号。用 62 进制表示的数字很短:7 个字符可覆盖 62^7,约 3.5 万亿个值。
计数器区间
协调器为每台应用服务器分配一段 ID 区间,例如每次 10,000 个。服务器在自己的区间内生成键,无需每次请求都协调,区间用完后申请新区间。
301 和 302
301 重定向是永久性的:浏览器和代理可能会缓存它,下次直接跳过你的服务器。302 是临时性的:每次点击都会回到你的服务器。
热点键
链接的流行度极度倾斜:少数链接获得大部分点击。缓存这几个链接就能服务大部分流量。
核心思路。 用 62 进制生成短键,用计数器区间实现无需协调的规模化,用 302 保留控制权和数据分析,用缓存应对倾斜分布。
需求
在继续阅读之前。 先列出需求,再根据数字推导键的长度,然后选择方案。
1.1 功能需求
- 为长 URL 创建短链接,可选自定义别名和过期时间。
- 将短链接重定向到其目标地址。
- 向所有者展示点击分析。
- 禁用链接(所有者或信任与安全团队)。
1.2 非功能需求
- 重定向延迟:全球 p95 低于 50 毫秒,不含目标站点。
- 重定向的可用性:99.99% 或更高;文档中的链接必须持续可用。
- 持久性:已创建的链接不会消失,除非被禁用。
- 唯一性:任何两个链接不共享同一个键。
- 对于可能指向私有内容的链接,键必须不可猜测。
1.3 约束与属性
重定向可用性是属性。 短链接会存在于文档、电子邮件和印刷材料中多年。读流量是约束。 平均每秒 116,000 次重定向,重定向路径决定了成本和延迟。
粗略估算
- 写入:每天 1 亿条链接 / 86,400 ≈ 每秒 1,160 条。
- 读取:写入的 100 倍 ≈ 平均每秒 116,000 次;峰值高出数倍。
- 五年内的链接数:1 亿 × 365 × 5 = 1,825 亿。
- 键长度:62^6 ≈ 570 亿太少;62^7 ≈ 3.5 万亿,覆盖五年还有余量。7 个字符。
- 存储:1,825 亿 × 500 字节(URL、键、所有者、时间戳)≈ 五年约 91 TB,未计副本。
- 缓存:如果最热门的 1% 链接获得大部分点击,而过去一年链接的 1% 约为 3.65 亿条、每条 500 字节,那么一个区域约需 180 GB 缓存;只缓存最热的一小部分链接即可捕获大部分命中。
- 点击事件:每秒 116,000 次 × 200 字节 ≈ 23 MB/s 进入分析流,每天约 2 TB。
核心思路。 每秒 1,160 次写入和 116,000 次读取,7 字符键,五年 91 TB,点击流比链接数据本身还大。
API 设计
阅读前思考。 同一个长 URL 被两个不同用户缩短。他们应该得到相同的 key 吗?
通常不应该。不同的所有者想要各自的分析数据,并且能够独立禁用自己的链接。只有按所有者区分时,为相同 URL 返回相同 key 才有意义,作为一种便利。
POST /v1/links
{long_url, custom_alias?, expires_at?}
-> 201 {key, short_url: "https://sho.rt/aZ3k9Qx"}
-> 409 alias taken
-> 422 URL blocked by safety checks
GET /:key
-> 302 Location: <long_url> Cache-Control: private, max-age=0
-> 404 unknown key
-> 410 disabled or expired
GET /v1/links/:key/stats?from=&to= (owner) -> clicks by day, country, referrer
PATCH /v1/links/:key {disabled: true} (owner)数据模型
links (key PRIMARY KEY, long_url, owner_id, created_at, expires_at, status)
-- key-value store, partitioned by key, replicated to every region
owner_links (owner_id, created_at, key) -- "my links" list
id_blocks (range_start, range_end, server_id, allocated_at)
clicks_by_day (key, day, country, referrer_domain, count) -- analytics store重定向路径只涉及
links
,按精确 key 查找。没有连接,没有范围查询,这就是键值存储适合的原因。
高层设计
5.1 单台服务器和一张 SQL 表
单台服务器写入带自增 ID 的行,将 ID 转换为 base 62 作为 key,并通过查找进行重定向。它能工作到单机的极限,而且自增计数器使 key 连续且可被猜测。
5.2 修复 1:用计数器区间生成 key
多台 API 服务器各自从协调器(一个小型强一致服务)获取一段 ID 区间,并在本地从该区间生成 key。
flowchart LR COORD[(Range coordinator)]:::new -->|block 50,000,000-50,009,999| A1[API server 1]:::svc COORD -->|block 50,010,000-50,019,999| A2[API server 2]:::svc A1 -->|next id -> permute -> base62| K1[key]:::store classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.3 修复 2:分布式键值存储
将链接存储在按 key 分区并跨区域复制的键值存储中。按 key 读取只需个位数毫秒。
5.4 修复 3:边缘的区域缓存
重定向在用户所在区域提供服务:边缘服务器先检查内存缓存,再查询区域副本。大多数点击不会离开该区域,且大多数命中缓存。
5.5 修复 4:将分析移出热路径
重定向将点击事件发送到流并立即返回。流处理器聚合计数。
5.6 修复 5:滥用控制
新 URL 在返回 key 之前会对照恶意软件和钓鱼网站名单进行检查,热门链接会被重新扫描。
5.7 组合后的设计
sequenceDiagram
autonumber
actor Cr as Creator
participant API as API server
participant S as Safety check
participant KV as Key-value store
actor V as Visitor
participant E as Edge server
participant C as Regional cache
participant ST as Click stream
Cr->>API: POST /links {long_url}
API->>S: check URL
S-->>API: ok
API->>API: id from local block, permute, base62
API->>KV: put key -> long_url (if absent)
API-->>Cr: short_url
V->>E: GET /aZ3k9Qx
E->>C: get key
alt hit
C-->>E: long_url
else miss
E->>KV: get key (regional replica)
E->>C: fill
end
E-->>V: 302 Location
E--)ST: click event (async)核心思想。 用 ID 区间生成 key,用复制的键值存储保存链接,用边缘缓存处理重定向,用流处理点击,并在创建时进行安全检查。
深入探讨
6.1 key 生成方案对比
阅读前思考。 比较四种生成 key 的方式。你会选哪一种?如果 key 必须不可被猜测,会有什么变化?
| 选项 | 方式 | 优点 | 缺点 |
|---|---|---|---|
| 全局计数器 + base 62 | 单一计数器,编码 | 无冲突,简单 | 中心瓶颈;key 连续、可被猜测 |
| 计数器区间 | 每台服务器一段区间 | 写入无需协调;无冲突 | 崩溃的服务器浪费其区间(可接受);除非做置换,否则仍可被猜测 |
| URL 哈希 | 取哈希的前 7 个字符 | 相同 URL 得到相同 key;无需计数器 | 冲突需要检测并重试;不同所有者得到相同 key |
| 随机 + 校验 | 7 个随机 base-62 字符;不存在时插入 | 不可猜测;无需协调 | 需要条件插入;随着空间填满,重试次数增加 |
计数器区间是吞吐量方面最强的默认方案。对于不可猜测的键,在编码之前,将每个计数器值通过一个可逆置换——即对 ID 空间的一个带密钥分组密码——进行处理。键保持唯一,因为置换是一对一的,且连续的 ID 会产生不相关的键。或者使用带条件插入的随机键:在 3.5 万亿个可能值中已有 1820 亿个链接时,五年后空间大约填满 5%,因此大约每 20 次插入会有 1 次冲突并重试一次,代价很低。
自定义别名共享键空间。使用同样的条件 put 插入它们;别名已被占用则返回 409。保留诸如
api
,
admin
和
login
.
区分答案优劣之处:键生成
WeakOne 全局计数器
使用自增键,没有针对扩展性或不可猜测性的规划。
GoodRanges 或随机键
使用计数器区间或带条件插入的随机键,并根据数字推导键长度。
Strong 对比并加固
对比所有方案,对计数器做置换或使用随机键以保证不可猜测性,计算空间填满时的冲突率,并处理自定义别名和保留词。
6.2 存储与复制
访问模式是单键读和带唯一性检查的单键写。分布式键值存储很合适:按键哈希分区,在每个区域内复制以保证持久性,并异步复制到其他区域以支持本地读取。
创建时以条件 put 写入键的主区域,因此唯一性在一个地方得到保证。其他区域会在大约一秒内收到该链接。另一个区域的访客若在链接复制完成前点击一个全新链接,会遇到缓存未命中和本地未命中;在返回 404 之前,回退到读取主区域。这个回退机制覆盖了链接生命周期的最初一秒。
6.3 重定向路径
在继续阅读之前。 301 还是 302?为你的选择辩护。
301 是永久重定向。浏览器会缓存它,通常是无限期缓存,因此该浏览器后续的点击会直接跳转到目标地址,而不再联系你。这节省了负载。但也意味着你会丢失分析中的这次点击,而且你永远无法对该浏览器进行不同的重定向,即使你因为链接变成恶意链接而将其禁用。
302 每次都会返回。你为每次点击付费,但保留了分析和控制能力。大多数商业短链接服务出于这个原因使用 302。添加
Cache-Control: private, max-age=0
,使共享缓存也不存储它。
延迟:从多个区域的边缘服务器提供重定向,每台服务器都有热点键的内存缓存和附近的键值副本。缓存命中可以在一两毫秒内响应,再加上到边缘的网络往返时间。
6.4 缓存热点链接
流量分布高度倾斜。一个病毒式传播的链接可能每秒获得数万次点击。每台边缘服务器上的进程内缓存,配合区域缓存集群作为后盾,可以吸收这些流量。带大小上限的最近最少使用策略效果很好。
有两种情况需要小心处理。负缓存:扫描器会请求随机键;对“未找到”的结果进行短时间缓存,以免它们不断冲击存储。禁用:当链接被禁用时,向每个区域发布失效通知,让缓存立即丢弃该链接,而不是等到生存时间过期。对于恶意链接,几分钟都至关重要。
6.5 分析
绝不要每次点击都写数据库。边缘服务器向流中发出一个小事件(键、时间戳、来自 IP 的国家、引荐域名、用户代理的哈希值),然后返回重定向。流处理器按分钟对每个键的计数进行聚合,并写入分析存储,以回答“按国家统计的每日点击量”。一分钟的延迟对用户是不可见的。
根据用户代理和请求频率对明显的机器人进行去重,并将其从计数中排除。不要将原始 IP 地址存储超过必要的时间。
6.6 滥用
- 创建时:对照恶意软件和钓鱼网站名单检查目标地址;封锁已知恶意域名;按账户和 IP 限制创建频率。
- 创建后:对突然变得热门的链接进行重新扫描,因为攻击者会先创建干净的链接,之后再在自己服务器上更换目标地址。
- 快速禁用:信任与安全团队可以禁用某个键,这会使全球缓存失效,并返回 410 状态码和警告页面。
- 枚举防护:使用不可猜测的键、按 IP 对 404 进行速率限制,且不采用顺序结构。
6.7 键生成,附代码
在继续阅读之前。 编写一个函数,将计数器值转换为不可猜测的 7 字符键,并证明它不会发生冲突。
import hashlib, hmac
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
SPACE = 62 ** 7 # about 3.5 trillion keys
def to_base62(n: int, width: int = 7) -> str:
chars = []
for _ in range(width):
n, r = divmod(n, 62)
chars.append(ALPHABET[r])
return "".join(reversed(chars))
def feistel_permute(n: int, secret: bytes, rounds: int = 4) -> int:
# Balanced Feistel network over a power-of-two domain, then cycle-walk into SPACE.
bits = (SPACE - 1).bit_length()
half = (bits + 1) // 2
mask = (1 << half) - 1
while True:
left, right = n >> half, n & mask
for i in range(rounds):
f = int.from_bytes(hmac.new(secret, bytes([i]) + right.to_bytes(8, "big"),
hashlib.sha256).digest()[:8], "big") & mask
left, right = right, left ^ f
n = (left << half) | right
if n < SPACE: # cycle-walk until inside the key space
return n
def key_for(counter: int, secret: bytes) -> str:
return to_base62(feistel_permute(counter, secret))Feistel 网络是一个置换:每一轮都是可逆的,因此不同的输入产生不同的输出。循环行走使输出保持在键空间内,同时保持一一对应。因此不同的计数器产生不同的键,无需冲突检查,且连续的计数器产生互不相关的键。将密钥保存在密钥管理器中;如果密钥泄露,键会重新变得可预测,但绝不会重复。
6.8 重定向路径的实际成本
作为练习:平均每秒 116,000 次重定向。在边缘缓存命中率为 95% 的情况下,键值存储在所有区域每秒只需处理约 5,800 次读取,这是一个很小的集群。如果没有缓存,它将承受全部 116,000 次读取。缓存在每个区域需要几百 GB 的内存;节省下来的是存储层的大部分读取容量,以及每次点击的几毫秒延迟。
点击流是更大的成本:每天约 2 TB。在几分钟内完成聚合,并将原始事件保留一个较短的窗口(例如 7 天)用于滥用调查;之后只保留聚合数据。
什么能区分出优秀的回答:深度
弱对 URL 进行哈希并寄希望于运气
截取 URL 的哈希值,寄希望于冲突很少发生,且不做任何检查。
GoodA 无冲突方案
使用计数器区间或随机密钥,配合条件插入。
Strong无冲突且不可猜测,但有成本
使用带密钥的 Feistel 网络对计数器进行置换,解释为什么不会发生冲突,并量化边缘缓存和点击流带来的成本与节省。
变体
7.1 可编辑的目标地址
有些产品允许所有者修改目标地址。此时必须使用 302,编辑时缓存需要失效处理,并且应保留目标地址历史记录以便滥用审查。
7.2 Pastebin
存储文本而非 URL。内容存入对象存储;键值存储保存指针。读取通过 CDN 提供内容。
7.3 企业品牌域名
客户使用自己的域名,例如
go.company.com
。密钥按域名划分作用域;键值存储的键变为
(domain, key)
.
7.4 流量增长十倍时
在每秒一百万次重定向的情况下,架构上没有任何变化:更多边缘服务器、更大的缓存、更多的流分区。需要重新检查的是密钥空间:每天新增 10 亿条链接,五年就是 1.8 万亿,约为 62^7 的一半。在密钥空间用满一半之前迁移到 8 字符密钥,这样随机密钥冲突和置换的循环行走成本仍然很低。
可迁移的模式
URL 短链接服务本质上是一个带异步旁路通道的全球复制键值查找系统。同样的架构适用于功能开关、配置查找和会话存储:让热点读取命中本地缓存,保持写入稀少且带条件,并把所有记账工作移到流中。
复习:30 秒答案
- 先说数字。 每秒 1,160 次写入和 116,000 次读取;五年内 1,820 亿条链接;7 个 base-62 字符。
- 计数器区间、置换计数器或随机密钥。 每次写入无需协调;需要时不可猜测。
- 键值存储,复制到多个区域。 在主区域执行条件写入;对全新链接提供回退读取。
- 从边缘缓存返回 302。 控制与分析;负缓存;禁用时全局缓存失效。
- 点击写入流;在创建时和流行时进行滥用检查。
测验
+如何推导密钥长度?
估算服务生命周期内的链接总数,这里是五年 1,825 亿条,然后选择空间有余量地超过该数的最短 base-62 长度。62^6 约为 570 亿,太小;62^7 约为 3.5 万亿。
+为什么计数器区间能避免中心瓶颈?
每台服务器一次性预留一段 ID 区间,然后在本地生成密钥。协调器每区间只需联系一次,而不是每条链接联系一次。
+如何让基于计数器的密钥不可猜测?
在编码之前,将每个计数值通过 ID 空间上的可逆带密钥置换。它保持一一对应,因此密钥仍然唯一,但连续的 ID 会映射到不相关的密钥。
+为什么大多数短链接服务使用 302 而不是 301?
301 可能会被浏览器无限期缓存,因此后续点击会跳过该服务。这会丢失分析数据,也无法再禁用或更改链接。而 302 每次都会回到该服务。
+为什么将点击发送到流中,而不是写入数据库?
每次点击都写数据库会给每个重定向增加延迟,并且在每秒 116,000 次点击的情况下产生巨大的写入负载。流将重定向与聚合处理解耦。
+为什么 Feistel 置换不会产生两个相同的密钥?
每一轮 Feistel 都是可逆的,因此整个网络是一个一一映射。循环行走(cycle-walking)确保它在密钥空间内保持一一对应。因此不同的计数器会得到不同的密钥。
+服务应该在什么时候迁移到 8 字符密钥?
远在 7 字符空间填满之前,例如当预计链接数达到 62^7 的一半时,这样冲突重试和循环行走仍能保持低成本。
资料来源与延伸阅读
- RFC 9110:HTTP 语义,重定向 3xx 定义了 301 和 302 的行为及缓存机制。
- Google Safe Browsing 描述了一个用于检查 URL 是否属于恶意软件和钓鱼网站列表的 API。
- 保形加密 涵盖了在固定大小的 ID 空间上的带密钥置换。
- 同样以读为主、缓存优先的思路也适用于附近地点搜索。