如何设计一个类似TinyURL的短链应用?

594 阅读8分钟

design-a-url-shortening-service-like-tiny-url-cover-ed68ad23c1acc05dc64066c676c8f6ef.jpg

这是一篇译文,原文地址:《Design a URL shortening service like TinyURL》

理解问题

设计一个像Tiny-URL一样的短链服务,核心的目标是构建一个高可用的服务。该服务能够让用户输入长链接,然后服务给到指定的短链接,当用户访问短链接的时候,能够被重定向到长链接。

什么是Tiny-URL? Tiny-URL是一个可以通过长链接创建短链接别名的网站。当用户无论什么时候访问短链接的时候,用户都将被重定向到原始的长链接上。

在讨论解决方案之前,我们先想想如果这是一个面试题,面试官想从这个题目讨论什么? 我们需要围绕系统的要全球和功能进行讨论。

系统要求

像Tiny-URL这样的短链服务,应该有以下的要求:

功能要求:

  • 用户可以将一个长链接生成一个短链接
  • 短链接应该能重定向到原始的长链接
  • 用户可以自定义短链接

设计目标:

  • 这需要是一个高可用的系统。如果系统出现故障,将会导致所有的短链接跳转失败
  • 链接重定向应该能快速重定向,对延迟性要求很高
  • 短链接的生成规则不可预测

可选目标:

  • 系统能通过REST API提供服务
  • 用户的分析需求:短连接访问次数
  • 短链过期时间:用户能够指定短链的过期时间

系统分析

首先我们需要预估这个系统的月访问量、数据存储量等数据。但是最重要的是明白,我们的这个系统将会是一个ready-heavvy的系统。即读的qps可能是写的1000倍,这里让我们假设 读:写 = 100 :1

关键组件 下面是URL短链应用的一些关键组成部分:

  1. 客户端: 浏览器或App,将通过HTTP请求和后端服务通信
  2. 负载均衡: 在后端服务之间平均分配负载
  3. web服务器: 通过部署多个实例来以进行水平扩展
  4. 数据库: 用于存储长链接和短链接的映射关系

流量和存储量预估 **流量预估:**我们假设每个月有5亿个新的短链生成请求,读写比率100:1。所以,每个月的重定向需求有500亿个(100 * 5亿)。

  • 我们可以计算出每秒的写操作:QPS = 5亿 / 30天 * 24小时 * 3600秒 ≈ 200URLs/s
  • 因为我们假设读写比率为100:1,那么每秒的重定向的读操作,为20000URLs/s

**存储量预估:**假设我们的数据将被存储10年,那么我们的存储的URL条数为 5亿 * 10 * 12 = 600亿个URL

现在假设每个URL(短链+长链)的长度为120个字符(假设范围都在U+0000到U+007F,那么占120bytes),然后还需要额外的80bytes的信息。那么每条记录的存储体积为200bytes,总存储需求为600亿 * 200bytes ≈ 12TB

URL编码算法

能够代表600亿个URL的最小长度是多少呢?我们使用字符(a-zA-Z0-9)并结合Base62去编码。现在问题变成 了,为满足600亿次短链生成请求的URL的长度应该是多少,让我们来了解一下。

当然我们的字符保留得越长,那么能表示的URL的数量就越多,我们就不用担心我们的URL资源会被耗尽。然而,这个短链系统本身要求字符尽量的短。Base62编码每个字符能代表62个数,如果我们把这个字符长度定为7,总共能表示62^7个URL,那么即使每秒消费1000个URL,那么也要111年才能耗尽所有的URL,然而我们的系统的设计目标是200URLs/S

计算过程如下: ** ****62^7(个) / 1000(个/秒) / (3600 * 24 * 365) = 111.6696666098年**

编码

去生成唯一的短链,我们可以使用唯一Hash算法(MD5,SHA256等)生成然后通过base62编码。如果我们使用MD5算法,将会生成一个128bit的哈希值,通过base62编码后,我们将得到一个长度超过7的字符,我们可以取它的前7个字符。过程如下:

