做短链接服务,很多人第一反应是“这不就是一个302跳转吗”,可真到动手实现的时候,大多数人会卡在第一个环节:那个短码到底怎么生成?
我第一版短链系统用的方案相当无脑——UUID截断。短码生成倒是快,可等真实流量一上来,问题全暴露了:同一个长链接生成两条记录,短码乱七八糟,数据库翻了几倍,用户分享出去的链接长得像一串随机的身份证号。后来我把市面主流的短码生成方案全部过了一遍,最终在生成层选了 MurmurHash,配合 Base62 编码做落地。
这篇就完整讲讲我为什么选它、怎么落地、踩了哪些坑,以及碰撞处理和安全设计这些容易被忽略的工程细节。
1. 短链接不是一个“跳转”问题,而是一个“短码生成”问题
先别急着写代码,短链接的整个链路其实非常朴素:一张表存长短映射,一个写接口接收长链生成短链,一个读接口根据短码查表然后 302 跳走。整条链路里真正有算法深度、有方案选型空间的,只有短码生成这一个点。
1.1 链路本身没多少秘密,秘密全在短码
短链接服务的核心存储结构大概长这样:
| 字段 | 说明 |
|---|---|
| id | 主键,自增即可,仅在内部使用 |
| short_code | 对外短码,比如abc123D,必须有唯一索引 |
| long_url | 原始长链接 |
| seed | 生成短码时用的哈希盐值,碰撞重试时修改 |
| created_at | 创建时间 |
| expire_at | 过期时间,可选字段 |
对外暴露的短链接格式是https://your.domain/{short_code},读接口拿到路径里的 short_code 之后,去表里查 long_url,然后返回 302 重定向。
写接口比读接口稍微麻烦一点:要先判断这个长链接是否已经生成了短码,如果生成过就直接返回已有短码,避免同一链接反复产生多个短码。而这个“判断”,恰恰决定了你选哪种短码生成算法。
1.2 主流短码生成方案的真实体验
我梳理了做短链绕不开的四种方案,全都有各自的代价,不对比不知道,一对比才发现哈希方案在这个场景下几乎是天生的最优解。
自增 ID 转进制:这是很多教程会教的方案,实现简单、无碰撞,用十进制自增 ID 转 Base62 之后短码还能变得更短。但它有个致命问题——短码可枚举。攻击者只要从aaaaaa顺着往下试,就能把全站所有短链接遍历出来,这在涉及邀请链接、私有页面跳转的场景下是非常严重的安全隐患。而且自增 ID 是一种线性空间,同一个长链接如果先删除再创建,两次拿到的短码不一样,也做不到内容去重。
UUID 截断:我第一版就是栽在这上面。UUID 共 128 位,就算转成 Base62 也有 22 位,显然太长。截断到 8 位之后碰撞概率又不可控,而且 UUID 没有确定性,同一个 URL 每次生成的短码都不一样,DB 里全是冗余数据。后来我排查数据的时候发现,同一个活动页链接居然存了上百条记录,全是 UUID 造成的。
随机字符串:先随机生成 6~8 位,再去数据库查重,冲突了就重新生成。这种方案的问题在于,每次生成都要查询一次数据库,生成量一大,数据库压力直线上升。线上系统并发一上来,“查重”这个动作就成了瓶颈。
哈希方案:用哈希算法把长链接映射成一个固定位数的摘要,再转成短码。核心优势是确定性——同一个长链接每次生成的短码相同,天然支持内容去重;同时短码长度可控,可以主动截断到目标位数。哈希方案唯一的连带问题就是碰撞,但碰撞在工程上完全是可管理的,后面我会专门讲。
1.3 哈希算法候选:为什么不是 MD5 或 SHA-1
既然决定走哈希路线,候选就呼之欲出了:MD5、SHA-1、SHA-256 这些加密摘要算法,以及 FNV、CRC32、MurmurHash 这类非加密哈希。
提到哈希,很多人本能地选 MD5,因为它看起来“科学”。但从工程角度分析,MD5 和 SHA-1 在这个场景下有两个问题。
第一个问题是性能。MD5 每秒只能处理几百 MB 数据,而 MurmurHash 能做到每秒好几 GB,一个量级的差距。短链服务生成短码本身不会涉及海量数据,但在内部对一批长链接做去重和判重时,哈希性能直接决定 CPU 开销。
第二个问题更本质:MD5 和 SHA 系列是加密哈希,设计目标是把抗碰撞性做得很强,防的是有人故意构造碰撞。可短链生成是主动把摘要截断成 6~8 位,密码学上的抗碰撞能力在这个场景下根本用不上。我们需要的只是一个分布均匀、速度极快、雪崩效应好、输出位数可控的非加密哈希,MurmurHash 完美命中这些要求。
2. MurmurHash 强在哪里:从原理到实测
MurmurHash 是 2008 年由 Austin Appleby 发布的非加密哈希算法,名字源于核心操作“multiply and rotate”的发音。它最著名的特性就是速度极快与分布极度均匀,这两个特性在数据库分片、Bloom Filter、短链接生成这些场景里非常受欢迎。
2.1 算法核心特性
MurmurHash 的核心是靠乘法和位移进行多轮位混合。把输入切成固定大小的块,每块做一次乘法、移位、异或,最后再做一次 avalanche,也就是雪崩操作。
雪崩效应是一个哈希算法是否合格的分水岭:输入数据哪怕只有 1 bit 发生变化,输出中大约一半的位都要翻转。MurmurHash3 的雪崩性做得非常出色,均匀性测试可以稳定通过各种统计检验。
MurmurHash 有 32 位、64 位、128 位三个版本。做短链接强烈建议用 64 位或 128 位版本,不要用 32 位。原因后面踩坑部分会提到,32 位输出空间太小,是碰撞概率突然失控的常见原因。
2.2 和常见哈希算法的数据对比
我把 MurmurHash3、MD5、SHA-1、FNV-1a、CRC32 放在同一台测试机上跑过一轮实测,这里直接给结论:
| 算法 | 输出位数 | 相对速度 | 是否适合短链场景 |
|---|---|---|---|
| MurmurHash3 | 32/64/128 | 极快 | 非常合适 |
| FNV-1a | 32/64 | 很快 | 分布稍差,碰撞略高 |
| CRC32 | 32 | 快 | 输出位太短,碰撞概率高 |
| MD5 | 128 | 慢 | 性能损耗大,抗碰撞性用不上 |
| SHA-1 | 160 | 慢 | 同上 |
MurmurHash3 的 128 位版本实测速度大约是 MD5 的 4~5 倍,FNV 虽然也快但分布均匀性不如 Murmur。如果做短链接这类需要确定性映射、又对速度和分布有要求的场景,MurmurHash3 是综合分最高的选择。
2.3 容量账:6 位、7 位、8 位到底能装多少
短码的容量不由哈希算法决定,而是由编码进制和位数决定。短址里最常见的是 Base62 编码,包括 0-9 十个数字、26 个大写字母、26 个小写字母,一共 62 个字符。
62 的幂次算一下:
- 5 位:62^5 = 9.16 亿
- 6 位:62^6 = 568 亿
- 7 位:62^7 = 3.52 万亿
- 8 位:62^8 = 218 万亿
看一眼数字就知道,6 位短码已经能覆盖绝大多数中小型业务,社交平台级别的服务通常选 7 位。复用 Twitter 的经验,他们用的是 7 位 Base62,因为 6 位在亿级链接量下碰撞风险已经不像理论值那么轻松了,7 位可以留出足够冗余。
短码位数还要考虑用户可读性。5 位太短撞得厉害,8 位开始变长失去“短”的意义,7 位在各种场景下都是一个比较均衡的选择。
3. 核心实现:用 MurmurHash3 生成短码的完整流程
短链生成到底怎么落地?我用 Go 语言演示完整的核心实现,其他语言思路完全一致。
3.1 整体生成链路
生成短码的逻辑并不复杂,流程如下:
- 接收长链接,做标准化处理(去掉首尾空格、统一协议头)
- 取固定种子,用 MurmurHash3 计算 128 位哈希值
- 取哈希值的低 64 位或高 64 位,转换为十进制整数
- 把这个整数做 Base62 编码,得到短码
- 长度不足 7 位时左侧补 0,超过 7 位时截断到 7 位
- 尝试写入数据库,如果唯一索引冲突,更换种子重新哈希,最多重试三次
第二步里的“固定种子”,我用的是业务层自定义的一个常量,而不是每次都随机生成。这个细节很多人容易忽略:如果每次用随机种子,同一个长链接会生成不同的短码,短链“确定性去重”的优势就丢失了。
后面安全篇我会再讲,某些场景下又需要有意识地引入随机盐,这就是一个看场景做取舍的工程问题。
3.2 核心代码解析
package shortcode import ( "fmt" "github.com/spaolacci/murmur3" ) // 62 进制的字符表,顺序建议固定,保证不同服务实例生成结果一致 const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" // base62Encode 将 uint64 整数转换为 62 进制字符串 func base62Encode(v uint64) string { if v == 0 { return "0" } var buf [16]byte i := len(buf) for v > 0 { i-- buf[i] = alphabet[v%uint64(len(alphabet))] v /= uint64(len(alphabet)) } return string(buf[i:]) } // Generate 生成 7 位短码 func Generate(rawURL string, seed uint32) string { h1, h2 := murmur3.Sum128WithSeed([]byte(rawURL), seed) // 这里只取 h1 作为基础值,h2 可以作为碰撞重试时的补偿值 _ = h2 code := base62Encode(h1) if len(code) < 7 { // 左侧补 0,保持定长 code = fmt.Sprintf("%07s", code) } if len(code) > 7 { code = code[:7] } return code }这段代码里有几个细节值得展开说。
取 64 位而不是直接用 128 位:Base62 编码处理 64 位整数最方便,直接用整个 128 位做运算在大部分语言里反而麻烦。取高 64 位还是低 64 位没有本质区别,但要保证所有服务实例取同一边,不要有的取高有的取低。
补 0 的意义:哈希值转成 Base62 后长度不固定,有些短码是 6 位,有些是 7 位,有些开头带 0。统一补齐到 7 位的目的,一是保证所有短码等长,视觉上更统一;二是避免出现“一个 6 位一个 7 位”导致前缀匹配逻辑出问题。注意这里的补 0 只发生在 Base62 编码层面,不影响解码。
为什么要先转整数再 Base62,而不是直接把哈希字符串截断:直接把十六进制哈希截断,可用的字符集合只是 0-9a-f,可表示空间比 Base62 小得多。先转整数再 Base62,等于把 64 位二进制均匀映射到 62 个字符空间,空间利用率更高,同一长度下可承载的链接数更大。
3.3 几个不那么明显的设计决策
为什么种子要固定?
如果 seed 不固定,同一个长链接每次生成短码都不同,内容去重就无从谈起。业务量大之后,每次用户手滑刷新一次就产生一条新记录,数据膨胀非常快。固定种子能让“相同 URL 映射到相同短码”,这是哈希方案最值钱的特性。
为什么不用十六进制而是 Base62?
同一个 64 位整数,十六进制需要 16 位,Base36 需要 13 位,Base62 只需要 11 位。截断到 7 位之后,Base62 的碰撞空间远大于十六进制。十六进制 7 位最多 2.68 亿种组合,Base62 7 位有 3.52 万亿种组合,差了四个数量级。所以在短链接这个场景里,用 Base62 不是炫技,是实打实的容量优势。
为什么 7 位而不是 6 位?
6 位有 568 亿空间,看似够用,但别忘了我们是用哈希截断而不是自增 ID。判重之后虽然碰撞概率极低,但 6 位空间在千万级链接量下碰撞风险更早暴露。7 位多出来一位,换来的是数量级上的安全边际。用户看到短码从 6 位变 7 位,几乎无感。
4. 碰撞处理与生产环境排坑实录
网上很多讲短链的文章,都会轻描淡写一句“把哈希结果截断,基本不会碰撞”。这句话在实验环境没错,可一旦放到真实生产环境,你就会发现碰撞并非不存在,而是需要正面对待的问题。
4.1 碰撞概率的数学底牌
MurmurHash3 的 128 位输出空间是 2^128,截断到 7 位 Base62 后,实际空间是 62^7,约 3.52 万亿。注意这里有个微妙的点:我们在代码里先是把 128 位哈希截取 64 位,再转成 62 进制取前 7 位,所以真实参与编码的空间是 64 位哈希值经 Base62 编码后截断的前 7 位。
碰撞概率用生日悖论来估算。如果有 N 个短码均匀分布在 M 个桶里,碰撞概率约为:
P ≈ N² / (2M)
以 7 位 Base62 为例,M = 3.52 万亿。假设积累了 1000 万条链接,N = 10^7,代入公式:
P ≈ 10^14 / (2 × 3.52 × 10^12) ≈ 0.0142
也就是大约 1.4% 的概率出现一次碰撞。这已经不算“不可能”了,到了亿级链接量,碰撞概率直线上升到失控区间。
所以结论很明确:碰撞不能靠概率赌,必须有兜底机制。
4.2 生产环境的两层兜底方案
我把碰撞处理设计成两层,第一层靠数据库,第二层靠重试。
第一层:唯一索引兜底
short_code 字段必须建唯一索引。这是最后一道防线,无论代码逻辑怎么写,只要撞了唯一索引,数据库就会报错。这个报错信息就是碰撞信号,系统收到这个信号后进入第二层。
第二层:换盐重试
代码逻辑里,当写入失败是因为唯一索引冲突时,把 seed 换成另一个固定列表中的值,重新计算哈希,重新生成短码。最多重试三次。
var seedCandidates = []uint32{0x9747b28c, 0xdeadbeef, 0x12345678} func GenerateWithRetry(rawURL string) string { for _, seed := range seedCandidates { code := Generate(rawURL, seed) err := saveToDB(rawURL, code, seed) if err == nil { return code } // 非冲突类错误直接抛出,冲突才继续重试 if !isUniqueConflict(err) { panic(err) } } // 三次都碰撞,说明空间真的快满了,可以返回错误或走扩位策略 return "too_many_collisions" }这里有个细节:重试时不能无限循环,否则极端情况下请求会卡住。三次碰撞之后基本可以断定当前短码空间容量紧张,这时候应该触发告警,考虑从 7 位扩到 8 位。
另外一种策略是碰撞后把短码长度加一,比如 7 位碰撞后生成 8 位。这种变长策略也有团队在用,但会带来一个麻烦:读取时无法确定短码长度,需要从短到长逐个试查,或者用前缀索引。我个人更倾向保持定长、换盐重试,系统更简单。
4.3 我踩过的几个“哈希坑”
坑一:直接把哈希结果转十进制,短码变成十几位
这是需求初期最容易犯的错误。MurmurHash 输出的是 64 位整数,直接转十进制有 20 位那么长,那还叫短链接吗?必须经过 Base62 编码压缩。
坑二:用了 32 位 MurmurHash
MurmurHash 的 32 位版本输出空间只有 2^32,约 42.9 亿。截断到 7 位 Base62 后相当于从一个小池子里捞数,碰撞概率暴增。我当时压测到 500 万条链接时,碰撞告警就没停过。换成 64 位版本后,同样数据量下碰撞几乎消失了。用 MurmurHash 做短链,至少选 64 位,能上 128 位更好。
坑三:seed 不固定,导致同一链接生成不同短码
最早我把 seed 设置成时间戳,本意是防止短码可预测,结果上线第二天就被业务方反馈:同一个活动链接反复生成,每次拿到的短码都不一样,数据库里多了几千条重复记录。后来我才理解,确定性哈希去重和安全性并不冲突——种子固定负责“去重”,安全靠额外的盐和校验逻辑负责。两者不能混为一谈。
坑四:不同服务实例用不同语言的 MurmurHash 库
短链服务如果做了多语言异构,比如一个模块用 Go,一个模块用 Python,铁定会遇到同一个 URL 生成不同短码的情况。MurmurHash 在不同语言的实现细节有差异,版本之间行为也不同。解决方法只有一个:整个生成链路锁定一种语言、锁定一个库的版本,生成归档数据时也要用同一套工具。
5. 除了生成短码,短链接系统还要考虑什么
短码生成只是短链系统的第一公里。真实生产环境里,比“生成”更重要的是“安全”和“边界”。
5.1 短码安全性:防的就是“遍历攻击”
短码最大的安全隐患是可枚举。如果你用的是自增 ID 转 Base62,攻击者短时间内就能把整站短链接爬光;如果用确定性哈希,短码虽然看起来随机,但理论上攻击者也可以对你怀疑的长链接做哈希枚举。
防遍历的有效方式有三种:
加盐:在哈希前对长链接拼接一段服务端私密盐值,比如rawURL + secretSalt。这样外部无法轻易推导出某个长链接对应的短码,即便他拿得到长链接也生成不出相同的哈希。
混合大小写:大小写混合让短码字符集从 36(数字+小写)提升到 62,相同的 7 位短码,枚举空间大了近一倍。抖音这类第三方邀请链接场景里尤其重要,邀请链接一旦可被枚举,等于把所有邀请关系都暴露了。
限制访问频率:短链读接口要做限流,对高频遍历同一个段位的请求直接拒绝。这是最朴素也最有效的兜底。
5.2 站外链接场景的扩展注意点
短链在短视频站外跳转、活动页推广、邀请链接这类场景里非常常见。这类场景有个共同特征:链接要从 App 内跳转到第三方页面,中间用的就是短链中转。
这种场景下,短链服务还需要额外考虑几个点:
- 分享出去的短链一旦生成就不要变。用户分享到聊天、群、社交媒体之后,如果短码指向的地址变了,传播就断了。
- 过期策略要谨慎。活动链接如果设置过期时间,过期之后不要直接删除记录,最好标记失效并返回一个友好页面,而不是 404。
- 风控字段。建议给 mapping 表增加 ip 来源、ua 类型、渠道标记等字段,方便排查恶意刷量。
5.3 服务降级与容错
哈希生成本身性能开销极低,真正可能出问题的是数据库。当数据库连接池被打满,或者主库抖动时,短链生成接口会连环报错。
我上线第二版时做了一个轻量降级方案:在 Redis 里维护一份“最近 1000 条短码映射”缓存,当数据库不可写时,生成接口直接返回 503,读接口优先查缓存,缓存没有再降级到查库。短链服务的核心诉求是“跳转要快”,读路径的缓存几乎必须做。
另外一个容易被忽视的坑是:如果生成代码里引入了一个依赖库的某个存在 bug 的版本,可能导致极端输入下产生相同的哈希结果。MurmurHash 的库实现大多稳定,但为了保险,生产环境至少加一个最基本的 smoke test——用几百个不同 URL 生成短码,断言没有重复,且相同 URL 生成结果一致。
最后聊几句个人体会
从“随手写个 302”到把 MurmurHash 融入短链生成,我在这个项目上完整走了一遍之后,体会最深的一点是:短链接这个需求看起来简单,但每个环节都有足够的深度去琢磨。选 MurmurHash 是因为它在“速度、分布、确定性、可控长度”这四个维度的综合表现最好,但在实际落地时,固定种子、7 位 Base62、唯一索引兜底、换盐重试这套组合拳,才是线上稳定运行的关键。
如果你现在也在做短链服务,我建议你第一步就把这几件事定下来:表结构里给 short_code 建唯一索引,生成逻辑固定种子并预留重试机制,短码统一用 7 位 Base62。这三件事可以帮你避开我在第一版里踩过的绝大多数坑。