哈希算法
那次事故之后
上次短码冲突的事故让我心有余悸。两个不同的长链接,居然生成了同一个短码。用户点击后跳到了别人的页面——这种 bug 发生一次就够要命了。
我花了一个晚上研究哈希算法。不是那种走马观花的浏览,而是真的打开 Python,一行一行跑代码,搞清楚到底哪里出了问题。
先从最根本的问题开始:如何把一个任意长度的 URL,变成一个固定长度的短标识符? 长链接可能几百上千个字符,但短码只有 6-8 个字符的空间。中间这个”压缩”的过程,怎么才能保证不丢信息、不出错?
最直觉的答案就是——哈希函数。
输入(任意长度) → 哈希函数 → 输出(固定长度)
"https://example.com/very/long/path" → MD5 → "b10a8db164e0754105b7a99be72e3fe5"
"https://example.com/short" → MD5 → "27b843d58f5a78936ab363b5e7b7f410"不管输入多长多短,输出的长度永远一样。这正是我需要的。
我用 MD5 生成的短码为什么冲突?
我之前的方案很简单:对 URL 算 MD5,截取前 6 位,搞定。
import hashlib
def generate_short_code(url, length=6):
"""截取 MD5 哈希的前 N 位"""
full_hash = hashlib.md5(url.encode()).hexdigest()
return full_hash[:length]
# 示例
print(generate_short_code("https://www.example.com")) # "b10a8d"
print(generate_short_code("https://www.example.com/")) # "66f0cf"
# 多了一个斜杠,哈希完全不同(雪崩效应)MD5 本身有几个好用的特性,这些特性让我一开始觉得它很靠谱:
- 确定性:同一个 URL,不管算多少次,结果都一样。这意味着相同的链接不会生成两个不同的短码。
- 雪崩效应:输入哪怕只差一个字符,输出也天差地别。上面那个例子,多了一个
/,哈希值从b10a8d变成了66f0cf,完全不相关。 - 不可逆:没法从哈希值反推出原始 URL。用户看到短码,猜不出原始链接长什么样。
- 速度快:微秒级就能算完。对于一个每秒可能处理上千个请求的短链接服务来说,这点很重要。
这些特性看起来完美。但问题不在 MD5 本身,而是出在我接下来的操作上——截断。
MD5 输出 32 个十六进制字符,也就是 128 位。32 个字符的”短链接”一点也不短——short.url/b10a8db164e0754105b7a99be72e3fe5,比我原来的 URL 还长。
所以我截取了前 6 位。这正是事故的根源。
截断之后的碰撞概率
我用生日悖论的公式算了一笔账。6 位十六进制,可能的组合数是 16^6 = 16,777,216,大约 1677 万。
def collision_probability(n, d):
"""
n 个元素在 d 个可能值中的冲突概率
基于生日悖论
"""
p_no_collision = 1.0
for i in range(n):
p_no_collision *= (d - i) / d
return 1 - p_no_collision
d_16_hex = 16 ** 6 # 6 位 16 进制:1677 万
print(f"存了 1000 条: {collision_probability(1000, d_16_hex):.6%}")
print(f"存了 1 万条: {collision_probability(10000, d_16_hex):.6%}")
print(f"存了 10 万条: {collision_probability(100000, d_16_hex):.6%}")
# 输出:
# 存了 1000 条: 0.029708%
# 存了 1 万条: 2.878517%
# 存了 10 万条: 95.181672%10 万条 URL,冲突概率就飙到了 95%。 难怪我出事了。这才几千条数据就开始撞车,我居然之前一直没注意到。
哈希碰撞并不是什么小概率黑天鹅,而是数学上必然会发生的事情。有限的空间(1677 万个坑),装越来越多的 URL,迟早撞车。这就像鸽笼里塞鸽子——笼子不够,必然有两只挤在一起。更让人不安的是,由于生日悖论,冲突不需要等到空间用完才出现,远在此之前就会大规模爆发。
我试了不同截取长度,整理了一张表:
| 截取长度 | 字符集 | 可能组合数 | 冲突概率(10 万条) |
|---|---|---|---|
| 6 位 | 16 进制 | 16^6 ≈ 1677 万 | 95% ⚠️ |
| 8 位 | 16 进制 | 16^8 ≈ 42.9 亿 | 0.12% |
| 6 位 | 62 进制 | 62^6 ≈ 568 亿 | 0.009% |
| 8 位 | 62 进制 | 62^8 ≈ 218 万亿 | 极低 |
6 位十六进制完全不够用。如果换成 62 进制,同样的 6 位,空间从 1677 万暴涨到 568 亿——但这需要先解决编码问题(下一节再说)。
常见哈希算法对比
我把能找到的哈希算法都过了一遍:
| 算法 | 输出长度 | 安全性 | 速度 | 适用场景 |
|---|---|---|---|---|
| MD5 | 128 位(32 字符) | 低(已破解) | 快 | 非安全场景 |
| SHA1 | 160 位(40 字符) | 中(已破解) | 快 | 非安全场景 |
| SHA256 | 256 位(64 字符) | 高 | 中 | 安全场景 |
| MurmurHash | 32-128 位 | 低 | 很快 | 哈希表、数据库 |
短链接不需要加密安全性,我不怕别人”破解”我的短码。我只需要:快速计算、低冲突率、输出长度合适。
所以 MD5 或 MurmurHash 理论上是不错的选择——前提是截取长度够用。
我试了 SHA256,碰撞确实更少
我接着尝试了 SHA256,想看看更强的哈希算法能不能解决问题。
import hashlib
def generate_sha256_code(url, hex_length=8):
"""用 SHA256 生成短码"""
full_hash = hashlib.sha256(url.encode()).hexdigest()
return full_hash[:hex_length]
url = "https://www.example.com/page1"
md5_code = hashlib.md5(url.encode()).hexdigest()[:8]
sha256_code = hashlib.sha256(url.encode()).hexdigest()[:8]
print(f"MD5: {md5_code}") # MD5: c4ca4238
print(f"SHA256: {sha256_code}") # SHA256: 50d858e0SHA256 的输出是 64 个十六进制字符,比 MD5 的 32 个多了一倍。截取前 8 位的话,碰撞概率确实更低——因为即便截断,每次哈希都是在一个更大的”指纹空间”里取样。
但问题没变:不管哈希算法多强,截取之后的空间还是有限的。 8 位十六进制就是 16^8 ≈ 42.9 亿种可能,和算法是 MD5 还是 SHA256 无关。
而且 SHA256 的计算速度比 MD5 慢大约 3-5 倍。短链接服务是要扛高并发的,每个请求都要做一次哈希计算,这个开销不能忽视。
我在心里列了个账:
| 维度 | MD5 | SHA256 |
|---|---|---|
| 输出长度 | 32 字符 | 64 字符 |
| 碰撞抗性(完整输出) | 弱 | 强 |
| 截断后碰撞率 | 取决于截取长度 | 相同截取长度下几乎一样 |
| 计算速度 | ~600 MB/s | ~200 MB/s |
| 短码长度 | 截断后一样短 | 截断后一样短 |
结论让我有点沮丧:换更强的哈希算法,并不能从根本上解决截断碰撞的问题。 碰撞率取决于截取后的空间大小,而不是哈希算法本身。SHA256 确实比 MD5 更安全、更抗碰撞——但那是指完整输出。一旦截断,安全性优势就消失了。
我需要换一个完全不同的思路。
哈希 vs 自增 ID:我终于理解了根本矛盾
那天晚上我盯着屏幕发呆,突然意识到一个更深层次的问题。
哈希方案的冲突是概率问题,不是技术问题。 不管我用 MD5、SHA256 还是 MurmurHash,不管截取 6 位还是 8 位——只要 URL 足够多,冲突迟早会发生。这是数学规律,不是代码 bug。
那有没有一种方案,永远不会冲突?
有。自增 ID。
class CounterShortener:
def __init__(self):
self.counter = 0
def shorten(self, long_url):
self.counter += 1
# 第 1 个链接 → 1,第 2 个 → 2,第 100 万个 → 1000000
# 永远不会重复
return str(self.counter)
shortener = CounterShortener()
print(shortener.shorten("https://example.com/a")) # "1"
print(shortener.shorten("https://example.com/b")) # "2"
print(shortener.shorten("https://example.com/c")) # "3"自增 ID 简单粗暴:1、2、3……每个数字都是唯一的,永远不会撞车。就像排队取号——每个人拿到的号码一定不一样。不用算哈希,不用担心碰撞,不用搞什么加盐重试。
但它有一个致命的缺点:暴露业务量。 看到短码是 1000000,就知道你只有 100 万条链接。看到短码是 15,就知道你这个服务刚上线。竞争对手扫一眼就能推算出你的用户规模。这就像餐厅门口的取号机,看到前面排到 800 号,就知道今天生意不错。
而且纯数字的短码太长了。100 万是 7 位数,1 亿是 9 位数——一点都不”短”。
我把两种方案的优缺点摆在一起:
| 维度 | 哈希方案 | 自增 ID |
|---|---|---|
| 唯一性 | ❌ 有冲突风险 | ✅ 绝对唯一 |
| 短码长度 | ✅ 固定长度 | ❌ 随数量增长 |
| 业务量暴露 | ✅ 不暴露 | ❌ 直接暴露 |
| 分布式友好 | ✅ 各节点独立计算 | ❌ 需要协调 |
| 复杂度 | 中(需要冲突处理) | 低(但需要分布式 ID) |
两种方案各有各的坑。纯哈希会冲突,纯自增会暴露数据。我需要一个能兼顾两者优点的方案。
那天晚上快凌晨两点了,我终于想通了一件事——如果能用自增 ID 保证唯一性,再用一种高效的编码把数字”伪装”成看起来随机的字符串,不就两全其美了吗?
我的决定:自增 ID + Base62 编码
想了一晚上,我做了决定:用自增 ID 保证唯一性,用 Base62 编码让短码更短、更不可预测。
class URLShortener:
BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def __init__(self):
self.url_to_short = {}
self.short_to_url = {}
self.counter = 0
def _to_base62(self, num):
"""将 10 进制数转换为 62 进制字符串"""
if num == 0:
return self.BASE62[0]
base62_str = ""
while num > 0:
num, remainder = divmod(num, 62)
base62_str = self.BASE62[remainder] + base62_str
return base62_str
def shorten(self, long_url):
# 检查是否已存在(去重)
if long_url in self.url_to_short:
return self.url_to_short[long_url]
# 自增 ID + Base62 编码
self.counter += 1
short_code = self._to_base62(self.counter)
# 存储双向映射
self.url_to_short[long_url] = short_code
self.short_to_url[short_code] = long_url
return f"https://short.url/{short_code}"
def restore(self, short_code):
return self.short_to_url.get(short_code)
# 使用示例
shortener = URLShortener()
url = "https://www.example.com/very/long/url/with/many/parameters"
print(shortener.shorten(url))
# https://short.url/4fcVwD ← 比纯数字短多了
print(shortener.restore("4fcVwD"))
# https://www.example.com/very/long/url/with/many/parameters为什么选这个方案?
- 自增 ID 永远不会冲突——数学上绝对唯一,不用再担心碰撞
- Base62 编码让数字更短——1000000 变成
4fcVwD,6 个字符搞定 - 62 进制有 568 亿种组合——6 位足够用很久
- 看不出业务量——
4fcVwD比1000000难猜多了
这不是完美的方案——自增 ID 在分布式环境下需要额外处理——但至少它从根本上消除了哈希碰撞的噩梦。
选择 Base62 不是随便拍脑袋的
做下决定的那一刻,我知道自己只是把问题往下推了一层。
Base62 只有 62 个字符(0-9, a-z, A-Z),为什么不用 64 个?为什么不用 36 个?为什么不用其他符号?
6 位 Base62 的空间是 568 亿,6 位 Base36 只有 21 亿,差了 27 倍。多出来的 26 个大写字母,让短码短了一大截。但多加 + 和 /(变成 Base64)呢?这两个字符在 URL 里有特殊含义,会引发编码问题。
背后的数学很讲究。 字符集的每一个选择,都在影响短码的长度、安全性和 URL 兼容性。
下一节,我需要把 Base62 的原理彻底搞清楚。
练习题
练习 1
为什么 MD5 适合短链接生成,但不适合密码存储?
MD5 适合短链接的原因:
- 快速计算:短链接需要实时生成,MD5 速度快
- 确定性:相同 URL 总是生成相同哈希,避免重复
- 安全性要求低:短链接不需要防破解,只需要唯一性
- 输出长度适中:128 位足够缩短后使用
MD5 不适合密码存储的原因:
- 已被破解:存在有效的碰撞攻击方法
- 计算太快:容易被暴力破解和彩虹表攻击
- 无盐值机制:相同密码产生相同哈希
- 专业攻击工具:存在大量 MD5 破解工具
密码存储应该使用 bcrypt、Argon2、PBKDF2 或 scrypt。这些算法故意设计得很慢,并支持盐值,能有效防止暴力破解。
练习 2
如果截取 MD5 哈希的前 6 位(16 进制),最多能表示多少个不同的短链接?什么时候冲突概率会超过 50%?
最大组合数:
6 位十六进制,每位 16 种可能:
16^6 = 16 × 16 × 16 × 16 × 16 × 16 = 16,777,216 ≈ 1677 万50% 冲突概率的阈值(生日悖论估算):
阈值 ≈ √(16^6 × ln2) ≈ √(16,777,216 × 0.693) ≈ √11,625,816 ≈ 3409也就是说,大约存 3400 条 URL 时,冲突概率就超过 50% 了。 这个数字比很多人想象的要小得多。
对比:
- 如果扩展到 8 位:16^8 ≈ 42.9 亿,50% 冲突阈值约 77,000 条
- 如果使用 62 进制 6 位:62^6 ≈ 568 亿,50% 冲突阈值约 280 万条