文章目录
1.什么是短链接?
我们先看看平时看到的链接
https://blog.youkuaiyun.com/zhiyikeji/article/details/123957845?spm=1001.2014.3001.5501
是不是又臭又长!!!那如果我们需要将某个链接发在某个文章或者推广给别人的时候,这么长看着也太不爽了,而短链接的出现就是用一个很短的URL来替代这个很长的家伙,当用户访问短链接的时候,会重定向到原来的链接。比如长下面这样:
sourl.cn/AmBh9B
如果平时我们注意的话,可能会注意到一些商业的推广短信上使用的也是短链接
那么问题来了,短链接其本质就是重定向,那么为啥我们还得弄个短链接过来呢?
2.为什么需要URL短链接
URL短链接用于为长URL创建较短的别名,我们称这些缩短的别名为“短链接”;
当用户点击这些短链接时,会被重定向到原始URL;
短链接在显示、打印、发送消息时可节省大量空间
例如,如果我们通过sourl缩短以下URL:
https://blog.youkuaiyun.com/zhiyikeji/article/details/123957845?spm=1001.2014.3001.5501
利用小码短链接生成器生成一个短链接如下:
sourl.cn/AmBh9B
缩短的URL几乎是实际URL大小的三分之一。
URL缩写经常用于优化设备之间的链接,跟踪单个链接以分析受众,衡量广告活动的表现,或隐藏关联的原始URL。
说了那么多,我们如果自己做出一个短链接系统,我们应该思考哪些问题呢?
3.系统要求和目标
无论在开发任何系统或者功能时,我们应该先要确定系统的定位和要达到的目标。
3.1 功能要求
- 给定一个URL,我们的服务应该为其生成一个较短且唯一的别名,这叫做短链接,此链接应足够短,以便于复制和粘贴到应用程序中;
- 当用户访问短链接时,我们的服务应该将他们重定向到原始链接;
- 用户应该能够选择性地为他们的URL选择一个自定义的短链接;
- 链接可以在指定时间跨度之后过期,用户应该能够指定过期时间。
3.2 非功能要求
- 系统必须高度可用。如果我们的服务关闭,所有URL重定向都将开始失败。
- URL重定向的延迟情况应该足够小;
- 短链接应该是不可猜测的。
3.3 用户体验优化
- 支持分析和统计,例如短链接的访问次数;
- 其他服务也应该可以通过RESTAPI访问我们的服务。
4.容量要求和限制
我们的系统将会有很大的访问量。会有对短链接的读取请求和创建短链接的写入请求。假设读写比例为100:1。
4.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
4.2 存储估计
假设我们将每个URL缩短请求(以及相关的缩短链接)存储5年。由于我们预计每个月将有5亿个新URL,因此我们预计存储的对象总数将为300亿:
5亿 * 5 年 * 12 月 = 300亿
假设每个存储的对象大约有500个字节(这只是一个估算值)。我们将需要15TB的总存储:
300亿*500bytes≈15TB
4.3 带宽估计
对于写请求,由于我们预计每秒有200个新的短链接创建,因此我们服务的总传入数据为每秒100KB:
200*500bytes≈100KB/s
对于读请求,预计每秒约有20,000个URL重定向,因此我们服务的总传出数据将为每秒10MB:
20000 * 500 bytes ≈10 MB/s
4.4 内存预估
对于一些热门访问的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 |
5.系统API设计
5.1 创建短链接接口
/**
* 创建短链接
*
* @param api_dev_key 分配给注册用户的开发者密钥,可以根据该值对用户的创建短链接数量进行限制;
* @param original_url 需要生成短链接的原始URL
* @param custom_alias 用户对于URL自定义的名称(默认为空)
* @param user_name 可以用在编码中的用户名(默认为空)
* @param expire_date 短链接的过期时间(单位:秒)
* @return 成功生成短链接将返回短链接URL;否则,将返回错误代码。
*/
public String createURL(String api_dev_key, String original_url, String custom_alias, String user_name, Long expire_date)
5.2 删除短链接
/**
* 删除短链接接口
*
* @param api_dev_key 分配给注册用户的开发者密钥
* @param url_key 要删除的短链接字符串
* @return 成功删除将返回delete success
*/
public String deleteURL(String api_dev_key, String url_key);
同时我们可以通过api_dev_key来限制用户,以防止滥用。
每个api_dev_key可以限制为每段时间创建一定数量的URL和重定向(可以根据开发者密钥设置不同的持续时间)。
6.数据库设计
我们在选择使用哪一种数据库之前,我们需要根据数据的特性来设计。
在我们短链接服务系统中的数据,存在以下特点:
- 需要存储十亿条数据记录;
- 存储的每个对象都很小(小于1K);
- 除了存储哪个用户创建了URL之外,记录之间没有任何关系;
- 我们的服务会有大量的读取请求。
关系型数据的设计之间的类图如下:
我们可以看到url和user之间并没有更复杂的联系,同时我们的url链接以亿为单位,所以传统的关系型数据库就不在适用,可以考虑nosql,如key-value数据库:redis,monogodb等,或者直接列数据库hive,hbase等大数据家族里的数据库。
7.系统基本设计与算法
我们接下来首先要解决的就是如何为给定的URL生成一个简短而且唯一的密钥。目前主要有两种解决方案:
- 对原URL进行编码
- 提前离线生成秘钥
7.1 对原URL进行编码
可以计算给定URL的唯一HASH值(例如,MD5或SHA256等)。
使用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。即使这样也有可能有冲突,需要不断生成直到得到唯一的密钥。
于是我们可以综合考虑,对于用户Id不应使用数据库的自增Id,如果我们采用分布式id算法的话(如:雪花算法),无论是否登录,我们都可以获取一个Id,依此id附加到url上。
7.2 离线生成密钥
可以有一个独立的密钥生成服务,我们就叫它KGS(Key Generation Service),它预先生成随机的六个字母的字符串,并将它们存储在数据库中。
每当我们想要生成短链接时,都去KGS获取一个已经生成的密钥并使用。这种方法更简单快捷。我们不仅不需要对URL进行编码,而且也不必担心重复或冲突。KGS将确保插入到数据库中的所有密钥都是唯一的。
7.2.1 并发问题
密钥一旦使用,就应该在数据库中进行标记,以确保不会再次使用。如果有多个服务器同时读取密钥,我们可能会遇到两个或多个服务器尝试从数据库读取相同密钥的情况。如何解决这个并发问题呢?
KGS可以使用两个表来存储密钥:
- 一个用于尚未使用的密钥
- 一个用于所有已使用的密钥。
一旦KGS将密钥提供给其中一个服务器,它就可以将它们移动到已使用的秘钥表中;可以始终在内存中保留一些密钥,以便在服务器需要时快速提供它们。
为简单起见,一旦KGS将一些密钥加载到内存中,它就可以将它们移动到Used Key表中。这可确保每台服务器都获得唯一的密钥。
如果在将所有加载的密钥分配给某个服务器之前KGS重启或死亡,我们将浪费这些密钥,考虑到我们拥有的秘钥很多,这种情况也可以接受。
还必须确保KGS不将相同的密钥提供给多个服务器,因此,KGS将秘钥加载到内存和将秘钥移动到已使用表的动作需要时同步的,或者加锁,然后才能将秘钥提供给服务器。
7.2.2 单点故障问题
由于我们的密钥依赖于数据库,所以一旦数据库服务器宕机,就会直接使我们的服务不可用,对于这种情况,我们可以从两个方面去解决:
第一,怕数据库宕机,我们可以考虑弄一个备库或者直接做一个数据库集群。
第二,我们可以提前将一批密钥加载到缓存,加载的位置推荐是分布式缓存中,以保证即使数据库不可用,也可提供一段时间的密钥服务。
7.2.3 如何完成密钥的查找
我们可以在数据库中查找密钥以获得完整的URL。
- 如果它存在于数据库中,则向浏览器发回一个“HTTP302 Redirect”状态,将存储的URL传递到请求的Location字段中。
- 如果密钥不在我们系统中,则发出HTTP 404 Not Found状态或将用户重定向回主页。
8.数据分区和复制
由于我们存储的url数以亿计,那么一个数据库节点在存储上可能不满足要求,并且单节点也不能支撑我们读取的要求。
因此,我们需要开发一种分区方案,将数据划分并存储到不同的数据库服务中。
8.1 基于范围分区
我们可以根据短链接的第一个字母将URL存储在不同的分区中。
什么意思呢?
我们将所有以字母’A/a’开头的URL保存在一个分区中,将以字母‘B/b’开头的URL保存在另一个分区中,以此类推。这种方法称为基于范围的分区。我们甚至可以将某些不太频繁出现的字母合并到一个数据库分区中。
8.2 基于hash值分区
在此方案中,我们对要存储的对象进行Hash计算。然后,我们根据Hash结果计算使用哪个分区。在我们的例子中,我们可以使用短链接的Hash值来确定存储数据对象的分区。
Hash函数会将URL随机分配到不同的分区中(例如,Hash函数总是可以将任何‘键’映射到[1…256]之间的一个数字,这个数字将表示我们在其中存储对象的分区。
这种方式有可能导致有些分区数据超载,可以使用一致性哈希算法解决。
同时我们只依赖于数据库,也无法解决实际应用的问题,下面我们引入缓存,进行热点数据的存储。
9.缓存
对于频繁访问的链接我们可以先加载到缓存,在我们通过密钥获取访问链接时先从redis获取,如果不能够获取的到再去访问数据库,在一定程度上缓解数据库压力,同时提高系统响应速度。
9.1 缓存容量
那么我们缓存多少数据呢?
可以从每天20%的流量开始,并根据客户端的使用模式调整所需的缓存服务器数量。如上所述,我们需要170 GB内存来缓存20%的日常流量。可以使用几个较小的服务器来存储所有这些热门URL。
但是缓存会有一个界限,同时当时设置成的热点数据可能过一段时间热度就没了,怎么办呢,此时引入我们的缓存淘汰策略
9.2 缓存淘汰策略
我们以redis为例
- volatile-Iru:从已设置过期时间的数据集(server.db[ i].expires)中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集(server.db[ i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[ i].expires)中任意选择数据淘汰
- allkeys-lru:从数据集(server.db[ i].dict)中挑选最近最少使用的数据淘汰
- allkeys-random:从数据集(server.db[ i].dict)中任意选择数据淘汰
- no-enviction(驱逐)∶禁止驱逐数据
很明显我们的过期时间不可能设置的很短,所以我们只能选取allkeys-lru策略,即从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰。
9.3 如何更新缓存
每当出现缓存未命中时,我们的服务器都会命中后端数据库。每次发生这种情况,我们都可以更新缓存并将新条目传递给所有缓存副本。每个副本都可以通过添加新条目来更新其缓存。如果副本已经有该条目,它可以简单地忽略它。
你以为缓解了数据库的访问压力,提高了访问速度就结束了吗?
对于高并发,大数据量的系统设计,我们不可能使用单一的服务体系,即将所有的服务只部署在一台服务器上,无论是数据库,还是缓存,还是我们的服务都应该以集群的方式部署,那么对于服务集群的命中应当如何去选取呢?
没错,我们可以使用负载均衡策略。
10.负载均衡
首先我们需要确定在哪些地方部署负载均衡?
10.1 负载均衡的位置选取
可以在系统中的三个位置添加负载均衡层:
- 在客户端和应用程序服务器之间;
- 在应用程序服务器和数据库服务器之间;
- 在应用程序服务器和缓存服务器之间。
10.2 负载均衡策略
可以使用简单的循环调度方法,在后端服务器之间平均分配传入的请求。这种负载均衡方式实现起来很简单,并且不会带来任何开销。此方法的另一个好处是,如果服务器死机,负载均衡可以让其退出轮换,并停止向其发送任何流量。
循环调度的一个问题是没有考虑服务器过载情况。因此,如果服务器过载或速度慢,不会停止向该服务器发送新请求。要处理此问题,可以放置一个更智能的解决方案,定期查询后端服务器的负载并基于此调整流量。
完成以上设计之后,我们还需要面临最后一个问题,就是数据是不是需要永久保留呢?
11.数据清除策略
数据应该永远保留,还是应该被清除?如果达到用户指定的过期时间,短链接应该如何处理?
我们给出两种解决方案:
- 持续扫描数据库,清除过期数据
- 懒惰删除策略
如果我们选择持续查询过期链接来删除,将会给数据库带来很大的压力;
可以慢慢删除过期的链接,并进行懒惰的方式清理。服务确保只有过期的链接将被删除,尽管一些过期的链接可以在数据库保存更长时间,但永远不会返回给用户。
每当用户尝试访问过期链接时,我们都可以删除该链接并向用户返回错误;
单独的清理服务可以定期运行,从存储和缓存中删除过期的链接;
此服务应该非常轻量级,并计划仅在预期用户流量较低时运行;
我们可以为每个短链接设置默认的到期时间(例如两年);
删除过期链接后,我们可以将密钥放回KGS的数据库中重复使用。
至此一个短链接系统的的架构设计就至此结束了