Skip to content

短链服务:短码怎么生成只是开头 ​

用长链接的哈希截取 32 位当短码,100 万条就撞了 105 次;20 个并发请求为同一个长链接建短链,「先查再写」建出了 20 行;一个 POST 请求经过 301 跳转,到了目标地址变成 GET,请求体也没了。短链服务的难点不在 Base62 编码,而在这些契约。

短链服务看上去只有两个接口:把长链接换成短码,把短码跳回长链接。真正要回答的问题有五个:

  1. 短码怎么生成,能不能被猜出来,规模上去之后会不会撞;
  2. 同一个长链接建两次,给同一个短码还是两个;
  3. 跳转用哪个状态码;
  4. 哪些目标地址不许存;
  5. 短链过期或删除之后,短码能不能再分配给别人。

本文在 MySQL 8.4.11、JDK 21 上用实验回答前四个,第五个按规范和工程经验给出做法。实验不访问任何外网地址,见文末配套实验。

创建校验目标地址协议、userinfo、解析结果规范化长链接算 url_hashINSERT 随机 7 位短码uk_code、uk_hash返回短码冲突时读出已有的不安全的目标:直接拒绝,不入库跳转GET /s/{code}只接受 GET缓存未命中再查库302 + Location不被浏览器默认缓存点击事件异步写入,失败只丢统计不存在或已删除的短码返回 404 / 410,短码不回收复用
图 1 · 创建时先校验目标地址,再靠唯一约束保证同一个长链接只有一个短码;跳转时只读缓存或数据库,返回 302,点击统计异步记录,失败不影响跳转

一、短码怎么生成 ​

常见的做法有三种:

生成方式实测主要问题
数据库自增 ID 转 Base62ID 1000000000 到 1000000004 得到 15FTGg、15FTGh、15FTGi、15FTGj、15FTGk连续可猜:拿到一个短码,就能遍历别人的
长链接的哈希截取 32 位再编码10 万个不同链接无碰撞;100 万个碰撞 105 次;1,000 万个碰撞 11,823 次规模 ×10,碰撞约 ×100;碰撞后还要另想办法
随机 7 位 Base62100 万个无重复;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^长度) 足够小,重试就几乎不会发生。

二、同一个长链接,只建一个短码 ​

业务上通常希望同一个长链接复用同一个短码:同一张海报被运营上传两次,不应该拆成两份点击统计。最直观的写法是先查再写:

java
String code = findByUrlHash(hash);       // 没有才插入
if (code == null) {
    code = randomCode();
    insert(code, hash, url);
}

实测 20 个请求同时为同一个长链接建短链,表上没有唯一约束,结果表里有 20 行,20 个调用方拿到了 20 个不同的短码。每个请求查的时候都还没有别人插入,于是都去插入了。

改成让数据库保证:url_hash 加唯一约束,直接插入,冲突时读出已有的短码:

sql
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)
);
java
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,响应能不能被默认缓存。

301302307308POST 跟随后默认可缓存适合短链GET,丢请求体GET,丢请求体保留 POST保留 POST是否否是统计会丢推荐没必要没必要短链只做 GET 跳转:用 302,想减少回源可以显式加 Cache-Control,而不是依赖 301 的隐式缓存
图 2 · RFC 9110 规定 301、302 允许把 POST 改成 GET,307、308 不允许;301、308 默认可被缓存。实测 JDK HttpClient 与 curl 在 301、302 时都改成了 GET 并丢掉请求体

实测一个 POST 请求带着 3 字节的请求体访问短链,目标地址收到的是:

状态码JDK HttpClientcurl 8.18
301GET,请求体 0 字节GET,请求体 0 字节
302GET,请求体 0 字节GET,请求体 0 字节
307POST,请求体 3 字节POST,请求体 3 字节
308POST,请求体 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。两种都要记下访问日志,还有人在访问的过期短码,说明它还在被使用,值得运营关注。

六、怎么用 ​

  1. 短码随机生成,唯一索引兜底,冲突时换一个重试;按总量估算长度,不要用哈希截断当短码。
  2. 需要复用时,另存长链接的哈希并加唯一约束,插入冲突后读出已有的短码,不要先查再写;规范化规则先定好。
  3. 跳转只接受 GET,用 302;需要缓存就显式设置 Cache-Control;点击统计异步写,失败不影响跳转。
  4. 目标地址按解析结果校验,只允许 http、https,拒绝 userinfo,看不懂的一律拒绝;服务要请求目标地址时,在连接时再校验一次。
  5. 短码不回收,已删除返回 410,查不到返回 404。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权