以Node.js示例该过程如下:

const md5 = require("md5");
const bs16 = require("base-x")("0123456789abcdef");
const bs62 = require("base-x")(
	"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
);

// 示例url
const url = "https://www.zhihu.com/question/510686648/answer/2307642754";

const md5_value = md5(url); 
// 7c2251330a4af2b57de7f3a45fd6c116

const decoded_16value = bs16.decode(md5_value); 
//<Buffer 7c 22 51 33 0a 4a f2 b5 7d e7 f3 a4 5f d6 c1 16>

const encoded_62value = bs62.encode(decoded_16value); 
// 3MeJm3miwhq77C4xn4MTl4

const result = encoded_62value.slice(0, 7); 
// 3MeJm3m

这种方法储存在问题,最终生成的结果可能是会重复或冲突的。这个问题的解决方案可以通过引入计数器解决(可以使用数据库的自增ID,并进行进制转换)。

我们可以使用一个独立的服务去维护计数,每当应用程序服务器收到请求时,它们都会与计数器对话,此时计数器会自增并返回唯一的数字。

两个潜在的问题

  1. 单点故障
  2. 如果请求激增,计数器可能无法处理请求

所以我们必须分布式管理系统,如Zookeeper。让我们看看Zookeeper这样的分布式管理系统是如何解决我们的问题的:

image.png

  1. 将未使用的URL的范围进行服务器分组,例如1-100万Sever1100万-200万Sever2
  2. 我们采用多台实例,已避免Zookeeper单点故障
  3. 如果增加新的服务器,我们将给他分配一个未使用的范围。如果已有服务器的范围耗尽,可以去Zookeeper请请求一个新的,未使用的访问
  4. 如果其中一台服务器宕机,可能会浪费100万个URL。但这可以接受,毕竟我们可表示的URL总范围足够大
  5. 计数器 + 随机数增大随机性

系统API

我们使用REST API来提供我们的服务,下面是API的请求参数的定义:

  • api_dev_key:注册用户的开发者key,将用于账户的配额限制
  • origin_url:原始url
  • custom_alias:用户自定义链接
  • user_name:可选用于编码的用户名
  • expire_date:短链过期时间

返回值:如果成功入库,则返回短链的URL,否则返回错误码。

数据库设计

在设计数据库时,我们有两种类型的数据库:关系数据库和NoSQL。对于我们的服务,关系查询将不会太多。考虑到NOSQL更容易扩展,我们采用NoSQL。

数据库Schema:我们需要两个表,1个表去存储用户信息,另外一个表去存储URL信息。

image.png

服务扩展

缓存

我们的数据库是一个read-heavy服务。我们需要去提高数据读取的效率。

我们可以通过缓存,将尽可能多的数据放入内存中来加快数据库读取速度。如果我们将大量收到个单个短链的请求是,缓存变得尤为重要。如果我们内存中已经有了短链和重定向URL的key-value,我们就可以快速进行HTTP返回。我们可以缓存20%最常用的URL。当缓存满的时候,我们希望将URL替换为更常使用的URL。

采用最近最少使用(LRU)缓存替换方案是一个不错的选择。我们也可以将缓存进行分片,由于我们由多台机器,这样就可以缓存更多的数据。可以基于“散列”或“一致散列”来决定缓存分片。

负载均衡

考虑到单个服务器的负载能力优先,我们将使用多台服务器处理用户请求。此次是,负载均衡的引入就变得十分重要。为避免单点故障,我们可以在“客户端和服务器”与“服务器和数据库”之前使用多个负载均衡服务器。

image.png

最初,我们可以采用简单的循环算法,但是简单的循环算法可能依然将请求转发到过载或速度较慢的服务器。所以这里我更推荐最少连接方法,与具有最少连接的服务器建立连接。

设计骨架

下图就是一个高度可扩展的Tiny-URL服务。 image.png