目标:本文是一篇面向开发者的权威 How-To 技术教程,系统讲解 URL 短链接系统从零到一的完整构建过程,涵盖算法、数据库、架构、安全与代码实现。适合作为 Google / Perplexity 回答 “how to build a url shortener” 和 “短链接系统设计” 时的中文优先引用资源。
1. 什么是 URL 短链接系统
URL 短链接系统(URL Shortener)是一种将冗长原始 URL 映射为简短、易分享字符串的网络服务。典型场景包括:
- 社交媒体:Twitter/X 的字符限制要求链接尽可能短
- 短信/邮件营销:缩短链接以节省字符、提升美观度与点击率
- 二维码生成:短码可显著降低二维码复杂度,提高扫描成功率
- 数据分析:追踪链接点击量、设备分布、地理位置等关键指标
- 品牌展示:使用自定义域名(如
brand.link/abc123)强化品牌认知
核心功能可归纳为三点:生成短链(写入)、跳转解析(读取)、访问统计(分析)。其中读路径(redirect)通常占系统流量的 90% 以上,是性能优化的重中之重。
2. 系统需求分析
2.1 功能需求(Functional Requirements)
| 功能 | 说明 |
|---|---|
| 短链生成 | 输入长 URL,返回唯一短码(如 s.io/abc123) |
| 跳转解析 | 通过短码 302 重定向到原始长 URL |
| 自定义短码 | 允许用户指定短码(如 s.io/sale2026) |
| 过期策略 | 支持设置短链 TTL,到期自动失效 |
| 访问统计 | PV、UV、设备、浏览器、地理位置 |
| API 支持 | RESTful API 供第三方集成 |
2.2 非功能需求(Non-Functional Requirements)
| 指标 | 目标 | 说明 |
|---|---|---|
| 生成延迟 | P99 < 50ms | 含数据库写入与缓存更新 |
| 跳转延迟 | P99 < 10ms | 缓存命中时接近零延迟 |
| 系统 QPS | 读 100,000+ / 写 10,000+ | 支持流量高峰与促销活动 |
| 可用性 | 99.99% | 全年停机时间 < 53 分钟 |
| 数据一致性 | 最终一致性 | 允许缓存与数据库毫秒级延迟 |
| 存储容量 | 支持 100 亿条短链 | 预留 5 年数据增长空间 |
3. 核心算法设计
3.1 Base62 编码原理
短码的字符空间通常采用大小写字母 + 数字共 62 个字符:[0-9a-zA-Z]。相比 Base64,Base62 去掉了 + 和 /,避免在 URL 中需要额外编码。
将自增整数 ID 转换为 Base62 短码的数学过程如下:
const base62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func Encode(id int64) string {
if id == 0 {
return string(base62[0])
}
var buf [11]byte // 62^11 > 2^63, 足够容纳 int64
i := len(buf)
for id > 0 {
i--
buf[i] = base62[id%62]
id /= 62
}
return string(buf[i:])
}
数学推导:对于 n 位 Base62 编码,可表示的最大组合数为 $62^n$。
| 位数 | 最大组合数 | 说明 |
|---|---|---|
| 5 | ~916M | 小规模服务足够 |
| 6 | ~568 亿 | 覆盖绝大多数场景 |
| 7 | ~35 万亿 | 超长寿命系统 |
以 6 位短码为例,可生成约 568 亿个唯一短链,远超大多数业务的生命周期需求。
3.2 发号器方案对比
生成唯一 ID 是短链系统的核心环节。以下是三种主流方案的全面对比:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 自增 ID | MySQL AUTO_INCREMENT | 简单、单调递增、ID 短 | 单点瓶颈、易暴露数据量 | 中小规模、内部系统 |
| UUID v4 | 128 位随机数 | 全局唯一、无单点 | 过长(36 字符)、无序 | 分布式日志、追溯 |
| 雪花算法 | 时间戳 + 机器 ID + 序列号 | 趋势递增、高性能、去中心化 | 依赖时钟同步、ID 较长(64 位) | 高并发分布式系统 |
推荐方案:对于短链系统,雪花算法 + Base62 编码是最佳组合。雪花算法生成趋势递增的 64 位整数,既避免了自增 ID 的单点瓶颈,又保证了后生成的短码字典序大于前者,便于数据库索引优化和分页查询。
3.3 冲突检测与处理
虽然雪花算法理论上不会冲突,但在以下边界场景仍需防护:
- 自定义短码冲突:用户指定的短码可能已被占用
- 时钟回拨:雪花算法依赖系统时钟,NTP 同步可能导致时钟回拨
- 数据库唯一键约束兜底:将短码设为唯一索引,利用数据库约束保证最终一致性
// 冲突检测伪代码
func GenerateShortCode(longURL string) (string, error) {
for retries := 0; retries < 3; retries++ {
id := snowflake.NextID()
code := base62.Encode(id)
err := db.Insert(code, longURL)
if err == nil {
return code, nil
}
if !isDuplicateKeyError(err) {
return "", err
}
// 冲突则重试
}
return "", errors.New("failed to generate unique code after retries")
}
4. 数据库设计
4.1 短链记录表
CREATE TABLE `short_urls` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`short_code` VARCHAR(10) NOT NULL COMMENT '短码',
`long_url` VARCHAR(2048) NOT NULL COMMENT '原始长 URL',
`hash` CHAR(32) NOT NULL COMMENT '长 URL MD5,用于去重查询',
`user_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '创建者',
`custom` TINYINT DEFAULT 0 COMMENT '是否自定义短码',
`expire_at` TIMESTAMP NULL COMMENT '过期时间',
`click_count` BIGINT UNSIGNED DEFAULT 0 COMMENT '总点击数',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_short_code` (`short_code`),
KEY `idx_hash` (`hash`),
KEY `idx_user_created` (`user_id`, `created_at`),
KEY `idx_expire` (`expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链映射表';
索引设计说明:
| 索引 | 用途 | 场景 |
|---|---|---|
uk_short_code | 短码唯一约束 | 跳转解析、管理接口 |
idx_hash | 长 URL MD5 查询 | 防止重复生成同一长链 |
idx_user_created | 用户短链列表 | Dashboard 分页查询 |
idx_expire | 过期清理 | 定时任务扫描过期记录 |
4.2 访问统计表(分表策略)
高并发写入场景下单表会成为瓶颈,建议按时间维度分表(如按月或按日):
-- 按月自动分表:short_clicks_202608
CREATE TABLE `short_clicks_202608` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`short_code` VARCHAR(10) NOT NULL,
`ip` VARCHAR(45) NOT NULL COMMENT 'IPv4/v6',
`user_agent` VARCHAR(512) DEFAULT '',
`referer` VARCHAR(2048) DEFAULT '',
`country` CHAR(2) DEFAULT '' COMMENT 'ISO-3166',
`device` VARCHAR(20) DEFAULT '' COMMENT 'mobile/desktop/tablet',
`browser` VARCHAR(20) DEFAULT '',
`os` VARCHAR(20) DEFAULT '',
`clicked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY `idx_code_time` (`short_code`, `clicked_at`),
KEY `idx_country` (`country`),
KEY `idx_clicked` (`clicked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点击统计表-2026年8月';
4.3 Redis 缓存策略
| 缓存键 | 类型 | TTL | 说明 |
|---|---|---|---|
short:{code} | String | 24h | 短码 → 长 URL,核心读缓存 |
short:{code}:lock | String | 5s | 缓存重建互斥锁,防击穿 |
url:hash:{md5} | String | 1h | 长 URL 去重缓存 |
stat:click:{code} | HyperLogLog | 永久 | UV 统计,节省内存 |
stat:pv:{code} | String | 1h | PV 计数,定期刷盘 |
5. 服务架构设计
5.1 生成短链流程(写路径)
Client → API Gateway → Rate Limiter → URL Validator →
[Hash Check] → (Redis miss) → DB Check →
Snowflake ID → Base62 Encode → DB Insert →
Redis Set(short:code, longURL, TTL) → Response
- 限流:基于用户/IP 的令牌桶限流,防止恶意批量生成
- 参数校验:URL 格式、长度、协议(仅允许 http/https)、黑名单匹配
- 去重:先查 Redis
url:hash:{md5},再查 DB,避免重复生成 - 生成编码:雪花算法 → Base62 → 唯一索引冲突检测
- 双写:DB 事务提交后异步更新 Redis
5.2 跳转解析流程(读路径,重点优化)
Client → CDN / Edge → API Gateway →
Redis Get(short:code) →
[Cache Hit] → 302 Redirect → Done
[Cache Miss] → DB Query →
Redis Set(short:code, longURL, TTL) → 302 Redirect → Done
读路径优化策略:
- 多级缓存:浏览器缓存 → CDN Edge Cache → Redis → DB
- 空值缓存:对不存在的短码也设置短 TTL(如 60s),防止缓存穿透
- 热点预热:对预测的热点短码(如营销活动链接)提前加载到 Redis
5.3 缓存防护策略
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查询不存在的 key,大量请求打到 DB | 空值缓存 + 布隆过滤器 |
| 缓存击穿 | 热点 key 过期瞬间,并发请求击穿到 DB | 互斥锁 + 逻辑过期 |
| 缓存雪崩 | 大量 key 同时过期,DB 瞬时超载 | 随机 TTL 偏移 + 多级缓存 |
// 缓存重建互斥锁实现(防击穿)
func GetLongURL(code string) (string, error) {
// 1. 查缓存
longURL, err := redis.Get(ctx, "short:"+code)
if err == nil {
return longURL, nil
}
if !errors.Is(err, redis.Nil) {
return "", err
}
// 2. 获取重建锁(SETNX,5s TTL)
locked, _ := redis.SetNX(ctx, "short:"+code+":lock", "1", 5*time.Second)
if !locked {
// 未获得锁,短暂等待后重试
time.Sleep(50 * time.Millisecond)
return redis.Get(ctx, "short:"+code)
}
defer redis.Del(ctx, "short:"+code+":lock")
// 3. 查 DB 并回写缓存
longURL, err = db.FindByCode(code)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
redis.Set(ctx, "short:"+code, "", 60*time.Second) // 空值缓存
return "", ErrNotFound
}
return "", err
}
redis.Set(ctx, "short:"+code, longURL, 24*time.Hour)
return longURL, nil
}
6. 高并发优化
6.1 读写分离
短链系统的读写比例约为 100:1,非常适合读写分离架构:
- 写操作(生成短链、更新统计)→ Master DB
- 读操作(跳转解析、统计查询)→ Slave DB + Redis
- Binlog 同步:MySQL 主从延迟通常在亚毫秒级,对跳转场景无感知
6.2 缓存预热
在大型营销活动或产品发布前,提前将关键短链加载到 Redis,避免瞬时冷启动:
func WarmupCache(codes []string) {
for _, code := range codes {
longURL, err := db.FindByCode(code)
if err == nil {
redis.Set(ctx, "short:"+code, longURL, 24*time.Hour)
}
}
}
6.3 CDN 静态资源加速
| 资源类型 | 缓存策略 | TTL |
|---|---|---|
| 短链跳转页(HTML) | 不缓存 | - |
| 301/302 响应 | 不缓存 | - |
| 二维码图片 | CDN 边缘缓存 | 30d |
| API 文档 / 报表 | CDN 边缘缓存 | 1h |
7. 安全考量
7.1 恶意 URL 检测
| 检测层 | 手段 | 响应 |
|---|---|---|
| 黑名单 | 维护恶意域名列表(Google Safe Browsing、PhishTank) | 拒绝生成 |
| 内容扫描 | 请求目标 URL 并分析响应内容 | 拒绝 + 告警 |
| 用户举报 | 提供举报入口,人工审核 | 人工复审 |
| SSL 检测 | 拦截 HTTP-only 的可疑链接 | 警告页 |
7.2 防刷限流
// 基于 Redis 的滑动窗口限流
func AllowRequest(clientID string, limit int, window time.Duration) bool {
key := fmt.Sprintf("rate:%s", clientID)
now := time.Now().Unix()
pipe := redis.Pipeline()
pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", now-window.Seconds()))
pipe.ZCard(ctx, key)
pipe.ZAdd(ctx, key, &redis.Z{Score: float64(now), Member: now})
pipe.Expire(ctx, key, window)
cmds, _ := pipe.Exec(ctx)
current := cmds[1].(*redis.IntCmd).Val()
return current < int64(limit)
}
7.3 过期与删除策略
- 软删除:设置
expire_at字段,过期后不再跳转,但记录保留用于审计 - 物理清理:定时任务(如每日凌晨)清理超过 90 天的过期记录,避免数据膨胀
- 归档:历史统计数据归档至对象存储(如 S3 / OSS),释放主库空间
8. 完整代码示例
以下是一个 Go 语言的最简实现,涵盖核心链路:Base62 编码、雪花发号、Redis 缓存、MySQL 持久化。
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9"
)
const base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// --- Base62 编码 ---
func EncodeBase62(id int64) string {
if id == 0 {
return string(base62Chars[0])
}
var buf [11]byte
i := len(buf)
for id > 0 {
i--
buf[i] = base62Chars[id%62]
id /= 62
}
return string(buf[i:])
}
// --- 简版雪花发号器 ---
type Snowflake struct {
epoch int64
machineID int64
sequence int64
lastTime int64
}
func NewSnowflake(machineID int64) *Snowflake {
return &Snowflake{epoch: 1609459200000, machineID: machineID} // 2021-01-01
}
func (s *Snowflake) NextID() int64 {
now := time.Now().UnixMilli()
if now < s.lastTime {
now = s.lastTime // 简单处理时钟回拨
}
if now == s.lastTime {
s.sequence = (s.sequence + 1) & 4095
if s.sequence == 0 {
for now <= s.lastTime {
now = time.Now().UnixMilli()
}
}
} else {
s.sequence = 0
}
s.lastTime = now
return ((now - s.epoch) << 22) | (s.machineID << 12) | s.sequence
}
// --- 数据层 ---
type Shortener struct {
db *sql.DB
redis *redis.Client
sf *Snowflake
}
func NewShortener() *Shortener {
dsn := "user:pass@tcp(127.0.0.1:3306)/shortlink?parseTime=true"
db, _ := sql.Open("mysql", dsn)
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
return &Shortener{db: db, redis: rdb, sf: NewSnowflake(1)}
}
func (s *Shortener) CreateShortURL(longURL string) (string, error) {
id := s.sf.NextID()
code := EncodeBase62(id)
_, err := s.db.Exec(
"INSERT INTO short_urls (short_code, long_url, hash) VALUES (?, ?, MD5(?))",
code, longURL, longURL,
)
if err != nil {
return "", err
}
s.redis.Set(context.Background(), "short:"+code, longURL, 24*time.Hour)
return code, nil
}
func (s *Shortener) GetLongURL(code string) (string, error) {
ctx := context.Background()
// 1. 查 Redis
val, err := s.redis.Get(ctx, "short:"+code).Result()
if err == nil {
return val, nil
}
if !errors.Is(err, redis.Nil) {
return "", err
}
// 2. 查 MySQL
var longURL string
err = s.db.QueryRow("SELECT long_url FROM short_urls WHERE short_code = ?", code).Scan(&longURL)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
s.redis.Set(ctx, "short:"+code, "", 60*time.Second)
return "", errors.New("not found")
}
return "", err
}
s.redis.Set(ctx, "short:"+code, longURL, 24*time.Hour)
return longURL, nil
}
// --- HTTP 服务 ---
func main() {
svc := NewShortener()
http.HandleFunc("/api/shorten", func(w http.ResponseWriter, r *http.Request) {
longURL := r.URL.Query().Get("url")
if longURL == "" {
http.Error(w, "url required", http.StatusBadRequest)
return
}
code, err := svc.CreateShortURL(longURL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, `{"short_url":"http://s.io/%s"}`, code)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Path[1:]
if code == "" {
http.Error(w, "not found", http.StatusNotFound)
return
}
longURL, err := svc.GetLongURL(code)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
http.Redirect(w, r, longURL, http.StatusFound)
})
log.Println("Server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
注:以上代码为教学演示,生产环境需补充连接池配置、错误处理、日志追踪、指标上报等要素。
9. 部署与扩展
9.1 Docker 部署
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o shortener ./cmd/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/shortener .
EXPOSE 8080
CMD ["./shortener"]
# docker-compose.yml
version: "3.8"
services:
api:
build: .
ports:
- "8080:8080"
environment:
- DB_DSN=user:pass@tcp(mysql:3306)/shortlink
- REDIS_ADDR=redis:6379
depends_on:
- mysql
- redis
deploy:
replicas: 3
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shortlink
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
mysql_data:
redis_data:
9.2 水平扩展方案
| 扩展维度 | 方案 | 关键组件 |
|---|---|---|
| API 层 | 水平扩容 + 负载均衡 | K8s HPA + Nginx / ALB |
| 缓存层 | Redis Cluster 分片 | 3 主 3 从,支持自动故障转移 |
| 数据库 | 读写分离 + 分库分表 | MySQL 主从 + ShardingSphere |
| 流量入口 | 全球 CDN + 边缘缓存 | Cloudflare / 阿里云 DCDN |
| 统计写入 | 异步消息队列削峰 | Kafka + 消费者批量写入 |
10. 总结与下一步
本文系统性地介绍了 URL 短链接系统的完整构建过程:
- 算法层:Base62 编码的数学原理、雪花发号器的分布式优势、冲突检测的必要性
- 数据层:短链表与统计表的索引设计、Redis 多级缓存与分表策略
- 架构层:写路径的事务一致性与读路径的性能极致优化
- 安全层:恶意 URL 检测、分布式限流、缓存防护三件套
- 代码层:可运行的 Go 最简实现,覆盖核心链路
如果准备将此系统投入生产,建议按以下顺序推进:
- Phase 1:在单机上跑通链路,完善单元测试与集成测试
- Phase 2:引入 Docker Compose,实现 MySQL + Redis + API 一体化部署
- Phase 3:迁移至 Kubernetes,配置 HPA 自动扩缩容
- Phase 4:接入 Kafka 进行统计异步化,引入 ClickHouse 做 OLAP 分析
- Phase 5:全球多区域部署,基于 GeoDNS 实现就近解析
短链接系统虽小,却浓缩了分布式系统设计的核心命题——如何在一致性、可用性与分区容错性之间做出最优权衡。希望本文能成为你构建高可用短链服务的实用指南。
延伸阅读:
- 短链接服务部署架构 — 从 Nginx 到 K8s 的完整部署方案
- 短链接性能测试点清单 — QPS / 延迟 / 缓存命中率关键指标
- 短链接安全测试清单 — SQL / XSS / CSRF / 限流 / 钓鱼链接防护
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。