短链系统设计
2017年,某内容平台在双十一做了一次短信营销,发送了1000万条短信,每条短信里带了一个商品链接。
链接是 https://www.example.com/product/detail?category=electronics&sub=phone&brand=apple&model=iphone15&color=silver&storage=256gb&campaign=1111&source=sms
80个字符的URL,加上短信平台的截断(超过70个字符自动截断),导致30%的用户收到的链接是残缺的。
短信打开率从预期的15%暴跌到3%。
解决方案很简单:把80字符的URL变成8个字符的短链。但这个"简单"背后,藏着一整套系统设计问题。
【架构权衡】
短链系统的本质是用时间换空间——用8个字符的短链,存储和还原原始URL的映射关系。它看起来简单,但涉及到高并发写入(千万级日均生成量)、海量存储(百亿级短链)、快速跳转(亿级日访问)的挑战。
一、需求澄清 🔴
1.1 面试问题清单
面试官:设计一个短链系统。
候选人:我想先确认几个问题:
-
日均生成量是多少?是几百条还是几亿条?
-
日均访问量是多少?是读多还是写多?
-
短链有效期需要多久?永久有效还是临时有效?
-
需要支持自定义短链吗?
面试官:日均生成1000万,日均访问10亿,永久有效,不需要自定义。
候选人:明白。按10亿日访问、1000万日生成来设计,核心是读远大于写(100:1),存储选型以读取性能为核心。
1.2 核心指标
读写比例:100:1(读10亿 vs 写1000万)
性能要求:
- 生成短链:P99 < 100ms
- 跳转访问:P99 < 50ms
- 可用性:99.99%(全年故障时间 < 52分钟)
存储估算:
- 短链元数据:32字节(短码8 + 原始URL平均200 + 时间戳8 + 统计字段16)
- 100亿条记录:32字节 × 100亿 = 320GB
- 考虑索引和冗余:1TB+
QPS估算:
- 日访问10亿,峰值因子5,峰值QPS = 10亿 × 5 / 86400 ≈ 58万
- Redis缓存命中率95%:回源QPS ≈ 2.9万
1.3 系统边界
需要实现:
1. 短链生成:长链 → 短链
2. 短链访问:短链 → 长链
3. 统计监控:访问量、UV、点击来源
不需要实现(或者降级处理):
1. 自定义短链(本次不考虑)
2. 批量生成(单条生成即可)
3. 长链去重(允许重复短链指向同一长链)
【架构权衡】
短链系统的设计有一个容易被忽略的点:写少读多。很多人第一反应是用数据库存储,但10亿日访问的打法,任何数据库都会被打挂。正确的思路是:Redis缓存 + 数据库持久化 + CDN加速。这是读多写少场景的经典套路。
二、短链生成算法 🔴
2.1 哈希方案
原理:MD5/SHA256长链,取前8位
示例:
MD5("https://www.example.com/product/detail") =
d41d8cd98f00b204e9800998ecf8427e(前8位:d41d8cd9)
问题:
1. 哈希碰撞:不同长链可能哈希到同一结果
2. 无规律:短链不递增,无法分区
3. 安全性:可以通过哈希反推原始URL
2.2 发号器方案(推荐)
原理:用分布式ID发号器生成递增序号,转62进制编码
ID生成:雪花算法 → 10进制ID → 62进制字符串
示例:
雪花ID = 20241112120000000xxxx
转62进制 = "abc123XY"
62进制字符集:
0-9 + a-z + A-Z = 62个字符
8位62进制 = 62^8 ≈ 218万亿
优点:
1. 递增:有利于数据库索引和分片
2. 无碰撞:ID全局唯一
3. 高效:无需存储哈希映射
2.3 62进制转换实现
// 长链转短码:ID → 62进制字符串
public class Base62Util {
private static final String CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static final int SCALE = 62;
public static String encode(long id) {
StringBuilder sb = new StringBuilder();
while (id > 0) {
int remainder = (int) (id % SCALE);
sb.append(CHARS.charAt(remainder));
id = id / SCALE;
}
return sb.reverse().toString();
}
// 雪花ID转8位短码
public static String toShortCode(long snowflakeId) {
return encode(snowflakeId);
}
}
// 示例:
// 雪花ID = 102400001
// 62进制 = 2Pnip3(只有6位)
// 补齐8位 = 00002Pnip3
// 或者直接用12位雪花ID:Base62(102400001) = 刚好8位
⚠️
短链长度不是越短越好。6位短链有568亿种组合,但8位有218万亿。如果你的日均生成量只有1000万,用6位就够了;如果未来可能增长到10亿级别,建议直接上8位。另外,发号器方案的前提是ID必须全局递增,否则转62进制后长度不可控。
2.4 面试追问
面试官:雪花ID转62进制后长度不固定怎么办?
候选人:两个方案:
一是补零对齐:统一生成8位,不够的前面补0,如 00002Pnip3
二是用固定位数截断:取雪花ID的低位,比如只取低40位,保证不超过8位62进制
我建议用补零方案,8位短链足够支撑218万亿次生成,用不完的。
三、数据存储 🔴
3.1 多级存储架构
访问链路:
用户 → CDN → Nginx → Redis集群 → MySQL分库分表
缓存层(Redis):
- 热点数据:最近7天生成的短链
- 命中率目标:95%
- 过期策略:TTL=7天,定期续期
持久层(MySQL):
- 全量数据:所有历史短链
- 分片策略:按短码哈希分库分表
- 副本:1主2从
3.2 数据库表设计
-- 短链元数据表
CREATE TABLE short_url (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(16) NOT NULL, -- 短链码
long_url VARCHAR(2048) NOT NULL, -- 原始URL
create_time DATETIME NOT NULL,
expire_time DATETIME DEFAULT NULL, -- 过期时间,NULL=永久
status TINYINT DEFAULT 1, -- 1=正常,0=禁用
creator_id BIGINT, -- 创建者ID
INDEX idx_short_code (short_code), -- 短码索引
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.3 分库分表策略
方案:按 short_code 哈希取模
分库:4个库
db = hash(short_code) % 4
分表:每个库8张表
table = hash(short_code) / 4 % 8
总表数:4 × 8 = 32张表
单表容量:100亿 / 32 ≈ 3亿条
扩容方案:
- 当前4库8表 = 32个表
- 扩容时改为8库8表 = 64个表
- 扩容迁移用双写 + 数据校验
3.4 缓存预热
Redis Key设计:
short:{code} → {long_url}
TTL策略:
- 生成后立即写入Redis,TTL=7天
- 访问时续期:TTL = max(TTL, 7天)
- 永久有效的链永不过期
热点数据:
- 近7天生成的短链都在Redis
- 命中率 = 近7天生成量 / 总生成量
- 假设7天生成7000万,Redis存7000万条 × 2KB ≈ 140GB
四、短链跳转流程 🟡
4.1 302跳转 vs 301跳转
301跳转(永久重定向):
- 浏览器缓存跳转结果
- 第二次访问直接跳转,不经过服务端
- 优点:性能好,减轻服务端压力
- 缺点:无法统计真实访问量
302跳转(临时重定向):
- 每次都经过服务端
- 优点:可以统计每次访问
- 缺点:无法利用浏览器缓存
结论:短链系统用302跳转,统计优先。
4.2 高性能跳转实现
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
// Step 1: Redis查询
String longUrl = redisTemplate.opsForValue().get("short:" + shortCode);
if (longUrl != null) {
// 访问统计异步上报
reportAccessAsync(shortCode);
// 续期TTL
redisTemplate.expire("short:" + shortCode, 7, TimeUnit.DAYS);
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(longUrl))
.build();
}
// Step 2: 数据库查询
LongUrlDO record = shortUrlDAO.selectByShortCode(shortCode);
if (record == null) {
return ResponseEntity.notFound().build();
}
// Step 3: 回填Redis
redisTemplate.opsForValue().set("short:" + shortCode, record.getLongUrl(), 7, TimeUnit.DAYS);
// Step 4: 统计上报
reportAccessAsync(shortCode);
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(record.getLongUrl()))
.build();
}
4.3 访问统计
统计维度:
- PV:每次访问
- UV:按IP/Cookie去重
- 来源:Referer字段(微信/微博/短信/APP等)
- 地域:IP解析地理位置
- 时间:每小时/每天聚合
实现方案:
- 实时统计:Redis HyperLogLog(UV)+ INCR(PV)
- 离线统计:Kafka → Flink → Hive
- 展示:Grafana看板
五、生产避坑 🟡
5.1 高频翻车点
翻车1:短链被微信/QQ拦截
问题:生成的短链被微信安全拦截,显示"网页包含诱导分享内容"
根因:短链域名被举报或被识别为营销链接
解决方案:
1. 使用多个域名轮换:d1.cn, d2.cn, d3.cn...
2. 域名备案正规化
3. 接入微信白名单
翻车2:Redis缓存雪崩
问题:大量短链在同一时刻过期,瞬间打到数据库
场景:系统上线时,所有短链TTL=7天,7天后同时过期
解决方案:
1. TTL加随机偏移:7天 + random(0, 1天)
2. 永不过期的短链单独处理
3. 异步续期:在过期前主动刷新热点数据
翻车3:短链生成QPS不足
问题:发号器QPS不够
场景:高峰期1000万/天的生成量,峰值QPS 5000
解决方案:
1. 批量预生成ID段:一次向发号器申请1000个ID
2. 本地ID生成器:用AtomicLong自增 + 机器标识
3. 热点数据本地缓冲:生成后先写本地队列,批量写Redis
翻车4:长链超长
问题:原始URL超过2048字符
场景:URL参数过多或Base64编码的长URL
解决方案:
1. 数据库字段设为TEXT或LONGTEXT
2. 长度超限时返回错误:长链超长,请缩短
3. 或者:长链存MySQL,短链存Redis,key指向MySQL记录
5.2 性能监控指标
六、工程代价 🟢
七、真实面试回放 🟡
面试官:设计一个短链系统,日均生成1000万,日均访问10亿,QPS能扛到多少?
候选人(小林):先做容量估算:
日访问10亿,峰值因子5,峰值QPS = 58万
Redis单机15万QPS,所以需要至少4台Redis集群
但Redis命中率能做到95%,回源QPS = 58万 × 5% = 2.9万
MySQL单表5000 QPS,需要6个分片,约12张表
结论:Redis集群能扛58万QPS,MySQL分库分表能扛2.9万回源QPS。
面试官:如果Redis全挂了怎么办?
小林:降级方案分三级:
第一级:本地缓存兜底热点数据
第二级:降级为数据库直查,但限流(最多1000 QPS)
第三级:返回"服务繁忙",用户稍后重试
面试官:10亿日访问的数据怎么存储?
小林:按7:3估算:7天内的访问量占总访问的70%,所以:
Redis存储:7天 × 10亿 × 70% = 49亿条 × 200字节 = 980GB,16台Redis足够了
MySQL存储:全量历史数据,约100亿条 × 200字节 = 2TB
分库分表:按short_code哈希,8库8表,单表最多约1.5亿条
面试官:短链被恶意刷怎么办?
小林:两个维度:
一是访问维度:如果某IP每秒访问超过1000次,限流或封禁
二是生成维度:每个账号限制生成频率,比如每秒最多生成10条
如果是分布式攻击,需要接入风控系统,识别机器行为
面试官:可以。你来总结一下核心设计。
小林:核心三点:
-
发号器生成短码:雪花ID + 62进制,保证唯一且递增
-
多级存储:Redis缓存(95%命中) + MySQL分库分表,全量数据持久化
-
三级降级:本地缓存 → 数据库直查 → 限流熔断,保证高可用
【面试官手记】
小林这场面试的关键亮点:
- 量化分析到位:QPS估算、存储估算都有具体数字
- 知道Redis缓存命中率的意义(58万QPS回源到2.9万)
- 降级方案分三级,有兜底意识
- 风控意识:知道短链可能被刷
这场面试属于P6中高级水平,亮点在于"量化"和"兜底",不是单纯的背架构图。
扣分点:没有讲到"短链安全性"(比如短链是否可以预测、是否可以枚举),这是P7的追问方向。
短链系统的设计,核心就三件事:
- 怎么生成:发号器 + 62进制
- 怎么存储:Redis缓存热点 + MySQL持久化
- 怎么跳转:302重定向 + 访问统计
记住:这是一个读多写少的系统,读写比100:1,所有设计都要围绕读取性能优化。