Skip to content

如何设计短链系统

一、问题描述

1.1 业务背景

短链系统用于将长URL转换为短URL,广泛应用于:

  • 社交分享:微博、微信分享链接
  • 营销推广:短信营销、二维码
  • 统计分析:点击统计、来源追踪
  • 美化链接:提升用户体验

典型产品

  • 新浪微博:t.cn
  • 腾讯:url.cn
  • 百度:dwz.cn

1.2 核心功能

基础功能

  1. 长链生成短链:输入长URL,返回短URL
  2. 短链跳转:访问短链,302重定向到长链
  3. 过期时间:支持设置链接有效期

进阶功能

  1. 统计分析:点击次数、来源分析、地域分布
  2. 自定义短链:用户指定短链后缀
  3. 防刷:防止恶意刷点击
  4. 安全检测:拦截恶意链接

1.3 技术挑战

唯一性保证

  • 如何保证短链唯一
  • 如何避免碰撞

高并发

  • 读QPS远大于写QPS(100:1)
  • 热门短链可能每秒万次访问
  • 如何快速跳转(<50ms)

短链生成算法

  • 如何生成尽可能短的链接
  • 如何保证分布均匀

1.4 面试考察点

  • 算法设计:短链生成算法
  • 数据库设计:如何存储短链映射
  • 性能优化:如何优化高并发跳转
  • 统计分析:如何设计统计系统

二、需求分析

2.1 功能性需求

需求描述优先级
FR1输入长URL,生成短URLP0
FR2访问短URL,302跳转到长URLP0
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 算法对比

算法原理优点缺点长度
自增IDID转62进制最短、简单可预测、不安全6位
HashMD5/SHA截取分布均匀可能碰撞6-8位
随机字符随机生成安全需要检查重复6-8位
发号器分布式ID唯一、高性能依赖发号器8位

3.2 推荐方案:自增ID + Base62

原理

  1. 使用MySQL自增ID(或分布式ID)
  2. 将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 --> I

4.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] % 16

6.3 性能数据

指标优化前优化后提升
生成短链200ms50ms4x
跳转响应100ms10ms10x
写QPS100100010x
读QPS100010万100x

七、面试要点

7.1 常见追问

Q1: 短链生成算法如何选择?

A: 推荐自增ID + Base62

  • 优点:唯一、最短、性能高
  • 缺点:可预测(可通过随机起始ID解决)
  • 备选:Hash算法(需处理碰撞)

Q2: 如何防止短链被恶意刷点击?

A:

  1. IP去重:同一IP一天只统计一次
  2. User-Agent检测:过滤爬虫
  3. 验证码:高频访问要求验证码
  4. 限流:IP维度限流

Q3: 短链和长链是一对一还是多对一?

A: 通常是多对一

  • 同一长链可以生成多个短链(不同用户、不同时间)
  • 如果要做到一对一,需要先查询是否已存在

Q4: 如何实现自定义短链?

A:

  1. 检查自定义码是否已被使用
  2. 直接使用自定义码,不用ID转换
  3. 建议收费或限制权限

7.2 扩展知识点

八、总结

短链系统设计要点:

  1. 算法选择:自增ID + Base62(推荐)
  2. 缓存策略:Redis缓存 + 本地缓存
  3. 性能优化:读写分离 + 分表
  4. 统计分析:异步统计 + 消息队列

面试中要能说清楚算法选择、缓存设计、性能优化、防刷机制等关键点。


相关场景题

正在精进