短链服务:短码怎么生成只是开头
用长链接的哈希截取 32 位当短码,100 万条就撞了 105 次;20 个并发请求为同一个长链接建短链,「先查再写」建出了 20 行;一个 POST 请求经过 301 跳转,到了目标地址变成 GET,请求体也没了。短链服务的难点不在 Base62 编码,而在这些契约。
短链服务看上去只有两个接口:把长链接换成短码,把短码跳回长链接。真正要回答的问题有五个:
- 短码怎么生成,能不能被猜出来,规模上去之后会不会撞;
- 同一个长链接建两次,给同一个短码还是两个;
- 跳转用哪个状态码;
- 哪些目标地址不许存;
- 短链过期或删除之后,短码能不能再分配给别人。
本文在 MySQL 8.4.11、JDK 21 上用实验回答前四个,第五个按规范和工程经验给出做法。实验不访问任何外网地址,见文末配套实验。
一、短码怎么生成
常见的做法有三种:
| 生成方式 | 实测 | 主要问题 |
|---|---|---|
| 数据库自增 ID 转 Base62 | ID 1000000000 到 1000000004 得到 15FTGg、15FTGh、15FTGi、15FTGj、15FTGk | 连续可猜:拿到一个短码,就能遍历别人的 |
| 长链接的哈希截取 32 位再编码 | 10 万个不同链接无碰撞;100 万个碰撞 105 次;1,000 万个碰撞 11,823 次 | 规模 ×10,碰撞约 ×100;碰撞后还要另想办法 |
| 随机 7 位 Base62 | 100 万个无重复;1,000 万个重复 11 次 | 需要唯一索引兜底,冲突时换一个重试 |
哈希截断的碰撞数和生日界的估算几乎一样:n² / (2 × 2³²),100 万时约 116,1,000 万时约 11,642。32 位只有 43 亿个取值,远小于直觉。随机 7 位 Base62 的空间是 62⁷,约 3.5 万亿,1,000 万个码的碰撞估算是 14 个,实测 11 个。碰撞的概率低但不是零,所以短码列必须有唯一索引,插入冲突时换一个码重试。
所以推荐的做法是:短码用随机生成,唯一索引兜底;需要「同一个长链接复用短码」时,另存一列长链接的哈希,而不是让短码从哈希推出来。 短码是否可猜,不是编码方式的问题,而是短码之间有没有规律。自增 ID 换成任何进制都是连续的,只能靠混淆、加盐或者干脆不用。
短码长度按规模定:每天新增多少、保留多久,算出总量,再让 总量² / (2 × 62^长度) 足够小,重试就几乎不会发生。
二、同一个长链接,只建一个短码
业务上通常希望同一个长链接复用同一个短码:同一张海报被运营上传两次,不应该拆成两份点击统计。最直观的写法是先查再写:
String code = findByUrlHash(hash); // 没有才插入
if (code == null) {
code = randomCode();
insert(code, hash, url);
}实测 20 个请求同时为同一个长链接建短链,表上没有唯一约束,结果表里有 20 行,20 个调用方拿到了 20 个不同的短码。每个请求查的时候都还没有别人插入,于是都去插入了。
改成让数据库保证:url_hash 加唯一约束,直接插入,冲突时读出已有的短码:
CREATE TABLE short_link (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(16) NOT NULL,
url_hash BINARY(32) NOT NULL, -- 规范化后的长链接的 SHA-256
url VARCHAR(2048) NOT NULL,
UNIQUE KEY uk_code (code),
UNIQUE KEY uk_hash (url_hash)
);try {
insert(randomCode(), hash, url);
} catch (SQLIntegrityConstraintViolationException e) {
if (!e.getMessage().contains("uk_hash")) {
// 撞的是短码:换一个再插
}
}
return findByUrlHash(hash); // 无论谁插入成功,都读同一行同样 20 个并发请求,表里只有 1 行,所有调用方拿到同一个短码。这和 分布式锁与并发控制实战 里「唯一索引代替查重锁」是同一个道理:判断重复这件事交给数据库的约束,而不是交给应用里的一次查询。
哪些链接算「同一个」,是业务要先定的规则:utm_source 不同的两个链接要不要合并,大小写、末尾斜线、参数顺序算不算不同。规范化规则定下来之后就不要轻易改,改了之后,旧数据的 url_hash 就和新算出来的对不上了。
三、跳转用哪个状态码
跳转有四个状态码可选。它们的差别有两处:跟随跳转时能不能把 POST 改成 GET,响应能不能被默认缓存。
实测一个 POST 请求带着 3 字节的请求体访问短链,目标地址收到的是:
| 状态码 | JDK HttpClient | curl 8.18 |
|---|---|---|
| 301 | GET,请求体 0 字节 | GET,请求体 0 字节 |
| 302 | GET,请求体 0 字节 | GET,请求体 0 字节 |
| 307 | POST,请求体 3 字节 | POST,请求体 3 字节 |
| 308 | POST,请求体 3 字节 | POST,请求体 3 字节 |
RFC 9110 允许 301、302 把 POST 改成 GET,不允许 307、308 这样做;另外,301 和 308 默认可以被缓存,302 和 307 不行。
对短链来说:
- 只接受 GET。 短链被贴在网页、短信、二维码里,点击就是 GET。需要转发 POST 的场景不该用短链。
- 用 302。 301 被浏览器缓存之后,同一个用户再点这个短链,浏览器直接去目标地址,不再经过短链服务:点击统计少算,短链改了目标地址也不生效。302 每次都会回到服务端。
- 想减少回源,就显式加
Cache-Control,自己决定缓存多久,而不是依赖 301 由客户端决定的隐式缓存。
点击统计放到跳转之后异步写:跳转只依赖「短码到长链接」的读取,统计写入失败只丢一次计数,不能让跳转变慢或失败。
四、哪些目标地址不许存
短链会被拿来隐藏真实地址,所以「目标地址校验」是短链服务自己的责任,不能留给点击者。实测校验函数对这些地址的判断:
| 目标地址 | 结果 | 依据 |
|---|---|---|
https://example.com/a?b=1 | 放行 | |
javascript:alert(1)、ftp://example.com/file | 拒绝 | 只允许 http、https |
data:text/html,<script>… | 拒绝 | 不是合法 URI |
http://example.com@127.0.0.1/ | 拒绝 | 包含 userinfo:看起来是 example.com,实际去 127.0.0.1 |
http://127.0.0.1/admin、http://localhost/admin、http://[::1]/ | 拒绝 | 解析到回环地址 |
http://169.254.169.254/latest/meta-data/ | 拒绝 | 链路本地地址,也是常见云平台的元数据地址 |
http://internal.example/(解析到 10.1.2.3) | 拒绝 | 按解析结果判断,而不是按名字 |
http://2130706433/ | 拒绝 | Java 把这个十进制写法解析为 127.0.0.1 |
http://0x7f000001/ | 拒绝 | Java 不认十六进制写法,解析失败,按拒绝处理 |
最后两行说明了一件事:同一个地址有很多种写法,校验方和浏览器的解析规则不一定一致。浏览器会把 0x7f000001 当成 127.0.0.1,Java 不会。所以解析失败或看不懂的地址一律拒绝,而不是放行。
校验的依据是「解析出来的 IP」,不是主机名,所以要把内网地址段列全:回环、私有网段(10/8、172.16/12、192.168/16)、链路本地、IPv6 的唯一本地地址。如果服务还会去请求目标地址(生成预览、做安全扫描),它还面临服务端请求伪造:校验时和真正请求时的 DNS 解析结果可能不同,要在发请求的那一刻按实际连接的 IP 再判断一次。实验里的解析器是桩,只验证了判断规则,没有覆盖这一点。
地址本身合法,不代表内容安全。钓鱼、恶意软件这类滥用靠的是别的手段:创建接口按用户和 IP 限流、目标域名黑名单、可疑链接先展示中间页、提供举报入口,以及对已经被举报的短码立即停用。这些是运营手段,没有放进实验。
五、过期与删除
短码不要回收复用。短链一旦被打印在海报上、写进了别人的文章里,就会在你无法控制的地方长期存在。把一个过期的短码分配给新的长链接,旧海报的读者会被带到一个完全不相干的地址,这可能是事故,也可能被人利用。
过期和删除的短码,按状态返回:确定已经删除的返回 410,查不到的返回 404。两种都要记下访问日志,还有人在访问的过期短码,说明它还在被使用,值得运营关注。
六、怎么用
- 短码随机生成,唯一索引兜底,冲突时换一个重试;按总量估算长度,不要用哈希截断当短码。
- 需要复用时,另存长链接的哈希并加唯一约束,插入冲突后读出已有的短码,不要先查再写;规范化规则先定好。
- 跳转只接受 GET,用 302;需要缓存就显式设置
Cache-Control;点击统计异步写,失败不影响跳转。 - 目标地址按解析结果校验,只允许
http、https,拒绝 userinfo,看不懂的一律拒绝;服务要请求目标地址时,在连接时再校验一次。 - 短码不回收,已删除返回 410,查不到返回 404。
配套实验
- codesphere-labs/system-design/short-url-service:三种短码生成方式的可枚举性与碰撞、20 个并发请求创建同一个长链接、四种跳转状态码下 JDK HttpClient 与 curl 的请求方法、目标地址校验(验证记录)
参考资料
- RFC 9110:HTTP Semantics,15.4 Redirection 3xx(301、302 允许把 POST 改成 GET,307、308 不允许;301、308 默认可缓存)
- RFC 3986:URI Generic Syntax,3.2.1 User Information
- OWASP:Server-Side Request Forgery Prevention Cheat Sheet
- JDK 21 API:HttpClient.Redirect