设计短链服务

一句话速记

短链服务核心:长 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 result

ID 生成(分布式)

方案: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 的取舍