AI摘要
做短链这件事,起因不是我觉得它有意思,是业务方找过来了。
公司在做大数据营销,一年上百场活动。运营发出去的落地页链接长这样:
https://campaign.xxx.com/activity/2024q3/summer-sale/index.html?channel=wechat&utm_source=xxx&utm_medium=xxx&utm_campaign=xxx&trace_id=xxx两百多个字符。发短信,一条 70 字,光链接就吃掉三条;印在海报上,二维码密得扫不出来;投信息流广告,用户看一眼就不想点。
比长度更麻烦的是数据。链接是运营手动复制粘贴发出去的,用户点没点、从哪个渠道点的、什么设备点的,我们一概拿不到。投了多少钱、带来多少转化,算不清。
所以才有了这个短链系统。需求说起来很简单:给一个长链接,返回一个短链接;用户访问短链接,跳转到原地址;顺带把点击行为记下来。
我负责整个系统从设计到上线。这套东西后来累计生成了 30 亿条以上的短链。
一、真正需要反复权衡的地方只有一处
短链系统看着简单,但有一个决策定下来之后,存储结构、缓存策略、分表方案都得跟着它走:
短码怎么生成。
而且它属于"改起来最贵"的那一类。短链一旦发出去,就是印在物料上、发在短信里的,想换生成规则,历史数据全得作废。
所以动手写代码之前,我把能想到的方案列了一遍,大概分三个方向:
- 自增序列:用一个全局递增的 ID,转成 62 进制
- 哈希映射:长链算哈希,截一段当短码
- 随机生成:随机出一个短码,查库确认没被占再用
下面按我实际评估的顺序讲。
二、先说哈希:算了一笔账就放弃了
哈希方案的思路很直觉:长链接算一次哈希,取几位当短码,MD5、MurmurHash 都是这一类。
它有个很吸引人的性质:确定性。同一个长链接算出来的哈希永远一样,所以同一条链接重复提交不会产生两个短码,天然幂等,不用额外做去重。
但我算了一下碰撞概率,就放弃了这个方向。
以 MurmurHash3 的 32 位输出为例,空间是 2 的 32 次方,约 42.9 亿。碰撞概率用生日悖论估:
P ≈ 1 - e^(-n² / 2N)
n = 已生成数量
N = 哈希空间(42.9 亿)代进去:
| 已生成数量 | 至少出现一次碰撞的概率 |
|---|---|
| 1 万 | 约 1.2% |
| 5 万 | 约 25% |
| 10 万 | 约 69% |
| 100 万 | 接近 100% |
10 万条短链,对一个营销系统来说是很小的量,但这个量级下碰撞已经几乎必然发生。
所以选哈希方案,就必须配套写一套"冲突检测 + 加盐重试"的逻辑:算出来的短码查一下库,被占了就换个盐重算,再查、再算。而且要处理一个很容易写错的细节——"重复提交"和"真碰撞"是两件事:
- 短码已存在,但对应的长链接就是当前这条 → 重复提交,应该直接复用,不能当冲突处理
- 短码已存在,但对应的长链接是另一条 → 真碰撞,需要换码
不区分的话,重复提交的链接会被反复分配新短码,白白浪费空间,统计数据也被拆散了。
除了复杂度,还有两处我不太满意:
一是长度。 42.9 亿的空间转成 Base62 是 6 位,而且固定 6 位。自增方案从 1 开始,起步只要 1 位、2 位。短链这个东西,"短"就是核心价值,能少一位是一位。
二是空间是死的。 42.9 亿这个上限不会变,但数据会一直涨。占用率越高、重试越频繁,而这个问题没有"优化"的余地,只能靠归档和分表去延缓。
MD5 比 MurmurHash 更不合适:128 位输出转 Base62 是 22 位,比原链接短不了多少;而且它是密码学哈希,设计目标是抗碰撞、不可逆,这两条短链场景都不需要——短码本来就是公开的,要防的也不是篡改。拿密码学哈希做一件纯映射的事,属于用错工具。
三、随机生成:撞了再试也行,但没必要
随机方案的逻辑最简单:随机一个 6 位短码,查库,没被占就用,被占了重新随机。
问题跟哈希方案类似——还是要查库、还是要处理碰撞。而且它比哈希更差的地方在于:连确定性都没有。同一条长链接提交两次会拿到两个不同短码,指向同一个页面。营销场景里这是常态(运营给不同渠道做同一条落地页),统计数据会被拆成好几份,后期得手动合并。
放弃。
四、自增序列:简单,而且是真的唯一
最后我选的是自增方案:用一个全局递增的 ID,转成 62 进制。
好处非常直接:
第一,绝对唯一。 发号器发出来的号不会重复,编码又是一一映射,所以短码从根上就不会撞。不需要查库、不需要重试、不需要处理"重复提交 vs 真碰撞"这类边界。整套生成逻辑就是"取号 → 编码"两步。
第二,短码最短。 ID 从 1 开始,编码结果自然从 1 位、2 位往上长。前几万条链接的短码只有 3 位、4 位,比哈希方案的固定 6 位短一截。全站短码的平均长度也跟着低不少。
第三,性能好。 一次发号调用加一次纯内存的进制转换,没有数据库查询,没有循环重试。
写到这里你可能会问:那 5 位用完了怎么办?
五、位数递增不需要写特殊逻辑
这是个我一开始也以为要处理的问题,动手之后发现根本不用写。
62 进制的编码空间:
62^4 = 14,776,336 约 1478 万
62^5 = 916,252,832 约 9.16 亿
62^6 = 56,800,235,584 约 568 亿ID 连续递增,编码又是标准的进位制转换,所以长度会自然从 1 位涨到 5 位;当 ID 越过 916252832 之后,编码结果自然是 6 位。
...
916252830 → "zzzzY" (5 位)
916252831 → "zzzzz" (5 位的最大值)
916252832 → "100000" (自然变成 6 位)不需要 if (id > 916252832) 这种判断,也不用做什么切换。进位制转换本身就负责了这件事。
不过"自动升级"有个前提要意识到:位数涨上去就回不来了。系统里会长期同时存在 5 位和 6 位的短码,所以建表时 code 字段的长度要留余量,解析侧也不能假设固定长度。这一点在设计阶段就定下来了。
六、但自增有个软肋:顺着就能爬
自增方案唯一让我不安的地方是可枚举性。
标准 Base62 的字符表是 0-9a-zA-Z 顺序排列,那么:
ID = 1 → https://s.xxx.com/1
ID = 2 → https://s.xxx.com/2
ID = 3 → https://s.xxx.com/3
...
ID = 62 → https://s.xxx.com/10任何人把最后那串字符从 1 开始往上加,就能把我们对外发过的链接捞一遍。
捞到的不只是链接,而是完整的活动清单:活动主题、上线顺序、投放节奏。做营销的都知道,竞对拿到你完整的活动列表意味着什么。
还有一层更细的:连续 ID 会暴露业务量级。看到一个短码是 5 位、另一个是 6 位,就能推算我们累计发了多少条;隔一段时间再看一次,还能算出增长速度。这些信息我们并不想公开。
所以问题不是"有没有人会这么干",而是这个设计把数据摆在了不该摆的位置上。
七、我的处理:换一张打乱的字符表
解决方式比我预想的简单:不用标准的 Base62 字符表,换一张打乱的。
标准表是 62 个字符按顺序排:
private static final String BASE62 =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";我们用的是一张自定义的乱序表(下面只列一部分示意,完整的表是 62 个不重复字符,实际值放在配置中心):
private static final String SHUFFLED =
"qA7mX2pL9bK4tR8wY3nZ6cV1dF5gH0jS ..."; // 共 62 位编码的代码不用动,只是查表的对象变了:
public static String encode(long id) {
if (id == 0) {
return String.valueOf(SHUFFLED.charAt(0));
}
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(SHUFFLED.charAt((int) (id % 62)));
id /= 62;
}
return sb.reverse().toString();
}效果:ID 1 和 ID 2 是连续的两个值,编码出来是两个毫不相关的短码。
ID = 1 → https://s.xxx.com/q
ID = 2 → https://s.xxx.com/A
ID = 3 → https://s.xxx.com/7为什么打乱不影响唯一性
这一点我一开始也绕了一下,想清楚其实很简单:
两张表都是 62 个字符的一个排列。 标准表是一个排列,乱序表也是一个排列,区别只是同一个下标取到的字符不同。编码算法本身没变,还是那个取余、相除、翻转的循环。
既然"ID → 下标序列"是一一映射,"下标序列 → 字符串"也是一一映射(表里没有重复字符),那么复合出来的"ID → 短码"仍然是一一映射。
所以短码仍然全局唯一,一个 ID 对应一个短码,不会有两个 ID 撞到同一个短码。 唯一性来自发号器,字符表只决定短码"看起来长什么样"。
顺带解决了业务量级的暴露
除了不能顺着遍历,还有一点是我后来才意识到的收益:
不打乱的话,短码本身就是一条业务曲线。看到 5 位和 6 位的分界点就知道累计量级,隔月再看一次能算出增速。
打乱之后这条曲线没了——从短码上看不出顺序信息,也就推算不出规模和增长速度。
八、诚实说:打乱字符表能防什么,不能防什么
这里得说清楚,免得把它当安全措施:
能防的:顺着 1、2、3 递增去遍历。这是最省事、最常见的爬法,换成乱序表之后就不成立了——攻击者拿到的短码之间没有任何规律可循。
不能防的:有决心的暴力遍历。5 位空间是 9.16 亿个组合,理论上扫一遍还是能捞到东西。但那需要发起巨量请求,正常系统上的限流和异常流量监控会先把它拦住。
所以这套东西的本质是"提高门槛",不是加密。 真正的安全边界要靠限流、鉴权、访问日志和风控去做,短码生成本身不承担这个职责。把它当盾牌是错的,当门槛是合适的。
还有一个前提:字符表本身必须保密。
如果这张表被提交进了 Git 仓库、或者写进了前端代码,可枚举性立刻原样回来——别人拿到表,反推 ID 是分分钟的事。所以我们把它放在配置中心,代码里只有占位,不同环境用不同的表。
九、发号器用 Redis 还是 MySQL
自增方案里,发号器绕不开,主要两个选择。
MySQL 自增主键:不引入新组件,天然持久,重启也不丢号。但每次生成都要写一次数据库,吞吐不理想。
Redis 的 INCR:纯内存操作,性能好得多,而且是原子操作,多实例并发取号不会重号。缺点是内存数据库要考虑持久化——如果 Redis 挂了又没有持久化兜底,计数器会回退,重新发出去的号就可能和数据库里已有的短码撞上。
我们用的是 Redis INCR,同时做了两件事兜住这个问题:一是开 AOF 持久化,二是服务启动时从数据库捞一次当前最大 ID,把计数器回填上去。这样即使 Redis 是从零起来的,也不会发重号。
回填这一步是设计阶段就想到的,不是出过事才补的。它的成本很低——一次查询,但省掉的是"短链指向了别人的页面"这种没法收拾的问题。
十、完整的链路
生成:
长链接
→ 查库判断是否已存在(同一长链接复用已有短码)
→ 不存在则向 Redis INCR 取一个号
→ 用打乱的字符表做 62 进制编码,得到短码
→ 落库,同时写 Redis 缓存
→ 返回 "https://s.xxx.com/" + 短码跳转:
用户访问短链
→ 先查 Redis(热点短链缓存在这里)
→ 命中直接 302 跳转
→ 未命中回查数据库,同时异步写回缓存
→ 记录一次点击:时间、来源渠道、设备信息落地页跳转对响应时间很敏感,用户点一下要等一两秒,体验基本就废了。所以热点短链走 Redis 缓存,把响应压到 100 毫秒以内,数据库只做兜底。
点击数据异步落库,不阻塞跳转。这部分后来成了营销效果分析的基础——哪个渠道点击多、什么时间段活跃,都从这儿看。
十一、跑到 30 亿之后会遇到什么
自增方案不是没有边界,只是它的边界和哈希方案不一样。
第一位是 ID 长度。 ID 用什么类型存有讲究:int 上限约 21 亿,5 位空间(9.16 亿)跑到一半就到了;换 long 实际上等于没有上限。我们表里用 bigint,短码是字符串,所以位数增长本身不构成问题。真正要留意的是解析侧不能假设固定长度——系统里会长期并存 5 位和 6 位。
第二位是号段的连续性。 自增号是连续的,意味着短码的"生成顺序"在数据库里可还原。对外有字符表挡着,不需要隐藏;但如果以后做多机房部署,号段分配就要重新设计,不能靠单点 Redis。
第三位是归档。 短链有生命周期,一次营销活动结束之后对应的短链基本就没人访问了。我们按创建时间和访问情况把冷数据迁到历史表,主表保持可控体积。这一步跟生成逻辑无关,但直接影响查询和索引维护的成本。
这三条里,只有第二条是我当时没完全想透的。前两条在设计阶段就考虑进去了。
十二、如果重来
会加上号段模式。 现在是每生成一条短链就调一次 Redis 的 INCR。改成一次取 1000 个号放本地内存、用完再取,Redis 压力能降两个数量级。代价是服务重启会浪费手里没用完的号(出现号段空洞),但短码本来就是无序的,空洞无所谓。这笔账很划算,当时我优先选了实现最简单的版本。
会让字符表支持轮换。 现在字符表是配置项,换表要处理历史数据——换表之后同一个 ID 编出来的短码就不一样了,老短链会失效。要做轮换,得在短码里留一位标识版本,或者维护多张表的映射。当时觉得没必要,但如果字符表真的泄露,没有轮换机制会很难受。
归档会做得更早。 我是做到中途才补上归档的,前面那段时间主表的增长曲线看着有点吓人。数据治理这种事最好在设计阶段就排进去,而不是等它长起来再处理。
最后
回头看,这个项目里我花时间最多的不是写代码,是在动手之前把几个方案都算了一遍。
哈希方案的碰撞概率、自增方案的位数边界、字符表打乱之后的映射关系——这些东西都不复杂,但必须在写第一行代码之前想清楚。因为它们决定了后面所有的存储结构和接口设计,改起来很贵。
选一个技术方案,知道它为什么行,和知道它为什么不行,是两件事。前者让你敢用,后者让你知道什么时候该换。
后面如果有精力,我想再写两篇:一篇讲这套短链系统的缓存设计(热点短链怎么压到 100 毫秒、穿透雪崩击穿具体怎么防),一篇讲点击数据的存储结构——那部分是另一个坑,数据量和查询模式跟短链主表完全不是一回事。