从0到1搭建短链接系统:哈希冲突+跳转高性能+访问统计——完整实现
引言
上个月市场部搞大促推广,十万条营销短信里带着原始链接——一条带渠道参数的 URL 足足 137 个字符,短信按 70 字拆条计费,一条短信被拆成三条,推广成本直接翻了三倍。更别提线下物料上印 137 个字符的 URL,二维码扫码后密密麻麻一屏。
短链接系统就是解决这个问题的:https://shop.example.com/product/detail?id=8847362&channel=sm&activity=d11&utm_source=xxx → https://s.example.cn/aB3xK9。看起来只是"把长的变短",但把它当生产系统做,会撞上三个经典工程问题:
- 短码怎么生成——哈希会冲突,冲突了怎么办?
- 跳转怎么扛住高并发——跳转是整个系统里 QPS 最高的接口,读多写少到什么程度?缓存怎么设计?
- 访问统计怎么不拖垮跳转——每次跳转都记一笔,跳转链路被统计拖慢就本末倒置了。
这篇文章从 0 到 1 完整实现:MurmurHash + Base62 生成短码 → Bloom Filter 检测冲突 → DB 存储 → Redis 缓存 → 302 重定向 → 异步访问统计,最后讲清楚为什么成熟方案会用发号器替代哈希。
一、需求拆解与总体架构
1.1 功能需求与量化指标
| 能力 | 需求 | 量化指标 |
|---|---|---|
| 短链生成 | 长链转短码,支持过期时间、自定义短码 | 写 QPS ~100(相对低频) |
| 短链跳转 | 短码 302 重定向到长链 | 读 QPS ~5000(读写比 50:1 起步) |
| 访问统计 | PV/UV/来源渠道统计,报表 T+1 | 跳转链路统计开销 < 1ms |
| 可靠性 | 短码永不可重复映射到不同长链 | 冲突率 0(数据正确性红线) |
先记住读写比这个数字:一个短链被创建一次,但会被点击成千上万次。这决定了整个架构的重心在"读路径"——跳转接口的设计质量就是系统的天花板。
1.2 总体架构
┌──────────┐ ①生链 ┌───────────────┐ 短码生成
│ 运营后台/ │ ───────▶ │ 短链服务 │ ──▶ MurmurHash + Base62
│ 开放API │ │ (创建/管理) │ ──▶ Bloom Filter 查重
└──────────┘ └───────┬───────┘
▼
┌─────────────────┐
│ DB: 短码+长链+ │ 唯一索引兜底
│ 过期时间+状态 │
└─────────────────┘
┌──────────┐ ②跳转 ┌───────────────┐ 命中? ┌────────────┐
│ 用户点击 │ ───────▶ │ 跳转服务 │─────────▶ │ Redis 热链 │
│ s.xx.cn/ │ │ (最高QPS) │ 未命中 └─────┬──────┘
│ aB3xK9 │ └───────┬───────┘───回填───────────┘
└──────────┘ ▼ 未命中
┌─────────────────┐ ┌────────────┐
│ DB(兜底) │ │ BloomFilter │ 兜底拦截
└─────────────────┘ │ "肯定不存在" │ 不存在的短码
└────────────┘
│
▼ ③统计(异步,不阻塞跳转)
┌─────────────────┐
│ MQ → 统计服务 → │ PV/UV/渠道报表
│ 统计表/数仓 │
└─────────────────┘
三个设计决策先立住:
| 决策 | 选择 | 理由 |
|---|---|---|
| 重定向码 | 302(不是 301) | 301 浏览器永久缓存,过期短链无法"复活"、统计丢失——跳转必须经过服务端 |
| 统计写法 | 异步(MQ/批量) | 跳转链路同步写库 = 每次跳转多一次 DB 写,QPS 天花板被统计压死 |
| 缓存策略 | Cache Aside + 热点常驻 | 跳转数据不可变(短码→长链映射不变),缓存命中率天然极高 |
二、短码生成:MurmurHash + Base62
2.1 为什么是 Base62
短码可用的字符集:0-9 a-z A-Z 共 62 个字符(不含易混淆的 -、_)。Base62 编码就是"把一个数字用 62 进制表示":
| 短码长度 | 62^n 组合数 | 够用吗 |
|---|---|---|
| 5 位 | 9.16 亿 | 日常场景够用 |
| 6 位 | 568 亿 | 主流选择:千万级短链下冲突概率极低 |
| 7 位 | 3.5 万亿 | 超大规模/焦虑型团队 |
6 位短码是成本与容量的甜点位——比 5 位多 62 倍容量,比 7 位短一截。
2.2 MurmurHash:快、散、稳
哈希函数选型:MD5/SHA 系列安全但重(我们不需要防碰撞的安全性),需要的是快 + 分布均匀 + 稳定(同一个长链每次哈希结果一致)。MurmurHash3 正是这个定位——非加密哈希里的性能王者,Redis Cluster 槽位、Kafka 分区都用它:
/**
* 长链 → 62进制短码(基础版)
* 依赖:com.google.guava:guava(Hashing.murmur3_128)
*/
public class ShortCodeGenerator {
private static final char[] BASE62 =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
public static String encode62(long num) {
StringBuilder sb = new StringBuilder();
while (num > 0) {
sb.append(BASE62[(int) (num % 62)]);
num /= 62;
}
// 补齐 6 位,左补 '0'
while (sb.length() < 6) {
sb.append('0');
}
return sb.reverse().toString();
}
public static String genShortCode(String longUrl) {
// murmur3_128 取前 64 位作为无符号长整型
long hash = Hashing.murmur3_128()
.hashString(longUrl, StandardCharsets.UTF_8)
.asLong();
return encode62(Math.abs(hash));
}
}
2.3 哈希方案的天生缺陷:冲突
问题立刻来了:568 亿的组合空间是"很大",但不是无限大。根据生日悖论,当已有短链数量 N 接近组合空间的平方根量级时,冲突概率就开始不可忽视:
6 位短码空间 S = 568 亿
生日悖论近似:N 条短链时任意两条冲突的概率 ≈ 1 - e^(-N²/2S)
N = 100 万 → 冲突概率 ≈ 0.09%
N = 1000 万 → 冲突概率 ≈ 8.8% ← 已经不可忽视
而且哈希冲突有个反直觉的坑:同一个长链重复提交会得到同一个短码(这其实是 feature:天然幂等);但两个不同长链哈希相同时,你必须给出确定的裁决——查库发现"这个短码已经指向别的长链",怎么办?
- 方案 A:换盐重试——加个随机盐再哈希一次,重新查重,直到不冲突。实现简单,代价是"同一长链可能得到不同短码"(幂等性被破坏,除非先查长链是否已存在)。
- 方案 B:布隆过滤器前置查重——在重试之前先用 O(1) 判断"短码大概率已存在",减少无效的 DB 查询(下一章展开)。
- 方案 C:干脆不哈希——用全局唯一 ID 发号器,从根上消灭冲突(第七章展开,成熟方案的选择)。
生产级哈希方案 = A + B 的组合:先查长链幂等 → MurmurHash → 布隆过滤器初筛 → DB 唯一索引终审 → 冲突则加盐重试。
三、Bloom Filter:冲突检测与穿透防御的双面手
3.1 原理一页纸
布隆过滤器是一个"大概率存在 / 肯定不存在"的概率结构:
- 底层是 m 位的 bit 数组 + k 个哈希函数;
- 写入:元素经 k 个哈希函数映射到 k 个位置,全部置 1;
- 查询:k 个位置任意一位是 0 → 肯定不存在;全 1 → 可能存在(可能是别人置的 1,即误判)。
两个关键特性对本系统至关重要:
- "肯定不存在"的判断 100% 可靠——用于拦截不存在的短码查询(缓存穿透防御);
- 空间极省——1 亿个元素、误判率 1%,只需要约 1.14GB... 不对,是约 116MB(每个元素约 9.6 bit),比 HashSet 存短码字符串省一个数量级。
3.2 实现:Redisson 的 RBloomFilter
/**
* 短码查重 + 穿透防御 双用途布隆过滤器
* 预期 1 亿短码,误判率 1%
*/
@Configuration
public class BloomFilterConfig {
@Bean
public RBloomFilter<String> shortCodeBloomFilter(RedissonClient redisson) {
RBloomFilter<String> filter = redisson.getBloomFilter("short:code:bloom");
// tryInit:expectedInsertions 预期插入量,falseProbability 误判率
// 返回 false 表示过滤器已存在(复用),不会重复初始化
filter.tryInit(100_000_000L, 0.01);
return filter;
}
}
@Service
@RequiredArgsConstructor
public class ShortLinkService {
private final RBloomFilter<String> bloomFilter;
/** 创建短链:布隆初筛 → DB 终审 → 冲突加盐重试 */
public String createShortLink(String longUrl, LocalDateTime expireAt) {
// 幂等:同一长链已存在直接返回(可选策略)
ShortLink exist = shortLinkMapper.selectByLongUrl(longUrl);
if (exist != null && !exist.isExpired()) {
return exist.getShortCode();
}
String code = ShortCodeGenerator.genShortCode(longUrl);
for (int i = 0; i < 3; i++) { // 最多重试 3 次
if (!bloomFilter.contains(code)) { // 布隆说"肯定不存在"
boolean inserted = tryInsert(longUrl, code, expireAt);
if (inserted) {
bloomFilter.add(code); // ✅ 入库成功,登记布隆
cacheLoader.warmUp(code); // 顺手预热
return code;
}
// 插入失败 = 唯一索引冲突(布隆误判或并发竞争)→ 加盐重试
}
code = ShortCodeGenerator.genShortCode(longUrl + "#salt#" + i);
}
throw new BizException("SHORT_CODE_GEN_FAIL");
}
private boolean tryInsert(String longUrl, String code, LocalDateTime expireAt) {
try {
shortLinkMapper.insert(new ShortLink(code, longUrl, expireAt));
return true;
} catch (DuplicateKeyException e) {
return false; // 唯一索引兜底:最终防线
}
}
}
3.3 布隆过滤器的三个工程注意点
| 注意点 | 说明 |
|---|---|
| 不能删除 | 标准布隆过滤器无法删除元素(位是共享的)。过期短码留在过滤器里只是"多几次误判进 DB 查询",无害;非要删除用 Counting Bloom Filter 或定期重建 |
| 重建方案 | 服务重启/扩容误判率恶化时:从 DB 全量 reload 短码重建(亿级约几分钟,凌晨低峰执行) |
| 误判率权衡 | 0.01 误判率 = 1% 的"不存在短码"会穿透到 DB。配合跳转接口的空值缓存(见第五章)可以把它压到接近 0 |
四、存储设计:一张表撑起跳转
4.1 表结构
CREATE TABLE short_link (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
short_code VARCHAR(16) NOT NULL COMMENT '短码',
long_url VARCHAR(2048) NOT NULL COMMENT '原始长链',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0禁用',
expire_at DATETIME DEFAULT NULL COMMENT '过期时间 NULL=永久',
creator VARCHAR(64) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_short_code (short_code), -- 核心唯一索引:冲突终审 + 跳转查询
KEY idx_long_url (long_url(64)), -- 幂等查询(前缀索引省空间)
KEY idx_expire_at (expire_at) -- 过期清理任务扫描
) ENGINE=InnoDB COMMENT='短链映射表';
设计要点:
| 要点 | 说明 |
|---|---|
| uk_short_code | 整个系统的正确性基石:布隆可能误判,最终防线是唯一索引 |
| long_url 用前缀索引 | 2048 长度的字段建全量索引太重;前缀 64 位足够区分 |
| 不做物理删除 | 过期短链被查询时判断 expire_at 返回 404 页;物理清理走定时任务 + 归档 |
| 单表容量评估 | 千万级短链单表无压力;到亿级再按 short_code 哈希分表(分表键=查询键,路由零成本) |
4.2 跳转 SQL 就一条
SELECT long_url, status, expire_at
FROM short_link
WHERE short_code = 'aB3xK9';
-- 唯一索引命中,单行返回——但别让 5000 QPS 直接打这里,这是第五章缓存的活
五、高性能跳转:Redis 缓存 + 302
5.1 301 vs 302:一个必须踩对的选项
| 状态码 | 语义 | 对短链系统的影响 |
|---|---|---|
| 301 永久重定向 | 浏览器缓存映射,后续不再请求短链服务 | ✅ 减少服务端压力;❌ 统计全丢、过期短链无法下线、换目标地址失效 |
| 302 临时重定向 | 每次都请求短链服务 | ✅ 统计完整、随时可改映射/下线;❌ 服务端承压(由缓存解决) |
结论:一律 302。业务短链 99% 需要点击数据和可管理性,"永久"是伪需求。个别纯 CDN 加速场景才考虑 301。
5.2 跳转接口完整实现
@RestController
@RequiredArgsConstructor
public class RedirectController {
private final StringRedisTemplate redis;
private final ShortLinkMapper shortLinkMapper;
private final RBloomFilter<String> bloomFilter;
private final AccessEventPublisher accessPublisher; // 统计事件发布
private static final String KEY_PREFIX = "short:url:";
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode,
HttpServletRequest request) {
// ① 短码格式校验(62进制6位),非法直接 404,挡住大量恶意扫描
if (!shortCode.matches("[0-9a-zA-Z]{6}")) {
return notFound();
}
// ② Redis 查缓存
String longUrl = redis.opsForValue().get(KEY_PREFIX + shortCode);
if (longUrl == null) {
// ③ 布隆过滤器兜底:肯定不存在 → 404,DB 一个查询都不浪费
if (!bloomFilter.contains(shortCode)) {
return notFound();
}
// ④ 查 DB
ShortLink link = shortLinkMapper.selectByCode(shortCode);
if (link == null) {
// ⑤ 空值缓存 60s:布隆误判的 1% 在这里被二次拦截
redis.opsForValue().set(KEY_PREFIX + shortCode, "", 60, TimeUnit.SECONDS);
return notFound();
}
if (!link.isAccessible()) { // 禁用或过期
return gone(); // 410 Gone
}
longUrl = link.getLongUrl();
// ⑥ 回填缓存:映射不可变,可以给长 TTL(如 7 天)
redis.opsForValue().set(KEY_PREFIX + shortCode, longUrl, 7, TimeUnit.DAYS);
} else if (longUrl.isEmpty()) {
return notFound(); // 空值缓存命中
}
// ⑦ 异步发统计事件(不阻塞跳转主链路)
accessPublisher.publish(shortCode, request);
// ⑧ 302 重定向
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(longUrl))
.build();
}
private ResponseEntity<Void> notFound() {
// 404 页面可以做成业务入口(活动页/官网),别浪费流量
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
5.3 链路各层的作用总结
请求 → 格式校验(正则,拦恶意扫描)
→ Redis(99.9% 的请求到这就结束了,单次 <1ms)
→ 布隆过滤器(拦"肯定不存在",DB 零消耗)
→ 空值缓存(拦布隆误判的 1%)
→ DB 唯一索引(最后兜底)
压测参考数据(4C8G 容器 × 2 实例 + 同机房 Redis,wrk 压测):纯缓存命中路径 QPS ≈ 18000,P99 ≈ 3ms;DB 回源路径 QPS ≈ 3500。缓存命中率稳定在 99.9% 以上——热点短链的访问高度集中(头部 1% 短链占 60% 流量),长 TTL 策略完全成立。
六、访问统计:异步化是唯一正解
6.1 为什么不能同步写
如果跳转时同步执行"insert 一条访问日志 + update 计数":
- 每次 5000 QPS 的跳转变成了 5000 QPS 的 DB 写,统计成为整个系统的性能瓶颈;
- 统计服务的任何抖动直接拖垮跳转——本末倒置。
6.2 方案:跳转只发事件,统计端批量落库
/**
* 跳转链路侧:只发事件,内存队列 + 批量发送,开销 < 1ms
* 流量大时直接换 Kafka/RocketMQ 生产者
*/
@Component
@RequiredArgsConstructor
public class AccessEventPublisher {
private final BlockingQueue<AccessEvent> queue = new LinkedBlockingQueue<>(100_000);
private final KafkaTemplate<String, String> kafka;
public void publish(String shortCode, HttpServletRequest request) {
AccessEvent event = new AccessEvent(
shortCode,
clientIp(request), // UV 依赖(脱敏后的 IP/设备指纹)
request.getHeader("User-Agent"), // 端型分析
request.getHeader("Referer"), // 来源
System.currentTimeMillis());
if (!queue.offer(event)) {
return; // 队列满直接丢弃:统计可容忍丢点,跳转不可容忍阻塞
}
}
/** 批量刷出到 MQ,5 秒或 500 条触发 */
@Scheduled(fixedDelay = 5000)
public void flush() {
List<AccessEvent> batch = new ArrayList<>(500);
queue.drainTo(batch, 500);
if (!batch.isEmpty()) {
kafka.send("short-link-access", JSON.toJSONString(batch));
}
}
}
/**
* 统计服务侧:消费事件 → 聚合落库(PV 计数 + UV 去重)
*/
@Component
public class AccessEventConsumer {
@KafkaListener(topics = "short-link-access", groupId = "access-stats")
public void onMessage(String message) {
List<AccessEvent> events = JSON.parseArray(message, AccessEvent.class);
for (AccessEvent e : events) {
// PV:Redis INCR,按 短码+天 聚合
redis.opsForValue().increment("pv:" + e.getShortCode() + ":" + dayKey(e.getTs()));
// UV:HyperLogLog 去重,内存占用固定 ~12KB/短码/天
redis.opsForHyperLogLog()
.add("uv:" + e.getShortCode() + ":" + dayKey(e.getTs()), e.getClientIp());
// 明细异步写数仓/ClickHouse,供渠道归因等深度分析
}
}
}
统计设计的取舍表:
| 维度 | 方案 | 理由 |
|---|---|---|
| PV | Redis INCR(短码+天 为 key) | 原子计数,定时刷回 MySQL |
| UV | HyperLogLog | 12KB 固定内存做到亿级去重,误差 0.81% 对报表完全可接受 |
| 明细 | MQ → 数仓/ClickHouse | 渠道归因、漏斗分析是离线场景,别在线上库做 |
| 丢失容忍 | 队列满丢弃 / Redis 未落盘丢失 | 统计是"软需求",跳转是"硬需求",永远让路 |
七、进阶:用发号器(Snowflake)替代哈希
7.1 哈希方案 vs 发号器方案
写流量起来之后(日增百万短链),哈希方案的两个别扭会放大:
- 冲突处理成为常态——1000 万存量时 8.8% 冲突率意味着"加盐重试"高频发生,且布隆重建时间越来越长;
- 短码长度不可控——哈希值分布均匀,短码"长短随机",总有一些尾部 0 的浪费。
发号器方案:不用长链内容生成短码,而是用一个全局递增 ID,编码成 62 进制——ID 唯一 → 短码必然唯一,冲突问题从物理上消失:
/**
* 发号器方案:Snowflake ID → Base62
* 关键:Snowflake 趋势递增 → 低 38 位变化空间足够,直接用(时间戳部分参与编码)
*/
public class SnowflakeShortCodeGenerator {
private final Snowflake snowflake; // MyBatis-Plus 内置 / 自研均可
public String genShortCode() {
long id = snowflake.nextId();
// 取 id 的低 38 位(2748 亿空间)+ 随机 6 位随机数混淆防遍历
long mixed = (id & 0x3FFFFFFFFL) ^ ThreadLocalRandom.current().nextInt(62);
return ShortCodeGenerator.encode62(mixed);
}
}
两个方案对比(选型就看这张表):
| 维度 | 哈希方案(MurmurHash) | 发号器方案(Snowflake/号段) |
|---|---|---|
| 冲突 | 存在,需布隆+重试+唯一索引 | 物理上无冲突 |
| 同长链幂等 | 天然幂等(同链同码) | 需额外查重实现幂等 |
| 短码可预测性 | 均匀随机,天然防遍历 | 递增可被遍历爬取 → 需混淆位 |
| 依赖 | 无 | 需要发号器(Snowflake 依赖机器时钟,需处理回拨) |
| 自定义短码 | 需额外逻辑 | 统一走"号段+自定义保留区间" |
| 适用规模 | 千万级以内 | 亿级以上 / 开放平台(对外发号) |
我们的最终架构:内部运营场景哈希方案足够(幂等省心);对外 API/超大规模用发号器。两者共用同一张表和同一条跳转链路——短码只是字符串,跳转侧根本不关心它怎么来的,这正是分层的好处。
八、常见问题
8.1 短链服务宕机,用户点击会怎样?
302 短链不缓存,跳转服务不可用 = 点击全部失败。高可用三件套:服务多实例 + Redis 主从/哨兵 + DB 直连兜底路径(缓存层全挂时,布隆+DB 链路仍能跳转,QPS 降低但不中断)。更狠的做法是 CDN 边缘缓存头部 1 万条热点短链的 302 响应(牺牲部分统计精度)。
8.2 短码被恶意遍历爬取怎么办?
哈希方案短码空间随机,遍历成本高;发号器方案递增可遍历。防御:① 短码加随机混淆位(第七章代码);② 同一 IP 高频访问不同短码 → 风控限流;③ 短链服务加鉴权开关(内部系统短链要求登录态)。
8.3 短链被滥用(spam 链接)怎么治理?
三道关:创建时黑名单校验(域名信誉库)+ 长链内容抽检;跳转时目标站点被举报/拉黑 → 拦截页;统计侧发现异常流量模式(单短码异常高频、异常 UA)自动禁用短码。开放平台的短链服务必须有治理,否则会沦为钓鱼链接的中转站。
8.4 Redis 全部失效(如主从切换数据丢失)怎么办?
冷启动雪崩防御:缓存 TTL 加随机抖动(7 天 ± 8h)避免同时过期;全量失效时靠布隆过滤器挡住不存在短码,存在短码回源 DB(QPS 3500 的回源能力 + 限流保护),系统降级运行不雪崩。恢复期用"提前后台预热 + 渐进放量"。
8.5 统计的 UV 用 IP 准确吗?
不准确但有参考价值:移动网络出口 IP 聚合、IPv6 变化都会失真。生产做法:IP + User-Agent 哈希做伪 ID,或前端种 Cookie/设备指纹拿真实 ID。统计精度取决于业务需要:运营报表 IP+UA 够用,广告计费必须上设备指纹 + 防作弊。
8.6 为什么不直接用现成的开源方案?
有(如自建基于 Nginx+Redis 的轻量方案、部分云厂商的短链服务)。自研的价值在于:统计深度定制(渠道归因)、与企业内权限/审计体系打通、数据自主。本文的架构恰好也是一次很好的综合练习——哈希、布隆过滤器、缓存、MQ、发号器,每一块都是后端面试与实战的高频组件。
九、总结
链路速查卡
创建链路(写,~100 QPS)
长链 → 幂等查重 → MurmurHash → Base62
→ Bloom Filter 初筛 → DB 唯一索引终审
→ 冲突加盐重试 → 成功:登记布隆 + 预热缓存
跳转链路(读,~5000 QPS)
短码 → 格式校验 → Redis(99.9%命中) → 布隆兜底
→ 空值缓存 → DB 回源 → 回填缓存(TTL 7天)
→ 异步发统计事件 → 302
统计链路(异步,可丢)
内存队列 → 批量 → MQ → PV(INCR) + UV(HyperLogLog) + 明细入数仓
关键数据
- 短码:6 位 Base62,568 亿空间
- 冲突概率:100 万短链 0.09%,1000 万 8.8%(发号器方案归零)
- 布隆过滤器:1 亿元素、1% 误判 ≈ 116MB
- 跳转性能:缓存命中 P99 3ms、QPS 18000(2 实例压测)
- 统计开销:跳转链路 <1ms(内存队列 + 批量发送)
一句话
短链系统的三层功力:短码生成层解决"唯一性"(哈希+布隆+唯一索引,或发号器一劳永逸);跳转层解决"高并发"(不可变映射让缓存命中率天然 99.9%,302 保住统计和可管理性);统计层解决"不拖累主链路"(异步是唯一正解,软需求永远给硬需求让路)。把读写分离的直觉刻进架构里,5000 QPS 的跳转不过是缓存面前的一碟小菜。
给团队的建议
| 阶段 | 建议 |
|---|---|
| 起步期(<100 万短链) | 哈希方案 + Redis 缓存,一天上线 |
| 成长期 | 引入布隆过滤器 + 空值缓存 + 统计异步化 |
| 规模期(亿级/开放 API) | 切发号器方案,短码按需分表,统计入数仓 |
| 永远要做 | 唯一索引兜底、302 而非 301、跳转链路限流与降级预案 |
互动话题:你们的短链服务是自研还是买的?短码生成用的哈希还是发号器?踩过什么统计的坑?评论区聊聊。
参考资料
- Guava Hashing(MurmurHash3)
- Redisson Bloom Filter 文档
- 维基百科:Bloom Filter
- MDN:HTTP 301 与 302 重定向
- Redis 官方:HyperLogLog(PFADD/PFCOUNT)
- Twitter Snowflake 分布式 ID 方案
- 短链系统设计经典讨论(System Design Primer)
标题:从0到1搭建短链接系统:哈希冲突+跳转高性能+访问统计——完整实现
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/08/1788593702072.html
公众号:服务端技术精选
- 引言
- 一、需求拆解与总体架构
- 1.1 功能需求与量化指标
- 1.2 总体架构
- 二、短码生成:MurmurHash + Base62
- 2.1 为什么是 Base62
- 2.2 MurmurHash:快、散、稳
- 2.3 哈希方案的天生缺陷:冲突
- 三、Bloom Filter:冲突检测与穿透防御的双面手
- 3.1 原理一页纸
- 3.2 实现:Redisson 的 RBloomFilter
- 3.3 布隆过滤器的三个工程注意点
- 四、存储设计:一张表撑起跳转
- 4.1 表结构
- 4.2 跳转 SQL 就一条
- 五、高性能跳转:Redis 缓存 + 302
- 5.1 301 vs 302:一个必须踩对的选项
- 5.2 跳转接口完整实现
- 5.3 链路各层的作用总结
- 六、访问统计:异步化是唯一正解
- 6.1 为什么不能同步写
- 6.2 方案:跳转只发事件,统计端批量落库
- 七、进阶:用发号器(Snowflake)替代哈希
- 7.1 哈希方案 vs 发号器方案
- 八、常见问题
- 8.1 短链服务宕机,用户点击会怎样?
- 8.2 短码被恶意遍历爬取怎么办?
- 8.3 短链被滥用(spam 链接)怎么治理?
- 8.4 Redis 全部失效(如主从切换数据丢失)怎么办?
- 8.5 统计的 UV 用 IP 准确吗?
- 8.6 为什么不直接用现成的开源方案?
- 九、总结
- 链路速查卡
- 关键数据
- 一句话
- 给团队的建议
- 参考资料
评论