> For the complete documentation index, see [llms.txt](https://vagrant.gitbook.io/grokking-system-design/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://vagrant.gitbook.io/grokking-system-design/she-ji-xiang-tinyurl-zhe-yang-de-duan-url-fu-wu.md).

# 设计像TinyURL这样的短URL服务

让我们设计一个URL缩短服务，比如TinyURL。该服务将提供短别名重定向到长url。

类似的服务:bit.ly, goo.gl, 2020.fm etc.

难度级别:简单

\##1. 为什么我们需要网址缩短

URL缩短用于为长URL创建更短的别名。当用户遇到这些别名时，会被重定向到原始URL。任何URL的短版本都可以节省大量的空间，无论我们何时使用它，例如，当打印或tweet作为tweet有字符限制时。

例如，如果我们通过TinyURL缩短这个页面:

<https://www.educative.io/collection/page/5668639101419520/5649050225344512/5668600916475904/>

我们可以得到:

<http://tinyurl.com/jlg8zpc>

缩短后的网址几乎是实际网址大小的3分之1。

URL缩短用于优化跨设备的链接，跟踪单个链接以分析受众和活动表现，以及隐藏关联的原始URL等。

如果你以前没有使用过tinyurl.com，请尝试创建一个新的缩短的URL，并花一些时间浏览他们的服务提供的不同选项。这将帮助你更好地理解这一章。

## 2. 系统的需求和目标

💡 你应该在面试一开始就明确要求，并提出问题，以找到面试官心目中系统的确切范围。

我们的网址缩短系统应符合下列要求:

### 功能需求:

给定一个URL，我们的服务应该生成一个更短且唯一的URL别名。

当用户访问较短的URL时，我们的服务应该将他们重定向到原始链接。

用户应该可以选择为他们的URL选择一个自定义别名。

链接将在特定的时间间隔后自动过期;用户还应该能够指定过期时间。

### 非功能性需求:

系统应该是高可用的。这是必需的，因为如果我们的服务停止，所有的URL重定向都将开始失败。

URL重定向应该以最小的延迟实时发生。

缩短的链接不应该是可猜测的(不可预测的)。

### 扩展要求:

分析，例如，多少次重定向发生?

我们的服务也应该可以被其他服务通过REST api访问。

## 3. 容量估算与约束

我们的系统会有很多读取量;与新的URL缩短相比，会有大量的重定向请求。让我们假设读和写的比例为100:1。

流量估计: 流量估计:如果我们假设每个月有5亿个新url被缩短，那么在同一时间内，我们可以预期(100亿个url => 50B)重定向。什么是查询每秒(QPS)为我们的系统?

每秒新增url缩短:

500 million / (30 days \* 24 hours \* 3600 seconds) \~= 200 URLs/s

5亿/(30天*24小时*3600秒)\~= 200个url)

URLs重定向秒:

50 billion / (30 days \* 24 hours \* 3600 sec) \~= 19K/s

存储估计: 因为我们预计每个月有5亿个新url，如果我们将这些对象保存5年;我们要存储的对象的总数是300亿个。

500 million \* 5 years \* 12 months = 30 billion

让我们假设我们要存储的每个对象可以是500字节(只是一个大概的数字，我们将在后面深入研究);我们需要15TB的总存储空间:

30 billion \* 500 bytes = 15 TB

带宽估计: 对于写请求，由于我们预计每秒钟有200个新的url，我们服务的总传入数据将是每秒100KB。

200 \* 500 bytes = 100 KB/s

对于读请求，由于我们期望每秒钟有\~19K的url重定向，我们服务的总输出数据将是每秒9MB。

19K \* 500 bytes \~= 9 MB/s

内存估计: 如果我们想缓存一些经常被访问的热门url，我们需要多少内存来存储它们?如果我们遵循80-20规则，即20%的url产生80%的流量，我们希望缓存这20%的热url。

因为我们每秒有19K个请求，所以我们每天会收到17亿个请求。

19K \* 3600 seconds \* 24 hours \~= 1.7 billion

为了缓存这些请求的20%，我们需要170GB的内存。

0.2 \* 1.7 billion \* 500 bytes \~= 170GB

高水平估计: 假设每月新增5亿url，读写比例为100:1，以下是我们服务的高水平估计总结:

| New URLs | 200/s   |
| -------- | ------- |
| URL重定向   | 19K/s   |
| I输入数据    | 100KB/s |
| 传出数据     | 9MB/s   |
| 贮存5年     | 15TB    |
| 内存缓存     | 170GB   |

## 4. 系统api

💡 一旦我们确定了需求，定义系统api总是一个好主意。这将明确地声明系统所期望的内容。

