短链接这件小事
一个长网址压成六位短码,访问时跳转,顺带计数。功能不大,但每个决定都能说清为什么。
这件事最容易想得简单:存一张映射表,取出来跳转,完了。真动手才会遇到几个必须回答的问题——用 301 还是 302、短码能不能自定义、内网地址要不要拦、失效了该返回什么、窄屏上那张表格怎么不塌。
这篇把每个选择连同它的代价一起写下来。
302,不是 301
永久重定向语义上更「正确」:这个短码永远指向那个网址。
但短链接的核心价值恰恰是可以改——目标换了、要临时下线、要设过期,都得能变。永久重定向会被浏览器和各层缓存记住,改完之后老访客还在往旧地址跳。
所以用临时重定向。代价是每次都要经过服务端,好处是每一次都能被计数、被改、被关掉。
短码的形状
^[A-Za-z0-9_-]{2,32}$
长度上限 32 是因为它要放进路径段,且不该长到能塞下别的信息。下界 2 是留出人工指定的空间。
短码可以由使用者填,也可以自动生成。问题在于短链接和站点功能共享同一层路径:如果短码随便填,就可能和某个功能页撞名,把页面顶掉。
所以有一份保留字清单,撞上就拒绝:
if (isReserved(code)) return 400; // 与系统路径同名
if (codeExists(code)) return 409; // 已经有人用了
这份清单不可能一次想全——每加一个功能就得回头补一条。所以它必须显眼地放在路由注册附近,而不是藏在工具函数里。
内网地址要拦
有个边界一开始被放过了:能不能缩短一个指向本机的地址。
技术上不构成服务端请求伪造——服务端从不主动访问目标,跳转由浏览器执行。所以它不是漏洞。
但它把内网地址挂到了公网上,等于给「探测内网」提供了一个固定入口,而且没有任何正当用途。
于是加了拦截,覆盖这几类:
localhost 及其子域
127.0.0.0/8 回环
10.0.0.0/8 私有
172.16.0.0/12 私有
192.168.0.0/16 私有
169.254.0.0/16 链路本地(云元数据服务在这一段)
::1 和 fc00::/7 IPv6 回环与私有
「不是漏洞」和「应该允许」是两件事。
三种失效,同一种回应
短码可能因为三种原因打不开:不存在、被停用、已过期。
对外一律返回同一张 404 页面,不区分原因。区分会泄露「这个短码曾经存在」——对公开的短链接服务,这个信息没有价值,只有害处。
而管理界面里三种状态是分开显示的,因为那里需要知道为什么。
几个校验
- 只接受
http与https。javascript:、data:、file:全部拒绝——协议白名单,不是黑名单。 - 长度上限 2000 字符。
- 有效期可选 1 / 7 / 30 / 90 / 365 天或永久,存绝对时间而不是「几天后」,避免修改配置影响存量。
- 点击计数在 302 写出之后累加,不阻塞跳转。
表格在窄屏会塌
管理界面是一张表:短码、目标、点击数、状态、操作。
宽屏正常,窄屏直接塌——表格按内容算最小宽度,而目标地址很长,于是其他列被压成一条线。第一次见到时以为路径列「消失」了,实际是被压到了零宽。
加宽容器只解决一半(720px → 1180px),另一半得放弃表格:窄屏改成卡片流,用命名区域重排:
@media (max-width: 900px) {
.link-table { grid-template-areas:
"short act"
"target target"
"state hits"; }
}
字段按重要性重排:短码和操作在最上,目标占满一行,状态与点击数在底部并排。
数据
一张表,八个字段:短码(主键)、目标、备注、点击数、启用位、过期时间、创建时间、最后访问时间。
没有独立的点击明细表。要统计趋势得另做,但那个需求现在不存在——加一张每天几十万行的表,只为看一条曲线,不划算。
线上的样子
写这篇的时候回头看了一眼线上,links 表是空的。
建过五条:两条测试用的(一条停用、一条设了当天过期),三条真的。跑了一段时间之后全部删掉了。
原因不是功能坏了,是我高估了它的用途。短链接最有价值的地方是「发到外面去」,而这个站几乎没有对外分发——没有社交账号、没有邮件列表、没有别人替我转。压短给谁看呢。
累计点击是个位数的时候就该停手了,而不是继续加功能。
顺带一个设计上的后果:删除是硬删,所以没有「这条短码以前指向过什么」的记录。如果哪天有人拿着旧短码问「它原来指向哪」,我答不上来。
对一个自用的工具,这个代价可以接受;对别人也在用的服务,这就得改成软删除——记下「停用时间」,跳转时返回一个「该链接已失效」的页面,而不是和别人新买的短码撞车。
一次跳转的代价
短链接的价值在于「短」,代价在于多一跳。
301 的话这一跳只付一次,之后由浏览器缓存;302 每次都要付。这里用 302,所以每次点击都要经过:
- 短码查表(主键命中)
- 过期与启用位判断
- 写
302响应头 - 累加点击数(在写出响应之后,不阻塞跳转)
第 4 步的顺序是有意的。如果先计数再跳转,数据库慢的时候(比如恰好在做 checkpoint),用户就会多等那几毫秒;而计数失败也不该让跳转失败。
实测同机请求:短码不存在的路径 2 ms(走了查表就返回),首页 4 ms,文章页 12 ms。也就是说查表这一步本身不到 2 毫秒——真正慢的是文章页的渲染。
真正跳转一次的成本要算上浏览器端:一次额外的 302 往返(局域网内可以忽略,4G 下是 100~200 ms),加上浏览器重新发起目标请求的时间。
所以短链接对「省时间」没有帮助,它省的是字符。如果目标地址本来就不长,用它只是多一跳。
对一个日访问三位数的站,这一跳完全无所谓。但如果你要做的是给别人用的短链接服务,这一跳就是硬成本:百万次点击 = 百万次数据库读写。那时候计数得改成「内存累加 + 定时落盘」,用可能丢失的精度换吞吐。
没做的几件事
没做点击明细。 只有总数,看不到「谁在什么时候点的」。真要做就是另一张表加另一套隐私决策,不如先空着。
没做短码回收。 删掉的短码不再分配,宁可浪费也不让旧链接跳到别人那里去。
没做独立短域名。 短链接的常识是用尽可能短的域,这里复用主域名加前缀——代价是短得有限,好处是不用再管一张证书、一个备案、一套解析。
最后一条是这类小功能共同的取舍:能用现有设施就不新开一套,即使它在某个维度上不是最优。