短链系统设计

2017年,某内容平台在双十一做了一次短信营销,发送了1000万条短信,每条短信里带了一个商品链接。

链接是 https://www.example.com/product/detail?category=electronics&sub=phone&brand=apple&model=iphone15&color=silver&storage=256gb&campaign=1111&source=sms

80个字符的URL,加上短信平台的截断(超过70个字符自动截断),导致30%的用户收到的链接是残缺的。

短信打开率从预期的15%暴跌到3%。

解决方案很简单:把80字符的URL变成8个字符的短链。但这个"简单"背后,藏着一整套系统设计问题。

【架构权衡】

短链系统的本质是用时间换空间——用8个字符的短链,存储和还原原始URL的映射关系。它看起来简单,但涉及到高并发写入(千万级日均生成量)、海量存储(百亿级短链)、快速跳转(亿级日访问)的挑战。

一、需求澄清 🔴

1.1 面试问题清单

面试官:设计一个短链系统。

候选人:我想先确认几个问题:

  1. 日均生成量是多少?是几百条还是几亿条?

  2. 日均访问量是多少?是读多还是写多?

  3. 短链有效期需要多久?永久有效还是临时有效?

  4. 需要支持自定义短链吗?

面试官:日均生成1000万,日均访问10亿,永久有效,不需要自定义。

候选人:明白。按10亿日访问、1000万日生成来设计,核心是读远大于写(100:1),存储选型以读取性能为核心。

1.2 核心指标

读写比例:100:1(读10亿 vs 写1000万)

性能要求:
- 生成短链:P99 < 100ms
- 跳转访问:P99 < 50ms
- 可用性:99.99%(全年故障时间 < 52分钟)

存储估算:
- 短链元数据:32字节(短码8 + 原始URL平均200 + 时间戳8 + 统计字段16)
- 100亿条记录:32字节 × 100亿 = 320GB
- 考虑索引和冗余:1TB+

QPS估算:
- 日访问10亿,峰值因子5,峰值QPS = 10亿 × 5 / 86400 ≈ 58万
- Redis缓存命中率95%:回源QPS ≈ 2.9万

1.3 系统边界

需要实现:
1. 短链生成:长链 → 短链
2. 短链访问:短链 → 长链
3. 统计监控:访问量、UV、点击来源

不需要实现(或者降级处理):
1. 自定义短链(本次不考虑)
2. 批量生成(单条生成即可)
3. 长链去重(允许重复短链指向同一长链)

【架构权衡】

短链系统的设计有一个容易被忽略的点:写少读多。很多人第一反应是用数据库存储,但10亿日访问的打法,任何数据库都会被打挂。正确的思路是:Redis缓存 + 数据库持久化 + CDN加速。这是读多写少场景的经典套路。

二、短链生成算法 🔴

2.1 哈希方案

原理:MD5/SHA256长链,取前8位

示例:
MD5("https://www.example.com/product/detail") = 
  d41d8cd98f00b204e9800998ecf8427e(前8位:d41d8cd9)

问题:
1. 哈希碰撞:不同长链可能哈希到同一结果
2. 无规律:短链不递增,无法分区
3. 安全性:可以通过哈希反推原始URL

2.2 发号器方案(推荐)

原理:用分布式ID发号器生成递增序号,转62进制编码

ID生成:雪花算法 → 10进制ID → 62进制字符串
示例:
  雪花ID = 20241112120000000xxxx
  转62进制 = "abc123XY"

62进制字符集:
0-9 + a-z + A-Z = 62个字符
8位62进制 = 62^8 ≈ 218万亿

优点:
1. 递增:有利于数据库索引和分片
2. 无碰撞:ID全局唯一
3. 高效:无需存储哈希映射

2.3 62进制转换实现

// 长链转短码:ID → 62进制字符串
public class Base62Util {

    private static final String CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    private static final int SCALE = 62;

    public static String encode(long id) {
        StringBuilder sb = new StringBuilder();
        while (id > 0) {
            int remainder = (int) (id % SCALE);
            sb.append(CHARS.charAt(remainder));
            id = id / SCALE;
        }
        return sb.reverse().toString();
    }

    // 雪花ID转8位短码
    public static String toShortCode(long snowflakeId) {
        return encode(snowflakeId);
    }
}

// 示例:
// 雪花ID = 102400001
// 62进制 = 2Pnip3(只有6位)
// 补齐8位 = 00002Pnip3
// 或者直接用12位雪花ID:Base62(102400001) = 刚好8位
⚠️

短链长度不是越短越好。6位短链有568亿种组合,但8位有218万亿。如果你的日均生成量只有1000万,用6位就够了;如果未来可能增长到10亿级别,建议直接上8位。另外,发号器方案的前提是ID必须全局递增,否则转62进制后长度不可控。

2.4 面试追问

面试官:雪花ID转62进制后长度不固定怎么办?

候选人:两个方案:

一是补零对齐:统一生成8位,不够的前面补0,如 00002Pnip3

二是用固定位数截断:取雪花ID的低位,比如只取低40位,保证不超过8位62进制

我建议用补零方案,8位短链足够支撑218万亿次生成,用不完的。

三、数据存储 🔴

3.1 多级存储架构

访问链路:
用户 → CDN → Nginx → Redis集群 → MySQL分库分表

缓存层(Redis):
- 热点数据:最近7天生成的短链
- 命中率目标:95%
- 过期策略:TTL=7天,定期续期

持久层(MySQL):
- 全量数据:所有历史短链
- 分片策略:按短码哈希分库分表
- 副本:1主2从

3.2 数据库表设计

-- 短链元数据表
CREATE TABLE short_url (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    short_code VARCHAR(16) NOT NULL,  -- 短链码
    long_url VARCHAR(2048) NOT NULL,  -- 原始URL
    create_time DATETIME NOT NULL,
    expire_time DATETIME DEFAULT NULL, -- 过期时间,NULL=永久
    status TINYINT DEFAULT 1,         -- 1=正常,0=禁用
    creator_id BIGINT,                -- 创建者ID
    INDEX idx_short_code (short_code), -- 短码索引
    INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.3 分库分表策略

方案:按 short_code 哈希取模

分库:4个库
db = hash(short_code) % 4

分表:每个库8张表
table = hash(short_code) / 4 % 8

总表数:4 × 8 = 32张表
单表容量:100亿 / 32 ≈ 3亿条

扩容方案:
- 当前4库8表 = 32个表
- 扩容时改为8库8表 = 64个表
- 扩容迁移用双写 + 数据校验

3.4 缓存预热

Redis Key设计:
short:{code} → {long_url}

TTL策略:
- 生成后立即写入Redis,TTL=7天
- 访问时续期:TTL = max(TTL, 7天)
- 永久有效的链永不过期

热点数据:
- 近7天生成的短链都在Redis
- 命中率 = 近7天生成量 / 总生成量
- 假设7天生成7000万,Redis存7000万条 × 2KB ≈ 140GB

四、短链跳转流程 🟡

4.1 302跳转 vs 301跳转

301跳转(永久重定向):
- 浏览器缓存跳转结果
- 第二次访问直接跳转,不经过服务端
- 优点:性能好,减轻服务端压力
- 缺点:无法统计真实访问量

302跳转(临时重定向):
- 每次都经过服务端
- 优点:可以统计每次访问
- 缺点:无法利用浏览器缓存

结论:短链系统用302跳转,统计优先。

4.2 高性能跳转实现

@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
    // Step 1: Redis查询
    String longUrl = redisTemplate.opsForValue().get("short:" + shortCode);

    if (longUrl != null) {
        // 访问统计异步上报
        reportAccessAsync(shortCode);
        // 续期TTL
        redisTemplate.expire("short:" + shortCode, 7, TimeUnit.DAYS);
        return ResponseEntity.status(HttpStatus.FOUND)
            .location(URI.create(longUrl))
            .build();
    }

    // Step 2: 数据库查询
    LongUrlDO record = shortUrlDAO.selectByShortCode(shortCode);
    if (record == null) {
        return ResponseEntity.notFound().build();
    }

    // Step 3: 回填Redis
    redisTemplate.opsForValue().set("short:" + shortCode, record.getLongUrl(), 7, TimeUnit.DAYS);

    // Step 4: 统计上报
    reportAccessAsync(shortCode);

    return ResponseEntity.status(HttpStatus.FOUND)
        .location(URI.create(record.getLongUrl()))
        .build();
}

4.3 访问统计

统计维度:
- PV:每次访问
- UV:按IP/Cookie去重
- 来源:Referer字段(微信/微博/短信/APP等)
- 地域:IP解析地理位置
- 时间:每小时/每天聚合

实现方案:
- 实时统计:Redis HyperLogLog(UV)+ INCR(PV)
- 离线统计:Kafka → Flink → Hive
- 展示:Grafana看板

五、生产避坑 🟡

5.1 高频翻车点

翻车1:短链被微信/QQ拦截