我们可以使用SOAP或REST api来公开服务的功能。下面是创建和删除url的api的定义:

creatURL(api\_dev\_key, original\_url, custom\_alias=None user\_name=None, expire\_date=None)

参数:

api\_dev\_key (string): 注册帐户的API开发人员密钥。这将用于根据用户分配的配额限制用户。

original\_url (string):需要缩短的原始URL。

custom\_alias (string): URL的可选自定义键。

user\_name (string):可选用于编码的用户名。

expire\_date (string):缩短URL的可选过期日期。

Returns: (string)

成功的插入将返回缩短的URL，否则将返回错误代码。

deleteURL(api\_dev\_key, url\_key)

其中“url\_key”是表示要检索的缩短URL的字符串。成功删除将返回“URL已删除”。

我们如何发现和防止滥用? 例如，任何服务都可能因为消耗当前设计中的所有键而导致我们破产。为了防止滥用，我们可以通过api\_dev\_key限制用户，即他们在特定时间内可以创建或访问多少URL。

## 5. 数据库设计

💡 在面试的早期阶段定义DB模式将有助于理解不同组件之间的数据流，并在之后指导数据分区。

关于我们将要存储的数据的性质的几点观察:

我们需要存储数十亿条记录。

我们要存储的每个对象都很小(小于1K)。

记录之间没有关系，除非我们想要存储哪个用户创建了哪个URL。

我们的服务是读多的。

数据库模式:

我们需要两个表，一个用于存储关于URL映射的信息，另一个用于存储用户数据。

