设计一个短链服务——从面试题到真实生产设计的完整对比
设计一个短链服务——从面试题到真实生产设计的完整对比
适读人群:准备系统设计面试的工程师、关注高并发系统架构的开发者 | 阅读时长:约16分钟 | 核心价值:用短链服务这道经典题,对比面试答案和生产实践的巨大差距
这道题我被问了三次
短链服务可能是系统设计面试里被问频率最高的题之一,没有之一。
我在三家不同公司的面试里都被问过这道题——一次是在候选人侧,两次是在面试官侧。作为候选人被问的那次,我回答得还算过得去,但直到后来真正参与过一个类似系统的落地,我才意识到面试答案和生产实践之间差了多远。
这篇文章我想把这两种视角都拿出来对比一下,面试里你该怎么回答,真实系统里会遇到哪些面试里完全没有涉及的问题。
先说一下这道题的标准面试答案
需求分析
短链服务的基本功能:
- 用户提交一个长链接,系统返回一个短链接(如
t.cn/Abc12) - 用户访问短链,系统重定向到原始长链
- 可选:统计点击数、点击来源分析、短链有效期管理
规模估算(面试时要主动问):假设每天新增 1 亿条短链,读写比大概是 100:1(短链生成少,访问多)。
那么:
- 写 QPS:1 亿 / 86400 ≈ 1200 次/秒
- 读 QPS:约 12 万次/秒
- 存储:一条记录大约 500 字节,1 亿条 * 500 字节 = 50GB/天
核心设计问题:短链 ID 怎么生成
这是这道题最核心的地方。
方案 A:自增 ID + Base62 编码
public String generateShortUrl(long id) {
String chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(chars.charAt((int)(id % 62)));
id /= 62;
}
return sb.reverse().toString();
}优点:短链可以做到最短(6 位 Base62 可以表示 620 亿个短链),有序,不会冲突。
缺点:需要一个全局自增 ID 服务,这个服务是单点,需要额外处理高可用问题。
方案 B:随机字符串 + 冲突检测
随机生成 6 位字符串,如果数据库里已有就重新生成。
优点:实现简单,无需全局 ID 服务。
缺点:随着数据增多冲突概率上升,最坏情况下重试很多次。6 位 Base62 只有 620 亿个组合,存了 62 亿条之后,每次平均要重试 10 次才能找到空闲的。
方案 C:哈希函数截断
对长链取 MD5,截取前 6 位作为短链。
优点:无状态,不需要数据库查询。
缺点:哈希冲突更难预测,不同长链可能映射到同一短链,必须做冲突处理。
面试里大多数答案到这里就结束了,选一个方案,简单说说利弊,架构图一画,差不多就过了。
基础架构图
这是面试里最常见的架构,没什么问题,但放到生产里就会开始出现各种你没想到的问题。
生产环境里遇到的真实问题
真实问题一:热 Key 问题远比想象中严重
我们上线初期,缓存策略是直接用短链作为 Key,对应长链作为 Value,缓存命中率大概在 95% 以上,看起来很理想。
但有一天一个活动链接被微信大量转发,一条短链在 10 分钟内被访问了几百万次。Redis 单个 Key 的 QPS 打满了,而且这个 Key 轮不到过期,一直是热的。
我们当时的处理方式是手动给这条记录单独做了主从分离——把热链接复制到多个 Redis 实例,每次读的时候随机选一个实例。
这在面试里几乎不会被考到,但在生产里是个真实的隐患。
更好的方案是在应用层加一个本地缓存(Caffeine),热 Key 直接在进程内命中,不打到 Redis。但这引入了多实例之间缓存不一致的问题,需要结合短链的 TTL 策略一起考虑。
// 本地缓存 + Redis 二级缓存
@Component
public class ShortUrlCache {
private final Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(60))
.build();
private final RedisTemplate<String, String> redisTemplate;
public String get(String shortCode) {
// 先查本地缓存
String url = localCache.getIfPresent(shortCode);
if (url != null) return url;
// 再查 Redis
url = redisTemplate.opsForValue().get("url:" + shortCode);
if (url != null) {
localCache.put(shortCode, url);
return url;
}
return null;
}
}真实问题二:点击统计的写入放大问题
每次短链被访问,都要更新点击数。如果是同步写数据库,12 万 QPS 的访问会带来 12 万 QPS 的写入,MySQL 扛不住。
面试里的典型答案是"用 Redis 计数,异步写回数据库",没问题,但细节里有坑。
我们当时用 Redis INCR 做点击计数,每分钟批量写回 MySQL。有一天 Redis 发生了一次计划外重启,未持久化的计数丢了大概 3 分钟的数据。
对于统计数据,几分钟的丢失是可以接受的,但我们当时没有做好告警,导致这个损失发现得很晚。
后来我们改成了 Redis + Kafka 双写——点击事件同时写 Redis 计数和推一条消息到 Kafka,Kafka 做持久化,异步消费写 DB。Redis 故障时,Kafka 里的数据还在,可以补数。
真实问题三:短链被恶意批量生成
有一段时间,我们发现有人在批量生成短链,然后不使用,目的是把我们的短链空间消耗掉(这也叫 ID 耗尽攻击)。
生成接口没有做限流,被一个脚本一小时内生成了 2000 万条短链,存储压力瞬间上来了,而且全是垃圾数据。
这个在面试里也几乎不会出现。修复方案是:
- 接口加限流,单 IP 每分钟最多生成 10 条
- 生成接口要求登录认证
- 长链接做归一化处理,相同的长链返回相同的短链(去重)
// 长链归一化 + 去重
public String createShortUrl(String longUrl, String userId) {
// 1. URL 归一化(去掉 utm 参数、统一协议等)
String normalizedUrl = normalizeUrl(longUrl);
// 2. 查重:同一用户、同一长链复用短链
String existing = shortUrlMapper.findByUserAndLongUrl(userId, normalizedUrl);
if (existing != null) return existing;
// 3. 生成新短链
long id = idGenerator.nextId();
String shortCode = base62Encode(id);
shortUrlMapper.insert(shortCode, normalizedUrl, userId);
return shortCode;
}真实问题四:重定向状态码影响 SEO
面试里通常说重定向用 302 或者 301。
301 是永久重定向,浏览器会缓存,下次直接跳转,不回到短链服务。优点是减少了服务器压力,缺点是短链被修改后浏览器不会知道。
302 是临时重定向,每次都会回到短链服务,可以实时更新目标地址,但每次都有一次额外请求。
我们最开始用的 301,有个业务需求要改短链目标地址,发现很多已经缓存了 301 的浏览器始终访问旧地址,造成了投诉。
后来我们统一改 302,但对于"不可变"的短链(比如加了 permanent=true 标识的),允许返回 301。
这个细节在面试里很少被考到,但在生产里是产品和运营团队明确需要的功能。
面试答案 vs 生产实践的差距汇总
| 问题维度 | 面试典型答案 | 生产实际遇到 |
|---|---|---|
| ID 生成 | 自增 ID 或哈希 | 全局 ID 服务的高可用、分号段预分配 |
| 缓存 | Redis 缓存 | 热 Key 本地缓存双层、缓存穿透保护 |
| 点击统计 | Redis 计数 | Redis + Kafka 双写容灾 |
| 安全 | 几乎不提 | 限流、鉴权、ID 耗尽攻击防护 |
| 重定向 | 301/302 二选一 | 按业务分两类,可配置 |
| 存储 | MySQL + Redis | 归一化去重、分库分表计划 |
一个更完整的生产级架构
最后说一点
短链服务之所以是经典面试题,是因为它麻雀虽小五脏俱全:涉及 ID 生成、缓存、读写分离、数据一致性、高可用,用一道题可以考察候选人对分布式系统的全面认知。
但真正的分布式系统,要比任何一道面试题复杂得多。
面试能通过,说明你有足够好的基础认知。把系统做到生产稳定,需要的是在那些基础认知之上,再踩一遍真实环境里的所有坑。
这两件事都很重要,只是不要把它们混为一谈。
