系统设计:URL 缩短系统设计目录

2025-06-07

系统设计:URL缩短器

系统设计

目录

让我们设计一个 URL 缩短器,类似于BitlyTinyURL等服务。

什么是 URL 缩短器?

URL 缩短服务会为长 URL 创建别名或短 URL。用户访问这些短链接时会被重定向到原始 URL。

例如,以下长 URL 可以更改为较短的 URL。

长网址https://karanpratapsingh.com/courses/system-design/url-shortener

短网址https://bit.ly/3I71d3o

为什么我们需要 URL 缩短器?

当我们分享 URL 时,URL 缩短器通常可以节省空间。用户也不太可能输错较短的 URL。此外,我们还可以跨设备优化链接,从而追踪单个链接。

要求

我们的URL缩短系统应满足以下要求:

功能要求

  • 给定一个 URL,我们的服务应该为其生成一个更短且唯一的别名。
  • 用户访问短链接时应被重定向到原始 URL。
  • 链接应在默认时间跨度后过期。

非功能性需求

  • 高可用性和最小延迟。
  • 该系统应具有可扩展性和高效性。

扩展要求

  • 防止滥用服务。
  • 记录重定向的分析和指标。

估计和约束

让我们从估计和约束开始。

注意:请务必与面试官核对任何与规模或交通相关的假设。

交通

这将是一个读取密集型的系统,因此我们假设100:1读/写比率为每月生成 1 亿个链接。

每月读/写次数

每月阅读量:

100 × 100   n = 10   b n / n h 100 \乘以 100 \space 百万 = 10 \space 十亿/月
对于写入也类似:
1 × 100   n = 100   n / n h 1 \times 100 \space 百万 = 100 \space 百万/月
我们的系统的每秒请求数(RPS)是多少?

每月 1 亿个请求相当于每秒 40 个请求。

100   n ( 三十   d 一个 y × 24   h r × 3600   e c n d ) = 40   R / e c n d \frac{1亿\space百万}{(30\space天\times 24\space小时\times 3600\space秒)} = \sim 40\space URLs/秒
有了100:1读/写比率,重定向的次数将是:
100 × 40   R / e c n d = 4000   r e e / e c n d 100 \times 40 \space URL/秒 = 4000 \space 请求/秒
### 带宽

由于我们预计每秒会有大约 40 个 URL,并且如果我们假设每个请求的大小为 500 字节,那么写入请求的总传入数据将是:

40 × 500   b y e = 20   B / e c n d 40 \times 500 \space 字节 = 20 \space KB/秒
类似地,对于读取请求,由于我们预计大约有 4K 重定向,因此总传出数据将是:
4000   R / e c n d × 500   b y e = 2   B / e c n d 4000 \space URL/秒 \times 500 \space 字节 = \sim 2 \space MB/秒
### 贮存

在存储方面,我们假设每个链接或记录在数据库中存储 10 年。由于我们预计每月会有大约 1 亿个新请求,因此我们需要存储的记录总数为:

100   n × 10   y e 一个 r × 12   n h = 12   b n 1亿\space百万\times 10\space年\times 12\space月=12\space十亿
和之前一样,假设每个存储的记录大约为 500 字节,那么我们需要大约 6TB 的存储空间:
12   b n × 500   b y e = 6   T B 12 \space 十亿 \space 乘以 500 \space 字节 = 6 \space TB
### 缓存

对于缓存,我们将遵循经典的帕累托原则,也称为 80/20 规则。这意味着 80% 的请求针对 20% 的数据,因此我们可以缓存大约 20% 的请求。

由于我们每秒收到大约 4K 个读取或重定向请求,这相当于每天 3.5 亿个请求。

4000   R / e c n d × 24   h r × 3600   e c n d = 350   n   r e e / d 一个 y 4000 \space URL/秒 \times 24 \space 小时 \times 3600 \space 秒 = \sim 350 \space 百万 \space 请求/天
因此,我们每天需要大约 35GB 的内存。
20   e r c e n × 350   n × 500   b y e = 三十五   B / d 一个 y 20 \space 百分比 \times 350 \space 百万 \times 500 \space 字节 = 35 \space GB/天
### 高级估算

以下是我们的高级估计:

类型 估计
写入(新 URL) 40/秒
读取(重定向) 4K/秒
带宽(传入) 20 KB/秒
带宽(传出) 2 MB/秒
储存(10年) 6 TB
内存(缓存) 每天约 35 GB

数据模型设计

接下来,我们将重点关注数据模型设计。以下是我们的数据库架构:

url-缩短器数据模型

最初,我们可以从两个表开始:

用户

name存储用户的详细信息,email、、createdAt等。

网址

包含新建短网址的创建用户的expirationhashoriginalURL等属性。我们也可以使用该列作为索引,提升查询性能。userIDhash

我们应该使用什么样的数据库?

由于数据不是强关系,因此 NoSQL 数据库(例如Amazon DynamoDBApache CassandraMongoDB)将是更好的选择,如果我们决定使用 SQL 数据库,那么我们可以使用Azure SQL 数据库Amazon RDS之类的数据库。

有关更多详细信息,请参阅SQL 与 NoSQL