![img\_12.png](https://676899807-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FglaR9JKvQu1ZxmgUlhWZ%2Fuploads%2Fgit-blob-5bc3ee5e6d48656223b00b7a6e1f0d72e1cbc3f3%2Fimg_12.png?alt=media)

我们应该使用什么样的数据库? 因为我们可能要存储数十亿行，而且我们不需要使用对象之间的关系——像Dynamo或Cassandra这样的NoSQL键值存储是一个更好的选择，它们也更容易伸缩。详情请参阅SQL vs NoSQL。如果我们选择NoSQL，我们不能在URL表中存储UserID(因为NoSQL中没有外键)，为此我们需要第三个表来存储URL和用户之间的映射。

## 6. 基本的系统设计和算法

我们在这里解决的问题是为给定的URL生成一个简短且唯一的键。在上面的例子中，我们得到的缩短URL是:" http: tinyurls .comjlg8zpc "，该URL的最后六个字符是我们想要生成的短键。我们将在这里探索两种解决方案:

a.编码实际URL

我们可以计算给定URL的唯一哈希值(例如，MD5或SHA256等)。然后可以对散列进行编码以显示。这种编码可以是base36 (\[a-z,0-9])或base62 (\[a-z, a-z, 0-9])，如果我们加上' - '和'。’，我们可以使用base64编码。一个合理的问题是;短键的长度应该是多少?6个、8个还是10个字符?

使用base64编码，一个6字母长的键将产生64^6 \~= 687亿可能的字符串

使用base64编码，一个8个字母长的键将产生64^8 \~= 281万亿可能的字符串

对于68.7B唯一的字符串，让我们假设在我们的系统中，六个字母的键就足够了。

如果我们使用MD5算法作为我们的哈希函数，它将产生一个128位的哈希值。在base64编码之后，我们将得到一个超过20个字符的字符串，那么我们如何选择我们的键呢?我们可以取前6(或8)个字母作为密钥。这可能会导致键重复，在此基础上，我们可以从编码字符串中选择一些其他字符或交换一些字符。

我们的解决方案有哪些不同的问题?我们的编码方案有以下几个问题:

如果多个用户输入相同的URL，他们可以得到相同的缩短URL，这是不可接受的。

如果部分URL是URL编码的呢? e.g., <http://www.educative.io/distributed.php?id=design>, and <http://www.educative.io/distributed.php%3Fid%3Ddesign> 除了URL编码外是相同的。

问题的解决方法: 我们可以给每个输入URL添加一个递增的序列号，使其惟一，然后生成它的哈希值。不过，我们不需要将这个序列号存储在数据库中。这种方法可能存在的问题可能是这个序列号有多大，它会溢出吗?增加序列号也会影响服务的性能。

另一种解决方案是，将用户id(应该是唯一的)附加到输入URL。但是，如果用户还没有登录，我们可以要求用户选择惟一密钥。即使在这之后，如果我们有冲突，我们必须不断生成一个键，直到我们得到一个唯一的。

![img\_13.png](https://676899807-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FglaR9JKvQu1ZxmgUlhWZ%2Fuploads%2Fgit-blob-924b665ac55b9b6867bf6abf88ccb5a42d0785c1%2Fimg_13.png?alt=media)

b. 离线生成密钥

我们可以有一个独立的密钥生成服务(KGS)，它预先随机生成六个字母字符串，并将它们存储在数据库中(我们称之为Key - db)。每当我们想要缩短URL时，我们只需要获取一个已经生成的键并使用它。这种方法将使事情变得非常简单和快速，因为我们不用对URL进行编码，也不用担心重复或冲突。KGS将确保插入key-DB中的所有密钥是唯一的。

并发性会导致问题吗? 一旦一个键被使用，就应该在数据库中标记它，这样它就不会被再次使用了。如果有多个服务器同时读取键值，我们可能会遇到这样的情况:两个或多个服务器试图从数据库读取相同的键值。我们如何解决这个并发问题?

服务器可以使用KGS来读取数据库中的密钥。KGS可以使用两个表来存储键，一个用于尚未使用的键，另一个用于所有已使用的键。只要KGS向其中一个服务器提供密钥，它就可以将密钥移动到使用的密钥表中。KGS总是可以在内存中保存一些密钥，以便在服务器需要它们时，能够快速提供它们。为了简单起见，只要KGS在内存中加载一些键，它就可以将它们移动到使用的键表中。通过这种方式，我们可以确保每个服务器都得到唯一的密钥。如果KGS在将所有加载的键分配给某个服务器之前死亡，我们将浪费这些键，我们可以忽略这些键，因为我们有大量的键。KGS还必须确保不会向多个服务器提供相同的密钥。为此，它必须同步(或锁定)保存键的数据结构，然后从该结构中删除键并将它们交给服务器。

key-DB的大小是多少? 使用base64编码，我们可以生成68.7B唯一的6个字母的密钥。如果我们需要一个字节来存储一个字母数字字符，我们可以将所有这些键存储在:

6 (characters per key) \* 68.7B (unique keys) => 412 GB.

假如KGS发生单点故障? 为了解决这个问题，我们可以拥有KGS的备用副本，当主服务器失效时，它可以接管生成和提供密钥。

每个应用服务器可以缓存key-DB中的一些键吗? 是的，这肯定能加快速度。尽管在本例中，如果应用程序服务器在使用所有键之前死亡，我们最终将失去这些键。这是可以接受的，因为我们有68B唯一的6个字母的钥匙。

如何执行键查找? 我们可以在数据库或键值存储库中查找键来获得完整的URL。如果存在，则向浏览器返回“HTTP 302重定向”状态，并在请求的“Location”字段中传递存储的URL。如果该密钥在我们的系统中不存在，则发出“HTTP 404 not Found”状态，或将用户重定向回主页。

我们应该对自定义别名施加大小限制吗? 由于我们的服务支持自定义别名，用户可以选择任何他们喜欢的“键”，但提供自定义别名不是强制性的。然而，对自定义别名施加大小限制是合理的(而且通常是可取的)，这样我们就有了一致的URL数据库。让我们假设用户可以指定最长16个字符的客户键(如上面的数据库模式所示)。

![img\_14.png](https://676899807-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FglaR9JKvQu1ZxmgUlhWZ%2Fuploads%2Fgit-blob-98bb42a510217b2564ade9e58cf9640b6e07706e%2Fimg_14.png?alt=media)

用于URL缩短的高级系统设计

## 7. 数据分区和复制

为了扩展我们的数据库，我们需要对它进行分区，以便它能够存储关于数十亿个URL的信息。我们需要提出一个分区方案，将我们的数据划分并存储到不同的DB服务器上。

a.基于范围的分区: 我们可以根据URL的首字母或哈希键将URL存储在单独的分区中。因此，我们将所有以字母“A”开头的url保存在一个分区中，而那些以字母“B”开头的url保存在另一个分区中，以此类推。这种方法称为基于范围的分区。我们甚至可以将某些不太经常出现的字母组合到一个数据库分区中。我们应该静态地提出这种分区方案，以便始终能够以可预测的方式存储和查找文件。

这种方法的主要问题是，它会导致服务器不平衡;如果我们决定将所有以字母“E”开头的url放入一个DB分区中，但后来我们意识到我们有太多以字母“E”开头的url，我们无法放入一个DB分区中。

b.基于哈希的分区: 在这个方案中，我们取我们正在存储的对象的哈希值，然后根据这个哈希值，我们找出这个对象应该去的DB分区。在我们的例子中，我们可以使用' key '的哈希值或实际的URL来确定存储文件的分区。我们的哈希函数将随机地将url分配到不同的分区中，例如，我们的哈希函数总是可以将任意键映射到\[1…256]之间的一个数字，而这个数字将代表存储我们对象的分区。

这种方法仍然会导致分区的重载，这可以通过使用一致的哈希来解决。

## 8. 缓存

我们可以缓存经常被访问的url。我们可以使用一些现成的解决方案，比如Memcache，它可以存储带有各自哈希值的完整url。应用服务器在访问后端存储之前，可以快速检查缓存是否有所需的URL。

我们应该有多少缓存? 我们可以从每天流量的20%开始，根据客户的使用模式，我们可以调整需要多少缓存服务器。如上所述，我们需要170GB的内存来缓存20%的日常流量，因为现在的服务器可以有256GB的内存，我们可以很容易地将所有缓存放入一台机器，或者我们可以选择使用几个较小的服务器来存储所有这些热url。

哪种cache逐出策略最适合我们的需求? 当缓存是满的，我们想替换一个链接与一个新的更热的URL，我们应该如何选择?最近最少使用(Least Recently Used, LRU)对于我们的系统来说是一个合理的策略。在此策略下，我们首先丢弃最近最少使用的URL。我们可以使用链接哈希图或类似的数据结构来存储我们的url和哈希表，这也将跟踪最近访问的url。

为了进一步提高效率，我们可以复制缓存服务器来在它们之间分配负载。

如何更新每个缓存副本?每当有一个缓存错过，我们的服务器将击中后端数据库。每当发生这种情况时，我们可以更新缓存并将新条目传递到所有缓存副本。每个副本都可以通过添加新条目来更新它们的缓存。如果副本已经拥有该条目，则可以忽略它。

![img\_15.png](https://676899807-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FglaR9JKvQu1ZxmgUlhWZ%2Fuploads%2Fgit-blob-cfbc466f7986c1e5ec1fa0d6fb76a5ffdf3b43c1%2Fimg_15.png?alt=media)

## 9. 负载均衡 (LB)

我们可以在系统的三个地方添加负载均衡层:

客户端和应用服务器之间

在应用程序服务器和数据库服务器之间

应用服务器和缓存服务器之间

最初，可以采用一种简单的轮询方法;将传入的请求平均分配到后端服务器。这个LB实现起来很简单，而且不引入任何开销。这种方法的另一个好处是，如果服务器死亡，LB将把它从轮换中移除，并停止向它发送任何流量。Round Robin LB的一个问题是，它不会考虑服务器负载。如果服务器负载过重或运行缓慢，LB将不会停止向该服务器发送新请求。为了解决这个问题，可以放置一个更智能的LB解决方案，它可以周期性地查询后端服务器的负载，并据此调整流量。

## 10. 清除或DB清理

条目应该永远存在还是应该被清除?如果达到了用户指定的过期时间，该链接会发生什么?如果我们选择主动搜索过期链接并删除它们，这将给我们的数据库带来很大的压力。我们可以慢慢地删除过期的链接，也可以做一个惰性清理。我们的服务将确保只有过期的链接将被删除，尽管一些过期的链接可以存活更长时间，但永远不会返回给用户。

当用户试图访问一个过期的链接时，我们可以删除该链接并返回一个错误给用户。

一个单独的清理服务可以定期运行，从我们的存储和缓存中删除过期的链接。此服务应该是非常轻量级的，并且只能在预期用户流量较低时调度运行。

我们可以为每个链接设置一个默认的过期时间，例如，两年。

在删除一个过期的链接之后，我们可以将密钥放回要重用的key- db中。

我们是否应该删除一段时间内(比如6个月)未被访问的链接?这可能很棘手。由于存储变得越来越便宜，我们可以决定永远保持链接。

![img\_16.png](https://676899807-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FglaR9JKvQu1ZxmgUlhWZ%2Fuploads%2Fgit-blob-039b0560bb2ddde8f0b1274004274e35135fbe9a%2Fimg_16.png?alt=media)

URL缩短的详细组件设计

## 11. 遥测

短URL被使用了多少次，用户位置是什么，等等?我们如何存储这些统计数据?如果它是每个视图上更新的DB行的一部分，当一个流行的URL被大量并发请求攻击时会发生什么?

我们可以有关于访问者的国家的统计，访问的日期和时间，网页指的点击，浏览器或平台从页面被访问和更多。

## 12. 安全与权限

用户是否可以创建私有URL或允许特定的一组用户访问URL?

我们可以在数据库中为每个URL存储权限级别(public - private)。我们还可以创建一个单独的表来存储具有查看特定URL权限的userid。如果用户没有权限并试图访问URL，我们可以返回一个错误(HTTP 401)。考虑到这一点，我们将数据存储在一个像Cassandra这样的NoSQL宽列数据库中，表存储权限的键将是' Hash '(或KGS生成的' key ')，列将存储那些有权限查看URL的用户的userid。
