设计一个短链接系统:哈希算法、跳转性能、防滥用的完整方案
设计一个短链接系统:哈希算法、跳转性能、防滥用的完整方案
适读人群:Java中高级工程师、系统设计面试备考者 | 阅读时长:约18分钟 | 难度:★★★☆☆
开篇故事
去年有个候选人来面试,简历写着"做过百万DAU的营销平台"。我出了一道题:设计一个短链接系统,支持每天一亿次跳转,要求跳转延迟P99在50毫秒以内,同时要防止竞争对手刷我们的广告预算。
候选人一开口就说:"用MD5截取前8位当短码,往数据库里一存,查的时候做个重定向就行了。"
我追问:"MD5冲突怎么处理?一亿次跳转数据库撑得住吗?竞争对手用脚本一秒打过来10万请求怎么办?"
他沉默了大概二十秒,说:"加索引,然后……加个if判断冲突就重新生成?"
这个回答暴露了一个普遍问题:很多工程师平时写CRUD写多了,遇到有规模要求的系统设计题,脑子里只有单机思维。短链接系统看着简单,实际上麻雀虽小五脏俱全——编码算法、缓存架构、跳转性能、防刷机制,每一块展开来都有很多工程决策要做。
这篇文章我把自己做过的短链接系统经验全部倒出来,面试官想听的、生产中踩过的坑,一次说清楚。
一、需求分析与规模估算
功能需求
核心功能就三个:
- 给一个长URL,生成一个短链接(例如
https://s.lz.cn/abc123) - 访问短链接,302跳转到原始长URL
- 后台能看到每条短链的点击统计(次数、地区、设备类型)
扩展功能:
- 自定义短码(品牌词,比如
s.lz.cn/spring2025) - 短链过期时间设置
- 防刷、防恶意URL
规模估算
假设这是一个中型营销平台的短链服务:
写入估算(创建短链):
- 每天新建短链:100万条
- 写入QPS:100万 / 86400 ≈ 12 QPS(低峰)
- 促销活动高峰:约10倍 = 120 QPS
读取估算(跳转请求):
- 每天一亿次跳转
- 平均读QPS:1亿 / 86400 ≈ 1160 QPS
- 活动高峰(假设集中在2小时内,流量占全天40%):
- 4000万 / 7200 ≈ 5560 QPS
- 瞬时峰值×3 = 约16000 QPS
存储估算:
- 每条短链记录约500字节(短码、长URL最长2048字节、创建时间、过期时间、统计字段)
- 按平均200字节估算(大部分URL不会那么长)
- 每年新建 1亿条 × 200字节 = 20GB/年
- 保留3年:60GB,对关系数据库来说完全可以接受
带宽估算:
- 跳转响应只含302头,Response Body为空,每次约200字节
- 峰值带宽:16000 QPS × 200字节 = 3.2 MB/s,极小
核心挑战不是存储,是读取延迟。 一亿次跳转,每次都要从数据库查短码对应的长URL,数据库根本扛不住,必须缓存。
二、系统架构设计
整体设计思路是:写路径保证正确性,读路径极致优化延迟,异步处理统计。
三、核心组件详解
3.1 短码生成算法
这是面试最容易被深挖的点,我见过的方案大概有三类:
方案一:哈希截断(不推荐生产)
对长URL做MurmurHash,取前8位,然后转Base62。问题是冲突概率随数据量上升,冲突时需要加盐重试,实现复杂且不优雅。
方案二:自增ID + Base62编码(推荐)
用数据库或Redis的自增ID,转成Base62字符串。62^6 = 568亿,6位码完全够用。
ID=1 → "000001" → 去掉前导零 → "1"
ID=56800 → Base62编码 → "OXi"
ID=100亿 → "AOtf1C"(6位)优点:无冲突,可预测长度,实现简单。
缺点:ID连续,短码有规律,竞争对手可以遍历(需要额外加固)。
方案三:雪花ID + Base62(生产推荐)
用雪花算法生成分布式唯一ID,再转Base62。ID不连续,防止遍历,也不依赖数据库自增。
实际我们生产用的是方案三,下面代码实现里会展开。
3.2 跳转缓存策略
跳转是读多写少的典型场景,缓存设计是重中之重。
缓存Key设计: shortlink:{shortCode} → 长URL字符串
TTL策略:
- 永久短链:Redis TTL设为30天,访问时续期(LRU近似淘汰)
- 带过期的短链:TTL = 短链过期时间,到期自动失效
缓存穿透防护: 布隆过滤器挡在Redis前面,不在布隆过滤器里的短码直接返回404,不会打到数据库。
热点短链问题: 一条爆款短链可能被百万人同时点击,Redis单Key的吞吐大约10万QPS,一般够用。极端情况可以在应用层加本地缓存(Caffeine),第一层本地缓存命中率能达到80%以上,大幅减少Redis压力。
3.3 防滥用机制
这是很多人做设计时容易忽略的部分,但在生产中非常重要。
IP限流: 单IP每分钟创建短链上限20条,跳转上限1000次。用Redis滑动窗口计数器实现。
设备指纹: 对于绕过IP限制的情况(代理池),额外收集UA、屏幕分辨率、Canvas指纹,组合哈希作为设备标识。
恶意URL检测:
- 域名黑名单(已知钓鱼/色情域名)
- Google Safe Browsing API接入
- 短时间内同一URL被大量不同用户访问,触发人工复核
点击刷量检测: 同一IP+同一短码,1分钟内超过10次,后续请求记录但不计入有效统计。
四、关键代码实现
4.1 Base62编码器
public class Base62Encoder {
private static final String CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static final int BASE = 62;
/**
* 将长整型ID编码为Base62字符串
* 最短1位,ID=568亿时6位
*/
public static String encode(long id) {
if (id == 0) return "0";
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(CHARS.charAt((int)(id % BASE)));
id /= BASE;
}
// 反转,低位在前需要翻转
return sb.reverse().toString();
}
public static long decode(String code) {
long result = 0;
for (char c : code.toCharArray()) {
result = result * BASE + CHARS.indexOf(c);
}
return result;
}
/**
* 补齐到固定长度,隐藏数据量规模
* 不足6位在前面填充随机字符使其达到6位
* 注意:填充方案需要在解码时去掉填充,或者用另一种方式
*/
public static String encodeWithPadding(long id, int targetLength) {
String base = encode(id);
if (base.length() >= targetLength) return base;
// 用确定性填充避免解码歧义:前缀 + 实际编码 + 长度标记
// 生产中更常见的做法:ID范围从1000000起步,保证至少4位
return base;
}
}4.2 短链创建服务
@Service
@Slf4j
public class ShortLinkCreateService {
@Autowired
private ShortLinkMapper shortLinkMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SnowflakeIdGenerator idGenerator;
@Autowired
private BloomFilterService bloomFilterService;
@Autowired
private UrlSafetyChecker urlSafetyChecker;
private static final String CACHE_KEY_PREFIX = "shortlink:";
private static final long DEFAULT_CACHE_TTL_SECONDS = 30 * 24 * 3600L; // 30天
/**
* 创建短链接
* @param request 包含longUrl、customCode(可选)、expireTime(可选)
*/
public ShortLinkCreateResponse create(ShortLinkCreateRequest request) {
// 1. 恶意URL检测
String longUrl = request.getLongUrl();
if (!urlSafetyChecker.isSafe(longUrl)) {
throw new BusinessException(ErrorCode.UNSAFE_URL, "URL安全检测未通过");
}
// 2. 处理自定义短码
String shortCode;
if (StringUtils.hasText(request.getCustomCode())) {
shortCode = request.getCustomCode();
// 检查是否已被占用
if (shortLinkMapper.existsByCode(shortCode)) {
throw new BusinessException(ErrorCode.CODE_ALREADY_EXISTS, "自定义短码已被使用");
}
} else {
// 3. 生成短码:雪花ID → Base62
long id = idGenerator.nextId();
shortCode = Base62Encoder.encode(id);
}
// 4. 构建实体入库
ShortLink entity = ShortLink.builder()
.shortCode(shortCode)
.longUrl(longUrl)
.createTime(LocalDateTime.now())
.expireTime(request.getExpireTime())
.status(ShortLinkStatus.ACTIVE)
.creatorId(request.getCreatorId())
.build();
shortLinkMapper.insert(entity);
// 5. 写入Redis缓存
String cacheKey = CACHE_KEY_PREFIX + shortCode;
long ttl = request.getExpireTime() != null
? ChronoUnit.SECONDS.between(LocalDateTime.now(), request.getExpireTime())
: DEFAULT_CACHE_TTL_SECONDS;
if (ttl > 0) {
redisTemplate.opsForValue().set(cacheKey, longUrl, ttl, TimeUnit.SECONDS);
}
// 6. 更新布隆过滤器
bloomFilterService.add(shortCode);
log.info("短链创建成功, shortCode={}, longUrl={}", shortCode, longUrl);
return ShortLinkCreateResponse.builder()
.shortCode(shortCode)
.shortUrl("https://s.lz.cn/" + shortCode)
.expireTime(request.getExpireTime())
.build();
}
}4.3 跳转服务(核心热路径)
@Service
@Slf4j
public class ShortLinkRedirectService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ShortLinkMapper shortLinkMapper;
@Autowired
private BloomFilterService bloomFilterService;
@Autowired
private ClickEventPublisher clickEventPublisher;
// 本地缓存,缓解Redis热点压力
private final Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
private static final String CACHE_KEY_PREFIX = "shortlink:";
// 防缓存穿透:不存在的key存空值
private static final String NULL_VALUE = "NULL";
/**
* 根据短码获取长URL,并记录点击事件
* 这是系统最热的路径,每个优化点都很关键
*/
public String redirect(String shortCode, HttpServletRequest httpRequest) {
// 第一层:本地缓存(避免高并发下Redis压力)
String longUrl = localCache.getIfPresent(shortCode);
if (longUrl != null) {
if (!NULL_VALUE.equals(longUrl)) {
publishClickAsync(shortCode, longUrl, httpRequest);
}
return NULL_VALUE.equals(longUrl) ? null : longUrl;
}
// 第二层:布隆过滤器(防缓存穿透)
if (!bloomFilterService.mightContain(shortCode)) {
localCache.put(shortCode, NULL_VALUE);
return null; // 直接404
}
// 第三层:Redis缓存
String cacheKey = CACHE_KEY_PREFIX + shortCode;
longUrl = redisTemplate.opsForValue().get(cacheKey);
if (longUrl != null) {
localCache.put(shortCode, longUrl);
publishClickAsync(shortCode, longUrl, httpRequest);
return longUrl;
}
// 第四层:数据库(缓存未命中,概率极低)
ShortLink entity = shortLinkMapper.findByCode(shortCode);
if (entity == null || entity.isExpired()) {
// 缓存空值,防止频繁打DB
redisTemplate.opsForValue().set(cacheKey, NULL_VALUE, 5, TimeUnit.MINUTES);
localCache.put(shortCode, NULL_VALUE);
return null;
}
longUrl = entity.getLongUrl();
// 回填缓存
long ttl = entity.getExpireTime() != null
? ChronoUnit.SECONDS.between(LocalDateTime.now(), entity.getExpireTime())
: 30 * 24 * 3600L;
redisTemplate.opsForValue().set(cacheKey, longUrl, ttl, TimeUnit.SECONDS);
localCache.put(shortCode, longUrl);
publishClickAsync(shortCode, longUrl, httpRequest);
return longUrl;
}
private void publishClickAsync(String shortCode, String longUrl, HttpServletRequest request) {
// 异步发送点击事件到Kafka,不阻塞跳转响应
ClickEvent event = ClickEvent.builder()
.shortCode(shortCode)
.longUrl(longUrl)
.ip(getClientIp(request))
.userAgent(request.getHeader("User-Agent"))
.referer(request.getHeader("Referer"))
.timestamp(System.currentTimeMillis())
.build();
clickEventPublisher.publishAsync(event);
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}4.4 限流实现(滑动窗口)
@Component
public class SlidingWindowRateLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String RATE_LIMIT_PREFIX = "ratelimit:";
/**
* 基于Redis的滑动窗口限流
* @param key 限流标识(如 "ip:192.168.1.1")
* @param limit 窗口内最大请求数
* @param windowSeconds 窗口大小(秒)
* @return true=允许通过,false=触发限流
*/
public boolean isAllowed(String key, int limit, int windowSeconds) {
String redisKey = RATE_LIMIT_PREFIX + key;
long now = System.currentTimeMillis();
long windowStart = now - windowSeconds * 1000L;
// Lua脚本保证原子性
String luaScript =
"local key = KEYS[1]\n" +
"local now = tonumber(ARGV[1])\n" +
"local window_start = tonumber(ARGV[2])\n" +
"local limit = tonumber(ARGV[3])\n" +
"local expire = tonumber(ARGV[4])\n" +
// 删除窗口外的旧记录
"redis.call('ZREMRANGEBYSCORE', key, 0, window_start)\n" +
// 计算当前窗口内的请求数
"local count = redis.call('ZCARD', key)\n" +
"if count < limit then\n" +
// 添加当前请求
" redis.call('ZADD', key, now, now)\n" +
" redis.call('EXPIRE', key, expire)\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(
script,
Collections.singletonList(redisKey),
String.valueOf(now),
String.valueOf(windowStart),
String.valueOf(limit),
String.valueOf(windowSeconds + 10)
);
return Long.valueOf(1L).equals(result);
}
}4.5 Controller层
@RestController
@RequestMapping
@Slf4j
public class ShortLinkController {
@Autowired
private ShortLinkCreateService createService;
@Autowired
private ShortLinkRedirectService redirectService;
@Autowired
private SlidingWindowRateLimiter rateLimiter;
/**
* 创建短链接接口
*/
@PostMapping("/api/shortlink")
public ApiResponse<ShortLinkCreateResponse> create(
@RequestBody @Validated ShortLinkCreateRequest request,
HttpServletRequest httpRequest) {
String ip = getClientIp(httpRequest);
// IP维度:每分钟最多创建20条
if (!rateLimiter.isAllowed("create:ip:" + ip, 20, 60)) {
return ApiResponse.fail(ErrorCode.RATE_LIMITED, "创建频率过高,请稍后再试");
}
return ApiResponse.success(createService.create(request));
}
/**
* 短链接跳转接口
* 这里用301还是302是个关键决策:
* 301=永久重定向,浏览器缓存,服务端统计不准
* 302=临时重定向,每次都来服务端,统计准确
* 生产推荐302
*/
@GetMapping("/{shortCode}")
public void redirect(
@PathVariable String shortCode,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
// 短码格式校验,防止注入攻击
if (!shortCode.matches("[0-9A-Za-z]{1,10}")) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
String ip = getClientIp(request);
// 跳转限流:单IP每分钟最多1000次(防刷)
if (!rateLimiter.isAllowed("redirect:ip:" + ip, 1000, 60)) {
response.sendError(429, "Too Many Requests");
return;
}
String longUrl = redirectService.redirect(shortCode, request);
if (longUrl == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.setStatus(HttpServletResponse.SC_FOUND); // 302
response.setHeader("Location", longUrl);
response.setHeader("Cache-Control", "no-cache, no-store");
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}五、扩展性设计
从1万QPS扩展到100万QPS
第一阶段(1万QPS):单机Redis + MySQL
- 单台Redis处理缓存,MySQL主从读写分离
- Nginx做负载均衡,2-4台应用服务器
- 这个阶段P99延迟轻松控制在20ms内
第二阶段(10万QPS):Redis集群 + MySQL分库
- Redis切换为集群模式,3主3从,每个主节点承担约3万QPS
- MySQL按shortCode的哈希值分16个库,每库4个表,共64张表
- 分库路由规则:
dbIndex = hash(shortCode) % 16 - 引入本地缓存(Caffeine),热点数据命中率超过85%
第三阶段(100万QPS):多级缓存 + CDN边缘节点
- CDN节点缓存热门短链的跳转结果,用户就近访问
- CDN缓存时间设为30秒(因为302不能永久缓存,短链可能被删除)
- Redis分片进一步扩容至12主12从
- 引入消息队列削峰,点击统计完全异步化
- 本地缓存容量提升至10万条,覆盖绝大多数热点
数据库分表策略细化:
public class ShortLinkRouter {
private static final int DB_COUNT = 16;
private static final int TABLE_COUNT = 4;
public String getDbName(String shortCode) {
int hash = Math.abs(shortCode.hashCode());
int dbIndex = hash % DB_COUNT;
return "shortlink_db_" + String.format("%02d", dbIndex);
}
public String getTableName(String shortCode) {
int hash = Math.abs(shortCode.hashCode());
int tableIndex = (hash / DB_COUNT) % TABLE_COUNT;
return "short_link_" + String.format("%02d", tableIndex);
}
}六、踩坑实录
坑1:用MD5截断导致的概率性短码冲突
第一版上线时用的MurmurHash截取前6位,系统运行了两个月后,某天早上收到报警:有用户点击短链,跳转到了完全不相关的页面。
排查下来是两个不同的长URL生成了相同的短码(哈希碰撞),后面存入的把前面的覆盖了。
当时处理方式是:冲突时自动加盐(在URL末尾拼接一个随机字符串再哈希),最多重试3次。但这个方案治标不治本,后来彻底换成了雪花ID + Base62,从根本上杜绝了冲突问题。
坑2:缓存雪崩,大量短链同时过期
有一次做营销活动,批量创建了50万条短链,统一过期时间是活动结束后30天。到了第31天凌晨,Redis里50万个Key同时过期,请求全部打到数据库,MySQL的QPS从平时的800瞬间飙到4万,直接把从库打挂了。
解决方案:过期时间加随机抖动,expireTime + random(0, 3600),把过期时间分散在一个小时内,不再集中爆发。
// 计算带抖动的TTL
long baseTtl = 30 * 24 * 3600L;
long jitter = ThreadLocalRandom.current().nextLong(3600); // 0~3600秒随机
redisTemplate.opsForValue().set(cacheKey, longUrl, baseTtl + jitter, TimeUnit.SECONDS);坑3:布隆过滤器误判率上升,导致大量合法短链被拒
布隆过滤器初始化时我按"最多存100万条"设的参数,误判率1%。后来系统跑了一年多,短链总量超过800万条,远超预期。布隆过滤器的误判率从1%上升到了20%,有约20%的合法短链被错误地判定为"不存在"直接返回404。
解决方案有两个:
- 定期重建布隆过滤器:从数据库把所有活跃短码重新写入一个新的布隆过滤器,然后原子替换
- 换用可扩容的布隆过滤器(Redisson的RBloomFilter支持自动扩容)
我们最终选了方案2,每天凌晨低峰期重建一次,配合Redisson的扩容策略。
坑4:短链生成接口成了SSRF攻击入口
有安全同事发现,如果传入的longUrl是内网地址(比如 http://192.168.1.1:8080/admin),短链会被成功创建,任何人访问这条短链就能通过我们的服务器访问到内网资源,这是一个典型的SSRF漏洞。
修复方式:在URL合法性校验中,增加对RFC 1918私有地址段的过滤,同时对URL做DNS解析,检查解析后的IP是否落在私有地址段。
public boolean isSafeUrl(String urlStr) {
try {
URL url = new URL(urlStr);
// 只允许http/https
if (!url.getProtocol().matches("https?")) return false;
InetAddress address = InetAddress.getByName(url.getHost());
// 阻止内网地址
if (address.isLoopbackAddress() ||
address.isSiteLocalAddress() ||
address.isLinkLocalAddress()) {
return false;
}
return true;
} catch (Exception e) {
return false;
}
}七、总结
短链接系统是系统设计里的经典题,考察点密度很高。总结一下核心决策:
| 决策点 | 选择 | 理由 |
|---|---|---|
| 短码生成 | 雪花ID + Base62 | 无冲突、不连续、分布式友好 |
| 跳转类型 | 302临时重定向 | 统计准确,可撤销 |
| 缓存策略 | 本地缓存 + Redis 两级 | 抗热点,延迟低 |
| 防穿透 | 布隆过滤器 | 轻量,效果好 |
| 统计方式 | 异步Kafka + ClickHouse | 不阻塞主流程 |
| 限流 | 滑动窗口,IP+设备双维度 | 兼顾精度和性能 |
面试时不仅要说出方案,更要解释为什么不选另一种。比如为什么用302不用301,为什么用雪花ID不用MD5,这些权衡背后的思考才是真正体现工程经验的地方。
