短链接系统怎样设计?短码怎样生成,跳转用 301 还是 302,访问量怎样统计?
短链接服务把长网址存进数据库,返回一个短码,访问短码时查出长网址并返回重定向。短码常用两种生成方式:用自增 ID 转成 62 进制,不会冲突但可以被顺序猜到;对长网址做哈希再截短,同一网址得到同一短码,但要检测冲突并重试。跳转用 301 时浏览器可以缓存,后续访问不再经过服务器,统计不到;需要统计或可能修改目标时用 302 或 307。统计在跳转时异步记一条访问日志,再离线聚合。
简答
短信里放不下一个几百字符的商品链接,就把它换成
https://s.example/aZ3k9。用户点开时,服务器拿aZ3k9查出原来的长网址,返回一个重定向,浏览器自动跳过去。所以核心就两步:生成短码并存下“短码到长网址”的映射;访问时查表并跳转。短码常用两种生成方式。一种是数据库给每条记录一个自增 ID,把 ID 转成 62 进制,用 0-9、a-z、A-Z 这 62 个字符表示,7 位就能表示三万多亿个链接,而且不会冲突。缺点是短码按顺序递增,别人可以挨个猜出所有链接。另一种是对长网址做哈希,取前几位当短码,同一个网址总是得到同一个短码,但截短以后不同网址可能撞上,所以写入前要检查,冲突了就加盐重新算。
跳转用哪个状态码要看需求。301 是永久重定向,浏览器可以把它缓存下来,下次直接跳,不再经过短链服务器,访问就统计不到了,目标网址改了也不会生效。所以要统计点击、可能修改目标的场景,一般用 302 或 307 这类临时重定向。
统计不要拖慢跳转。跳转时只把短码、时间、来源、设备这些信息写进消息队列或日志,立刻返回重定向,再由后台任务异步聚合成每天的访问量。还要注意安全:短链会掩盖真实地址,所以要检查目标网址是不是恶意站点,并对生成接口限流。
整体流程
生成短链时,前端把长网址提交给服务端,服务端校验它是合法的 http 或 https 地址,生成短码,写入一张表:短码、长网址、创建人、创建时间、过期时间。返回给用户 https://s.example/ 加上短码。
访问短链时,服务端从路径里取出短码,先查缓存,再查数据库。查到了就返回重定向响应,Location 头里写长网址,浏览器收到后自动请求这个地址。查不到或者已经过期,就返回 404 页面。
读远远多于写,一个短链生成一次,可能被点几百万次。所以“短码到长网址”这层映射适合放进 Redis 这类缓存,跳转接口基本只读缓存。
自增 ID 怎样转成 62 进制
62 进制的写法和十进制转二进制一样:不断除以 62,余数对应字符表里的一个字符,倒过来排就是结果。下面的 encode 和 decode 互为逆运算:
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function encode(id) {
if (id === 0) return ALPHABET[0];
let code = '';
while (id > 0) {
code = ALPHABET[id % 62] + code;
id = Math.floor(id / 62);
}
return code;
}
function decode(code) {
let id = 0;
for (const ch of code) id = id * 62 + ALPHABET.indexOf(ch);
return id;
}
console.log(encode(61)); // Z
console.log(encode(62)); // 10
console.log(encode(100000000)); // 6LAze
console.log(decode('6LAze')); // 100000000
console.log(62 ** 7); // 3521614606208
console.log(encode(62 ** 7 - 1)); // ZZZZZZZ
61 是最后一个字符 Z,62 就进位成 10,和十进制里 9 之后是 10 一样。一亿只要 5 位,7 位能表示 62 ** 7 也就是三万五千多亿个 ID,最大的 7 位短码是 ZZZZZZZ。
这种方式为什么不会冲突?因为数据库的自增 ID 本身不重复,转换又是一一对应的。可是它有两个问题。一是可以被猜到:拿到 6LAze,把它加一就是下一个用户的链接,内部分享的文档链接就可能被人遍历出来。二是短码长度会随数量增长,前几个链接只有一两位。
常见的改法是先把 ID 打乱再编码,比如用一个可逆的混淆函数把连续的 ID 映射成看起来随机的数字,或者用发号器每次分配一段 ID,再从一个较大的起点开始,让短码长度一开始就固定。ID 超过 Number.MAX_SAFE_INTEGER,也就是 2⁵³ − 1 时,JavaScript 的普通数字就无法精确表示了,要改用 BigInt,不过 7 位短码对应的最大值远小于这个上限。
哈希截短怎样处理冲突
另一种思路是不依赖数据库 ID,直接对长网址算哈希,取前 7 个字符作为短码。好处是同一个长网址总是得到同一个短码,天然去重;分布式部署时也不需要一个统一的发号器。
问题在于截短。完整的哈希值几乎不会重复,可只取前几位,两个不同的网址就可能得到同一个短码。所以写入前必须检查:短码已存在且对应的是另一个网址,就在网址后面加一个盐重新算,直到不冲突。下面为了让冲突容易出现,只取 1 位短码演示:
function hash(str) {
// FNV-1a 32 位哈希,只用于演示
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const store = new Map(); // 短码 → 长网址
function shorten(url, length) {
for (let salt = 0; salt < 10; salt++) {
let h = hash(salt === 0 ? url : url + '#' + salt);
let code = '';
for (let i = 0; i < length; i++) {
code += ALPHABET[h % 62];
h = Math.floor(h / 62);
}
const existing = store.get(code);
if (existing === url) return code + '(已存在)';
if (existing === undefined) {
store.set(code, url);
return code + (salt > 0 ? `(冲突后重试 ${salt} 次)` : '');
}
}
throw new Error('重试次数用完');
}
console.log(shorten('https://a.example/1', 1)); // B
console.log(shorten('https://a.example/1', 1)); // B(已存在)
const results = [];
for (let i = 2; i <= 40; i++) results.push(shorten(`https://a.example/${i}`, 1));
console.log(results.filter((r) => r.includes('冲突')).length > 0 ? '出现过冲突' : '没有冲突'); // 出现过冲突
console.log(store.size); // 40
同一个网址第二次提交,算出同一个短码,发现表里存的就是它自己,直接返回,不新建。40 个网址挤进 62 个位置,一定有撞车的,撞上的那些加盐重算以后换了位置,最后 40 个网址都存了进去。
真实系统用 7 位短码,冲突概率低很多,但量大以后仍然会发生,所以检查不能省。在数据库里,检查和写入要用短码的唯一约束一起完成,靠“先查再写”两步,两个请求同时写入同一个短码时还是会冲突。
两种方式怎么选?自增 ID 简单、不冲突,适合大多数场景,再做一层混淆避免被猜;哈希适合要求“同一网址只有一个短码”的场景。也可以直接用随机数生成短码,同样要靠唯一约束处理冲突。
跳转用 301 还是 302
301 和 302 都会让浏览器自动请求 Location 里的地址,区别在“永久”和“临时”。MDN 说,永久重定向意味着原来的网址不应该再使用,搜索引擎会把链接权重转给新地址;临时重定向则不会。HTTP 规范 RFC 9110 还写明,301 响应可以被启发式缓存,也就是没有缓存头时浏览器也可以自己决定缓存它;302 没有这一条。
这对短链意味着什么?用 301 时,浏览器缓存了跳转结果,同一个用户再点这个短链,可能直接跳到长网址,请求根本不到短链服务器,这次点击就统计不到。目标网址后来改了,或者链接被封禁了,缓存过的浏览器也不知道。所以需要统计、需要能改目标的短链服务,一般用 302,并加上 Cache-Control: private, max-age=0 或 no-store 之类的缓存头,明确不让缓存。
还有一个细节:MDN 提到,按 Fetch 标准,浏览器对 POST 请求收到 301 或 302 时会改用 GET 去请求新地址。短链基本都是 GET 请求,没有影响;如果需要保留请求方法,临时的用 307,永久的用 308。
访问量怎样统计
最直接的做法是每次跳转在数据库里给计数加一,可是热门链接每秒可能有上万次访问,同一行反复更新会成为瓶颈,还会拖慢跳转。
所以跳转接口只做一件事:查到长网址后,把一条访问记录,包括短码、时间、Referer、User-Agent、IP 推算的地区,发到消息队列或写进日志,然后立刻返回重定向。后台任务消费这些记录,按短码和日期聚合成访问量、来源、设备分布,写进统计表,统计页面读的是聚合后的结果。
需要实时显示的点击数,可以先在 Redis 里用计数器累加,定期写回数据库。
常见追问
怎样防止短链被拿来传播钓鱼网站?
生成时校验协议只允许
http和https,把目标网址和恶意网址库比对;访问时如果发现目标后来被标记为危险,就不跳转,改为显示提示页。生成接口要按用户和 IP 限流,防止有人批量生成。
用户想自定义短码怎么办?
自定义短码和自动生成的放在同一张表里,同样靠唯一约束防止重复。要限制长度和字符集,并保留一批词,比如
admin、api,避免和系统路由冲突。自动生成的短码长度最好和常见的自定义短码错开,减少互相占用。
短链要不要支持过期?
一般要支持。表里存过期时间,跳转时检查,过期就返回提示页。缓存的过期时间不要超过链接本身的过期时间,否则链接过期后还能从缓存里跳转。