差不多 5 年前逛 github 的时候偶然发现了 hashids 这个东西,当时就觉得 hashids 很适合用在短网址系统上,于是花了一天时间撸了个简单的短网址系统出来。

当时是纯粹为了练手,项目写完放 github 上,然后就在 v2ex 上发了个贴,最初稍微维护了一下后来也就没有再管。前几天上 github 发现,这个几年没维护的项目靠自然流量也有 200 star 了,看来短网址这个东西的需求仍然很旺盛。

现在回过头来看当时的实现方案还是比较粗糙的,个人项目玩玩可以,用在商业生产环境就不太够了。不过目前倒也没有自建短网址的需求,不会再像当时一样自己再撸一套,但短网址系统的设计思路和一些坑倒是可以整理一下。

背景

什么是短网址系统?

短网址已经是一个老生常谈的话题了,这块想必不用多说,它的作用主要就是把一个很长的网址转换成一个较短的地址。像微博的 t.cn、百度的 dwz.cn、腾讯的 url.cn 都是常见的短网址系统。

为什么要用短网址系统?

现在的很多链接由于需要带上很多参数来提供业务所需的数据,所以往往非常冗长,而相应地转换成短网址后能带来很多益处:

  • 在分发和使用的时候更方便、清爽
  • 更好地适应微博、短信等有字数限制的场景
  • 降低生成二维码的复杂度,提升扫码识别率
  • 可以一定程度上隐藏部分参数,比如 aff 等
  • 能够实现链接跳转的跟踪和各维度数据统计
  • 原网址失效后可以不改变短网址,只修改跳转关系
  • 个性短网址更有利于品牌建设和营销

设计思路

短网址系统做到可用是比较简单的,但是想要高性能、可靠且安全的话,涉及到的很多点也能展开得非常细。

长短链映射

短网址系统最核心的功能就是长短链之间的转换了,长网址的长度可以认为是没有限制的,所以使用可逆的压缩或者加密算法将其转换成一个长度有限的短网址是不可能做到的,我们必须借助 DB 或者其他存储系统来实现映射的存储。

发号

那么自然会产生的一种思路就是将长网址存入一张表,然后使用 DB 中的自增主键来作为短网址,此时我们得到的短网址类似这样 xx.xx/1xx.xx/2。这其实就是发号式短网址的基本思路。

在此基础上我们可以做一些微小的改良来得到更短的链接,比如把 10 进制的 ID 转换成 16 进制,或者 a-z A-Z 0-9 这些字符组成的 62 进制,此时在数字比较大的时候我们得到的短网址就会类似 xx.xx/Ad3Ef

上面一步完成后得到的地址看上去就和目前常见的短网址非常相似了,但这种直接暴露 ID 的方式会产生安全上的问题,别有用心的人发现短网址的规律之后可以很轻易地遍历出整个系统中存储的所有长网址了。这就意味着可能会被人直接拖库。

文中最初提到的 hashids 则是解决这个问题的一个极佳方案。它提供一种能把任意正整数转换为指定位数字符串的可逆算法,基于 salt 安全性也有保障。并且虽然它名字中含有 hash,但实际生成的字符串不会发生碰撞,当数字过大无法用指定位数字符串表示的时候会自动增加一位。

实测下来使用 62 进制字符串的情况下,6 位长度字符串可以表示至少 1 亿条数据,7 位长度可以表示近 50 亿条数据,可以说完全足够业务使用了。至于 hashids 的具体实现原理可以查看它的官网介绍

Hash

既然使用发号的方式也不能直接暴露 ID,那是否也可以使用 hash 来进行长网址的缩短呢?我们都知道 hash 是一定会存在碰撞的,在长地址空间不确定的情况下,碰撞概率以及碰撞后的处理都会存在很大的不确定性,所以一般来说 hash 在大规模的线上短链接系统中是不能被接受的。

不过近些年才出现的 MurmurHash 对于类似场景则有一定程度的优化,它诞生也就十年出头,就已经被很多著名的开源项目使用,比如 Redis、Nginx、Hadoop 等等。对于规律性较强的 key,它的随机分布特征表现更良好,更好的平衡性和更低的碰撞率使得它也能够在特定场景下用于短网址系统。

重定向跳转

实现了长短链的映射转换之后,只要再完成短链到长链的重定向跳转也就实现了短网址系统的核心功能了。

