设计一个短链服务
设计一个短链服务
让我们设计一个类似TinyURL的URL缩短服务。此服务将提供短别名重定向到长url。类似的服务:bit.ly, goo.gl, qlink.me等等。难度:简单
1. 为什么我们需要URL缩短?
URL缩短用于为长URL创建更短的别名。我们称这些缩短的别名为“短链接”。当用户点击这些短链接时,会被重定向到原始URL。短链接在显示、打印、发送或推特时节省了大量空间。此外,用户不太可能键入较短的url。
例如,如果我们通过TinyURL缩短这个页面:
https://www.educative.io/collection/page/5668639101419520/5649050225344512/5668600 916475904 /
我们可以得到:
http://tinyurl.com/jlg8zpc
例如微博这种,这种字数有限制的,需要短链接这种服务.
缩短后的URL几乎是实际URL大小的三分之一。
URL缩短用于优化跨设备链接,跟踪单个链接以分析受众和活动表现,并隐藏关联的原始URL。
如果你以前没有使用过tinyurl.com,请尝试创建一个新的缩短的URL,并花一些时间浏览他们提供的各种服务选项。这将对你理解本章有很大帮助。
在一些场景下,我们希望将长链接转变为短链接,进行发布.例如微博这种.我们可以通过发布的短链接访问到长链接指向的资源.
我们希望最终各种url访问的链路情况,例如广告投放的场景,这个时候,我们也可以通过短链接服务,来监控长链接指向资源的访问情况.
2.系统需求和目标
在面试开始的时候,一定需要和面试官进行沟通清楚他希望的系统是什么样子的.
我们的网址缩短系统应符合下列要求:
功能需求:
- 给定一个URL,我们的服务应该生成一个更短和唯一的别名。这就是所谓的短链接。
- 当用户访问短链接时,我们的服务应该将他们重定向到原始链接。
- 用户应该有选择地为他们的URL选择一个自定义短链接。
- 链接将在标准的默认时间范围后过期。用户应该能够指定
过期时间。
非功能需求
- 系统应该具有高可用性。这是必需的,因为如果我们的服务关闭,所有的URL重定向都将开始失败.
- URL重定向应该实时发生,延迟最小.
- 缩短的链接不应该是可猜测的(不可预测的).
扩展要求:
- 分析;例如,重定向发生了多少次?
- 其他服务也应该可以通过REST api访问我们的服务。
3.容量估计和约束条件
我们的系统将需要大量的读操作。与新URL缩短相比,将会有很多重定向请求。让我们假设读和写的比例为100:1。
流量估算:假设我们每个月将有5亿个新URL缩短,读/写比率为100:1,我们预计在同一时期会有500亿重定向:
存储估计:假设我们将每个URL缩短请求(以及相关的缩短链接)存储5年。因为我们预计每个月将有5亿个新url,所以我们预计存储的对象总数将达到300亿:
让我们假设每个存储的对象大约为500字节(这只是一个大概的估计,稍后我们将深入研究)。我们总共需要15TB的存储空间:
带宽估计:对于写请求,由于我们预计每秒200个新url,我们的服务的总数据将是每秒100KB:
缓存估计:如果我们想缓存一些经常被访问的热url,我们需要多少内存来存储它们?如果我们遵循80-20规则,即20%的url产生80%的流量,我们希望缓存这20%的热url。
由于我们每秒有20K个请求,我们每天将收到17亿个请求:
高级估计:假设每月新增5亿个url,读写比率为100:1,每条记录的大小是500b.以下是对我们服务的高级估计汇总:
写QPS: 200/s
读QPS: 20K/s
入口带宽:100KB/s
出口带宽:10MB/s
5年存储数据量: 15TB
缓存量: 170GB
总结
我们在估算容量的时候想什么?
1.一般会估算一个业务目标:一个月生成多少条记录.就表示有多少写入的记录
2.假设一条记录的长度大小是多少
3.假设一个读写比例.
4.根据写入的记录目标统计出写Qps.
5.根据读写比例记录出读Qps
6.根据数据存储的时间(5年)计算出需要存储的数量.
7.根据读写QPS和数据大小计算出出口和入口带宽.
8.根据28法则,同时假设缓存存储时间,然后统计出当前系统中需要的缓存数量.(这个数量比较模糊)
4.系统接口
一旦我们确定了需求,定义系统api总是一个好主意。这应该明确地说明期望从系统中得到什么。搞清楚需求,最终这些需求以接口的形式确定下来
我们可以使用SOAP或REST api来公开服务的功能。下面是创建和删除url的api的定义:
创建短链接
createURL(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的过期日期。
返回结果:
成功的插入将返回缩短的URL;否则,它将返回错误代码。
删除url
deleteURL(api_dev_key, url_key)
其中“url_key”是表示要检索的缩短URL的字符串。删除成功返回' URL已删除'。
如何防止和发现滥用?
恶意用户可以通过使用当前设计中的所有URL键使我们破产。为了防止滥用,我们可以通过他们的api_dev_key限制用户。每个api_dev_key可以被限制为每个时间段的URL创建和重定向的特定数量(这可能被设置为每个开发密钥的不同持续时间)。
5. 数据库设计
在设计的早期阶段定义DB模式将有助于理解不同组件之间的数据流,并在以后指导数据分区。
以下是对我们将要存储的数据性质的几点考量:
- 我们需要存储数十亿条记录。
- 我们存储的每个对象都很小(小于1K)。
- 记录之间没有关系—除了存储哪个用户创建了一个URL之外。
- 我们的服务阅读量很大。
数据库模式:
我们需要两个表:一个用于存储关于URL映射的信息,另一个用于创建短链接的用户数据。
这里有两种表:一张是长短url映射表
用户信息表.
我们应该使用什么样的数据库?
因为我们预期存储数十亿行,而且我们不需要使用对象之间的关系.
像DynamoDB、Cassandra或Riak这样的NoSQL键值存储是更好的选择。选择NoSQL也更容易扩展。请参阅SQL vs NoSQL了解更多细节。
6. 基本系统设计与算法
我们在这里解决的问题是,如何为给定的URL生成一个简短和唯一的键。
在第1节的TinyURL示例中,缩略的URL是"http://tinyurl.com/jlg8zpc"。这个URL的最后六个字符是我们想要生成的短键。这里我们将探讨两种解决方案:
如何讲一个长的url转变为一个6个字母的字符串
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万亿可能的字符串
对于687亿的唯一字符串,让我们假设6个字母键足以满足我们的系统。
如果我们使用MD5算法作为散列函数,它将产生128位散列值。在base64编码之后,我们将得到一个超过21个字符的字符串(因为每个base64字符编码散列值的6位)。因为每个短键只有8个字符的空间,那么我们将如何选择我们的键呢?我们可以用前6(或8)个字母作为密钥。但是,这可能会导致键重复,因此我们可以从编码字符串中选择一些其他字符或交换一些字符。
我们的解决方案有哪些不同的问题?我们的编码方案有以下几个问题:
- 如果多个用户输入相同的URL,他们会得到相同的缩短URL,这是不可接受的。
- 如果部分URL是URL编码的呢?例如,http://www.educative.io/distributed.php?id=design和http://www.educative.io/distributed.php%3Fid%3Ddesign是相同的,只是URL编码不同。
解决这些问题的方法:
我们可以为每个输入URL添加一个递增的序号,使其惟一,然后生成它的哈希值。但是,我们不需要在数据库中存储这个序列号。这种方法可能存在的问题是不断增加的序列号。能溢出吗?添加一个递增的序号也会影响服务的性能。
另一种解决方案是将用户id(应该是唯一的)附加到输入URL。但是,如果用户还没有登录,我们将不得不要求用户选择惟一键。即使在这之后,如果我们有一个冲突,我们必须不断生成一个密钥,直到我们得到一个唯一的密钥。
b.离线生成密钥
我们可以有一个独立的密钥生成服务(KGS),它预先生成随机的6个字母字符串,并将它们存储在数据库中(我们称之为Key - db)。当我们想要缩短URL时,我们只需要取一个已经生成的键并使用它。这种方法将使事情变得非常简单和快速。我们不仅不编码URL,而且不必担心重复或冲突。KGS将确保插入key-DB的所有键都是惟一的
并发性会导致问题吗?
一旦使用了一个键,就应该在数据库中标记它,以确保它不会再次被使用。如果有多个服务器同时读取键,我们可能会遇到这样的场景:两个或多个服务器试图从数据库读取相同的键。我们如何解决这个并发问题?
服务器可以使用KGS读取/标记数据库中的键。KGS可以使用两个表存储键:一个用于尚未使用的键,另一个用于所有已使用的键。一旦KGS将密钥提供给其中一个服务器,它就可以将它们移动到使用的密钥表。KGS可以始终在内存中保留一些键,以便在服务器需要时快速提供它们。
为了简单起见,一旦KGS在内存中加载了一些键,它就可以将它们移动到使用的键表中。这可以确保每个服务器获得唯一的键。如果KGS在将所有加载的密钥分配给某个服务器之前失效,那么我们将浪费这些密钥—鉴于我们拥有的密钥数量巨大,这是可以接受的。
KGS还必须确保不对多个服务器提供相同的密钥。为此,它必须同步(或锁定)持有密钥的数据结构,然后再将密钥移出并交给服务器
这里如何保证唯一:
1.这里是首先生成一批在唯一的键值对存储起来.
2.然后在内存中创建一个数据结构,批量从db中获取到内存中,进行预分配.可能会丢失一些键.
3.去db中取键值的服务需要适用分布式锁来帮助实现.
key-DB的大小是多少?使用base64编码,我们可以生成68.7B唯一的6个字母键。如果我们需要一个字节来存储一个字母数字字符,我们可以存储所有这些键:
KGS难道不是单点故障吗? 是的,为了解决这个问题,我们可以有一个KGS的备用副本,当主服务器死亡时,备用服务器可以接管生成并提供密钥。
每个应用服务器可以从key-DB缓存一些键吗? 是的,这肯定能加快速度。尽管在本例中,如果应用服务器在使用所有密钥之前死亡,我们将最终丢失这些密钥。这是可以接受的,因为我们有687亿唯一的6个字母键
我们如何执行键查找? 我们可以在数据库或键值存储中查找该键以获得完整的URL。如果存在,则向浏览器发出“HTTP 302重定向”状态,将存储的URL传递到请求的“位置”字段。如果该键在我们的系统中不存在,发出“HTTP 404未找到”状态或重定向用户回到主页。
我们应该对自定义别名施加大小限制吗? 我们的服务支持自定义别名。用户可以选择任何他们喜欢的“键”,但提供一个自定义别名不是强制性的。但是,对自定义别名施加大小限制是合理的(通常也是可取的),以确保我们拥有一致的URL数据库。让我们假设用户可以为每个客户键指定最多16个字符(如上面的数据库模式所示)。
7. 数据分区和复制
为了扩展我们的数据库,我们需要对它进行分区,以便它可以存储关于数十亿个url的信息。我们需要提出一种分区方案,将数据划分并存储到不同的DB服务器。
a.基于范围的分区:我们可以根据URL的第一个字母或哈希键将URL存储在单独的分区中。因此,我们将所有以字母“A”开头的url保存在一个分区中,将以字母“B”开头的url保存在另一个分区中,以此类推。这种方法称为基于范围的分区。我们甚至可以将某些不太频繁出现的字母组合到一个数据库分区中。我们应该提出一个静态分区方案,这样我们就可以始终以可预测的方式存储/查找文件。
这种方法的主要问题是它会导致服务器不平衡。例如:我们决定把所有以字母“E”开头的url放到一个数据库分区中,但后来我们意识到我们有太多以字母“E”开头的url。
b.基于哈希的分区:在这个方案中,我们获取所存储对象的哈希值。然后根据散列计算使用哪个分区。在我们的例子中,我们可以使用“key”的哈希值或实际的URL来确定存储数据对象的分区。
我们的哈希函数将随机地将url分配到不同的分区中(例如,我们的哈希函数总是可以将任意键映射到一个介于[1…256]之间的数字),而这个数字将代表我们存储对象的分区。
这种方法仍然会导致分区过载,这可以通过使用一致哈希来解决。
8. 缓存
我们可以缓存经常访问的url。我们可以使用一些现成的解决方案,比如Memcache,它可以用各自的散列存储完整的url。在命中后端存储之前,应用服务器可以快速检查缓存中是否有所需的URL。
我们应该有多少缓存?我们可以从每天20%的流量开始,根据客户的使用模式,我们可以调整需要多少缓存服务器。根据上面的估计,我们需要170GB内存来缓存20%的日常流量。由于现代服务器可以拥有256GB内存,所以我们可以很容易地将所有缓存放入一台机器中。或者,我们可以使用几个较小的服务器来存储所有这些热url。
哪种缓存移除策略最适合我们的需求?当缓存已满时,我们想要用更新/更热的URL替换链接,我们该如何选择?最近最少使用(LRU)对于我们的系统来说是一个合理的策略。在此策略下,我们首先丢弃最近最少使用的URL。我们可以使用一个链接哈希图或类似的数据结构来存储我们的url和哈希值,这也将跟踪最近被访问的url。
为了进一步提高效率,我们可以复制缓存服务器来在它们之间分配负载。
如何更新每个缓存副本?每当缓存丢失时,我们的服务器就会访问后端数据库。每当发生这种情况时,我们可以更新缓存并将新条目传递给所有缓存副本。每个副本可以通过添加新条目来更新它们的缓存。如果副本已经有该条目,它可以简单地忽略它。
9. 负载均衡器(LB)
我们可以在系统的三个地方添加负载平衡层:
- 客户机和应用程序服务器之间
- 应用服务器与数据库服务器之间;
- 应用服务器和缓存服务器之间
最初,我们可以使用简单的Round Robin方法,将传入请求平均分配到后端服务器。这种负载均衡实现简单,不引入任何开销。这种方法的另一个好处是,如果服务器死亡,LB将把它从循环中取出,并停止向它发送任何流量。
Round Robin LB的一个问题是没有考虑服务器负载。当某台服务器负载过重或运行速度较慢时,LB将不会停止向该服务器发送新的请求。为了解决这个问题,可以放置一个更智能的LB解决方案,定期查询后端服务器的负载,并根据负载调整流量。
10. 清除或数据库清理
条目是应该永远保留还是应该被清除?如果达到用户指定的过期时间,该链接会发生什么情况?
如果我们选择主动搜索过期链接来删除它们,这将给我们的数据库带来很大的压力。相反,我们可以缓慢地删除过期链接,并进行惰性清理。我们的服务将确保只有过期的链接将被删除,尽管一些过期的链接可以存活更长时间,但永远不会返回给用户。
•当用户试图访问一个过期链接时,我们可以删除该链接并返回一个错误给用户。
•一个单独的清理服务可以定期运行,从我们的存储和缓存删除过期链接。这个服务应该是非常轻量级的,并且只能在预计用户流量很低的情况下调度运行。
•我们可以为每个链接设置默认的过期时间(例如,两年)。删除过期链接后,我们可以把密钥放回key- db中以供重用。
•我们是否应该删除在一段时间内没有被访问的链接,比如六个月?这可能是棘手的。由于存储越来越便宜,我们可以决定永远保持链接。
//todo 这里缺少一张图片来表示整体的架构图.
11. 数据统计
一个短URL被使用了多少次,用户位置等?我们如何存储这些统计数据?如果它是每个视图上更新的DB行的一部分,那么当一个流行的URL被大量并发请求猛烈攻击时会发生什么?
一些值得跟踪的统计数据:访问者的国家,访问的日期和时间,网页点击,浏览器,或访问页面的平台。
12. 安全与权限
用户是否可以创建私有URL或允许特定用户访问URL?
我们可以在数据库中存储每个URL的权限级别(公共/私有)。我们还可以创建一个单独的表来存储具有查看特定URL权限的userid。如果用户没有权限并试图访问URL,我们可以返回一个错误(HTTP 401)。假设我们将数据存储在像Cassandra这样的NoSQL宽列数据库中,表存储权限的键将是' Hash '(或KGS生成的' key ')。这些列将存储那些具有查看URL权限的用户的userid。
13 总结
当我们拿到一个系统的时候,应该如何设计一个系统系统.
1.先沟通当前系统的功能.确定当前系统使用的场景,使用这个系统的相关方.当前系统的核心流程.确定当前系统的核心对象.对象是如何写入的,对象接口是如何读取的.
2.根据第一步骤中确定的功能点,确定当前系统中的向外提供的接口.
3.估算容量,假设当前核心对象的容量,以及数据存储的规模.未来可能的增长的空间.做一个假设,数据的记录条数,存活时间,每条数据的大小.读写比例.确定记录条数,磁盘大小,网络带宽,缓存,关键qps.
4.根据数据之间的关系,数据的结构.选择合适的存储容器.如果数据之间有关系就适用sql,如果数据之间没有关系,就适用nosql.
5.组件的复制和自动切换.帮助实现高可用.
6.组件的shading + loadbalance确定当前主键的可扩展.
7.过期数据的清除.
8.提升系统的性能,主要是缓存的适用.
9.系统的埋点和监控,确定系统各个维度需要确定的监控指标.
10.绘制系统的架构图.