AI摘要

为支撑营销长链缩短和点击追踪,核心是短码生成。权衡哈希、随机与自增后,选用全局自增ID转62进制:天然唯一、短码最短、无冲突重试。再用打乱字符表编码,防顺序遍历并隐藏业务量级,但不等于加密。发号用Redis INCR,配合AOF和启动时从数据库回填最大ID;热点短链走Redis缓存,点击异步落库。系统累计生成超30亿条短链。

做短链这件事,起因不是我觉得它有意思,是业务方找过来了。

公司在做大数据营销,一年上百场活动。运营发出去的落地页链接长这样:

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 亿条以上的短链。

一、真正需要反复权衡的地方只有一处

短链系统看着简单,但有一个决策定下来之后,存储结构、缓存策略、分表方案都得跟着它走:

短码怎么生成。

而且它属于"改起来最贵"的那一类。短链一旦发出去,就是印在物料上、发在短信里的,想换生成规则,历史数据全得作废。

所以动手写代码之前,我把能想到的方案列了一遍,大概分三个方向:

  1. 自增序列:用一个全局递增的 ID,转成 62 进制
  2. 哈希映射:长链算哈希,截一段当短码
  3. 随机生成:随机出一个短码,查库确认没被占再用

下面按我实际评估的顺序讲。

二、先说哈希:算了一笔账就放弃了

哈希方案的思路很直觉:长链接算一次哈希,取几位当短码,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 毫秒、穿透雪崩击穿具体怎么防),一篇讲点击数据的存储结构——那部分是另一个坑,数据量和查询模式跟短链主表完全不是一回事。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:30 亿条短链背后的架构选择:为什么我用自增 ID + 打乱字符表,而不用哈希
▶ 本文链接:https://www.huangleicole.com/backend-related/125.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