项目简介:TinyURL是一项在线服务,允许用户将长网址缩短为简洁的短网址,以便于分享和使用。这种服务尤其适用于社交媒体和电子邮件,因为这些平台对链接长度可能有限制。TinyURL的使用非常简单,只需在它的网站上输入长网址,然后系统会自动生成一个短网址供你使用和分享。
现在让我们设计一个像TinyURL一样的URL缩短服务。这个服务将提供短别名来重定向到长URL。
类似的产品有:bit.ly, ow.ly, short.io
系统难度级别:初级
1、为什么我们需要URL缩短?
URL缩短用于为长URL创建更短的别名。我们将这些缩短后的别名称为“短链接”。当用户点击这些短链接时,他们会被重定向到原始URL。短链接在显示、打印、发送信息或发推文时节省了大量空间。此外,用户输入错误的可能性也会随着URL的缩短而减少。
例如,如果我们通过TinyURL缩短以下URL:
https://minorstone.com/archives/xi-tong-jia-gou-de-jing-sui-18-ge-bi-dong-de-she-ji-gai-nian-yi-lan
我们会得到:
http://tinyurl.mobi/JDZR
缩短后的URL几乎只有实际URL的三分之一大小。
URL缩短用于优化设备间的链接,跟踪单个链接以分析观众,测量广告活动的效果,或者隐藏关联的原始URL。
如果你之前没有使用过tinyurl.com,请尝试创建一个新的缩短URL,并花些时间了解他们服务提供的各种选项。这将对你理解这一章节有很大帮助。
2、系统的需求和目标
你应该在面试开始时就明确需求。务必提问,以找出面试官心中系统的准确范围。
我们的URL缩短系统应满足以下要求:
功能性需求:
非功能性需求:
扩展需求:
3、容量估计和约束
我们的系统将是读取密集型的。与新的URL缩短相比,将会有大量的重定向请求。我们假设读取和写入的比例是100:1。
流量估计:假设我们每月将有5亿次新的URL缩短,以100:1的读/写比率,我们可以预期在同一时期内有500亿次重定向:
100∗500M=>50B
我们系统的每秒查询数(QPS)是多少?每秒新的URL缩短数量:
500million/(30days∗24hours∗3600seconds)= 200URLs/s
考虑到100:1的读/写比率,每秒的URL重定向数量将是:
100 * 200 URLs/s = 20K/s
存储估计:我们假设存储每个URL缩短请求(及关联的缩短链接)5年。由于我们预计每月会有5亿个新的URL,因此我们预计存储的对象总数将是300亿个:
500million∗5years∗12mnotallow=30billion
我们假设每个存储的对象大约为500字节(这只是一个大致的估计——我们稍后会深入研究)。我们需要15TB的总存储:
30billion∗500bytes=15TB
带宽估计:对于写请求,由于我们预计每秒会有200个新的URL,我们的服务总入站数据将是每秒100KB:
200∗500bytes=100KB/s
对于读请求,由于我们预计每秒将有大约20K个URL重定向,我们的服务总出站数据将是每秒10MB:
20K∗500bytes= 10MB/s
内存估计:如果我们想缓存一些经常访问的热门URL,我们需要多少内存来存储它们?如果我们遵循80-20的规则,即20%的URL产生80%的流量,我们希望缓存这20%的热门URL。
由于我们每秒有20K个请求,我们每天将获得17亿个请求:
20K∗3600seconds∗24hours= 1.7billion
为了缓存这20%的请求,我们需要170GB的内存。
0.2∗1.7billion∗500bytes= 170GB
这里需要注意的一点是,由于会有许多重复请求(相同的URL),我们实际的内存使用量将少于170GB。
高层次估计:假设每月有5亿个新的URL,并且读写比率为100:1,以下是我们服务的高层次估计的概述:
新网址 |
200/秒 |
URL 重定向 |
20K/秒 |
传入数据 |
100KB/秒 |
传出数据 |
10MB/秒 |
储存5年 |
15TB |
缓存内存 |
170GB |
4、API定义
一旦我们确定了需求,接下来需要定义系统API。需要明确说明系统的预期功能。
我们可以使用SOAP或REST API来公开我们服务的功能。创建和删除URL的API定义可能如下:
createURL(api_dev_key, original_url, custom_alias=None, user_name=None, expire_date=None)
参数:
返回:(字符串)
成功插入返回缩短后的URL;否则,返回一个错误码。
deleteURL(api_dev_key, url_key)
其中url_key是表示要检索的缩短URL的字符串;成功删除后返回URL Removed。
如何检测和防止滥用?恶意用户可以通过消耗当前设计中的所有URL键会造成大量的冗余key。为了防止滥用,我们可以通过他们的api_dev_key限制用户。每个api_dev_key可以限制在一定时间段内创建和重定向URL的数量(该时长可能会根据开发者密钥设置为不同的时长)。
5、数据库设计
在早期系统设计阶段定义DB模式对理解数据在各个组件之间的流动非常有帮助,随后会引导我们进行数据分区。
我们将存储的数据具有以下特点:
数据库架构:
我们需要两个表:一个用于存储URL映射信息,另一个用于存储创建短链接的用户的数据。
我们应该使用什么类型的数据库?由于我们预计将存储数十亿行,而且我们不需要使用对象之间的关系—一个NoSQL存储,如DynamoDB,Cassandra或Riak会是一个更好的选择。一个NoSQL选择也将更容易扩展。
6、基础系统设计和算法
我们这里要解决的问题是如何为给定的URL生成一个短而唯一的键。
在第一部分的TinyURL示例中,缩短的URL是“http://tinyurl.mobi/JDZR”。这个URL的最后八个字符就是我们想要生成的短键。我们将在这里探讨两种解决方案:
A. 编码实际的URL
我们可以计算给定URL的唯一哈希值(例如D5或**SHA256**等)。然后,哈希可以进行编码以便显示。这种编码可以是Base36([a-z ,0-9])或Base62([A-Z, a-z, 0-9]),如果我们添加 '+' 和 '/',我们可以使用Base64编码。一个合理的问题是,短键的长度应该是多少?6、8还是10个字符?
使用Base64编码,长度为6个字符的键将产生646= 68764^6 = ~687646= 687亿个可能的字符串。
使用Base64编码,长度为8个字符的键将产生648= 28164^8 = ~281648= 281万亿个可能的字符串。
假设对于我们的系统来说,有687亿个唯一字符串的六个字符键就足够了。
如果我们使用MD5算法作为我们的哈希函数,它将产生一个128位的哈希值。经过Base64编码后,我们会得到一个超过21个字符的字符串(因为每个Base64字符编码了哈希值的6位)。现在我们每个短键只有6(或8)个字符的空间;那么我们怎么选择我们的键呢?我们可以取前6(或8)个字符作为键。这可能会导致键重复;为了解决这个问题,我们可以从编码字符串中选择一些其他字符或交换一些字符。
我们的解决方案有哪些问题?我们的编码方案有以下几个问题:
解决问题的方法:我们可以在每个输入URL后附加一个递增的序列号使其唯一,然后生成哈希。然而,我们不需要在数据库中存储这个序列号。这种方法可能的问题可能是一个不断增加的序列号。它会溢出吗?附加一个递增的序列号也将影响服务的性能。
另一种解决方案是在输入URL后附加用户id(userId是唯一的)。然而,如果用户没有登录,我们将不得不要求用户选择一个唯一性键。
B. 离线生成键
我们可以设立一个独立的键生成服务(KGS),预先生成随机的六位字符串,并将其存储在数据库中(我们称之为key-DB)。每当我们想要缩短一个URL,我们就会拿一个已经生成的键来使用。这种方式非常简单和快速。我们不仅不需要编码URL,而且不必担心重复或冲突。KGS会确保插入key-DB的所有键都是唯一的。
**并发会引起问题吗?**一旦一个键被使用,就应该在数据库中标记,以确保它不再被使用。如果有多个服务器同时读取键,我们可能会遇到两个或更多的服务器试图从数据库中读取同一个键的情况。我们如何解决这个并发问题?
服务器可以使用KGS在数据库中读取/标记键。KGS可以使用两个表来存储键:一个用于尚未使用的键,另一个用于所有已使用的键。一旦KGS将键提供给其中一个服务器,就可以将它们移动到已使用的键表中。KGS可以始终在内存中保留一些键,以便在服务器需要时快速提供。
为了简单起见,一旦KGS在内存中加载了一些键,就可以将它们移动到已使用的键表中。这确保每个服务器得到的键都是唯一的。如果KGS在将所有加载的键分配给某个服务器之前死机,我们将会浪费这些键——鉴于我们拥有的键的数量庞大,这可能是可以接受的。
KGS还必须确保不给多个服务器相同的键。为此,它必须在从中移除键并将它们提供给服务器之前,对保存键的数据结构进行同步(或对其加锁)。
key-DB的大小会是多少?使用base64编码,我们可以生成687亿个唯一的六位键。如果我们需要一个字节来存储一个字母数字字符,我们可以将所有这些键存储在:
6(每个键的字符数)* 68.7B(唯一键)= 412 GB
KGS会出现单点故障吗?会的。为了解决这个问题,我们可以设置一个备用的KGS副本。当主服务器出现故障时,备用服务器可以接管,生成并提供键。
每个应用服务器可以从key-DB中缓存一些键吗?是的,这肯定可以加快速度。然而,在这种情况下,如果应用服务器在消耗完所有键之前出现故障,我们将最终失去这些键。鉴于我们有687亿个唯一的六位键,这是可以接受的。
我们如何执行键查找?我们可以在数据库中查找键以获取完整的URL。如果它存在于数据库中,就向浏览器返回HTTP 302 Redirect状态,并在请求的Location字段中传递存储的URL。如果我们的系统中没有该键,则返回HTTP 404 Not Found状态或将用户重定向回主页。
我们应该对自定义别名设定大小限制吗?我们的服务支持自定义别名。用户可以自由选择他们喜欢的key,但提供自定义别名并不是必须的。然而,为了确保我们有一个一致的URL数据库,设定一个自定义别名的大小限制是合理的(甚至是用户期望的)。我们假设用户可以为每个自定义键指定最多16个字符(如上述数据库模式所示)。
7、数据分区和复制
为了扩展我们的数据库,我们需要对其进行分区,以便它能存储数十亿个URL的信息。因此,我们需要设计一个分区方案,将我们的数据划分并存储到不同的数据库中。
A. 基于范围的分区:我们可以根据哈希键的第一个字母在不同的分区中存储URL。因此,我们将所有以字母“A”(和“a”)开头的URL哈希键存储在一个分区中,将以字母“B”开头的URL存储在另一个分区中,以此类推。这种方法被称为基于范围的分区。我们甚至可以将某些出现频率较低的字母组合到一个数据库分区中。因此,我们应开发一个静态分区方案,始终以可预测的方式存储/查找URL。
这种方法的主要问题是可能导致数据库服务器不平衡。例如,我们决定将所有以字母“E”开头的URL放入一个数据库分区,但后来我们发现以字母“E”开头的URL过多。
B. 基于哈希的分区:在这种方案中,我们对存储的对象进行哈希。然后,我们根据哈希值计算使用哪个分区。在我们的情况下,我们可以获取key或短链接的哈希值,以确定我们存储数据对象的分区。
我们的哈希函数将随机地将URL分配到不同的分区(例如,我们的哈希函数可以始终将任何key映射到[1...256]之间的一个数字)。这个数字将表示我们存储对象的分区。
这种方法仍然可能导致分区负载过大,这可以通过使用'一致性哈希'来解决。
8、缓存
我们可以缓存频繁访问的URL。我们可以使用任何现成的解决方案,比如Memcached,它可以存储完整的URL及其对应的哈希值。因此,应用服务器在访问后端存储之前,可以快速检查缓存是否有所需的URL。
我们应该有多少缓存内存?我们可以从日流量的20%开始,根据客户的使用模式,我们可以调整需要多少缓存服务器。如上所估计,我们需要170GB的内存来缓存日流量的20%。目前的一些服务器可以拥有256GB的内存,我们可以轻松将所有缓存放入一台机器。或者,我们可以使用几台小一点的服务器来存储所有这些热门URL。
哪种缓存驱逐策略最适合我们的需求?当缓存满了,我们想用一个新的/更热门的URL替换一个链接,我们应该如何选择?最近最少使用(LRU)可能是我们系统的一个合理策略。根据这个策略,我们首先丢弃最近最少使用的URL。我们可以使用Linked Hash Map或类似的数据结构来存储我们的URL和哈希值,这也会记录最近访问过的URL。
为了进一步提高效率,我们可以复制我们的缓存服务器来分配它们之间的负载。
每个缓存副本如何更新?每当有一个缓存未命中,我们的服务器会访问后端数据库。当这种情况发生,我们可以更新缓存,并将新的条目传递给所有的缓存副本。每个副本都可以通过添加新的条目来更新其缓存。如果副本已经有了该条目,它可以简单地忽略它。
9、负载均衡(LB)
我们可以在系统中的三个位置添加负载均衡层:
- 客户端与应用服务器之间
- 应用服务器与数据库服务器之间
- 应用服务器与缓存服务器之间
最初,我们可以使用一个简单的轮询方法,将进入的请求均等地分配到后端服务器。这种负载均衡方法简单易行,不会引入任何额外的开销。这种方法的另一个优点是,如果一个服务器死机,负载均衡器会将其从轮询中移除,停止向其发送任何流量。
轮询负载均衡的一个问题是,我们没有考虑到服务器的负载。如果一个服务器过载或运行缓慢,负载均衡器不会停止向该服务器发送新的请求。为了处理这个问题,我们可以放置一个更优的负载均衡解决方案,它定期查询后端服务器的负载,并根据负载情况调整流量。
10、清除或数据库清理
key条目是否应永久存在,还是应该被清除?如果达到用户设定的过期时间,链接应该怎么处理?
如果我们选择持续寻找过期链接并将其删除,这将对我们的数据库产生很大压力。相反,我们可以慢慢地删除过期的链接,进行懒人清理。我们的服务将确保只删除过期的链接,尽管有些过期链接可能会存在更长时间,但永远不会被返回给用户。
- 每当用户试图访问一个已过期的链接,我们可以删除该链接并向用户返回错误。
- 我们可以设置一个单独的清理服务,定期从我们的存储和缓存中删除过期的链接。这个服务需要非常轻量,只在预计用户流量较低的时候运行。
- 我们可以为每个链接设定一个默认的过期时间(例如两年)。
- 删除过期链接后,我们可以将该key重新放回Key-DB,以供重复使用。
- 我们是否应删除一段时间(比如说六个月)内没有被访问过的链接?这可能有点不合适。由于存储成本越来越低,我们可以决定永久保存链接。
11、数据跟踪
我们如何统计短链接被使用的次数、用户的位置等信息?我们如何储存这些统计信息?如果它是数据库中的一部分,每次查看都需要更新,那么当一个流行的短链接被大量并发请求瞬间涌入时,会发生什么?
一些值得追踪的统计数据:访客的国家、访问的日期和时间、引导点击的网页、访问页面的浏览器或平台。
12、安全和权限
用户能否创建私有URL或者允许特定的用户组访问某个URL?
我们可以在数据库中每个URL的条目里存储访问权限级别(公开/私有)。我们也可以创建一个单独的表来存储有权访问特定URL的用户的UserID。如果一个用户没有权限但试图访问一个URL,我们可以返回一个错误(HTTP 401)。考虑到我们的数据储存在一个类似Cassandra的NoSQL宽列数据库中,储存权限的表的key将是‘哈希值’(或KGS生成的key)。列将储存有权查看URL的用户的UserID。