如何生成短网址编码

1,230 阅读5分钟
原文链接: zhuanlan.zhihu.com

这是一篇迟来的讨论,因为短网址大行其道还是在2010年前后,部分人刚刚用上智能手机的时候。不过,似乎仍然有一些有趣的地方可以讨论。


短码方案是指将任意长度的url编码成新的固定长度的URL,并且新URL的长度要大大小于之前的URL。同时,一般还要求URL所使用的字符容易识别和输入,并且不包含不合法的URL字符。实际上,这是将任意长度的字符串压缩成较短的固定长度的字符串的一个特例。

将任意长度的字符串无损压缩成固定长度的较短字符串,一般会借助字典,比如RLE算法。在我们这个例子里,期望对原URL直接编码,一步到位生成易识别、易输入的短码几乎是不可能的。假如原URL长度为20字符,短码使用5位字符方案,粗略计算的话,压缩比达到了 20^62 / 5^62之多,显然任何无损压缩是不可能达到这么高的比例的,我们知道比较好的压缩算法,可能最高也就是90%左右(比如对日志进行压缩时)。

因此方案必然是先进行摘要,然后对摘要值进行编码,然后建立编码结果到原网址的映射。在对压缩后的网址进行还原时,通过hash查找出对应的键值就可以了。所以,整个方案包括: 1. 用什么算法对原网址进行散列(摘要)? 2. 如何将散列值进一步转换(编码)成易识别、易输入的短码? 3. 如何保存映射表?

摘要算法的选择

选择摘要算法时主要考虑性能与冲突概率。 对字符串进行摘要,比较知名的有md5,crc等算法 。这里介绍一个名为xxhash的算法。根据他的benchmark,比crc和md5都要快:

xxhash算法可以生成64bit的摘要,如果只是为自己的网站生成短码方案,这个长度是足够了(注意crc32,md5-32应该都是128bit)。如果是专业提供网址压缩服务,则应该考虑MD5-32或者更长的摘要算法。 xxhash摘要算法的使用如下所示:

import xxhash

digest_tool = xxhash.xxh64()

def get_hash(text: str):
  digest_tool.reset()
  digest_tool.update(text)
  return digest_tool.intdigest()

我们返回int型的hash值,以便后面的编码方法使用。

编码方案的选择

假设我们只能使用5位短字符串,要将一个64bit的整数完全编码,仍然是不可能的。我们的选择有hex编码(字符集包含16个字符,可以编码16^5,约100万个网址),或者使用全部字母(包含大小写)和数字,这样字符集大小为62(一般称作base62),可以编码62^5,约9亿个网址)。与64bit可以表示的样本空间相比,又损失了不少。显然, hex编码是不可取的。但base62也有其缺点,因为包含了容易混淆,不易识别的字符对0和o,1和l。因此,在我们的编码方案中,我们需要将其排除掉,只使用58位字符集,这个方案一般被称为base58编码,这也是比特币中常用的地址编码方案。

BASE58 = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ"
def encode(num, alphabet=BASE58):
    if num == 0:
        return alphabet[0]
    arr = []
    base = len(alphabet)
    while num:
        num, rem = divmod(num, base)
        arr.append(alphabet[rem])
    arr.reverse()
    return ''.join(arr)

如果使用5位短码的话,可以编码的网址约6.5亿。

除法陷阱:python 2与python 3的区别

上面的算法取自https://stackoverflow.com/a/1119769/1210352。在同一个问题下,还有一个未采纳的回答如下:

import string
BASE_LIST = string.digits + string.letters + '_@'
BASE_DICT = dict((c, i) for i, c in enumerate(BASE_LIST))

def base_decode(string, reverse_base=BASE_DICT):
    length = len(reverse_base)
    ret = 0
    for i, c in enumerate(string[::-1]):
        ret += (length ** i) * reverse_base[c]

    return ret

def base_encode(integer, base=BASE_LIST):
    if integer == 0:
        return base[0]

    length = len(base)
    ret = ''
    while integer != 0:
        ret = base[integer % length] + ret
        integer /= length

    return ret

答主认为这个算法比被接受的答案更快。如果仔细观察,这段代码实际上有两个问题: 1. 这段代码实际上是python 2的。在python 3中, integer /= length会得到一个浮点结果,实际上导致这个算法很难结束(因为integer很难变为0)。 2. 算法使用了字符串拼接,这应该比前一个方法更慢,特别是会产生大量的临时字符串。

映射表的保存

现在,我们可以对任意的URL进行编码了:

def convert_short_url(url):
    _h = get_hash(url)
    short = encode(_h)
    return short, _h

根据具体的业务要求,可以使用redis或者数据库来保存短码与长码之间的映射。但在保存这个映射时,建议使用64bit的整数hash来作为键值,可能会得到更好的查找性能,所以convert_short_url也将这个hash值返回出来。

测试

下表显示了分别使用4,5,6位短码时,对100万个网址进行编码所用的时间以及碰撞率:

n_char  |time cost   |collision rate
--------|------------|--------------- 
  4     | 4.96秒     |5.9% 
  5     |  5.2秒     |0.11%
  6     |  5.39秒    |0.003%

也就是说,如果你的网站需要编码约100万个网址的话,只使用4位短码,可能会产生5.9%的碰撞率。所谓碰撞,就是指两个不同的URL被编码成为同一个短码。