短链系统
把一个很长的 URL 变成一个很短的码,用户访问短链时跳回原地址。表面看是个字符串压缩问题,实际是个分布式映射问题,短码要全局唯一,跳转要扛住高并发。本章只回答这两个核心点。
1. 短链的本质是映射
短链做的事不是压缩 URL,是建立「短码到长 URL」的一对一映射。长 URL 存进数据库,拿到一个自增或生成的唯一 ID,把这个 ID 编码成短码返回。用户访问短码,解码出 ID,查出长 URL,302 跳转。
长 URL ──存库──▶ 唯一 ID ──编码──▶ 短码
短码 ──解码──▶ 唯一 ID ──查库──▶ 长 URL ──302──▶ 跳转所以短链系统的两个核心技术点,一个是短码怎么生成得又快又不重复,另一个是跳转这种读多写少的请求怎么扛住。
2. 短码生成,哈希和发号两条路
短码要短,一般用 62 进制,字符集是大小写字母加数字,一个 6 位的 62 进制数能表示约 568 亿个 ID(62⁶),足够用。
生成方式有两种。
第一种是哈希。把长 URL 做个 MD5 或 SHA 哈希,取前几位当短码。这条路有个绕不开的问题,哈希会碰撞,两个不同的长 URL 可能算出同一个短码。碰撞了要么加盐重算,要么用布隆过滤器先判重。哈希方案的好处是不依赖任何中心节点,坏处是碰撞处理让逻辑变脏。
第二种是发号。不关心长 URL 内容,直接用一个全局唯一的自增 ID,把它编码成短码。ID 唯一,短码就唯一,不存在碰撞。发号方案的难点回到了分布式 ID 生成,单库自增扛不住高并发,要用雪花算法或号段模式。
// 发号方案,ID 编码成 62 进制短码
private static final char[] BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
public String encode(long id) {
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(BASE62[(int) (id % 62)]);
id /= 62;
}
return sb.reverse().toString();
}结论是发号方案更干净。短码的唯一性由 ID 的唯一性保证,不用处理碰撞。ID 生成的细节见 分布式 ID 生成。
3. 高并发跳转靠缓存
短链的读写比极度悬殊,一个短码生成后可能被访问几百万次,是典型的读多写少。跳转请求直接查数据库,数据库会被打穿。
解法是把「短码到长 URL」的映射放进缓存。跳转时先查缓存,命中直接 302,未命中查库再回填。
public String resolve(String code) {
String url = redis.opsForValue().get("short:" + code);
if (url != null) return url;
url = urlMapper.selectByCode(code);
if (url != null) {
redis.opsForValue().set("short:" + code, url, 7, TimeUnit.DAYS);
}
return url;
}短链映射几乎不变,一个短码生成后,它指向的长 URL 基本固定。这意味着缓存可以设很长的 TTL,命中率极高,绝大多数跳转根本到不了数据库。缓存的读写策略见 缓存写路径。
4. 短链的额外问题
有两个容易被忽略的点。
一是有效期。短码是有限的,发出去的短码要不要过期。要过期就得在生成时记录过期时间,跳转时判断,过期返回失效页。不过期则短码永久占用 ID 空间,需要更大的 ID 位数。
二是跳转统计。短链常被用来统计点击量,每次跳转要记一条访问日志。这个写请求如果和跳转同步,会拖慢跳转。常规做法是跳转时不落库,异步把点击事件丢进消息队列,离线统计,见 消息场景。
5. 结论
短链系统拆开看就两件事。短码生成本质是分布式 ID 加 62 进制编码,用发号方案避免碰撞。高并发跳转本质是读缓存,短链映射几乎不变,缓存命中率能撑到极高。把这两个点想清楚,短链系统就没有神秘之处了。