什么是短 url
短 url, 顾名思义,就是将长网址缩短到一个很短的网址,用户访问这个短网址可以重定向到原本的长网址(还原)。这样可以达到易于记忆、转换的目的,还有隐藏链接参数,利于短信推广的作用,常用于有字数限制的短信、微博、二维码等场景。
建立一个发号器,每次有一个新的长 URL 进来,我们就增加一,并且将新的数值返回。第一个来的 url 返回”www.x.cn/0", 第二个返回"www.x.cn/1".
细节问题
短 url 的还原跳转用 301 还是 302?
301 是永久重定向,302 是临时重定向。
短地址一经生成就不会变化,所以用 301 是符合 http 语义的。同时浏览器会对 301 请求保存一个比较长期的缓存,这样就减轻对服务器的压力;而且 301 对于网址的 SEO 有一定的提升。但是很多情况下我们需要对接口点击或者用户行为进行一些业务监控处理的时候,301 明显就不合适了(浏览器直接按照缓存数据跳转了), 所以很多业务场景下还是采用 302 比较合适。
字符超长问题
即使到了 10 亿 (Billion) 转换而成的 62 进制也无非是 6 位字符,所以长度基本不在考虑范围内,这个范围足够使用了。
对应关系如何存储?
这个对应数据肯定是要落盘的,不能每次系统重启就重新排号,所以可以采用 mysql 等数据库来存储。而且如果数据量小且 qps 低,直接使用数据库的自增主键就可以实现.
如何保证长短链接一一对应?
按照上面的发号器策略,是不能保证长短链接的一一对应的,你连续用同一个 URL 请求两次,结果值都是不一样的.
为了实现长短链接一一对应,我们需要付出很大的空间代价,尤其是为了快速响应,我们可以需要在内存中做一层缓存,这样子太浪费了.
但是可以实现一些变种的,来实现部分的一一对应,比如将最近 / 最热门的对应关系存储在 K-V 数据库中,这样子可以节省空间的同时,加快响应速度.
短 URL 的存储
我们返回的短 URL 一般是将数字转换成 62 进制,这样子可以更加有效的缩短 URL 长度,那么 62 进制的数字对计算机来说只是字符串,怎么存储呢?直接存储字符串对等值查找好找,对范围查找等太不友好了.
其实可以直接存储 10 进制的数字,这样不仅占用空间少,对查找的支持较好,同时还可以更加方便的转换到更多 / 更少的进制来进一步缩短 URL.
短码安全问题
按照算法从 0-61 都是 1 位字符,然后 2 位、3 位… 这样的话很容易被人发现规律并进行攻击,当然防御手段很多,请求签名之类的安全验证手段不在本文讨论范围内。
首先计数器可以从一个比较大的随机中间值开始,比如从 10000 开始计数,他的 62 进制是 2Bi 3 位的字符串;
然后采用一些校验位算法 (比如 Luhn 改进一下),计算出 1 位校验位拼接起来,4 位短码,这样可以排除一定的安全风险;
再加点安全料的话,可以在 62 进制的转换过程中把排序好的 62 个字母数字随机打乱,比如 ABCD1234 打乱成 1BC43A2D, 转换的 62 进制也就更难 hack 了;
最后如果仍不放心,还可以在某些位置(比如 1,3,5)插入随机数,让人无法看出规律来也可以达到良好的效果。
同一长网址短 url 是否应该相同问题
发号策略中,是不判断长地址是否已转过,所以造成结果就是一长对多短,有人说浪费空间,建立一个长对短的 map 存储即可,但是用 map 存储本身就是浪费大量空间,甚至是用大空间换小空间,这就要考虑是否真有必要做一一对应,不能一对多;
最简单方案:建一个长对短的 map, 空间换空间,
更好的方案:用 map 存储” 最近” 生成的长对短关系,一小时过期机制实现 LRU 淘汰
长对短流程:
这个 “最近” 表中查看一下,看长地址有没有对应的短地址
有就直接返回,并且将这个 key-value 对的过期时间重置为一小时
如果没有,就通过发号器生成一个短地址,并且将这个 “最近” 表中,过期时间为 1 小时
当一个地址被频繁使用,那么它会一直在这个 key-value 表中,总能返回当初生成那个短地址,不会出现重复的问题。如果它使用并不频繁,那么长对短的 key 会过期,LRU 机制自动就会淘汰掉它。
这样在空间和发号数量之间取得了一个平衡,此处也应该看具体的业务需求来,是否会存在一对多的情况。比如下单未支付,给用户发短信召回,短信内的短 url 里面存在用户昵称,订单号等个性化信息,即不需要增加这一逻辑环节了。
高并发
如果直接存储在 MySQL 中,当并发请求增大,对数据库的压力太大,可能会造成瓶颈,这时候是可以有一些优化的.
缓存
上面保证长短链接一一对应中也提到过缓存,这里我们是为了加快程序处理速度.
可以将热门的长链接 (需要对长链接进来的次数进行计数), 最近的长链接 (可以使用 redis 保存最近一个小时的) 等等进行一个缓存,保存在内存中或者类似 redis 的内存数据库中,如果请求的长 URL 命中了缓存,那么直接获取对应的短 URL 进行返回,不需要再进行生成操作.
批量发号
每一次发号都需要访问一次 MySQL 来获取当前的最大号码,并且在获取之后更新最大号码,这个压力是比较大的.
我们可以每次从数据库获取 10000 个号码,然后在内存中进行发放,当剩余的号码不足 1000 时,重新向 MySQL 请求下 10000 个号码。在上一批号码发放完了之后,批量进行写入.
这样可以将对数据库持续的操作移到代码中进行,并且异步进行获取和写入操作,保证服务的持续高并发.
分布式
上面设计的系统是有单点的,那就是发号器是个单点,容易挂掉.
可以采用分布式服务,分布式的话,如果每一个发号器进行发号之后都需要同步给其他发号器,那未必也太麻烦了.
换一种思路,可以有两个发号器,一个发单号,一个发双号,发号之后不再是递增 1, 而是递增 2.
类比可得,我们可以用 1000 个服务,分别发放 0-999 尾号的数字,每次发号之后递增 1000. 这样做很简单,服务互相之间基本都不用通信,做好自己的事情就好了.