设计类似 TinyURL 的 URL 缩短服务 (改进版)
1. 为什么需要缩短网址?
长 URL 难以记忆、分享和输入。对于用户来说,短链接(如 tiny.url/vzet59pa
)更易于在社交媒体、短信或印刷材料上传播,并降低了输入错误率。
对于服务提供者(如 Twitter、Bitly 或公司内部),它是一个强大的工具。它允许服务方:
- 跟踪分析: 统计每个链接的点击次数、来源、用户地理分布等。
- 衡量效果: 用于 A/B 测试或衡量广告活动的投资回报率。
- 管理链接: 可以在不更改短链接的情况下,动态地更新背后的原始长 URL。
- 安全代理: 在重定向前检查原始链接是否为恶意网站,保护用户。
2. 系统需求与目标
-
功能要求:
- 创建: 给定一个长 URL,系统必须生成一个唯一的、更短的 URL 别名(短链接)。
- 重定向: 当用户访问短链接时,系统必须将其重定向到原始的长 URL。
- 自定义: 用户应能够选择一个自定义的短链接(例如
tiny.url/my-brand
),前提是该别名未被占用。 - 过期: 链接应有可配置的过期时间(例如,默认为 2 年,或由用户指定)。
-
非功能性需求:
- 高可用性: 系统必须达到 99.99% 或更高的正常运行时间。服务宕机会导致所有 URL 重定向失败,这是不可接受的。
- 极低延迟: URL 重定向(读取操作)必须极快(例如,p99 延迟 < 50ms)。这是系统的核心用户体验。
- 不可预测性: 自动生成的短链接不应该是可猜测的(例如,不是
1, 2, 3...
这样的递增 ID),以防止恶意爬取。 - 可扩展性: 系统必须能够水平扩展以处理数十亿级别的链接和极高的读取流量。
-
扩展要求:
- 分析: 必须跟踪重定向的次数、时间、来源、地理位置等元数据。
- API: 必须通过 REST API 向其他服务(包括内部和第三方)开放。
- 安全: 必须能够检测和标记指向恶意软件/钓鱼网站的链接,并在重定向时警告用户。
3. 容量估算与约束
这是一个极端读取密集型的系统。
-
读写比: 假设为 100:1 (100 次重定向对应 1 次新链接创建)。
-
写入量 (新 URL): 假设每月 5 亿个新链接。
500,000,000 / (30 天 * 24 * 3600 秒) ≈
200 QPS (每秒 200 个新链接)。
-
读取量 (重定向):
200 QPS * 100 (读写比) ≈
20,000 QPS (每秒 2 万次重定向)。
-
存储量 (5 年):
5 亿/月 * 12 个月 * 5 年 =
300 亿 条记录。- 假设每条记录(ShortKey, LongURL, UserID, Timestamps)平均占用 500 字节。
300 亿 * 500 字节 ≈
15 TB。
关键设计驱动因素:
- 20,000 QPS 读取 :重定向路径必须是内存优先的。任何磁盘 I/O(数据库读取)都必须被缓存。绝对禁止在此路径上执行数据库写入。
- 300 亿条记录:必须使用可水平扩展的 NoSQL 数据库(如 DynamoDB, Cassandra)。
- 200 QPS 写入:这是一个中等负载,但必须高效且无瓶颈。
4. 系统接口定义
服务将通过 RESTful API 公开,以支持 Web 客户端、移动应用和第三方集成。
-
POST /api/v1/shorten
- Body:
{"long_url": "...", "custom_alias": "...", "expires_in_days": 7}
- Response (201):
{"short_url": "http://tiny.url/aZ8xL9p"}
- Response (409):
{"error": "Custom alias already taken"}
- Body:
-
GET /{shortened_url}
- Response (302):
Location: {original_long_url}
(用于浏览器重定向) - Response (404):
{"error": "Not Found"}
(如果链接不存在或已过期) - Response (403): (显示一个警告页面,如果链接被标记为恶意)
- Response (302):
-
GET /api/v1/analytics/{shortened_url}
- Response (200):
{"key": "aZ8xL9p", "click_count": 10500, "created_at": "...", "analytics_data": [...]}
- Response (200):
-
GET /api/v1/user/urls
- (需要认证)
- Response (200):
{"urls": [{"key": "aZ8xL9p", ...}, {"key": "my-brand", ...}]}
-
DELETE /api/v1/{shortened_url}
- (需要认证和所有权)
- Response (204): No Content (删除成功)
5. 数据库设计 (改进)
我们将采用 NoSQL 数据库(如 AWS DynamoDB)作为主存储,它能自动处理 15TB+ 数据的扩展。
表名:URLMapping
-
ShortKey
(String, 分区键 (Partition Key) ):- 这是我们的短链接(例如
aZ8xL9p
或my-brand
)。 - 这是数据表的主键,所有高效查询都将通过它进行。
- 这是我们的短链接(例如
-
LongURL
(String): 原始的长 URL(例如https://www.designgurus.org/...
)。 -
UserID
(String, GSI 分区键):- 创建此链接的用户 ID(如果用户已登录)。
- 在此字段上创建二级索引 (GSI) ,以高效支持
GET /api/v1/user/urls
API。
-
CreationTimestamp
(Timestamp): 链接创建的 Unix 时间戳。 -
ExpirationTimestamp
(Timestamp, TTL 属性):- 链接的过期时间戳。
- 我们将配置数据库使用此字段来自动删除过期条目。
-
ClickCount
(Number):- 总点击次数。
- [重要] 此字段将由分析服务异步批量更新 ,绝不 在每次
GET
重定向时实时更新。
-
IsFlagged
(Boolean):- 安全标记(
true
/false
),用于标记恶意链接(详见第 12 节)。
- 安全标记(
6. 基本系统设计和算法 (关键改进)
我们将彻底废除"密钥生成服务 (KGS)" 。
KGS 是一个经典的设计模式,但它本身是一个有状态的、复杂的单点瓶颈。管理一个包含数百 GB"未使用密钥"的数据库,并处理其并发、故障转移和分发,比核心业务逻辑还要复杂。
改进的算法:7 位随机密钥 + 数据库重试
-
增加密钥长度: 6 位(687 亿)对于 300 亿条目来说太短了,碰撞概率会变得很高 (生日悖论)。我们将使用 7 位 Base64 字符串(
[A-Z, a-z, 0-9, _, -]
)。- <math xmlns="http://www.w3.org/1998/Math/MathML"> 6 4 7 ≈ 4.4 64^7 \approx 4.4 </math>647≈4.4 万亿 个唯一密钥。
- 300 亿条目在 4.4 万亿的密钥空间中,占用率 < 1% 。这使得随机碰撞概率极低。
-
写入(创建)流程 (
POST /shorten
):-
请求进入应用服务器(一个无状态的 Go/Java/Python 服务)。
-
情况 A:如果用户提供了自定义别名 (custom_alias = "my-brand"):
a. 使用 ShortKey = "my-brand"。
b. 尝试将其写入数据库,并使用 "条件写入" (例如 DynamoDB 的 ConditionExpression = "attribute_not_exists(ShortKey)")。
c. 如果写入成功,返回 201。
d. 如果写入失败(条件不满足),说明该键已存在,向用户返回 409 Conflict ("自定义别名已被占用") 错误。
-
情况 B:如果用户未提供别名(99% 的情况):
a. 应用服务器在本地循环(例如,最多 3 次):
b. 生成一个随机的 7 位 Base64 字符串(例如 aZ8xL9p)。
c. 使用 ShortKey = "aZ8xL9p" 尝试"条件写入"(同上)。
d. 如果写入成功,立即跳出循环,返回 201。
e. 如果写入失败(因为极小概率的碰撞),循环继续,生成下一个随机密钥。
-
此改进的优点:
- 无单点瓶颈: KGS 被彻底移除。
- 无状态: 应用服务器是完全无状态的,可以无限水平扩展。
- 极简架构: 不再需要管理"已使用/未使用"的密钥数据库。
- 统一逻辑: "创建随机链接"和"创建自定义链接"现在是同一个数据库操作(条件写入)。
7. 数据分区和复制 (改进)
我们不需要手动实现数据分区。
- 分区(Sharding): 我们通过选择
ShortKey
作为 NoSQL 数据库(如 DynamoDB)的分区键 ,数据库会自动为我们处理分区。我们的 7 位随机 密钥(例如aZ8xL9p
,bK2qR5s
)具有极高的基数和随机性。这确保了写入和读取均匀分布 在所有数据库分区上,天然地避免了热点问题。 - 复制(Replication): 复制由云数据库自动管理。例如,DynamoDB 会自动将数据同步复制到 3 个不同的可用区(Data Centers),确保了高可用性和持久性。
8. 缓存 (改进)
缓存是满足 20,000 QPS 读取性能的唯一途径。我们将使用一个分布式的内存缓存集群(如 Redis 或 Memcached)。
-
缓存内容: 缓存
ShortKey
到LongURL
的直接映射。 -
缓存策略: 采用读穿 (Read-Through) 策略。
-
读取(重定向)流程 (
GET /aZ8xL9p
):-
应用服务器收到请求
GET /aZ8xL9p
。 -
服务器首先 查询缓存(Redis)集群:
GET "aZ8xL9p"
。 -
缓存命中 (Cache Hit) - (预期 >99% 的情况):
a. 缓存立即返回 LongURL。
b. 服务器异步地向分析队列(Kafka)发送一条消息(见第 11 节)。
c. 服务器向用户返回 HTTP 302 重定向 到 LongURL。
-
缓存未命中 (Cache Miss):
a. 服务器查询主 NoSQL 数据库:GET Item WHERE ShortKey = "aZ8xL9p"。
b. 如果数据库中不存在(或已过期),返回 404 Not Found。
c. 如果存在,数据库返回 LongURL。
d. 服务器异步地("即发即忘")将此 (ShortKey, LongURL) 写回缓存,并设置一个合理的 TTL(例如 24 小时)。
e. 执行上述步骤 3b 和 3c。
-
9. 负载均衡器 (LB)
我们将在系统的最前端部署 L7 负载均衡器(如 Nginx, AWS ALB)。
-
终止 HTTPS: LB 负责处理 SSL/TLS 加密解密,减轻后端应用服务器的 CPU 负担。
-
健康检查: LB 会持续检查后端服务器的健康状况,自动剔除无响应的实例。
-
内容路由: 它将根据 HTTP 路径智能地路由流量。例如:
GET /aZ8xL9p
(短链接) -> 路由到"重定向服务"集群。POST /api/v1/shorten
-> 路由到"API 服务"集群。GET /api/v1/analytics/*
-> 路由到"分析 API"集群。
10. 清除或数据库清理 (关键改进)
废除所有手动的清理脚本或 Cron Jobs。
运行 DELETE FROM ... WHERE ... 这样的批量删除操作会对生产数据库造成巨大压力,引发 I/O 风暴。
-
改进的方案:原生 TTL(Time-to-Live)
- 写入时: 当
POST /shorten
时,我们计算ExpirationTimestamp
(例如NOW() + 2 years
)。 - 数据库配置: 我们在
URLMapping
表上启用 TTL 功能,并将其指向ExpirationTimestamp
字段。 - 自动清理: 数据库(DynamoDB/Cassandra/MongoDB)自动会在后台扫描并删除时间戳已过期的行。
- 写入时: 当
-
优点: 零运维成本,没有定时任务,没有删除操作引起的负载峰值。密钥也永不回收(4.4T 密钥空间足够大,回收密钥比它带来的好处要复杂得多)。
11. 遥测 (分析) (关键改进)
这是本设计的第二个核心 。在 20,000 QPS 的读取负载下,绝对禁止 在重定向路径上执行任何数据库写入(例如 UPDATE ... SET ClickCount = ClickCount + 1
)。
-
改进的流程:异步流处理 (Async Stream Processing)
-
事件发射 (在 App Server):
- 如第 8 节所述,当一个
GET
请求(无论是缓存命中还是未命中)成功时,应用服务器唯一 要做的额外工作就是向一个高吞吐量的消息队列 (如 Kafka 或 AWS Kinesis)"即发即忘"地提交一条 JSON 消息。 - 这个操作在 1 毫秒内完成,对主重定向延迟的影响可以忽略不计。
- 消息内容:
{"key": "aZ8xL9p", "timestamp": "...", "ip_hash": "...", "user_agent": "...", "referer": "..."}
- 如第 8 节所述,当一个
-
事件处理 (独立的消费者服务):
- 一个完全独立的微服务(例如,一个 Go/Flink/Spark 应用)订阅 Kafka 主题。
- 该服务在内存中 对事件进行聚合(例如,使用
map["aZ8xL9p"]++
)。
-
批量更新:
- 每隔 10 秒(或累计 10000 条事件),该服务将聚合后的计数批量写入数据库。
- 方案 A (简单): 写回主 NoSQL 数据库(
UPDATE URLMapping SET ClickCount = ClickCount + 150 WHERE ShortKey = "aZ8xL9p"
)。 - 方案 B (更优): 将数据写入一个专门的分析数据库 (如 ClickHouse, Redshift, BigQuery)。
GET /api/v1/analytics/*
API 将从这个数据库读取,从而实现生产库和分析库的完全隔离。
-
12. 安全和权限 (改进)
-
权限 (用户数据):
- 所有 API 端点(
POST
,DELETE
,GET /user
)都必须通过 API 密钥或 OAuth 令牌进行身份验证。 - 在
DELETE /api/v1/{key}
时,服务器必须检查认证后的UserID
是否与URLMapping
表中该key
的UserID
字段相匹配,确保用户只能删除自己的链接。 GET /api/v1/user/urls
API 将使用UserID
上的二级索引 (GSI) 来高效查询该用户的所有链接。
- 所有 API 端点(
-
改进 - 恶意链接防护 (安全):
-
我们必须防止服务被用于传播恶意软件或钓鱼网站。
-
流程(异步检查):
- 当
POST /shorten
收到一个新的LongURL
时,除了写入主数据库,应用服务器还将其提交到一个单独的 Kafka 主题 (例如url-security-check
)。 - 一个独立的"安全工作者"服务消费此主题。
- 该工作者调用Google Safe Browsing API 或其他第三方服务来检查
LongURL
。 - 如果该 URL 被标记为"恶意",该工作者将更新 主数据库中的
URLMapping
表,设置IsFlagged = true
。
- 当
-
重定向时的检查:
- 当
GET /aZ8xL9p
时,如果从缓存或数据库中获取的条目IsFlagged == true
: - 系统不会返回 302 重定向。
- 相反,它会返回
HTTP 403 Forbidden
或渲染一个全屏警告页面("此链接被标记为不安全,是否继续?")。 - (缓存失效 :当安全工作者将
IsFlagged
设为true
时,它还必须向缓存发送一个DELETE "aZ8xL9p"
命令,以确保下一次访问能读到最新的安全状态。)
- 当
-
13. 知识点
分析数据库 (ClickHouse, Redshift, BigQuery)
专门用于"数据仓库"和"大数据分析"的工具,和我们平时用的业务数据库(如 MySQL, DynamoDB, Redis)目的完全不同 。在行业里被称为 OLTP vs OLAP。
a. 业务数据库 (OLTP - 在线事务处理)
- 你熟悉的: MySQL, PostgreSQL, DynamoDB , Redis。
-
特点:
- 快: 必须在几毫秒内完成操作。
- 简单: 操作都非常简单,比如
GET User WHERE UserID=123
(查一个用户),或者UPDATE Order SET Status='Shipped' WHERE OrderID=456
(改一个订单)。 - 高并发: 必须能同时处理成千上万个这样的简单操作。
- 它不擅长的: 复杂的"分析"查询。如果你问 MySQL:"过去 5 年,所有 30 岁以下、在周二购买了红色商品的用户,总共花了多少钱?" ------ 数据库会崩溃 。因为它必须一行一行地扫描整个数据库(几十亿条记录)来回答你。
b. 分析数据库 (OLAP - 在线分析处理)
-
ClickHouse , BigQuery (Google), Redshift (AWS), Snowflake。
-
用途: 运行 "数据分析"
-
特点:
- 快(针对分析): 专门为上面那种"过去 5 年..."的复杂问题优化。
- 海量: 专门用于扫描海量(PB级)数据。
- 慢(针对单条): 它们不适合,也不能用于"在线业务"。它们插入一条数据可能要几秒钟,查询一条数据也可能很慢。
- 并发低: 它们不处理上万的 QPS,可能同时只有几个"分析师"在用。
为什么它们这么快(和 NoSQL 的区别)?
- NoSQL / MySQL (行存储)
- ClickHouse / BigQuery (列存储)
总结: 把事件(点击)写入 Kafka,然后:
- 消费者 A(分析服务) 把数据聚合后写入 NoSQL (DynamoDB) ,用来给用户看"你的总点击是 10500 次"。
- 消费者 B(数据仓库) 把每一条 原始事件都写入 BigQuery (OLAP) ,用来给公司内部的"分析师"提问:"上个月从巴西来的点击有多少?"
Kafka 安全主题与缓存
"单独的 Kafka 主题"所触发的安全服务 ,在完成它的工作时,必须做两件事:
- 更新 NoSQL 数据库(设置
IsFlagged = true
)。 - 主动向缓存(Redis)发送一个
DELETE "key-123"
命令。
"删除" 是解决缓存失效问题最简单、最可靠的办法。
二级索引 (GSI)
当启用 GSI 时,是在告诉数据库:"请你额外维护第二本书"。
这第二本书: 不按"章节号"排序,而是按 "作者名" (UserID
)排序。
这本书的内容很简单,就是 [作者名] -> [章节号列表]
:
'Adam' -> [key-001, key-007, ...]
'John' -> [key-002, key-100, key-500, ...]
'Mary' -> [key-003, ...]
当写入一个新章节,数据库(DynamoDB) 会自动做两件事:
- 把章节内容存入 "主书" (按
ShortKey
排序)。 - 把
key-500
这个引用添加到 "第二本书" (John
的作者名下)。
问题查询 db.Query(ctx, "UserID = 'John'")
:
- 数据库的操作: 管理员极其聪明 。它发现这个查询是按
UserID
查的,于是它根本不看"主书" 。
- 它直接跑去 "第二本书" (GSI),用二分查找法(极快)找到
'John'
这一行。
- 它立刻拿到了所有章节号列表:
[key-002, key-100, key-500]
。
- 结果: 查询在几毫秒内完成。
总结: GSI 是数据库层 的一种功能,只需要在创建表的时候声明 它("请在 UserID
字段上创建一个 GSI")。之后,数据库会自动维护它,而 Go 应用代码只需要正常地 Query
那个字段,数据库就会自动使用这个索引来加速查询。