如何设计短链系统
一、问题描述
1.1 业务背景
短链系统用于将长URL转换为短URL,广泛应用于:
- 社交分享:微博、微信分享链接
- 营销推广:短信营销、二维码
- 统计分析:点击统计、来源追踪
- 美化链接:提升用户体验
典型产品:
- 新浪微博:t.cn
- 腾讯:url.cn
- 百度:dwz.cn
1.2 核心功能
基础功能:
- 长链生成短链:输入长URL,返回短URL
- 短链跳转:访问短链,302重定向到长链
- 过期时间:支持设置链接有效期
进阶功能:
- 统计分析:点击次数、来源分析、地域分布
- 自定义短链:用户指定短链后缀
- 防刷:防止恶意刷点击
- 安全检测:拦截恶意链接
1.3 技术挑战
唯一性保证:
- 如何保证短链唯一
- 如何避免碰撞
高并发:
- 读QPS远大于写QPS(100:1)
- 热门短链可能每秒万次访问
- 如何快速跳转(<50ms)
短链生成算法:
- 如何生成尽可能短的链接
- 如何保证分布均匀
1.4 面试考察点
- 算法设计:短链生成算法
- 数据库设计:如何存储短链映射
- 性能优化:如何优化高并发跳转
- 统计分析:如何设计统计系统
二、需求分析
2.1 功能性需求
| 需求 | 描述 | 优先级 |
|---|---|---|
| FR1 | 输入长URL,生成短URL | P0 |
| FR2 | 访问短URL,302跳转到长URL | P0 |
| FR3 | 支持自定义短链后缀 | P1 |
| FR4 | 支持设置过期时间 | P1 |
| FR5 | 统计点击次数 | P1 |
| FR6 | 来源分析(Referer、IP、设备) | P2 |
| FR7 | 防刷检测 | P2 |
2.2 非功能性需求
性能需求:
- 生成短链:<100ms
- 跳转响应:<50ms
- 支持QPS:写1000+,读10万+
可用性需求:
- 系统可用性:99.9%
- 短链永久有效(除非设置过期)
短链要求:
- 长度:6-8个字符
- 字符集:[a-zA-Z0-9](62个字符)
- 可读性:避免歧义字符(0O、1lI)
2.3 数据规模估算
假设:
- 日新增短链:100万
- 日访问量:1亿次
- 保存时间:3年
计算:
总短链数 = 100万 × 365 × 3 ≈ 10亿
存储 = 10亿 × (8B短链 + 200B长链 + 50B其他) ≈ 250GB
写QPS = 100万 / 86400 ≈ 12 QPS
读QPS = 1亿 / 86400 ≈ 1157 QPS(峰值5000+)三、短链生成算法
3.1 算法对比
| 算法 | 原理 | 优点 | 缺点 | 长度 |
|---|---|---|---|---|
| 自增ID | ID转62进制 | 最短、简单 | 可预测、不安全 | 6位 |
| Hash | MD5/SHA截取 | 分布均匀 | 可能碰撞 | 6-8位 |
| 随机字符 | 随机生成 | 安全 | 需要检查重复 | 6-8位 |
| 发号器 | 分布式ID | 唯一、高性能 | 依赖发号器 | 8位 |
3.2 推荐方案:自增ID + Base62
原理:
- 使用MySQL自增ID(或分布式ID)
- 将ID转换为62进制(a-z, A-Z, 0-9)
优点:
- 唯一性保证
- 长度最短
- 性能高
实现:
go
package shorturl
import (
"strings"
)
const base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// IDToShortCode 将ID转换为短链码
func IDToShortCode(id int64) string {
if id == 0 {
return string(base62Chars[0])
}
var result []byte
base := int64(len(base62Chars))
for id > 0 {
remainder := id % base
result = append([]byte{base62Chars[remainder]}, result...)
id = id / base
}
return string(result)
}
// ShortCodeToID 将短链码转换为ID
func ShortCodeToID(code string) int64 {
var id int64
base := int64(len(base62Chars))
for _, char := range code {
id = id*base + int64(strings.IndexByte(base62Chars, byte(char)))
}
return id
}
// 示例
// ID: 1234567890
// Base62: 1LY7VK
// 长度: 6位3.3 Hash算法(备选)
go
// HashToShortCode 使用MD5生成短链
func HashToShortCode(url string) string {
hash := md5.Sum([]byte(url))
// 取前6个字节
hashBytes := hash[:6]
// 转换为Base62
var result []byte
for _, b := range hashBytes {
result = append(result, base62Chars[int(b)%62])
}
return string(result)
}
// 需要检查碰撞
func GenerateWithHash(url string) (string, error) {
for i := 0; i < 10; i++ { // 最多重试10次
code := HashToShortCode(url + fmt.Sprint(i))
if !Exists(code) {
return code, nil
}
}
return "", errors.New("hash collision")
}四、系统设计
4.1 架构图
mermaid
graph TB
subgraph 客户端
A[用户]
end
subgraph 接入层
B[Nginx]
B --> C[短链生成API]
B --> D[短链跳转API]
end
subgraph 业务层
C --> E[短链服务]
D --> F[跳转服务]
D --> G[统计服务]
end
subgraph 缓存层
H[Redis]
H --> H1[短链映射<br/>Key: shortCode<br/>Value: longUrl]
H --> H2[点击统计<br/>Key: click:shortCode<br/>Value: count]
end
subgraph 存储层
I[MySQL]
I --> I1[短链表]
I --> I2[统计表]
end
subgraph 消息队列
J[Kafka]
G --> J
J --> K[统计消费者]
K --> I2
end
A --> B
E --> H
E --> I
F --> H
F --> I4.2 数据库设计
短链映射表
sql
CREATE TABLE short_url (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(10) UNIQUE NOT NULL COMMENT '短链码',
long_url VARCHAR(2048) NOT NULL COMMENT '原始URL',
user_id BIGINT COMMENT '创建用户',
expire_time DATETIME COMMENT '过期时间',
click_count INT DEFAULT 0 COMMENT '点击次数',
status TINYINT DEFAULT 1 COMMENT '状态 1正常 2已过期 3已删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_short_code (short_code),
KEY idx_user (user_id, create_time),
KEY idx_long_url (long_url(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='短链映射表';点击统计表
sql
CREATE TABLE url_statistics (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(10) NOT NULL,
click_date DATE NOT NULL,
click_count INT DEFAULT 0,
unique_ip_count INT DEFAULT 0,
referer_stats JSON COMMENT '来源统计',
device_stats JSON COMMENT '设备统计',
region_stats JSON COMMENT '地域统计',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_code_date (short_code, click_date),
KEY idx_date (click_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='URL统计表';4.3 Redis数据结构
redis
# 1. 短链映射(String)
Key: url:{shortCode}
Value: {longUrl}
TTL: 永久(或根据expire_time)
# 2. 点击统计(String)
Key: click:{shortCode}
Value: {count}
TTL: 永久
# 3. 今日点击(Hash)
Key: click:today:{shortCode}
Field: {hour}
Value: {count}
TTL: 1天
# 4. IP去重(Set,防刷)
Key: click:ip:{shortCode}:{date}
Value: {ip1, ip2, ...}
TTL: 1天五、核心实现
5.1 Go实现
点击查看完整实现
go
package shorturl
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"time"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
)
type ShortURLService struct {
db *gorm.DB
redis *redis.Client
}
type ShortURL struct {
ID int64
ShortCode string
LongURL string
UserID int64
ExpireTime *time.Time
ClickCount int
Status int8
CreateTime time.Time
UpdateTime time.Time
}
// Generate 生成短链
func (s *ShortURLService) Generate(ctx context.Context, longURL string, userID int64, expireDays int) (string, error) {
// 1. 检查URL是否已存在
existing, err := s.findByLongURL(longURL)
if err == nil && existing != nil {
return existing.ShortCode, nil
}
// 2. 创建短链记录
shortURL := &ShortURL{
LongURL: longURL,
UserID: userID,
Status: 1,
CreateTime: time.Now(),
}
if expireDays > 0 {
expireTime := time.Now().AddDate(0, 0, expireDays)
shortURL.ExpireTime = &expireTime
}
// 3. 插入数据库(获取自增ID)
err = s.db.Create(shortURL).Error
if err != nil {
return "", err
}
// 4. ID转短链码
shortCode := IDToShortCode(shortURL.ID)
// 5. 更新短链码
err = s.db.Model(shortURL).Update("short_code", shortCode).Error
if err != nil {
return "", err
}
// 6. 写入Redis
cacheKey := fmt.Sprintf("url:%s", shortCode)
ttl := time.Duration(0) // 永久
if shortURL.ExpireTime != nil {
ttl = time.Until(*shortURL.ExpireTime)
}
s.redis.Set(ctx, cacheKey, longURL, ttl)
return shortCode, nil
}
// Redirect 短链跳转
func (s *ShortURLService) Redirect(ctx context.Context, shortCode string, clientIP string) (string, error) {
// 1. 从Redis获取
cacheKey := fmt.Sprintf("url:%s", shortCode)
longURL, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
// 缓存命中
// 异步统计
go s.recordClick(shortCode, clientIP)
return longURL, nil
}
// 2. 从数据库查询
var shortURL ShortURL
err = s.db.Where("short_code = ? AND status = 1", shortCode).First(&shortURL).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", fmt.Errorf("short url not found")
}
return "", err
}
// 3. 检查是否过期
if shortURL.ExpireTime != nil && shortURL.ExpireTime.Before(time.Now()) {
return "", fmt.Errorf("short url expired")
}
// 4. 写回Redis
ttl := time.Duration(0)
if shortURL.ExpireTime != nil {
ttl = time.Until(*shortURL.ExpireTime)
}
s.redis.Set(ctx, cacheKey, shortURL.LongURL, ttl)
// 5. 异步统计
go s.recordClick(shortCode, clientIP)
return shortURL.LongURL, nil
}
// recordClick 记录点击
func (s *ShortURLService) recordClick(shortCode, clientIP string) {
ctx := context.Background()
// 1. 总点击数+1
clickKey := fmt.Sprintf("click:%s", shortCode)
s.redis.Incr(ctx, clickKey)
// 2. 今日点击统计
hour := time.Now().Hour()
todayKey := fmt.Sprintf("click:today:%s", shortCode)
s.redis.HIncrBy(ctx, todayKey, fmt.Sprint(hour), 1)
s.redis.Expire(ctx, todayKey, 24*time.Hour)
// 3. IP去重(防刷)
date := time.Now().Format("2006-01-02")
ipKey := fmt.Sprintf("click:ip:%s:%s", shortCode, date)
// 检查IP是否已记录
isMember, _ := s.redis.SIsMember(ctx, ipKey, clientIP).Result()
if isMember {
return // 今天已点击,不重复统计
}
// 记录IP
s.redis.SAdd(ctx, ipKey, clientIP)
s.redis.Expire(ctx, ipKey, 24*time.Hour)
// 4. 发送到消息队列(异步持久化)
// s.mq.Send("click_topic", ClickMessage{...})
}
// GetStatistics 获取统计信息
func (s *ShortURLService) GetStatistics(ctx context.Context, shortCode string) (*Statistics, error) {
// 1. 总点击数
clickKey := fmt.Sprintf("click:%s", shortCode)
totalClicks, _ := s.redis.Get(ctx, clickKey).Int64()
// 2. 今日点击分布
todayKey := fmt.Sprintf("click:today:%s", shortCode)
hourlyClicks, _ := s.redis.HGetAll(ctx, todayKey).Result()
// 3. 今日独立IP数
date := time.Now().Format("2006-01-02")
ipKey := fmt.Sprintf("click:ip:%s:%s", shortCode, date)
uniqueIPs, _ := s.redis.SCard(ctx, ipKey).Result()
return &Statistics{
TotalClicks: totalClicks,
TodayClicks: s.sumHourlyClicks(hourlyClicks),
UniqueIPs: uniqueIPs,
HourlyClicks: hourlyClicks,
}, nil
}
// GenerateCustom 生成自定义短链
func (s *ShortURLService) GenerateCustom(ctx context.Context, longURL, customCode string, userID int64) (string, error) {
// 1. 检查自定义短链是否已被使用
var count int64
err := s.db.Model(&ShortURL{}).Where("short_code = ?", customCode).Count(&count).Error
if err != nil {
return "", err
}
if count > 0 {
return "", fmt.Errorf("custom short code already exists")
}
// 2. 创建短链
shortURL := &ShortURL{
ShortCode: customCode,
LongURL: longURL,
UserID: userID,
Status: 1,
CreateTime: time.Now(),
}
err = s.db.Create(shortURL).Error
if err != nil {
return "", err
}
// 3. 写入Redis
cacheKey := fmt.Sprintf("url:%s", customCode)
s.redis.Set(ctx, cacheKey, longURL, 0)
return customCode, nil
}
func (s *ShortURLService) findByLongURL(longURL string) (*ShortURL, error) {
var shortURL ShortURL
err := s.db.Where("long_url = ? AND status = 1", longURL).First(&shortURL).Error
if err != nil {
return nil, err
}
return &shortURL, nil
}
func (s *ShortURLService) sumHourlyClicks(hourlyClicks map[string]string) int64 {
var sum int64
for _, count := range hourlyClicks {
var n int64
fmt.Sscanf(count, "%d", &n)
sum += n
}
return sum
}
type Statistics struct {
TotalClicks int64
TodayClicks int64
UniqueIPs int64
HourlyClicks map[string]string
}5.2 Java实现
java
@Service
public class ShortURLService {
@Autowired
private ShortURLMapper shortURLMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 生成短链
*/
public String generate(String longUrl, Long userId, Integer expireDays) {
// 1. 检查是否已存在
ShortURL existing = shortURLMapper.findByLongUrl(longUrl);
if (existing != null) {
return existing.getShortCode();
}
// 2. 创建短链记录
ShortURL shortURL = new ShortURL();
shortURL.setLongUrl(longUrl);
shortURL.setUserId(userId);
shortURL.setStatus((byte) 1);
shortURL.setCreateTime(new Date());
if (expireDays != null && expireDays > 0) {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, expireDays);
shortURL.setExpireTime(cal.getTime());
}
// 3. 插入数据库
shortURLMapper.insert(shortURL);
// 4. ID转短链码
String shortCode = Base62.encode(shortURL.getId());
// 5. 更新短链码
shortURL.setShortCode(shortCode);
shortURLMapper.updateShortCode(shortURL.getId(), shortCode);
// 6. 写入Redis
String cacheKey = "url:" + shortCode;
redisTemplate.opsForValue().set(cacheKey, longUrl);
return shortCode;
}
/**
* 短链跳转
*/
public String redirect(String shortCode, String clientIp) {
// 1. 从Redis获取
String cacheKey = "url:" + shortCode;
String longUrl = (String) redisTemplate.opsForValue().get(cacheKey);
if (longUrl != null) {
// 异步统计
CompletableFuture.runAsync(() -> recordClick(shortCode, clientIp));
return longUrl;
}
// 2. 从数据库查询
ShortURL shortURL = shortURLMapper.findByShortCode(shortCode);
if (shortURL == null) {
throw new NotFoundException("短链不存在");
}
// 3. 检查过期
if (shortURL.getExpireTime() != null &&
shortURL.getExpireTime().before(new Date())) {
throw new ExpiredException("短链已过期");
}
// 4. 写回Redis
redisTemplate.opsForValue().set(cacheKey, shortURL.getLongUrl());
// 5. 异步统计
CompletableFuture.runAsync(() -> recordClick(shortCode, clientIp));
return shortURL.getLongUrl();
}
/**
* 记录点击
*/
private void recordClick(String shortCode, String clientIp) {
// 总点击数+1
String clickKey = "click:" + shortCode;
redisTemplate.opsForValue().increment(clickKey, 1);
// 今日点击
int hour = LocalDateTime.now().getHour();
String todayKey = "click:today:" + shortCode;
redisTemplate.opsForHash().increment(todayKey, String.valueOf(hour), 1);
redisTemplate.expire(todayKey, 1, TimeUnit.DAYS);
// IP去重
String date = LocalDate.now().toString();
String ipKey = "click:ip:" + shortCode + ":" + date;
Boolean isMember = redisTemplate.opsForSet().isMember(ipKey, clientIp);
if (Boolean.TRUE.equals(isMember)) {
return;
}
redisTemplate.opsForSet().add(ipKey, clientIp);
redisTemplate.expire(ipKey, 1, TimeUnit.DAYS);
}
}java
public class Base62 {
private static final String BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int BASE = BASE62.length();
/**
* ID转Base62
*/
public static String encode(long id) {
if (id == 0) {
return String.valueOf(BASE62.charAt(0));
}
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(BASE62.charAt((int) (id % BASE)));
id /= BASE;
}
return sb.reverse().toString();
}
/**
* Base62转ID
*/
public static long decode(String code) {
long id = 0;
for (char c : code.toCharArray()) {
id = id * BASE + BASE62.indexOf(c);
}
return id;
}
}六、性能优化
6.1 缓存优化
布隆过滤器(防止缓存穿透):
go
// 判断短链是否存在
func (s *ShortURLService) Exists(shortCode string) bool {
return s.bloomFilter.Test([]byte(shortCode))
}本地缓存(热点数据):
go
// Guava Cache
type LocalCache struct {
cache *cache.Cache
}
func (c *LocalCache) Get(shortCode string) (string, bool) {
if val, found := c.cache.Get(shortCode); found {
return val.(string), true
}
return "", false
}6.2 数据库优化
分表:
sql
-- 按短链码首字母分表
CREATE TABLE short_url_0 LIKE short_url;
CREATE TABLE short_url_1 LIKE short_url;
-- ... 共16张表
-- 路由规则
table_index = shortCode[0] % 166.3 性能数据
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 生成短链 | 200ms | 50ms | 4x |
| 跳转响应 | 100ms | 10ms | 10x |
| 写QPS | 100 | 1000 | 10x |
| 读QPS | 1000 | 10万 | 100x |
七、面试要点
7.1 常见追问
Q1: 短链生成算法如何选择?
A: 推荐自增ID + Base62
- 优点:唯一、最短、性能高
- 缺点:可预测(可通过随机起始ID解决)
- 备选:Hash算法(需处理碰撞)
Q2: 如何防止短链被恶意刷点击?
A:
- IP去重:同一IP一天只统计一次
- User-Agent检测:过滤爬虫
- 验证码:高频访问要求验证码
- 限流:IP维度限流
Q3: 短链和长链是一对一还是多对一?
A: 通常是多对一
- 同一长链可以生成多个短链(不同用户、不同时间)
- 如果要做到一对一,需要先查询是否已存在
Q4: 如何实现自定义短链?
A:
- 检查自定义码是否已被使用
- 直接使用自定义码,不用ID转换
- 建议收费或限制权限
7.2 扩展知识点
八、总结
短链系统设计要点:
- 算法选择:自增ID + Base62(推荐)
- 缓存策略:Redis缓存 + 本地缓存
- 性能优化:读写分离 + 分表
- 统计分析:异步统计 + 消息队列
面试中要能说清楚算法选择、缓存设计、性能优化、防刷机制等关键点。
相关场景题:
