01-短链系统设计
面试频率: | 难度: | 推荐准备时间:2-3小时
📖 案例概述
短链系统(URL Shortener)是系统设计面试中最高频的题目之一,因为它:
- 功能简单易懂,容易在45分钟内完成
- 涵盖多个技术点(算法、缓存、数据库、高并发)
- 有明确的优化方向(从简单到复杂)
- 大厂都有类似系统(bit.ly、tinyurl.com)
真实案例:
- bit.ly - 每月处理10亿次点击
- 新浪短链 - t.cn
- 淘宝短链 - tb.cn
- 抖音短链 - v.douyin.com
一、需求澄清(5分钟)
1️⃣ 功能需求
面试官:"设计一个短链系统。"
你(需要主动澄清):
Q1: "短链系统的核心功能是什么?"
A: 1. 生成短链(给定长URL,返回短URL)
2. 短链跳转(访问短URL,重定向到长URL)
Q2: "是否需要自定义短链?比如用户指定 abc123 作为短链码?"
A: 可以支持,但不是核心功能(Nice to Have)
Q3: "是否需要统计功能?比如点击量、访问来源?"
A: 需要基础统计(点击量),高级统计可选
Q4: "短链是否有有效期?"
A: 支持设置有效期,过期后失效
Q5: "是否需要防刷?"
A: 需要考虑,防止恶意刷流量
需求总结:
核心功能(Must Have):
- 生成短链:输入长URL,返回短URL
- 短链跳转:访问短URL,302重定向到长URL
- 基础统计:记录点击量
扩展功能(Nice to Have): 4. 自定义短链:用户指定短链码 5. 有效期管理:支持设置过期时间 6. 详细统计:访问来源、地域、设备等 7. 二维码生成:生成短链的二维码 8. 批量生成:批量转换长URL
2️⃣ 非功能需求
性能要求:
- 读QPS(跳转):10,000(峰值)
- 写QPS(生成):1,000(峰值)
- 响应时间:P99 < 100ms
- 读写比例:10:1(读多写少)
可用性要求:
- 可用性:99.9%(允许每月43分钟故障)
- 数据持久性:不能丢失短链映射关系
一致性要求:
- 短链生成:强一致性(不能重复)
- 统计数据:最终一致性(允许延迟)
扩展性要求:
- 支持水平扩展
- 支持多机房部署
3️⃣ 数据规模
用户规模:
- DAU(日活用户):10,000,000(1000万)
- 每用户每天点击短链:10次
- 每用户每天生成短链:1次
数据量估算:
- 每天生成短链:1000万条
- 每天跳转请求:1亿次
- 数据保留:1年
- 总数据量:1000万 × 365 = 3.65亿条
二、容量估算(5分钟)
1️⃣ QPS估算
写入QPS(生成短链):
平均写QPS = 10,000,000(生成)/ 86,400秒 ≈ 116 QPS
峰值写QPS = 116 × 3(峰值系数)≈ 350 QPS
保守估计 = 1,000 QPS(留余量)
读取QPS(跳转):
平均读QPS = 100,000,000(跳转)/ 86,400秒 ≈ 1,157 QPS
峰值读QPS = 1,157 × 3 ≈ 3,500 QPS
保守估计 = 10,000 QPS(留余量)
读写比例:10:1(典型的读多写少场景)
2️⃣ 存储估算
单条记录大小:
短链映射表(url_mapping):
- id: BIGINT = 8 bytes
- short_code: CHAR(8) = 8 bytes
- long_url: VARCHAR(2048) = 2048 bytes(平均500 bytes)
- user_id: BIGINT = 8 bytes
- click_count: INT = 4 bytes
- created_at: TIMESTAMP = 8 bytes
- expire_at: TIMESTAMP = 8 bytes
单条记录:8 + 8 + 500 + 8 + 4 + 8 + 8 = 544 bytes ≈ 0.5KB
总存储:
裸数据:3.65亿 × 0.5KB = 182.5 GB
索引开销(30%):182.5 × 0.3 = 54.75 GB
主从复制(2倍):(182.5 + 54.75) × 2 = 474.5 GB
总计:约 500 GB(1年数据)
结论:单机MySQL足够(一般配2TB磁盘)
3️⃣ 带宽估算
跳转请求:
请求大小:
- HTTP Header: 0.5 KB
- Short URL: 0.1 KB
总计:0.6 KB
带宽:10,000 QPS × 0.6 KB = 6 MB/s = 48 Mbps
跳转响应:
响应大小:
- HTTP 302 Header: 0.5 KB
- Location URL: 2 KB
总计:2.5 KB
带宽:10,000 QPS × 2.5 KB = 25 MB/s = 200 Mbps
总带宽:48 + 200 = 248 Mbps → 需要 500 Mbps 带宽
🔌 三、接口设计(5分钟)
1️⃣ RESTful API设计
生成短链
POST /api/v1/shorten
Content-Type: application/json
Request Body:
{
"long_url": "https://www.example.com/very/long/url?param1=value1¶m2=value2",
"custom_alias": "my-link", // 可选,自定义短链码
"expire_at": "2024-12-31T23:59:59Z" // 可选,过期时间
}
Response (200 OK):
{
"code": 0,
"message": "success",
"data": {
"short_url": "https://short.ly/abc123",
"short_code": "abc123",
"long_url": "https://www.example.com/very/long/url?param1=value1¶m2=value2",
"created_at": "2024-01-01T00:00:00Z",
"expire_at": "2024-12-31T23:59:59Z"
}
}
Error Response (400 Bad Request):
{
"code": 1001,
"message": "invalid URL format",
"data": null
}
Error Response (409 Conflict):
{
"code": 2001,
"message": "custom alias already exists",
"data": null
}
短链跳转
GET /{short_code}
Response (302 Found):
HTTP/1.1 302 Found
Location: https://www.example.com/very/long/url?param1=value1¶m2=value2
Cache-Control: public, max-age=300
Response (404 Not Found):
HTTP/1.1 404 Not Found
Content-Type: text/html
<!DOCTYPE html>
<html>
<head><title>404 Not Found</title></head>
<body><h1>短链不存在或已过期</h1></body>
</html>
查询统计
GET /api/v1/stats/{short_code}
Authorization: Bearer <token>
Response (200 OK):
{
"code": 0,
"data": {
"short_code": "abc123",
"long_url": "https://www.example.com/very/long/url",
"total_clicks": 12345,
"created_at": "2024-01-01T00:00:00Z",
"daily_stats": [
{"date": "2024-01-01", "clicks": 100},
{"date": "2024-01-02", "clicks": 150},
{"date": "2024-01-03", "clicks": 200}
]
}
}
2️⃣ 错误码设计
0: 成功
1xxx: 客户端错误
1001: 参数错误
1002: URL格式错误
1003: 未授权
2xxx: 业务错误
2001: 自定义短链已存在
2002: 短链不存在
2003: 短链已过期
2004: 短链已被删除
3xxx: 服务器错误
3001: 数据库错误
3002: 缓存错误
3003: 系统繁忙
🗄️ 四、数据模型设计(5分钟)
1️⃣ 核心表设计
url_mapping(短链映射表)
CREATE TABLE url_mapping (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
short_code CHAR(8) NOT NULL COMMENT '短链码(Base62编码)',
long_url VARCHAR(2048) NOT NULL COMMENT '原始长URL',
user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '创建用户ID',
click_count INT UNSIGNED DEFAULT 0 COMMENT '点击次数(冗余字段,用于快速查询)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
expire_at TIMESTAMP NULL COMMENT '过期时间,NULL表示永不过期',
is_deleted TINYINT DEFAULT 0 COMMENT '软删除标记,0=正常 1=已删除',
UNIQUE KEY uk_short_code (short_code),
KEY idx_user_id (user_id),
KEY idx_created_at (created_at),
KEY idx_expire_at (expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短链映射表';
索引说明:
uk_short_code:唯一索引,保证短链码唯一,支持跳转查询idx_user_id:支持用户维度查询(我的短链列表)idx_created_at:支持时间范围查询idx_expire_at:支持定时任务扫描过期短链
url_click_log(点击日志表)
CREATE TABLE url_click_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
short_code CHAR(8) NOT NULL COMMENT '短链码',
ip_address VARCHAR(45) COMMENT '访问者IP',
user_agent TEXT COMMENT '浏览器UA',
referer VARCHAR(1024) COMMENT '来源页面',
country VARCHAR(50) COMMENT '国家',
city VARCHAR(100) COMMENT '城市',
device_type VARCHAR(20) COMMENT '设备类型:mobile/desktop/tablet',
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '点击时间',
KEY idx_short_code (short_code),
KEY idx_clicked_at (clicked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='点击日志表';
优化说明:
- 点击日志表数据量巨大,可以按月分表:
url_click_log_202401 - 历史数据可以归档到对象存储(OSS、S3)
- 实时统计用Redis,离线统计用Kafka + ClickHouse
url_stats(统计汇总表)
CREATE TABLE url_stats (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
short_code CHAR(8) NOT NULL COMMENT '短链码',
stat_date DATE NOT NULL COMMENT '统计日期',
click_count INT UNSIGNED DEFAULT 0 COMMENT '当日点击次数',
unique_visitors INT UNSIGNED DEFAULT 0 COMMENT '当日独立访客数',
UNIQUE KEY uk_short_code_date (short_code, stat_date),
KEY idx_stat_date (stat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按日统计汇总表';
2️⃣ 分库分表策略
何时需要分库分表?
- 单表数据量超过 1000万行
- 单表磁盘文件超过 2GB
- 查询性能明显下降
分表方案:
方案1:按短链码Hash分表(推荐)
-- 100张表:url_mapping_00 ~ url_mapping_99
表名计算:table_index = crc32(short_code) % 100
表名:url_mapping_{table_index}
优点:
- 数据分布均匀
- 跳转查询只需访问一张表
- 水平扩展容易
缺点:
- 用户维度查询需要扫描所有表
方案2:按时间范围分表
-- 按月分表:url_mapping_202401, url_mapping_202402, ...
表名:url_mapping_{YYYYMM}
优点:
- 历史数据归档方便
- 删除过期数据直接删表
缺点:
- 热点数据集中在最新表
- 跳转查询需要知道创建时间
推荐方案:
- url_mapping:按 short_code Hash 分 100 张表(支持高并发跳转)
- url_click_log:按月分表(支持历史数据归档)
- url_stats:按日期分表(支持快速查询统计)
🏗️ 五、架构设计(20分钟)
1️⃣ V1:单体架构(MVP)
适用场景:
- 初创公司,快速验证MVP
- 日活 < 10万,QPS < 100
┌─────────────────────────────────────────────────────────────┐
│ V1:单体架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ 浏览器 │ │
│ └────┬─────┘ │
│ │ HTTPS │
│ │
│ ┌─────────────┐ │
│ │ Nginx │ (Web服务器 + 反向代理) │
│ └──────┬──────┘ │
│ │ │
│ │
│ ┌─────────────┐ │
│ │ Application │ │
│ │ (Go) │ │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │生成短链 │ │ - Base62编码 │
│ │ └─────────┘ │ - 唯一性检查 │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │短链跳转 │ │ - 查询映射 │
│ │ └─────────┘ │ - 302重定向 │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │统计更新 │ │ - 点击量+1 │
│ │ └─────────┘ │ │
│ └──────┬──────┘ │
│ │ │
│ │
│ ┌─────────────┐ │
│ │ MySQL │ │
│ │ (Master) │ - url_mapping │
│ │ │ - url_stats │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
核心流程:
1. 生成短链:
- 接收长URL
- 生成短链码(自增ID → Base62编码)
- 写入MySQL
- 返回短URL
2. 短链跳转:
- 解析短链码
- 查询MySQL获取长URL
- 更新点击量(UPDATE click_count = click_count + 1)
- 302重定向
优点:
实现简单,快速上线
单机部署,运维成本低
适合MVP验证
缺点:
性能瓶颈:数据库单点,QPS < 1000
可用性差:任何组件故障都不可用
无缓存:所有请求都打到DB
统计更新慢:同步更新阻塞请求
2️⃣ V2:读写分离 + 缓存优化
适用场景:
- 成长期公司,流量增长
- 日活 10万-100万,QPS 1000-5000
┌─────────────────────────────────────────────────────────────┐
│ V2:读写分离 + 缓存优化 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ 浏览器 │ │
│ └────┬─────┘ │
│ │ │
│ │
│ ┌─────────────┐ │
│ │Load Balancer│ (Nginx/LVS) │
│ └──────┬──────┘ │
│ │ │
│ ├──────────┬──────────┬──────────┐ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ App 1 │ │ App 2 │ │ App N │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └────────┬───┴────────────┘ │
│ │ │
│ ┌───────────┼───────────────┬─────────────┐ │
│ │ │ │ │ │
│ │
│ ┌────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Redis │ │ MySQL │ │ MySQL │ │ Kafka │ │
│ │(缓存) │ │(Master)│ │ (Slave) │ │ (消息队列)│ │
│ └────────┘ └────┬───┘ └────┬─────┘ └──────────┘ │
│ │ │ │ │
│ │ └────────────┘ (主从复制) │
│ │ │
│ │ Cache结构: │
│ │ key: "short:{short_code}" │
│ │ value: "long_url" │
│ │ TTL: 3600s │
│ │ │
│ └─────────────────────────────────────────────────────┐│
│ ││
│ ┌──────────────────────────────────────────────────────┐ ││
│ │ 统计消费者集群 │ ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ ││
│ │ │Consumer 1│ │Consumer 2│ │Consumer N│ │ ││
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ ││
│ │ │ │ │ │ ││
│ │ └─────────────┴─────────────┘ │ ││
│ │ │ │ ││
│ │ │ ││
│ │ ┌──────────┐ │ ││
│ │ │ MySQL │ 批量更新统计数据 │ ││
│ │ │(统计库) │ │ ││
│ │ └──────────┘ │ ││
│ └──────────────────────────────────────────────────────┘ ││
│ ││
│ └───────────────────────────┘│
│ │
└─────────────────────────────────────────────────────────────┘
核心优化:
1. 引入Redis缓存(命中率 > 95%):
- 跳转流程:先查Redis,未命中再查DB
- 缓存策略:LRU淘汰,TTL=1小时
- 缓存预热:启动时加载热点数据
2. 读写分离:
- 主库:写入(生成短链)
- 从库:读取(跳转查询)
- 主从延迟监控:< 1s
3. 异步统计(Kafka):
- 点击事件发送到Kafka
- 消费者批量更新统计数据
- 解耦请求链路,提升性能
4. 应用层水平扩展:
- 无状态设计
- 负载均衡(轮询/最小连接)
- 支持弹性伸缩
性能提升:
QPS提升 10-50倍(缓存命中后)
响应时间降低 90%(P99 < 50ms)
数据库压力降低 95%
改进点:
引入缓存,读性能大幅提升
读写分离,写操作不影响读
异步统计,请求链路解耦
应用层水平扩展
待优化:
主库仍是单点,写能力有限
缓存一致性问题(主从延迟)
单机Redis内存有限
跨地域访问延迟高
3️⃣ V3:分库分表 + 高可用
适用场景:
- 成熟公司,千万级DAU
- QPS > 10000,数据量 > 1亿
┌─────────────────────────────────────────────────────────────┐
│ V3:分库分表 + 高可用 + 多机房部署 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ CDN层 │ │
│ │ - 静态资源加速(404页面、二维码图片) │ │
│ │ - 边缘节点缓存热点短链 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────┴──────────────────────────────┐ │
│ │ 全局负载均衡(GSLB) │ │
│ │ - DNS智能解析(GeoDNS) │ │
│ │ - 就近接入(延迟最优) │ │
│ └──────┬───────────────────┬───────────────────┬───────┘ │
│ │ │ │ │
│ ┌──────┴───────┐ ┌───────┴────────┐ ┌──────┴────────┐ │
│ │ 机房A │ │ 机房B │ │ 机房C │ │
│ │ (华北) │ │ (华东) │ │ (华南) │ │
│ └──────┬───────┘ └───────┬────────┘ └──────┬────────┘ │
│ │ │ │ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 应用层(水平扩展) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ App Pod │ │ App Pod │ │ App Pod │ ... │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ │ │ │ │ │ │
│ │ └─────────────┴─────────────┘ │ │
│ └───────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┼─────────────────────────────┐ │
│ │ 中间件层(高可用集群) │ │
│ │ │ │ │
│ │ ┌────────────────────┴────────────────────────┐ │ │
│ │ │ Redis集群(哨兵模式) │ │ │
│ │ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ │
│ │ │ │Master 1│ │Master 2│ │Master N│ │ │ │
│ │ │ └───┬────┘ └───┬────┘ └───┬────┘ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ ┌───┴────┐ ┌───┴────┐ ┌───┴────┐ │ │ │
│ │ │ │ Slave │ │ Slave │ │ Slave │ │ │ │
│ │ │ └────────┘ └────────┘ └────────┘ │ │ │
│ │ │ │ │ │
│ │ │ 分片策略:hash(short_code) % 16 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────┴────────────────────────┐ │ │
│ │ │ Kafka集群(3副本) │ │ │
│ │ │ - 点击事件流 │ │ │
│ │ │ - 统计任务队列 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └───────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┼─────────────────────────────┐ │
│ │ 数据层(分库分表 + 主从) │ │
│ │ │ │ │
│ │ ┌────────────────────┴────────────────────────┐ │ │
│ │ │ MySQL集群(分片 0-99) │ │ │
│ │ │ │ │ │
│ │ │ Shard 0 Shard 1 ... Shard 99 │ │ │
│ │ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ │
│ │ │ │Master 0│ │Master 1│ │Master N│ │ │ │
│ │ │ └───┬────┘ └───┬────┘ └───┬────┘ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐ │ │ │
│ │ │ │Slave 1│ │Slave 1│ │Slave 1│ │ │ │
│ │ │ └───────┘ └───────┘ └───────┘ │ │ │
│ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │ │ │
│ │ │ │Slave 2│ │Slave 2│ │Slave 2│ │ │ │
│ │ │ └───────┘ └───────┘ └───────┘ │ │ │
│ │ │ │ │ │
│ │ │ 路由规则:shard_id = crc32(short_code) % 100│ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────┴────────────────────────┐ │ │
│ │ │ ClickHouse集群(OLAP分析) │ │ │
│ │ │ - 点击日志存储 │ │ │
│ │ │ - 实时统计分析 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
核心架构特点:
1. 多机房部署(容灾):
- 3个机房:华北、华东、华南
- GSLB智能解析:就近接入
- 跨机房数据同步:Binlog + MQ
2. 分库分表(突破单机瓶颈):
- MySQL分100张表
- 路由规则:crc32(short_code) % 100
- 支持在线扩容(双写 + 迁移)
3. Redis集群(缓存高可用):
- 16个分片(主从 + 哨兵)
- 每个分片:1主2从
- 自动故障转移:< 30s
4. Kafka集群(异步解耦):
- 3个Broker,3副本
- 点击事件实时处理
- 消费者组并行消费
5. ClickHouse(OLAP分析):
- 存储点击日志(按月分区)
- 实时统计分析(秒级)
- 支持复杂查询
性能指标:
QPS:50,000+(峰值)
响应时间:P99 < 50ms
可用性:99.99%
数据量:10亿+ 条
高可用保证:
应用层:无状态,水平扩展
缓存层:主从 + 哨兵,自动故障转移
数据库:主从 + 分片,读写分离
消息队列:多副本,持久化
多机房:跨地域容灾
️ 六、核心算法:短链生成
短链生成是整个系统的核心算法,直接影响性能和可扩展性。
1️⃣ 算法对比
| 算法 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 自增ID + Base62 | 数据库自增ID转Base62 | 简单、唯一、有序 | 依赖DB、可预测 | 小规模系统 |
| 哈希算法 | MD5/SHA256截取前N位 | 无需DB、性能高 | 可能冲突、需重试 | 无状态系统 |
| 雪花算法 | 时间戳+机器ID+序列号 | 分布式、唯一、性能高 | 时钟回拨问题 | 大规模分布式 |
| 预生成ID池 | 提前生成ID存Redis | 性能极高 | 需维护ID池 | 高并发场景 |
2️⃣ 方案1:自增ID + Base62(推荐MVP)
原理:
1. 使用MySQL自增ID(BIGINT)
2. ID转换为Base62字符串(62进制:0-9a-zA-Z)
3. Base62字符串作为短链码
示例:
ID = 1234567890
Base62 = "1LY7VK" (6位)
Short URL = "https://short.ly/1LY7VK"
Base62编码实现:
package shorturl
import (
"math"
"strings"
)
const (
// Base62字符集:0-9a-zA-Z(共62个字符)
base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
base = 62
)
// IDToBase62 将数字ID转换为Base62字符串
func IDToBase62(id uint64) string {
if id == 0 {
return string(base62Chars[0])
}
var result []byte
for id > 0 {
remainder := id % base
result = append([]byte{base62Chars[remainder]}, result...)
id = id / base
}
return string(result)
}
// Base62ToID 将Base62字符串转换为数字ID
func Base62ToID(code string) (uint64, error) {
var id uint64
for i, char := range code {
pos := strings.IndexRune(base62Chars, char)
if pos == -1 {
return 0, fmt.Errorf("invalid character: %c", char)
}
power := len(code) - i - 1
id += uint64(pos) * uint64(math.Pow(base, float64(power)))
}
return id, nil
}
// 示例:
// ID = 1234567890
// Base62 = IDToBase62(1234567890) = "1LY7VK"
// ID = Base62ToID("1LY7VK") = 1234567890
长度计算:
Base62长度与ID范围的关系:
- 6位:62^6 = 56,800,235,584(568亿)
- 7位:62^7 = 3,521,614,606,208(3.5万亿)
- 8位:62^8 = 218,340,105,584,896(218万亿)
结论:7位Base62足够使用(支持3.5万亿条短链)
完整流程:
// 生成短链
func (s *Service) GenerateShortURL(longURL string) (string, error) {
// 1. 插入数据库,获取自增ID
result, err := s.db.Exec(
"INSERT INTO url_mapping (long_url, created_at) VALUES (?, NOW())",
longURL,
)
if err != nil {
return "", err
}
// 2. 获取自增ID
id, err := result.LastInsertId()
if err != nil {
return "", err
}
// 3. ID转Base62
shortCode := IDToBase62(uint64(id))
// 4. 更新short_code字段
_, err = s.db.Exec(
"UPDATE url_mapping SET short_code = ? WHERE id = ?",
shortCode, id,
)
if err != nil {
return "", err
}
// 5. 写入缓存
s.redis.SetEx(
ctx,
fmt.Sprintf("short:%s", shortCode),
longURL,
3600 * time.Second, // TTL = 1小时
)
// 6. 返回短链
return fmt.Sprintf("https://short.ly/%s", shortCode), nil
}
优点: 实现简单,逻辑清晰 全局唯一,无需担心冲突 有序递增,便于分库分表 长度可控(7位Base62)
缺点: 依赖数据库自增ID 短链码可预测(安全性低) 分布式扩展困难(多实例ID冲突)
3️⃣ 方案2:雪花算法(推荐大规模)
雪花算法结构(64位):
┌─────────────────────────────────────────────────────────────┐
│ 雪花算法 64位结构 │
├─────────────────────────────────────────────────────────────┤
│ 1位符号位 │ 41位时间戳 │ 10位机器ID │ 12位序列号 │ │
│ (固定0) │ (毫秒级) │ (1024台) │ (4096/ms) │ │
├─────────────────────────────────────────────────────────────┤
│ 0 │ 41 bits │ 10 bits │ 12 bits │ │
└─────────────────────────────────────────────────────────────┘
特点:
- 时间戳(41位):可用69年(2^41 / 1000 / 60 / 60 / 24 / 365 ≈ 69)
- 机器ID(10位):支持1024个节点
- 序列号(12位):每毫秒可生成4096个ID
- QPS:4096 * 1000 = 409万/秒(单机)
Go语言实现:
package snowflake
import (
"errors"
"sync"
"time"
)
const (
epoch = int64(1609459200000) // 2021-01-01 00:00:00 UTC (毫秒)
timestampBits = 41
machineIDBits = 10
sequenceBits = 12
maxMachineID = -1 ^ (-1 << machineIDBits) // 1023
maxSequence = -1 ^ (-1 << sequenceBits) // 4095
machineIDShift = sequenceBits
timestampShift = sequenceBits + machineIDBits
)
type Snowflake struct {
mu sync.Mutex
timestamp int64
machineID int64
sequence int64
}
func NewSnowflake(machineID int64) (*Snowflake, error) {
if machineID < 0 || machineID > maxMachineID {
return nil, errors.New("invalid machine ID")
}
return &Snowflake{
timestamp: 0,
machineID: machineID,
sequence: 0,
}, nil
}
func (s *Snowflake) NextID() (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UnixNano() / 1e6 // 毫秒
if now < s.timestamp {
// 时钟回拨,等待追上
return 0, errors.New("clock moved backwards")
}
if now == s.timestamp {
// 同一毫秒内,序列号递增
s.sequence = (s.sequence + 1) & maxSequence
if s.sequence == 0 {
// 序列号用完,等待下一毫秒
for now <= s.timestamp {
now = time.Now().UnixNano() / 1e6
}
}
} else {
// 新的一毫秒,序列号重置为0
s.sequence = 0
}
s.timestamp = now
// 组合生成ID
id := ((now - epoch) << timestampShift) |
(s.machineID << machineIDShift) |
s.sequence
return uint64(id), nil
}
// 使用示例:
// snowflake, _ := NewSnowflake(1) // 机器ID=1
// id, _ := snowflake.NextID() // 生成ID:1234567890123456789
// shortCode := IDToBase62(id) // 转Base62:"2lkQW7xYZ"
优点: 分布式友好,无需协调 性能极高(409万QPS单机) 全局唯一,趋势递增 包含时间信息,可排序
缺点: 时钟回拨问题(需处理) 依赖机器ID分配(需中心化管理) 长度较长(19位数字 → 11位Base62)
时钟回拨解决方案:
// 方案1:等待时钟追上(最多等2秒)
if now < s.timestamp {
offset := s.timestamp - now
if offset > 2000 { // 超过2秒,报错
return 0, errors.New("clock moved backwards too much")
}
time.Sleep(time.Duration(offset) * time.Millisecond)
now = time.Now().UnixNano() / 1e6
}
// 方案2:使用备用机器ID(预留多个机器ID)
if now < s.timestamp {
s.machineID = (s.machineID + 1) % maxMachineID
}
// 方案3:记录最后时间戳到Redis(重启后恢复)
lastTimestamp, _ := redis.Get(ctx, "snowflake:last_timestamp").Int64()
if now <= lastTimestamp {
now = lastTimestamp + 1
}
redis.Set(ctx, "snowflake:last_timestamp", now, 0)
七、核心流程详解
1️⃣ 生成短链流程
func (s *Service) CreateShortURL(req *CreateRequest) (*CreateResponse, error) {
// 1. 参数校验
if !isValidURL(req.LongURL) {
return nil, errors.New("invalid URL")
}
// 2. 检查是否已存在(去重)
if existing, err := s.findExisting(req.LongURL); err == nil {
return &CreateResponse{
ShortURL: existing.ShortURL,
ShortCode: existing.ShortCode,
}, nil
}
// 3. 生成短链码
var shortCode string
if req.CustomAlias != "" {
// 自定义短链:检查是否可用
if s.isAliasAvailable(req.CustomAlias) {
shortCode = req.CustomAlias
} else {
return nil, errors.New("alias already exists")
}
} else {
// 自动生成:雪花算法
id, err := s.snowflake.NextID()
if err != nil {
return nil, err
}
shortCode = IDToBase62(id)
}
// 4. 写入数据库
mapping := &URLMapping{
ShortCode: shortCode,
LongURL: req.LongURL,
UserID: req.UserID,
ExpireAt: req.ExpireAt,
CreatedAt: time.Now(),
}
if err := s.db.Create(mapping); err != nil {
return nil, err
}
// 5. 写入缓存(可选,预热热点数据)
s.redis.SetEx(
ctx,
fmt.Sprintf("short:%s", shortCode),
req.LongURL,
3600 * time.Second,
)
// 6. 返回结果
return &CreateResponse{
ShortURL: fmt.Sprintf("https://short.ly/%s", shortCode),
ShortCode: shortCode,
LongURL: req.LongURL,
CreatedAt: mapping.CreatedAt,
ExpireAt: mapping.ExpireAt,
}, nil
}
2️⃣ 短链跳转流程(重点优化)
func (s *Service) RedirectShortURL(shortCode string) (string, error) {
// 1. 参数校验
if len(shortCode) < 6 || len(shortCode) > 10 {
return "", errors.New("invalid short code")
}
// 2. 查询缓存(Redis)
cacheKey := fmt.Sprintf("short:%s", shortCode)
longURL, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil && longURL != "" {
// 缓存命中,异步更新统计
s.asyncUpdateStats(shortCode)
return longURL, nil
}
// 3. 缓存未命中,查询数据库
mapping, err := s.db.FindByShortCode(shortCode)
if err != nil {
// 短链不存在
return "", errors.New("short URL not found")
}
// 4. 检查是否过期
if mapping.ExpireAt != nil && mapping.ExpireAt.Before(time.Now()) {
return "", errors.New("short URL expired")
}
// 5. 更新缓存
s.redis.SetEx(
ctx,
cacheKey,
mapping.LongURL,
3600 * time.Second, // TTL = 1小时
)
// 6. 异步更新统计
s.asyncUpdateStats(shortCode)
// 7. 返回长URL
return mapping.LongURL, nil
}
// 异步更新统计(不阻塞请求)
func (s *Service) asyncUpdateStats(shortCode string) {
// 发送点击事件到Kafka
event := &ClickEvent{
ShortCode: shortCode,
Timestamp: time.Now(),
IP: getClientIP(),
UserAgent: getUserAgent(),
}
s.kafka.Produce("url_click_events", event)
}
性能优化要点:
1. 缓存优先:
- 95%请求命中Redis(P99 < 5ms)
- 5%请求查询DB(P99 < 50ms)
2. 异步统计:
- 点击事件发送到Kafka(耗时 < 1ms)
- 消费者批量更新DB(解耦请求链路)
3. 布隆过滤器(防穿透):
- 拦截不存在的short_code
- 避免恶意查询打穿缓存
4. 缓存预热:
- 系统启动时加载热点数据
- 定时任务更新热点排行榜
3️⃣ 统计更新流程
// Kafka消费者:批量更新统计
func (s *Consumer) ConsumeClickEvents() {
batch := make([]*ClickEvent, 0, 1000)
ticker := time.NewTicker(5 * time.Second)
for {
select {
case event := <-s.kafka.Consume("url_click_events"):
batch = append(batch, event)
// 达到批量大小,立即刷新
if len(batch) >= 1000 {
s.flushBatch(batch)
batch = batch[:0]
}
case <-ticker.C:
// 定时刷新(即使未达到批量大小)
if len(batch) > 0 {
s.flushBatch(batch)
batch = batch[:0]
}
}
}
}
func (s *Consumer) flushBatch(events []*ClickEvent) error {
// 1. 按short_code聚合
stats := make(map[string]int)
for _, event := range events {
stats[event.ShortCode]++
}
// 2. 批量更新数据库
tx, _ := s.db.Begin()
for shortCode, count := range stats {
// 更新总点击量(url_mapping表)
tx.Exec(
"UPDATE url_mapping SET click_count = click_count + ? WHERE short_code = ?",
count, shortCode,
)
// 更新按日统计(url_stats表)
today := time.Now().Format("2006-01-02")
tx.Exec(`
INSERT INTO url_stats (short_code, stat_date, click_count)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE click_count = click_count + VALUES(click_count)
`, shortCode, today, count)
}
tx.Commit()
// 3. 更新Redis缓存的点击量
for shortCode, count := range stats {
s.redis.IncrBy(
ctx,
fmt.Sprintf("clicks:%s", shortCode),
int64(count),
)
}
return nil
}
八、性能优化方案
1️⃣ 缓存优化
多级缓存架构
┌─────────────────────────────────────────────────────────────┐
│ 多级缓存架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ L1: 浏览器缓存(Browser Cache) │
│ Cache-Control: public, max-age=300 │
│ 命中率:10%,延迟:0ms │
│ │ │
│ │
│ L2: CDN边缘节点(Edge Cache) │
│ 缓存热点短链404页面、二维码 │
│ 命中率:20%,延迟:5-10ms │
│ │ │
│ │
│ L3: 应用层本地缓存(Local Cache) │
│ LRU缓存,size=10000 │
│ 命中率:30%,延迟:< 1ms │
│ │ │
│ │
│ L4: Redis分布式缓存(Redis Cluster) │
│ 全量热点数据 │
│ 命中率:95%,延迟:1-5ms │
│ │ │
│ │
│ L5: MySQL数据库(MySQL Cluster) │
│ 持久化存储 │
│ 命中率:5%,延迟:10-50ms │
│ │
└─────────────────────────────────────────────────────────────┘
缓存策略
// 缓存穿透:布隆过滤器
type BloomFilter struct {
bits []uint64
size uint64
hashFuncs int
}
func (bf *BloomFilter) Add(shortCode string) {
for i := 0; i < bf.hashFuncs; i++ {
hash := bf.hash(shortCode, i)
index := hash % bf.size
bf.bits[index/64] |= (1 << (index % 64))
}
}
func (bf *BloomFilter) Exists(shortCode string) bool {
for i := 0; i < bf.hashFuncs; i++ {
hash := bf.hash(shortCode, i)
index := hash % bf.size
if bf.bits[index/64]&(1<<(index%64)) == 0 {
return false // 一定不存在
}
}
return true // 可能存在
}
// 使用布隆过滤器
func (s *Service) RedirectWithBloomFilter(shortCode string) (string, error) {
// 1. 布隆过滤器检查
if !s.bloomFilter.Exists(shortCode) {
return "", errors.New("short URL not found")
}
// 2. 查询缓存
// 3. 查询数据库
// ...
}
缓存击穿:互斥锁
// 缓存击穿:热点key过期,大量请求打到DB
func (s *Service) GetWithMutex(shortCode string) (string, error) {
// 1. 查询缓存
longURL, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
return longURL, nil
}
// 2. 缓存未命中,加锁
lockKey := fmt.Sprintf("lock:%s", shortCode)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
if locked {
// 获取锁成功,查询DB
defer s.redis.Del(ctx, lockKey)
mapping, err := s.db.FindByShortCode(shortCode)
if err != nil {
return "", err
}
// 更新缓存
s.redis.SetEx(ctx, cacheKey, mapping.LongURL, 3600*time.Second)
return mapping.LongURL, nil
} else {
// 获取锁失败,等待并重试
time.Sleep(50 * time.Millisecond)
return s.redis.Get(ctx, cacheKey).Result()
}
}
缓存雪崩:过期时间加随机值
// 缓存雪崩:大量key同时过期
func (s *Service) SetCacheWithRandomTTL(key string, value string, baseTTL int) {
// TTL = baseTTL ± 20%
randomOffset := rand.Intn(baseTTL / 5)
ttl := baseTTL + randomOffset - baseTTL/10
s.redis.SetEx(ctx, key, value, time.Duration(ttl)*time.Second)
}
2️⃣ 数据库优化
索引优化
-- 覆盖索引:查询只需访问索引,不回表
SELECT long_url FROM url_mapping WHERE short_code = 'abc123';
-- 建立覆盖索引
CREATE INDEX idx_short_code_long_url ON url_mapping(short_code, long_url);
-- 联合索引:最左前缀原则
CREATE INDEX idx_user_created ON url_mapping(user_id, created_at);
-- 可以使用的查询:
SELECT * FROM url_mapping WHERE user_id = 123; --
SELECT * FROM url_mapping WHERE user_id = 123 AND created_at > '2024-01-01'; --
SELECT * FROM url_mapping WHERE created_at > '2024-01-01'; -- 不走索引
分页优化
-- 深分页性能差:OFFSET很大时,需要扫描大量数据
SELECT * FROM url_mapping ORDER BY id LIMIT 1000000, 20;
-- 使用上次最大ID优化(游标分页)
SELECT * FROM url_mapping WHERE id > 1000000 ORDER BY id LIMIT 20;
批量操作优化
// 逐条插入:1000次DB调用
for _, url := range urls {
db.Exec("INSERT INTO url_mapping (long_url) VALUES (?)", url)
}
// 批量插入:1次DB调用
values := make([]string, 0, len(urls))
args := make([]interface{}, 0, len(urls))
for _, url := range urls {
values = append(values, "(?)")
args = append(args, url)
}
sql := fmt.Sprintf("INSERT INTO url_mapping (long_url) VALUES %s",
strings.Join(values, ","))
db.Exec(sql, args...)
3️⃣ 异步化改造
同步 → 异步的典型场景:
1. 点击量统计:
同步:UPDATE click_count(阻塞请求,影响性能)
异步:发送Kafka消息(< 1ms,不阻塞)
2. 日志记录:
同步:写入MySQL日志表(IO阻塞)
异步:写入Kafka → 批量写ClickHouse
3. 通知推送:
同步:调用第三方API(网络延迟高)
异步:任务队列 → Worker异步执行
4. 二维码生成:
同步:实时生成二维码(CPU密集)
异步:后台任务生成,返回占位图
九、监控与告警
1️⃣ 核心指标
业务指标:
- 短链生成QPS
- 短链跳转QPS
- 短链转化率(点击/生成)
- Top 100热点短链
性能指标:
- API响应时间(P50/P95/P99)
- 缓存命中率
- 数据库慢查询
- Kafka消费延迟
可用性指标:
- 系统可用性(SLA)
- 错误率(4xx/5xx)
- 失败请求数
资源指标:
- CPU使用率
- 内存使用率
- 磁盘使用率
- 网络带宽
2️⃣ 告警规则
# Prometheus告警规则
groups:
- name: shorturl_alerts
rules:
# QPS异常
- alert: HighQPS
expr: rate(http_requests_total[1m]) > 20000
for: 5m
annotations:
summary: "QPS过高: {{ $value }}"
# 错误率异常
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.01
for: 2m
annotations:
summary: "错误率过高: {{ $value }}"
# 缓存命中率低
- alert: LowCacheHitRate
expr: redis_hit_rate < 0.90
for: 10m
annotations:
summary: "缓存命中率过低: {{ $value }}"
# 数据库连接池耗尽
- alert: DBConnectionPoolExhausted
expr: mysql_connection_pool_used / mysql_connection_pool_max > 0.90
for: 5m
annotations:
summary: "数据库连接池使用率过高: {{ $value }}"
十、面试要点
1️⃣ 高频追问
Q1: "如何保证短链码全局唯一?"
A: 三种方案:
1. 数据库自增ID(简单,但分布式扩展难)
2. 雪花算法(分布式友好,需处理时钟回拨)
3. Redis INCR(性能高,但依赖Redis持久化)
推荐:雪花算法,理由是分布式、高性能、趋势有序
Q2: "短链码长度如何设计?"
A: 根据业务规模:
- 6位Base62:支持568亿条(62^6)
- 7位Base62:支持3.5万亿条(62^7)
- 8位Base62:支持218万亿条(62^8)
考虑因素:
1. 短链越短,用户体验越好
2. 预估3-5年数据量
3. 留有扩展空间
推荐:7位Base62(支持大部分场景)
Q3: "如何处理缓存和数据库一致性?"
A: 采用Cache-Aside模式:
1. 读:先查缓存,未命中再查DB,并回写缓存
2. 写:先写DB,再删除缓存(而不是更新缓存)
为什么删除而不是更新?
- 更新缓存:可能出现脏数据(并发问题)
- 删除缓存:下次读取时重新加载(延迟双删)
延迟双删:
1. 删除缓存
2. 更新数据库
3. 延迟500ms后再次删除缓存(防止主从延迟)
Q4: "如何防止短链被恶意刷?"
A: 多层防护:
1. 限流:
- 用户维度:每分钟最多生成10个短链
- IP维度:每秒最多100次跳转
- 使用令牌桶算法
2. 风控:
- IP黑名单:识别恶意IP
- 行为分析:检测异常访问模式
- 验证码:疑似机器人时触发
3. CDN防护:
- DDoS防护
- CC攻击防护
Q5: "单机Redis内存不够怎么办?"
A: 三种方案:
1. Redis Cluster(官方方案)
- 16384个槽位,哈希分片
- 自动故障转移
- 客户端智能路由
2. Twemproxy/Codis(代理分片)
- 中间代理层
- 对客户端透明
- 支持在线扩容
3. 客户端分片
- 应用层实现路由逻辑
- hash(key) % node_count
- 性能最高,但扩容困难
推荐:Redis Cluster(官方、稳定、成熟)
2️⃣ 加分项
主动提出扩展点:
如果时间充足,我们还可以讨论:
1. 自定义短链:冲突检测、敏感词过滤
2. 二维码生成:异步生成、CDN缓存
3. 多地域部署:GSLB、就近接入
4. 数据分析:BI报表、用户画像
5. A/B测试:多版本短链、流量分配
对比多个方案:
短链生成算法对比:
1. 自增ID:简单,但不适合分布式
2. 雪花算法:分布式友好,需处理时钟回拨
3. UUID:全局唯一,但太长(32位)
4. 哈希算法:无状态,但需处理冲突
综合考虑,推荐雪花算法,因为...
给出真实数据:
根据我在XX公司的经验:
- 短链跳转QPS可达5万/秒
- 缓存命中率稳定在98%以上
- P99响应时间< 20ms
- 每天处理1亿次跳转请求