短 URL 系统是怎么设计的?

新浪微博那种输入完全url转换为短url是怎么设计的
关注者
2,560
被浏览
565,266

19 个回答

看了一下 @Tang minyi 的回答,也非常不错,如果面试者这么回答,我也会很高兴的。 对于我用词比较激烈的问题,我面试时真实想法既是如此,也懒得润色了。

--------------------------

这个问题看到就想答。 个人相关:三年前在公司做过一个短地址服务,目前在线上跑。 而这个问题,也是我现在招聘面试题里面必考的一道,这一道题里面有很多可考的地方,能够相对综合的考察候选人的功力。

最烂的回答 实现一个算法,将长地址转成短地址。实现长和短一一对应。然后再实现它的逆运算,将短地址还能换算回长地址。 这个回答看起来挺完美的,然后候选人也会说现在时间比较短,如果给我时间我去找这个算法就解决问题了。但是稍微有点计算机或者信息论常识的人就能发现,这个算法就跟永动机一样,是永远不可能找到的。即使我们定义短地址是100位。那么它的变化是62的100次方。62=10数字+26大写字母+26小写字母。无论这个数多么大,他也不可能大过世界上可能存在的长地址。所以实现一一对应,本身就是不可能的。 再换一个说法来反驳,如果真有这么一个算法和逆运算,那么基本上现在的压缩软件都可以歇菜了,而世界上所有的信息,都可以压缩到100个字符。这~可能吗。

另一个很烂的回答 和上面一样,也找一个算法,把长地址转成短地址,但是不存在逆运算。我们需要把短对长的关系存到DB中,在通过短查长时,需要查DB。 怎么说呢,没有改变本质,如果真有这么一个算法,那必然是会出现碰撞的,也就是多个长地址转成了同一个短地址。因为我们无法预知会输入什么样的长地址到这个系统中,所以不可能实现这样一个绝对不碰撞的hash函数。

比较烂的回答 那我们用一个hash算法,我承认它会碰撞,碰撞后我再在后面加1,2,3不就行了。 ok,这样的话,当通过这个hash算法算出来之后,可能我们会需要做btree式的大于小于或者like查找到能知道现在应该在后面加1,2,或3,这个也可能由于输入的长地址集的不确定性。导致生成短地址时间的不确定性。同样烂的回答还有随机生成一个短地址,去查找是否用过,用过就再随机,如此往复,直到随机到一个没用过的短地址。

正确的原理 上面是几种典型的错误回答,下面咱们直接说正确的原理。 正确的原理就是通过发号策略,给每一个过来的长地址,发一个号即可,小型系统直接用mysql的自增索引就搞定了。如果是大型应用,可以考虑各种分布式key-value系统做发号器。不停的自增就行了。第一个使用这个服务的人得到的短地址是xx.xx/0 第二个是 xx.xx/1 第11个是 xx.xx/a 第依次往后,相当于实现了一个62进制的自增字段即可。

几个子问题

1. 62进制如何用数据库或者KV存储来做? 其实我们并不需要在存储中用62进制,用10进制就好了。比如第10000个长地址,我们给它的短地址对应的编号是9999,我们通过存储自增拿到9999后,再做一个10进制到62进制的转换,转成62进制数即可。这个10~62进制转换,你完全都可以自己实现。

