设计短链服务
一句话速记
短链服务核心:长 URL → 短码(6-8位 Base62)→ 存储(KV)→ 302/301 重定向。难点在于:短码唯一性(如何生成不冲突的短码)、高性能重定向(缓存加速)、可选功能(自定义短码、点击统计、过期时间)。典型 QPS:读(重定向)10 万+ >> 写(创建短链)几百。
系统需求
核心功能:
POST /shorten { longUrl } → { shortUrl: "https://t.co/ABC123" }
GET /ABC123 → 302 重定向到原始 URL
可选功能:
自定义短码(如 /my-sale 而不是随机码)
过期时间(短链 N 天后失效)
点击统计(访问次数、地域分布、设备类型)
用户管理(某人创建的所有短链)
规模估算:
写:1000 QPS(创建短链)
读:10 万 QPS(重定向,读写比 100:1)
存储:100 亿条短链,每条 ~500 bytes = 5 TB
缓存:热门短链 20% 覆盖 80% 流量
核心设计
1)短码生成策略
方案 A:Hash(MD5 + 截取,有冲突概率):
import hashlib, base64
def generate_short_code(long_url: str) -> str:
md5 = hashlib.md5(long_url.encode()).digest()
# Base64 编码后取前 8 位(去掉 URL 不安全字符)
code = base64.urlsafe_b64encode(md5).decode()[:8]
return code # 如:"aB3kZ9mN"
# 问题:不同 URL 可能生成相同 code(概率极低但存在)
# 解决:INSERT 失败(唯一键冲突)时追加随机字符重试方案 B:自增 ID + Base62 编码(推荐,无冲突):
import string
BASE62_CHARS = string.digits + string.ascii_letters # 0-9A-Za-z,62个字符
def id_to_code(num: int) -> str:
"""将数字 ID 转换为 Base62 短码"""
result = []
while num > 0:
result.append(BASE62_CHARS[num % 62])
num //= 62
return ''.join(reversed(result)).zfill(6) # 补零到 6 位
# 示例:
# ID=1 → "000001"
# ID=62 → "000010"
# ID=238328 → "001000"(62^3)
# ID=56800235584 → "ZZZZZZ"(62^6 的上限,6位 Base62 支持约 568 亿条)
def code_to_id(code: str) -> int:
result = 0
for char in code:
result = result * 62 + BASE62_CHARS.index(char)
return resultID 生成(分布式):
方案:MySQL 自增 ID(简单,但写入是单点瓶颈)
雪花 ID(Snowflake,分布式,高性能,但 ID 位数较长)
号段模式(Leaf-segment):批量从 DB 取 1000 个 ID,本地消费
推荐:号段模式(DB 只需偶尔写,性能好)
每台服务器缓存 1000 个 ID
耗尽后批量取新号段(1000个)
DB 只有 10 万 QPS / 1000 = 100 QPS 的写入
2)存储设计
数据库(MySQL + 分库分表):
CREATE TABLE short_links (
id BIGINT PRIMARY KEY, -- 自增 ID(对应短码)
short_code VARCHAR(10) UNIQUE NOT NULL, -- 短码(索引)
long_url TEXT NOT NULL, -- 原始 URL
user_id BIGINT DEFAULT NULL, -- 创建者(可选)
created_at DATETIME NOT NULL,
expired_at DATETIME DEFAULT NULL, -- 过期时间
click_count BIGINT DEFAULT 0 -- 点击次数(定期异步更新)
);
-- 按 short_code 分库分表(10 个表,按 short_code Hash 路由)
-- 或按 created_at 时间分表(按月)Redis 缓存(缓存热门短链):
Key:short_link:{short_code}
Value:long_url(直接存原始 URL)
TTL:1 小时(热门短链;冷门短链 Cache Miss 后 DB 回填)
写流程:创建短链 → 写 DB → 写 Redis(异步,提高创建速度)
读流程:Redis 命中 → 直接返回 long_url(>99% 的请求在此结束)
Redis 未命中 → 查 DB → 回填 Redis → 返回
3)重定向实现
@GetMapping("/{code}")
public ResponseEntity<Void> redirect(@PathVariable String code) {
// 1. 查 Redis 缓存
String longUrl = redis.get("short_link:" + code);
if (longUrl == null) {
// 2. Cache Miss,查 DB
ShortLink link = linkDao.findByCode(code);
if (link == null) {
return ResponseEntity.notFound().build();
}
if (link.isExpired()) {
return ResponseEntity.status(410).build(); // 410 Gone
}
longUrl = link.getLongUrl();
// 3. 回填缓存
redis.setex("short_link:" + code, 3600, longUrl);
}
// 4. 异步记录点击(不阻塞重定向)
asyncExecutor.execute(() -> recordClick(code));
// 5. 重定向(302 临时重定向,不缓存)
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", longUrl)
.build();
}301 vs 302:
301(永久重定向):
浏览器缓存,下次直接跳转(不再请求短链服务)
优点:减少服务器压力
缺点:无法追踪点击数(已缓存,不走服务器),无法修改目标 URL
302(临时重定向):
每次都经过短链服务器
优点:可以统计点击,可以修改目标 URL,可以设置过期
缺点:服务器压力大
推荐:302(功能完整),加 Redis 缓存扛住高 QPS
4)点击统计
方案 1:同步写 DB(简单,但高并发下 DB 写成瓶颈)
方案 2:Redis INCR + 定时落库
每次点击:INCR short_link:click:{code}
定时任务(每分钟):读 Redis 点击数 → 批量更新 DB
方案 3:MQ 异步(精确统计,含详细信息)
每次点击发 MQ 消息:{ code, ua, ip, referer, timestamp }
消费者批量写入点击日志表(Hive/ClickHouse 分析)
5)自定义短码
@PostMapping("/shorten")
public ShortLinkResponse create(@RequestBody CreateRequest req) {
String code = req.getCustomCode();
if (code != null) {
// 自定义短码:验证格式 + 唯一性
if (!code.matches("[a-zA-Z0-9_-]{3,20}")) {
throw new BadRequestException("自定义短码格式错误");
}
if (linkDao.existsByCode(code)) {
throw new ConflictException("该自定义短码已被占用");
}
} else {
// 自动生成
long id = idGenerator.nextId(); // 号段模式获取 ID
code = base62Encode(id);
}
ShortLink link = new ShortLink(code, req.getLongUrl(), req.getExpiredAt());
linkDao.save(link);
return new ShortLinkResponse("https://t.co/" + code);
}关键数字与权衡
存储:
100 亿条 × 500B = 5 TB(MySQL + 分库分表,按 short_code Hash 分 16 个库)
缓存:
热门 20% 短链:20 亿 × 500B = 1 TB(Redis Cluster,实际热点远远<20%)
实际缓存 1 亿条:1 亿 × 100B = 10 GB(很合理)
QPS:
写:1000 QPS → MySQL 单机可扛
读:10 万 QPS → Redis 缓存承担 99%,DB 只受 1000 QPS
延伸追问
- Q:短码有 6 位 Base62,会不会被穷举? → 6 位 Base62 有 62^6 = 568 亿种组合,穷举实际上很困难。但如果需要防猜测:1) 不用自增 ID(直接暴露规律),改用随机 ID;2) 对重定向接口加 IP 维度限流(每 IP 每秒最多 10 次重定向)。
- Q:如果 Redis 挂了,系统还能用吗? → 能用,但性能大幅下降。10 万 QPS 的重定向都打到 MySQL,MySQL 可能扛不住。需要:a) Redis 高可用(主从+哨兵/Cluster);b) 本地内存缓存(Caffeine)作为 L1 缓存兜底(容量小但速度极快)。
我的记法
- 短码:自增 ID → Base62 编码(6位,62^6 = 568 亿,无冲突)
- 存储:MySQL(持久化)+ Redis(缓存,key=short_link:{code}, value=longUrl)
- 重定向:Redis 命中直接返回 302;未命中查 DB 回填
- 302 vs 301:统计 + 可修改选 302,减少压力选 301
- 统计:Redis INCR + 定时落库 / MQ 异步精确统计
- 一句话:「ID→Base62 生成短码,Redis 缓存长URL,302 重定向,INCR 统计点击」
状态
- 已背速记
- 能写 ID→Base62 编解码代码
- 能解释 301 vs 302 的取舍