问题:生成的短链被微信安全拦截,显示"网页包含诱导分享内容"
根因:短链域名被举报或被识别为营销链接
解决方案:
1. 使用多个域名轮换:d1.cn, d2.cn, d3.cn...
2. 域名备案正规化
3. 接入微信白名单

翻车2:Redis缓存雪崩

问题:大量短链在同一时刻过期,瞬间打到数据库
场景:系统上线时,所有短链TTL=7天,7天后同时过期
解决方案:
1. TTL加随机偏移:7天 + random(0, 1天)
2. 永不过期的短链单独处理
3. 异步续期:在过期前主动刷新热点数据

翻车3:短链生成QPS不足

问题:发号器QPS不够
场景:高峰期1000万/天的生成量,峰值QPS 5000
解决方案:
1. 批量预生成ID段:一次向发号器申请1000个ID
2. 本地ID生成器:用AtomicLong自增 + 机器标识
3. 热点数据本地缓冲:生成后先写本地队列,批量写Redis

翻车4:长链超长

问题:原始URL超过2048字符
场景:URL参数过多或Base64编码的长URL
解决方案:
1. 数据库字段设为TEXT或LONGTEXT
2. 长度超限时返回错误:长链超长,请缩短
3. 或者:长链存MySQL,短链存Redis,key指向MySQL记录

5.2 性能监控指标

指标目标值告警阈值
跳转P99延迟<30ms>50ms
跳转P999延迟<100ms>200ms
Redis命中率>95%<90%
短链生成成功率>99.9%<99.5%
系统可用性>99.99%<99.9%

六、工程代价 🟢

维度评估
开发成本中(核心功能简单,存储和缓存复杂)
运维成本高(Redis集群 + MySQL分库分表)
存储成本高(百亿级数据,约2-5TB原始数据)
扩展性好(分库分表 + 缓存预热可水平扩展)
回滚风险低(降级为直接返回错误,不影响其他服务)

七、真实面试回放 🟡

面试官:设计一个短链系统,日均生成1000万,日均访问10亿,QPS能扛到多少?

候选人(小林):先做容量估算:

日访问10亿,峰值因子5,峰值QPS = 58万

Redis单机15万QPS,所以需要至少4台Redis集群

但Redis命中率能做到95%,回源QPS = 58万 × 5% = 2.9万

MySQL单表5000 QPS,需要6个分片,约12张表

结论:Redis集群能扛58万QPS,MySQL分库分表能扛2.9万回源QPS。

面试官:如果Redis全挂了怎么办?

小林:降级方案分三级:

第一级:本地缓存兜底热点数据

第二级:降级为数据库直查,但限流(最多1000 QPS)

第三级:返回"服务繁忙",用户稍后重试

面试官:10亿日访问的数据怎么存储?

小林:按7:3估算:7天内的访问量占总访问的70%,所以:

Redis存储:7天 × 10亿 × 70% = 49亿条 × 200字节 = 980GB,16台Redis足够了

MySQL存储:全量历史数据,约100亿条 × 200字节 = 2TB

分库分表:按short_code哈希,8库8表,单表最多约1.5亿条

面试官:短链被恶意刷怎么办?

小林:两个维度:

一是访问维度:如果某IP每秒访问超过1000次,限流或封禁

二是生成维度:每个账号限制生成频率,比如每秒最多生成10条

如果是分布式攻击,需要接入风控系统,识别机器行为

面试官:可以。你来总结一下核心设计。

小林:核心三点:

  1. 发号器生成短码:雪花ID + 62进制,保证唯一且递增

  2. 多级存储:Redis缓存(95%命中) + MySQL分库分表,全量数据持久化

  3. 三级降级:本地缓存 → 数据库直查 → 限流熔断,保证高可用

【面试官手记】

小林这场面试的关键亮点:

  1. 量化分析到位:QPS估算、存储估算都有具体数字
  2. 知道Redis缓存命中率的意义(58万QPS回源到2.9万)
  3. 降级方案分三级,有兜底意识
  4. 风控意识:知道短链可能被刷

这场面试属于P6中高级水平,亮点在于"量化"和"兜底",不是单纯的背架构图。

扣分点:没有讲到"短链安全性"(比如短链是否可以预测、是否可以枚举),这是P7的追问方向。

短链系统的设计,核心就三件事:

  1. 怎么生成:发号器 + 62进制
  2. 怎么存储:Redis缓存热点 + MySQL持久化
  3. 怎么跳转:302重定向 + 访问统计

记住:这是一个读多写少的系统,读写比100:1,所有设计都要围绕读取性能优化。