2. 如何保证同一个长地址,每次转出来都是一样的短地址 上面的发号原理中,是不判断长地址是否已经转过的。也就是说用拿着百度首页地址来转,我给一个xx.xx/abc 过一段时间你再来转,我还会给你一个 xx.xx/xyz。这看起来挺不好的,但是不好在哪里呢?不好在不是一一对应,而一长对多短。这与我们完美主义的基因不符合,那么除此以外还有什么不对的地方? 有人说它浪费空间,这是对的。同一个长地址,产生多条短地址记录,这明显是浪费空间的。那么我们如何避免空间浪费,有人非常迅速的回答我,建立一个长对短的KV存储即可。嗯,听起来有理,但是。。。这个KV存储本身就是浪费大量空间。所以我们是在用空间换空间,而且貌似是在用大空间换小空间。真的划算吗?这个问题要考虑一下。当然,也不是没有办法解决,我们做不到真正的一一对应,那么打个折扣是不是可以搞定?这个问题的答案太多种,各有各招,我这就不说了。(由于实在太多人纠结这个问题,请见我最下方的更新

3. 如何保证发号器的大并发高可用 上面设计看起来有一个单点,那就是发号器。如果做成分布式的,那么多节点要保持同步加1,多点同时写入,这个嘛,以CAP理论看,是不可能真正做到的。其实这个问题的解决非常简单,我们可以退一步考虑,我们是否可以实现两个发号器,一个发单号,一个发双号,这样就变单点为多点了?依次类推,我们可以实现1000个逻辑发号器,分别发尾号为0到999的号。每发一个号,每个发号器加1000,而不是加1。这些发号器独立工作,互不干扰即可。而且在实现上,也可以先是逻辑的,真的压力变大了,再拆分成独立的物理机器单元。1000个节点,估计对人类来说应该够用了。如果你真的还想更多,理论上也是可以的。

4. 具体存储如何选择 这个问题就不展开说了,各有各道,主要考察一下对存储的理解。对缓存原理的理解,和对市面上DB、Cache系统可用性,并发能力,一致性等方面的理解。

5. 跳转用301还是302 这也是一个有意思的话题。首先当然考察一个候选人对301和302的理解。浏览器缓存机制的理解。然后是考察他的业务经验。301是永久重定向,302是临时重定向。短地址一经生成就不会变化,所以用301是符合http语义的。同时对服务器压力也会有一定减少。 但是如果使用了301,我们就无法统计到短地址被点击的次数了。而这个点击次数是一个非常有意思的大数据分析数据源。能够分析出的东西非常非常多。所以选择302虽然会增加服务器压力,但是我想是一个更好的选择。

大概就是这样。

------五一假期后更新------- 就回答一点大家最纠结的问题吧,就是如何实现同一个长地址多次转换,出来还是同一个短地址。

我上面其实讲到了,这个方案最简单的是建立一个长对短的hashtable,这样相当于用空间来换空间,同时换取一个设计上的优雅(真正的一对一)。

实际情况是有很多性价比高的打折方案可以用,这个方案设计因人而异了。那我就说一下我的方案吧。

我的方案是:用key-value存储,保存“最近”生成的长对短的一个对应关系。注意是“最近”,也就是说,我并不保存全量的长对短的关系,而只保存最近的。比如采用一小时过期的机制来实现LRU淘汰。

这样的话,长转短的流程变成这样: 1 在这个“最近”表中查看一下,看长地址有没有对应的短地址 1.1 有就直接返回,并且将这个key-value对的过期时间再延长成一小时 1.2 如果没有,就通过发号器生成一个短地址,并且将这个“最近”表中,过期时间为1小时

所以当一个地址被频繁使用,那么它会一直在这个key-value表中,总能返回当初生成那个短地址,不会出现重复的问题。如果它使用并不频繁,那么长对短的key会过期,LRU机制自动就会淘汰掉它。

当然,这不能保证100%的同一个长地址一定能转出同一个短地址,比如你拿一个生僻的url,每间隔1小时来转一次,你会得到不同的短地址。但是这真的有关系吗?

前言

今天,我们来谈谈如何设计一个高性能短链系统,短链系统设计看起来很简单,但每个点都能展开很多知识点,也是在面试中非常适合考察侯选人的一道设计题,本文将会结合我们生产上稳定运行两年之久的高性能短链系统给大家简单做下设计这套系统所涉及的一些思路,希望对大家能有一些帮助。

本文将会从以下几个方面来讲解,每个点包含的信息量都不少,相信大家看完肯定有收获

  • 短链有啥好处,为啥要设计它,用长链不香吗
  • 短链跳转的基本原理
  • 短链生成的几种方法
  • 短链的架构支撑

注:里面涉及到不少布隆过滤器,snowflake 等技术,由于不是本文重点,所以建议大家看完后再自己去深入了解,不然展开讲篇幅会很长

短链有啥好处,用长链不香吗

来看下以下极客时间发我的营销短信,点击下方蓝色的链接(短链)



浏览器的地址栏上最终会显示一条如下的长链。



那么为啥要用短链表示,直接用长链不行吗,用短链的话有如下好外

1、链接变短,在对内容长度有限制的平台发文,可编辑的文字就变多了

最典型的就是微博,限定了只能发 140 个字,如果一串长链直接怼上去,其他可编辑的内容就所剩无几了,用短链的话,链接长度大大减少,自然可编辑的文字多了不少。

再比如一般短信发文有长度限度,如果用长链,一条短信很可能要拆分成两三条发,本来一条一毛的短信费变成了两三毛,何苦呢。另外用短链在内容排版上也更美观。

2、我们经常需要将链接转成二维码的形式分享给他人,如果是长链的话二维码密集难识别,短链就不存在这个问题了,如图示



3、链接太长在有些平台上无法自动识别为超链接

如图示,在钉钉上,就无法识别如下长链接,只能识别部分,用短地址无此问题



短链跳转的基本原理

从上文可知,短链好处多多,那么它是如何工作的呢。我们在浏览器抓下包看看



可以看到请求后,返回了状态码 302(重定向)与 location 值为长链的响应,然后浏览器会再请求这个长链以得到最终的响应,整个交互流程图如下



主要步骤就是访问短网址后重定向访问 B,那么问题来了,301 和 302 都是重定向,到底该用哪个,这里需要注意一下 301 和 302 的区别

  • 301,代表 永久重定向,也就是说第一次请求拿到长链接后,下次浏览器再去请求短链的话,不会向短网址服务器请求了,而是直接从浏览器的缓存里拿,这样在 server 层面就无法获取到短网址的点击数了,如果这个链接刚好是某个活动的链接,也就无法分析此活动的效果。所以我们一般不采用 301。
  • 302,代表 临时重定向,也就是说每次去请求短链都会去请求短网址服务器(除非响应中用 Cache-Control 或 Expired 暗示浏览器缓存),这样就便于 server 统计点击数,所以虽然用 302 会给 server 增加一点压力,但在数据异常重要的今天,这点代码是值得的,所以推荐使用 302!

短链生成的几种方法

1、哈希算法

怎样才能生成短链,仔细观察上例中的短链,显然它是由固定短链域名 + 长链映射成的一串字母组成,那么长链怎么才能映射成一串字母呢,哈希函数不就用来干这事的吗,于是我们有了以下设计思路



那么这个哈希函数该怎么取呢,相信肯定有很多人说用 MD5,SHA 等算法,其实这样做有点杀鸡用牛刀了,而且既然是加密就意味着性能上会有损失,我们其实不关心反向解密的难度,反而更关心的是哈希的运算速度和冲突概率。

能够满足这样的哈希算法有很多,这里推荐 Google 出品的 MurmurHash 算法,MurmurHash 是一种非加密型哈希函数,适用于一般的哈希检索操作。与其它流行的哈希函数相比,对于规律性较强的 key,MurmurHash 的随机分布特征表现更良好。非加密意味着着相比 MD5,SHA 这些函数它的性能肯定更高(实际上性能是 MD5 等加密算法的十倍以上),也正是由于它的这些优点,所以虽然它出现于 2008,但目前已经广泛应用到 Redis、MemCache、Cassandra、HBase、Lucene 等众多著名的软件中。

画外音:这里有个小插曲,MurmurHash 成名后,作者拿到了 Google 的 offer,所以多做些开源的项目,说不定成名后你也能不经意间收到 Google 的 offer ^_^。

MurmurHash 提供了两种长度的哈希值,32 bit,128 bit,为了让网址尽可通地短,我们选择 32 bit 的哈希值,32 bit 能表示的最大值近 43 亿,对于中小型公司的业务而言绰绰有余。对上文提到的极客长链做 MurmurHash 计算,得到的哈希值为 3002604296,于是我们现在得到的短链为 固定短链域名+哈希值 = gk.link/a/3002604296

如何缩短域名?

有人说人这个域名还是有点长,还有一招,3002604296 得到的这个哈希值是十进制的,那我们把它转为 62 进制可缩短它的长度,10 进制转 62 进制如下:



于是我们有 (3002604296)10 = (3hcCxy)16,一下从 10 位缩短到了 6 位!于是现在得到了我们的短链为 gk.link/a/3hcCxy

画外音:6 位 62 进制数可表示 568 亿的数,应付长链转换绰绰有余

如何解决哈希冲突的问题?

既然是哈希函数,不可避免地会产生哈希冲突(尽管概率很低),该怎么解决呢。

我们知道既然访问访问短链能跳转到长链,那么两者之前这种映射关系一定是要保存起来的,可以用 Redis 或 Mysql 等,这里我们选择用 Mysql 来存储。表结构应该如下所示

CREATE TABLE `short_url_map` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `lurl` varchar(160) DEFAULT NULL COMMENT '长地址',
  `surl` varchar(10) DEFAULT NULL COMMENT '短地址',
  `gmt_create` int(11) DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

于是我们有了以下设计思路。

  1. 将长链(lurl)经过 MurmurHash 后得到短链。
  2. 再根据短链去 short_url_map 表中查找看是否存在相关记录,如果不存在,将长链与短链对应关系插入数据库中,存储。
  3. 如果存在,说明已经有相关记录了,此时在长串上拼接一个自定义好的字段,比如「DUPLICATE」,然后再对接接的字段串「lurl + DUPLICATE」做第一步操作,如果最后还是重复呢,再拼一个字段串啊,只要到时根据短链取出长链的时候把这些自定义好的字符串移除即是原来的长链。

以上步骤显然是要优化的,插入一条记录居然要经过两次 sql 查询(根据短链查记录,将长短链对应关系插入数据库中),如果在高并发下,显然会成为瓶颈。

画外音:一般数据库和应用服务(只做计算不做存储)会部署在两台不同的 server 上,执行两条 sql 就需要两次网络通信,这两次网络通信与两次 sql 执行是整个短链系统的性能瓶颈所在!

所以该怎么优化呢

  1. 首先我们需要给短链字段 surl 加上唯一索引
  2. 当长链经过 MurmurHash 得到短链后,直接将长短链对应关系插入 db 中,如果 db 里不含有此短链的记录,则插入,如果包含了,说明违反了唯一性索引,此时只要给长链再加上我们上文说的自定义字段「DUPLICATE」,重新 hash 再插入即可,看起来在违反唯一性索引的情况下是多执行了步骤,但我们要知道 MurmurHash 发生冲突的概率是非常低的,基本上不太可能发生,所以这种方案是可以接受的。

当然如果在数据量很大的情况下,冲突的概率会增大,此时我们可以加布隆过滤器来进行优化。

用所有生成的短网址构建布隆过滤器,当一个新的长链生成短链后,先将此短链在布隆过滤器中进行查找,如果不存在,说明 db 里不存在此短网址,可以插入!

画外音:布隆过滤器是一种非常省内存的数据结构,长度为 10 亿的布隆过滤器,只需要 125 M 的内存空间。

综上,如果用哈希函数来设计,总体的设计思路如下



用哈希算法生成的短链其实已经能满足我们的业务需求,接下来我们再来看看如何用自增序列的方式来生成短链

2、自增序列算法

我们可以维护一个 ID 自增生成器,比如 1,2,3 这样的整数递增 ID,当收到一个长链转短链的请求时,ID 生成器为其分配一个 ID,再将其转化为 62 进制,拼接到短链域名后面就得到了最终的短网址,那么这样的 ID 自增生成器该如何设计呢。如果在低峰期发号还好,高并发下,ID 自增生成器的的 ID 生成可能会系统瓶颈,所以它的设计就显得尤为重要。

主要有以下四种获取 id 的方法

1、类 uuid

简单地说就是用 UUID uuid = UUID.randomUUID(); 这种方式生成的 UUID,UUID(Universally Unique Identifier)全局唯一标识符,是指在一台机器上生成的数字,它保证对在同一时空中的所有机器都是唯一的,但这种方式生成的 id 比较长,且无序,在插入 db 时可能会频繁导致页分裂,影响插入性能。

2、Redis

用 Redis 是个不错的选择,性能好,单机可支撑 10 w+ 请求,足以应付大部分的业务场景,但有人说如果一台机器扛不住呢,可以设置多台嘛,比如我布置 10 台机器,每台机器分别只生成尾号0,1,2,... 9 的 ID, 每次加 10即可,只要设置一个 ID 生成器代理随机分配给发号器生成 ID 就行了。



不过用 Redis 这种方案,需要考虑持久化(短链 ID 总不能一样吧),灾备,成本有点高。

3、Snowflake

用 Snowflake 也是个不错的选择,不过 Snowflake 依赖于系统时钟的一致性。如果某台机器的系统时钟回拨,有可能造成 ID 冲突,或者 ID 乱序。

4、Mysql 自增主键

这种方式使用简单,扩展方便,所以我们使用 Mysql 的自增主键来作为短链的 id。简单总结如下:



那么问题来了,如果用 Mysql 自增 id 作为短链 ID,在高并发下,db 的写压力会很大,这种情况该怎么办呢。

考虑一下,一定要在用到的时候去生成 id 吗,是否可以提前生成这些自增 id ?

方案如下:

设计一个专门的发号表,每插入一条记录,为短链 id 预留 (主键 id * 1000 - 999) 到 (主键 id * 1000) 的号段,如下

发号表:url_sender_num



如图示:tmp_start_num 代表短链的起始 id,tmp_end_num 代表短链的终止 id。

当长链转短链的请求打到某台机器时,先看这台机器是否分配了短链号段,未分配就往发号表插入一条记录,则这台机器将为短链分配范围在 tmp_start_num 到 tmp_end_num 之间的 id。从 tmp_start_num 开始分配,一直分配到 tmp_end_num,如果发号 id 达到了 tmp_end_num,说明这个区间段的 id 已经分配完了,则再往发号表插入一条记录就又获取了一个发号 id 区间。

画外音:思考一下这个自增短链 id 在机器上该怎么实现呢, 可以用 redis, 不过更简单的方案是用 AtomicLong,单机上性能不错,也保证了并发的安全性,当然如果并发量很大,AtomicLong 的表现就不太行了,可以考虑用 LongAdder,在高并发下表现更加优秀。

整体设计图如下



解决了发号器问题,接下来就简单了,从发号器拿过来的 id ,即为短链 id,接下来我们再创建一个长短链的映射表即可, 短链 id 即为主键,不过这里有个需要注意的地方,我们可能需要防止多次相同的长链生成不同的短链 id 这种情况,这就需要每次先根据长链来查找 db 看是否存在相关记录,一般的做法是根据长链做索引,但这样的话索引的空间会很大,所以我们可以对长链适当的压缩,比如 MD5,再对长链的 MD5 字段做索引,这样索引就会小很多。这样只要根据长链的 md5 去表里查是否存在相同的记录即可。所以我们设计的表如下

CREATE TABLE `short_url_map` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '短链 id',
  `lurl` varchar(10) DEFAULT NULL COMMENT '长链',
  `md5` char(32) DEFAULT NULL COMMENT '长链md5',
  `gmt_create` int(11) DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

当然了,数据量如果很大的话,后期就需要分区或分库分表了。

请求短链的高并发架构设计

在电商公司,经常有很多活动,秒杀,抢红包等等,在某个时间点的 QPS 会很高,考虑到这种情况,我们引入了 openResty,它是一个基于 Nginx 与 Lua 的高性能 Web 平台,由于 Nginx 的非阻塞IO模型,使用 openResty 可以轻松支持 100 w + 的并发数,一般情况下你只要部署一台即可,同时 openResty 也自带了缓存机制,集成了 redis 这些缓存模块,也可以直接连 mysql。不需要再通过业务层连这些中间件,性能自然会高不少



如图示,使用 openResty 省去了业务层这一步,直达缓存层与数据库层,也提升了不少性能。

总结

本文对短链设计方案作了详细地剖析,旨在给大家提供几种不同的短链设计思路,文中涉及到挺多像布隆过滤器,openRestry 等技术,文中没有展开讲,建议大家回头可以去再详细了解一下。再比如文中提到的 Mysql 页分裂也需要对底层使用的 B+ tree 数据结构,操作系统按页获取等知识有比较详细地了解,相信大家各个知识点都吃透后会收获不小。

一下收获 100 多个赞,感谢大家认可,有收获的话,劳烦大家点点手指帮忙点个赞支持一下呀,最后分享一套我看过的计算机经典书籍,都是非常经典的,涉及到网络,操作系统,数据结构和算法等,相信大家看了肯定有用