重定向这块主要涉及的是 301 和 302 的选择,当然 http 的跳转除了 301、302 之外 还有 303、307、308,不过在短网址系统中一般是用不到的。

简单来说 301 是永久重定向,302 是临时重定向。如果短链生成后就不会变化,那么使用 301 则更符合 http 语义,同时浏览器会缓存 301 跳转,这也能很大程度上减轻服务器压力。

不过在很多使用场景中我们往往都想要对链接跳转的数据进行统计,而且如果我们也需要实现活链功能,也就是同一个短链指向的长链后续可以修改的话,那就只有选择 302。

优化

上面两步做完之后,一个简单的可用的短网址系统就实现了,不过如果想要在大型商用系统中使用还是需要做些性能上的优化。

缓存

性能优化最基本的自然是缓存,短链到长链的转换基本上就是一个 key-value 的查询过程,所以使用常见的 Redis 等缓存方案来缓存长短链的对应关系再合适不过了。先走缓存再查数据库,数据库对应字段索引合理的情况下,整个系统的性能就不会差。

在缓存大小有限的情况下也可以选择缓存热点数据,至于涉及到的其他诸如分布式缓存、缓存穿透和雪崩、迁移数据预热等等展开来又是一个比较大的话题了。

布隆过滤器

布隆过滤器也是一种非常适用于短网址系统的算法,它可以用来快速判断某个 url 是否在大量的 url 集合当中,并且空间复杂度也很低,我们大约只要 500M 左右的内存空间就能完成 20 亿左右 url 集合的判断。

布隆过滤器的具体原理就不赘述了,大概讲讲它在前面提到的发号和 Hash 两种长短链映射方式中的使用场景。

在发号式生成短链的系统中,如果同一个长链被转换多次,那么在没有判断长链是否已存在的情况下,这个地址会被转换成多个不同的短链。如果我们希望同一个长链多次转换的结果相同,那么查库显然比较低效,而缓存是 key-value 的,反过来查询 value 也很低效,这时就可以使用布隆过滤器。

而在 Hash 生成短链的系统中,布隆过滤器则可以用来很方便地判断哈希碰撞,所以说这个算法非常适合各种短网址系统。

发号器选择

发号式生成短网址方式的核心是发号器,也就是 ID 生成器,一般来说使用 MySQL 等数据库的自增主键就够了,毕竟大多数短网址系统应该是读操作更多,写操作较少。

不过硬要深究的话,也可以使用 Redis、Snowflake 来实现发号器,但这些方案无疑会极大地增加系统复杂度,除非是超大型规模的短网址项目才会考虑到。或者采用 uuid 的方式实现分布式的 ID 生成,但 uuid 的长度又有些过长了。

语言选择

语言选择放在最后是因为个人觉得对于短网址这种简单业务来讲,在大多数项目里语言的选择并不会是一个很关键的注意点。

我几年前的那个项目是使用 PHP 加上一个基本只有路由功能的轻框架实现的,在这种文件 IO 较低的场景下主要的开销其实还是数据库层面的,与加上缓存带来的提升相比,语言的选择带来的影响就比较小了。

当然追求极致性能的话自然是有比 PHP 更适合的选择的,之前就有人把我的项目用 nginx + lua,也就是 OpenResty 改写出来一个版本。而现在如果让我再写一个短网址系统的话,综合性能以及开发和维护成本,我则可能更倾向于使用 Go 来实现。

可能的坑

短网址系统遇到的坑除了技术层面以外,一般都是恶意攻击和不合规滥用带来的。

短网址系统如果在转换地址的时候没有限制的话,攻击者很容易实现无限短址套娃,从而消耗大量服务器资源并产生大量垃圾数据,所以一般短网址系统是不让转换自身域名的地址的,也不让转换其他短网址域名的地址,防止攻击者通过多个短网址服务实现循环套娃。

至于不合规的滥用就主要是,很多涉及赌博色情等行业的网站会使用公开的短网址系统对他们网址进行包装,因为他们自己的域名一般已经被很多平台加入黑名单了,所以通过短网址的方式绕过。一旦遇到这种被滥用的情况,我们的域名很可能就会被微信、QQ、微博等平台封禁,损失惨重。

也正是因为容易遇到各种恶意使用者,短网址系统目前也很少有开放 API 的,基本都是各大平台自建之后自己使用,而那些不知名的短网址往往也都死得很快。

参考