API 设计

让我们为我们的服务做一个基本的 API 设计:

创建 URL

此 API 应根据原始 URL 在我们的系统中创建一个新的短 URL。



createURL(apiKey: string, originalURL: string, expiration?: Date): string


Enter fullscreen mode Exit fullscreen mode

参数

API 密钥(string):用户提供的 API 密钥。

原始网址(string):要缩短的原始网址。

到期日期 ( Date):新 URL 的到期日期(可选)

返回

短网址(string):新的缩短网址。

获取 URL

此 API 应从给定的短 URL 中检索原始 URL。



getURL(apiKey: string, shortURL: string): string


Enter fullscreen mode Exit fullscreen mode

参数

API 密钥(string):用户提供的 API 密钥。

短网址(string):映射到原始网址的短网址。

返回

原始 URL ( string):要检索的原始 URL。

删除网址

此 API 应从我们的系统中删除给定的短网址。



deleteURL(apiKey: string, shortURL: string): boolean


Enter fullscreen mode Exit fullscreen mode

参数

API 密钥(string):用户提供的 API 密钥。

短网址(string):需要删除的短网址。

返回

结果(boolean):表示操作是否成功。

为什么我们需要 API 密钥?

您肯定已经注意到了,我们使用 API 密钥来防止服务被滥用。使用此 API 密钥,我们可以限制用户每秒或每分钟的请求数量。这是开发者 API 的标准做法,应该能够满足我们的扩展需求。

高层设计

现在让我们对我们的系统进行高层设计。

URL 编码

我们系统的主要目标是缩短给定的 URL,让我们看看不同的方法:

Base62 方法

在这种方法中,我们可以使用Base62对原始 URL 进行编码,它由大写字母 AZ、小写字母 az 和数字 0-9 组成。

b e r   f   R = 6 2 \space URL 的数量 = 62^N
在哪里,

N:生成的URL的字符数。

因此,如果我们想要生成一个长度为 7 个字符的 URL,我们将生成约 3.5 万亿个不同的 URL。

6 25 = 916   n   R   6 26 = 56.8   b n   R   6 27 = 3.5   r n   R \begin{gather*} 62^5 = \sim 916 \space 百万 \space URL \ 62^6 = \sim 56.8 \space 十亿 \space URL \ 62^7 = \sim 3.5 \space 万亿 \space URL \end{gather*}
这是这里最简单的解决方案,但它不能保证密钥不重复或抗碰撞。

MD5方法

MD5 消息摘要算法一种广泛使用的哈希函数,它生成一个 128 位哈希值(或 32 位十六进制数字)。我们可以使用这 32 位十六进制数字来生成 7 个字符长的 URL。

D 5 ( r n 一个 r ) b 一个 e 62 e n c d e h 一个 h MD5(原始网址) \rightarrow base62encode \rightarrow hash
然而,这给我们带来了一个新问题,那就是重复和冲突。我们可以尝试重新计算哈希值,直到找到唯一的哈希值,但这会增加系统的开销。最好还是寻找更可扩展的方法。

反击方法

在这种方法中,我们将从一台服务器开始,该服务器将维护生成的密钥的数量。一旦我们的服务收到请求,它就会联系计数器,计数器返回一个唯一的数字并递增。当下一个请求到来时,计数器再次返回该唯一数字,如此循环。

n e r ( 0 3.5   r n ) b 一个 e 62 e n c d e h 一个 h 计数器(0-3.5 \space 万亿)\rightarrow base62encode \rightarrow hash
这种方法的问题在于它很快就会成为单点故障。而且,如果我们运行多个计数器实例,可能会发生冲突,因为它本质上是一个分布式系统。

为了解决这个问题,我们可以使用分布式系统管理器,例如Zookeeper,它可以提供分布式同步功能。Zookeeper 可以为我们的服务器维护多个范围。

R 一个 n e   1   1 1 000 000   R 一个 n e   2   1 000 001 2 000 000   R 一个 n e   3   2 000 001 3 000 000   \begin{align*} & 范围 \space 1:\space 1 \rightarrow 1,000,000 \ & 范围 \space 2:\space 1,000,001 \rightarrow 2,000,000 \ & 范围 \space 3:\space 2,000,001 \rightarrow 3,000,000 \ & ... \end{align*}
一旦服务器达到其最大范围,Zookeeper 就会将未使用的计数器范围分配给新服务器。这种方法可以保证 URL 不重复且不会发生冲突。此外,我们可以运行多个 Zookeeper 实例来消除单点故障。

密钥生成服务(KGS)

正如我们所讨论的,大规模生成唯一密钥且避免重复和冲突可能颇具挑战性。为了解决这个问题,我们可以创建一个独立的密钥生成服务 (KGS),它会提前生成唯一密钥并将其存储在单独的数据库中以供后续使用。这种方法可以简化我们的工作。

如何处理并发访问?

一旦使用了密钥,我们可以在数据库中对其进行标记,以确保不会重复使用它,但是,如果有多个服务器实例同时读取数据,则两个或多个服务器可能会尝试使用相同的密钥。

