淦!做一个短链接生成系统需要考虑这么多
什么是短链接
短链接顾名思义,就是一个比较短的链接(
我好像说了个废话),我们平时看到的链接可能长这样:
又臭又长有没有(
没错,这是一个WX公众号链接),那如果我们需要将某个链接发在某个文章或者推广给别人的时候,这么长看着也太不爽了,而短链接的出现就是用一个很短的URL来替代这个很长的家伙,当用户访问短链接的时候,会重定向到原来的链接。比如长下面这样:
你如果平时有注意的话,各种商业短信上的链接也是会转成特别短的:
这个特别短的URL就是短链接。
为什么需要URL短链接
URL短链接用于为长URL创建较短的别名,我们称这些缩短的别名为“短链接”;当用户点击这些短链接时,会被重定向到原始URL;短链接在显示、打印、发送消息时可节省大量空间。
例如,如果我们通过sourl缩短以下URL:
https://juejin.cn/user/2119514147536087?share_token=0954d699-3dfa-4d49-9b39-fec78ff91572
我们可以得到一个短链接:
缩短的URL几乎是实际URL大小的三分之一。
URL缩写经常用于优化设备之间的链接,跟踪单个链接以分析受众,衡量广告活动的表现,或隐藏关联的原始URL。
如果你以前没有使用过sourl,可以尝试创建一个新的URL短链接,并花一些时间浏览一下他们的服务提供的各种选项。可以让你更好的理解这篇文章。
系统的要求和目标
在完成一个功能或者开发一个系统时,先确定系统的定位和要达到的目标是一个好的习惯,这可以让你在设计和开发过程中有更清晰的思路。
我们的短链接系统应满足以下要求:
功能要求:
- 给定一个URL,我们的服务应该为其生成一个较短且唯一的别名,这叫做短链接,此链接应足够短,以便于复制和粘贴到应用程序中;
- 当用户访问短链接时,我们的服务应该将他们重定向到原始链接;
- 用户应该能够选择性地为他们的URL选择一个自定义的短链接;
- 链接可以在指定时间跨度之后过期,用户应该能够指定过期时间。
非功能要求:
- 系统必须高度可用。如果我们的服务关闭,所有URL重定向都将开始失败;
- URL重定向的延迟情况应该足够小;
- 短链接应该是不可猜测的。
扩展要求:
- 支持分析和统计,例如短链接的访问次数;
- 其他服务也应该可以通过RESTAPI访问我们的服务。
容量要求和限制
我们的系统将会有很大的访问量。会有对短链接的读取请求和创建短链接的写入请求。假设读写比例为100:1。
访问量预估:
假设我们每个月有5亿个新增短链接,读写比为100:1,我们可以预计在同一时间内有500亿重定向:
100 * 5亿 => 500亿
我们系统的QPS(每秒查询数量)是多少?每秒的新短链接为:
5亿/ (30天 * 24小时 * 3600 秒) ≈ 200 URLs/s
考虑到100:1读写比,每秒URL重定向将为:
100 * 200 URLs/s = 20000/s
存储预估:
假设我们将每个URL缩短请求(以及相关的缩短链接)存储5年。由于我们预计每个月将有5亿个新URL,因此我们预计存储的对象总数将为300亿:
5亿 * 5 年 * 12 月 = 300亿
假设每个存储的对象大约有500个字节(这只是一个估算值)。我们将需要15TB的总存储:
300亿*500bytes≈15TB
带宽预估:
对于写请求,由于我们预计每秒有200个新的短链接创建,因此我们服务的总传入数据为每秒100KB:
200*500bytes≈100KB/s
对于读请求,预计每秒约有20,000个URL重定向,因此我们服务的总传出数据将为每秒10MB:
20000 * 500 bytes ≈10 MB/s
内存预估:
对于一些热门访问的URL为了提高访问速率,我们需要进行缓存,需要多少内存来存储它们?如果我们遵循二八原则,即20%的URL产生80%的流量,我们希望缓存这20%的热门URL。
由于我们每秒有20,000个请求,因此我们每天将收到17亿个请求:
20000 * 24 * 3600 ≈ 17亿
要缓存这些请求中的20%,我们需要170 GB的内存:
17亿 * 0.2 * 500bytes ≈ 170GB
这里需要注意的一件事是,由于将会有许多来自相同URL的重复请求,因此我们的实际内存使用量可能达不到170 GB。
整体来说,假设每月新增5亿个URL,读写比为100:1,我们的预估数据大概是下面这样:
类型 | 预估数值 |
新增短链接 | 200/s |
短链接重定向 | 20000/s |
传入数据 | 100KB/s |
传出数据 | 10 MB/s |
存储5年容量 | 15 TB |
内存缓存容量 | 170 GB |
系统API设计
一旦我们最终确定了需求,就可以定义系统的API了,这里则是要明确定义我们的系统能提供什么服务。
我们可以使用REST API来公开我们服务的功能。以下是用于创建和删除URL的API的定义:
创建短链接接口
String createURL(api_dev_key, original_url, custom_alias=None, user_name=None, expire_date=None)
参数列表:
api_dev_key:分配给注册用户的开发者密钥,可以根据该值对用户的创建短链接数量进行限制;
original_url:需要生成短链接的原始URL;
custom_alias :用户对于URL自定义的名称;
user_name :可以用在编码中的用户名;
expire_date :短链接的过期时间;
返回值:
成功生成短链接将返回短链接URL;否则,将返回错误代码。
删除短链接接口
String deleteURL(api_dev_key, url_key)
其中url_key
是表示要删除的短链接字符串;成功删除将返回delete success
。
如何发现和防止短链接被滥用?
恶意用户可以通过使用当前设计中的所有URL密钥来对我们进行攻击。为了防止滥用,我们可以通过用户的api_dev_key
来限制用户。每个api_dev_key
可以限制为每段时间创建一定数量的URL和重定向(可以根据开发者密钥设置不同的持续时间)。
数据模型设计
在开发之前完成数据模型的设计将有助于理解各个组件之间的数据流。
在我们短链接服务系统中的数据,存在以下特点:
- 需要存储十亿条数据记录;
- 存储的每个对象都很小(小于1K);
- 除了存储哪个用户创建了URL之外,记录之间没有任何关系;
- 我们的服务会有大量的读取请求。
我们需要创建两张表,一张用于存储短链接数据,一张用于存储用户数据;
应该使用怎样的数据库?
因为我们预计要存储数十亿行,并且不需要使用对象之间的关系-所以像mongoDB、Cassandra这样的NoSQL存储是更好的选择。选择NoSQL也更容易扩展。
基本系统设计与算法
现在需要解决的问题是如何为给定的URL生成一个简短且唯一的密钥。主要有两种解决方案:
- 对原URL进行编码
- 提前离线生成秘钥
对原URL编码
可以计算给定URL的唯一HASH值(例如,MD5或SHA256等)。然后可以对HASH进行编码以供显示。该编码可以是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算法作为我们的HASH函数,它将产生一个128位的HASH值。在Base64编码之后,我们将得到一个超过21个字符的字符串(因为每个Base64字符编码6位HASH值)。
现在我们每个短链接只有6(或8)个字符的空间,那么我们将如何选择我们的密钥呢?
我们可以取前6(或8)个字母作为密钥,但是这样导致链接重复;要解决这个问题,我们可以从编码字符串中选择一些其他字符或交换一些字符。
我们的解决方案有以下问题:
- 如果多个用户输入相同的URL,他们可以获得相同的短链接,这种情况应该不允许出现;
- 如果URL本身的某些部分是经过URL编码的,该怎么处理?例如,除了url编码之外,
https://juejin.cn/user/2119514147536087
,和https://juejin.cn/user/2119514147536087%3Fid%3D
是相同的。
解决办法:
我们可以将递增的序列号附加到每个输入URL以使其唯一,然后生成其散列。不过,我们不需要将此序列号存储在数据库中。此方法可能存在的问题是序列号不断增加会导致溢出。添加递增的序列号也会影响服务的性能。
另一种解决方案可以是将用户ID附加到输入URL。但是,如果用户尚未登录,我们将不得不要求用户选择一个唯一的key。即使这样也有可能有冲突,需要不断生成直到得到唯一的密钥。
离线生成秘钥
可以有一个独立的密钥生成服务,我们就叫它KGS(Key Generation Service),它预先生成随机的六个字母的字符串,并将它们存储在数据库中。每当我们想要生成短链接时,都去KGS获取一个已经生成的密钥并使用。这种方法更简单快捷。我们不仅不需要对URL进行编码,而且也不必担心重复或冲突。KGS将确保插入到数据库中的所有密钥都是唯一的。
会存在并发问题吗?
密钥一旦使用,就应该在数据库中进行标记,以确保不会再次使用。如果有多个服务器同时读取密钥,我们可能会遇到两个或多个服务器尝试从数据库读取相同密钥的情况。如何解决这个并发问题呢?
KGS可以使用两个表来存储密钥:一个用于尚未使用的密钥,一个用于所有已使用的密钥。
一旦KGS将密钥提供给其中一个服务器,它就可以将它们移动到已使用的秘钥表中;可以始终在内存中保留一些密钥,以便在服务器需要时快速提供它们。
为简单起见,一旦KGS将一些密钥加载到内存中,它就可以将它们移动到Used Key表中。这可确保每台服务器都获得唯一的密钥。
如果在将所有加载的密钥分配给某个服务器之前KGS重启或死亡,我们将浪费这些密钥,考虑到我们拥有的秘钥很多,这种情况也可以接受。
还必须确保KGS不将相同的密钥提供给多个服务器,因此,KGS将秘钥加载到内存和将秘钥移动到已使用表的动作需要是同步的,或者加锁,然后才能将秘钥提供给服务器。
KGS是否存在单点故障?
要解决KGS单点故障问题,我们可以使用KGS的备用副本。当主服务器死机时,备用服务器可以接管以生成和提供密钥。
每个应用服务器是否可以换成一些Key?
可以,这样可以减少对KGS的访问,不过,在这种情况下,如果应用服务器在使用所有密钥之前死亡,我们最终将丢失这些密钥。但是因为我们的秘钥数量很多,这点可以接受。
如何完成秘钥查找?
我们可以在数据库中查找密钥以获得完整的URL。如果它存在于数据库中,则向浏览器发回一个“HTTP302 Redirect”
状态,将存储的URL传递到请求的Location
字段中。如果密钥不在我们系统中,则发出HTTP 404 Not Found
状态或将用户重定向回主页。
数据分区和复制
因为我们要存储十亿个URL数据,那么一个数据库节点在存储上可能不满足要求,并且单节点也不能支撑我们读取的要求。
因此,我们需要开发一种分区方案,将数据划分并存储到不同的数据库服务中。
基于范围分区:
我们可以根据短链接的第一个字母将URL存储在不同的分区中。因此,我们将所有以字母'A/a'开头的URL保存在一个分区中,将以字母‘B/b’开头的URL保存在另一个分区中,以此类推。这种方法称为基于范围的分区。我们甚至可以将某些不太频繁出现的字母合并到一个数据库分区中。
基于hash值分区:
在此方案中,我们对要存储的对象进行Hash计算。然后,我们根据Hash结果计算使用哪个分区。在我们的例子中,我们可以使用短链接的Hash值来确定存储数据对象的分区。
Hash函数会将URL随机分配到不同的分区中(例如,Hash函数总是可以将任何‘键’映射到[1…256]之间的一个数字,这个数字将表示我们在其中存储对象的分区。
这种方式有可能导致有些分区数据超载,可以使用一致性哈希算法解决。
缓存
对于频繁访问的热点URL我们可以进行缓存。缓存的方案可以使用现成的解决方案,比如使用memcached,Redis等,因此,应用服务器在查找数据库之前可以快速检查高速缓存是否具有所需的URL。
如果确定缓存容量?
可以从每天20%的流量开始,并根据客户端的使用模式调整所需的缓存服务器数量。如上所述,我们需要170 GB内存来缓存20%的日常流量。可以使用几个较小的服务器来存储所有这些热门URL。
选择哪种淘汰策略?
淘汰策略是指当缓存已满时,如果我们想用更热点的URL替换链接,我们该如何选择?
对于我们的系统来说,最近最少使用(LRU)是一个合理的策略。在此策略下,我们首先丢弃最近最少使用的URL;我们可以使用一个短链接或短链接的HASH值作为key的Hash Map或类似的数据结构来存储URL和访问次数。
如何更新缓存?
每当出现缓存未命中时,我们的服务器都会命中后端数据库。每次发生这种情况,我们都可以更新缓存并将新条目传递给所有缓存副本。每个副本都可以通过添加新条目来更新其缓存。如果副本已经有该条目,它可以简单地忽略它。
负载均衡
可以在系统中的三个位置添加负载均衡层:
- 在客户端和应用程序服务器之间;
- 在应用程序服务器和数据库服务器之间;
- 在应用程序服务器和缓存服务器之间。
可以使用简单的循环调度方法,在后端服务器之间平均分配传入的请求。这种负载均衡方式实现起来很简单,并且不会带来任何开销。此方法的另一个好处是,如果服务器死机,负载均衡可以让其退出轮换,并停止向其发送任何流量。
循环调度的一个问题是没有考虑服务器过载情况。因此,如果服务器过载或速度慢,不会停止向该服务器发送新请求。要处理此问题,可以放置一个更智能的解决方案,定期查询后端服务器的负载并基于此调整流量。
数据清除策略
数据应该永远保留,还是应该被清除?如果达到用户指定的过期时间,短链接应该如何处理?
- 持续扫描数据库,清除过期数据。
- 懒惰删除策略
如果我们选择持续查询过期链接来删除,将会给数据库带来很大的压力;可以慢慢删除过期的链接,并进行懒惰的方式清理。服务确保只有过期的链接将被删除,尽管一些过期的链接可以在数据库保存更长时间,但永远不会返回给用户。
- 每当用户尝试访问过期链接时,我们都可以删除该链接并向用户返回错误;
- 单独的清理服务可以定期运行,从存储和缓存中删除过期的链接;
- 此服务应该非常轻量级,并计划仅在预期用户流量较低时运行;
- 我们可以为每个短链接设置默认的到期时间(例如两年);
- 删除过期链接后,我们可以将密钥放回KGS的数据库中重复使用。
结语
以上就是开发一个短链接生成系统要做的方方面面,可能还有一些没有考虑到的地方,欢迎私信交流!如果对你有一点点帮助,点个赞+再看是对我最大的鼓励。
我是小黑,一个在互联网“苟且”的程序员。
流水不争先,贵在滔滔不绝