解决这个问题最简单的方法是将键存储在两个表中。一旦某个键被使用,我们就将其移动到一个单独的表中,并设置适当的锁定。此外,为了提高读取速度,我们可以将一些键保留在内存中。

KGS数据库估计

根据我们的讨论,我们可以生成最多约 568 亿个独特的 6 个字符长的密钥,这将导致我们必须存储 300 GB 的密钥。

6   c h 一个 r 一个 c e r × 56.8   b n = 390   B 6 \space 个字符 \times 56.8 \space 十亿 = \sim 390 \space GB
虽然对于这个简单的用例来说 390 GB 似乎很多,但重要的是要记住这是我们整个服务生命周期的大小,并且密钥数据库的大小不会像我们的主要数据库那样增加。

缓存

现在,我们来谈谈缓存。根据我们的估算,我们每天大约需要 35 GB 的内存来缓存 20% 的服务请求。对于这种用例,我们可以将RedisMemcached服务器与 API 服务器一起使用。

有关详细信息,请参阅缓存

设计

现在我们已经确定了一些核心组件,让我们开始系统设计的初稿。

url-缩短器基本设计

工作原理如下:

创建新的 URL

  1. 当用户创建新的 URL 时,我们的 API 服务器会从密钥生成服务 (KGS) 请求一个新的唯一密钥。
  2. 密钥生成服务向 API 服务器提供唯一密钥,并将该密钥标记为已使用。
  3. API 服务器将新的 URL 条目写入数据库和缓存。
  4. 我们的服务向用户返回 HTTP 201(已创建)响应。

访问 URL

  1. 当客户端导航到某个短 URL 时,请求就会发送到 API 服务器。
  2. 请求首先访问缓存,如果在那里找不到条目,​​则从数据库中检索,并向原始 URL 发出 HTTP 301(重定向)。
  3. 如果在数据库中仍然找不到该密钥,则会向用户发送 HTTP 404(未找到)错误。

详细设计

现在是时候讨论我们设计的细节了。

数据分区

为了扩展数据库,我们需要对数据进行分区。水平分区(又称分片)是一个很好的第一步。我们可以使用以下分区方案:

  • 基于哈希的分区
  • 基于列表的分区
  • 基于范围的分区
  • 复合分区

上述方法仍然会导致数据和负载分布不均匀,我们可以使用一致性哈希来解决这个问题。

有关更多详细信息,请参阅分片一致性哈希

数据库清理

这更像是我们服务的维护步骤,取决于我们是保留过期条目还是将其删除。如果我们决定删除过期条目,我们可以通过两种不同的方式进行:

主动清理

在主动清理中,我们将运行一个单独的清理服务,该服务将定期从存储和缓存中移除过期链接。这将是一个非常轻量级的服务,类似于cron 作业

被动清理

对于被动清理,我们可以在用户尝试访问过期链接时删除相应条目。这可以确保数据库和缓存的延迟清理。

缓存

现在让我们来讨论一下缓存

使用哪种缓存驱逐策略?

正如我们之前讨论过的,我们可以使用RedisMemcached等解决方案并缓存 20% 的每日流量,但哪种缓存驱逐策略最适合我们的需求?

对我们的系统来说,最近最少使用(LRU)策略可能是一个不错的选择。在这个策略中,我们首先丢弃最近最少使用的键。

如何处理缓存未命中?

每当出现缓存未命中时,我们的服务器可以直接访问数据库并使用新条目更新缓存。

指标和分析

记录分析和指标是我们的扩展需求之一。我们可以将访客的国家/地区、平台、浏览次数等元数据与 URL 条目一起存储在数据库中并进行更新。

安全

为了安全起见,我们可以引入私有 URL 和授权机制。可以使用单独的表来存储有权访问特定 URL 的用户 ID。如果用户没有适当的权限,我们可以返回 HTTP 401(未授权)错误。

我们还可以使用API 网关,因为它们可以开箱即用地支持授权、速率限制和负载平衡等功能。

识别并解决瓶颈

url-缩短器高级设计

让我们识别并解决设计中的单点故障等瓶颈:

  • “如果 API 服务或密钥生成服务崩溃怎么办?”
  • “我们将如何在组件之间分配流量?”
  • “我们如何才能减轻数据库的负载?”
  • “如果KGS使用的密钥数据库出现故障怎么办?”
  • “如何提高我们的缓存的可用性?”

为了使我们的系统更具弹性,我们可以执行以下操作:

  • 运行我们的服务器和密钥生成服务的多个实例。
  • 在客户端、服务器、数据库和缓存服务器之间引入负载平衡器。
  • 由于我们的数据库是一个读取密集型系统,因此对其使用多个读取副本。
  • 我们的关键数据库的备用副本,以防万一它出现故障。
  • 我们的分布式缓存有多个实例和副本。

本文是我在 Github 上提供的开源系统设计课程的一部分。

文章来源:https://dev.to/karanpratapsingh/system-design-url-shortener-10i5
PREV
Javascript 设置 CSS 使用 javascript 3 设置 CSS 样式非常有趣。谢谢你的提示 ;) style.setContent 错误❌
NEXT
系统设计:Uber 系统设计目录