短链接设计方案


短链接设计方案

短链接服务是互联网中常见的服务,它将长 URL 转换为短 URL,便于分享和传播。本文详细介绍两种短链接生成方案的设计思路、实现方式、优缺点分析以及适用场景。

方案一:MD5 哈希截取方案

设计思路

方案一采用 MD5 哈希算法对原始 URL 进行加密,然后截取前 10 位作为短码。这是一种无状态、无存储的设计方案。

实现原理

1. 算法流程

1
原始 URL → MD5 哈希 → 截取前 10 位 → 短码

2. 具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"crypto/md5"
"encoding/hex"
"fmt"
)

func generateShortCode(url string) string {
// 1. 计算 MD5 哈希值
hash := md5.Sum([]byte(url))
hashStr := hex.EncodeToString(hash[:])

// 2. 截取前 10 位作为短码
shortCode := hashStr[:10]

return shortCode
}

// 示例
func main() {
url := "https://www.example.com/very/long/url/path?param1=value1&param2=value2"
shortCode := generateShortCode(url)
fmt.Printf("原始 URL: %s\n", url)
fmt.Printf("短码: %s\n", shortCode)
// 输出: 短码: a1b2c3d4e5
}

3. 技术细节

  • MD5 算法特性

    • 输入任意长度的字符串,输出固定 128 位(32 个十六进制字符)
    • 相同输入必然产生相同输出(确定性)
    • 不同输入可能产生相同输出(哈希冲突)
  • 短码长度

    • 10 位十六进制字符
    • 理论容量:16^10 = 1,099,511,627,776(约 1 万亿)
    • 实际可用容量受哈希冲突影响

优点分析

1. 无需存储介质

  • 无状态设计:服务端不需要存储原始 URL 和短码的映射关系
  • 计算即得:每次请求时直接计算,无需查询数据库
  • 架构简单:不需要额外的存储组件(如 Redis、MySQL)

2. 性能优势

  • 响应速度快:MD5 计算是 CPU 密集型操作,耗时极短(微秒级)
  • 无 I/O 开销:不需要访问数据库或缓存,减少网络延迟
  • 高并发支持:无状态设计,易于水平扩展

3. 实现简单

  • 代码量少:核心逻辑只需几行代码
  • 维护成本低:无数据一致性问题,无存储故障风险
  • 部署简单:不需要配置数据库连接池等复杂组件

缺点分析

1. 哈希冲突问题

问题描述

  • MD5 虽然输出空间很大,但截取前 10 位后,冲突概率显著增加
  • 根据生日悖论,当短码数量达到约 √(16^10) ≈ 100 万时,冲突概率达到 50%

冲突示例

1
2
3
4
// 不同的 URL 可能生成相同的短码
url1 := "https://example.com/page1"
url2 := "https://example.com/page2"
// 虽然 MD5 值不同,但前 10 位可能相同

影响

  • 多个不同的原始 URL 可能映射到同一个短码
  • 用户访问短链接时,无法确定应该跳转到哪个原始 URL
  • 可能导致数据混乱和用户体验问题

2. 无法控制有效期

问题描述

  • 短码与原始 URL 的映射关系是固定的(由 MD5 算法决定)
  • 无法为短链接设置过期时间
  • 无法主动删除或失效某个短链接

实际场景

  • 营销活动结束后,短链接应该失效,但无法实现
  • 恶意链接无法及时下线
  • 临时分享链接无法自动过期

解决方案缺失

1
2
3
4
5
// 无法实现这样的功能
func generateShortCodeWithExpiry(url string, expiry time.Duration) string {
// 无法在短码中编码过期时间信息
// 因为短码完全由 URL 决定
}

3. 安全性问题

容易被攻击

  1. URL 枚举攻击

    • 攻击者可以猜测或枚举可能的短码
    • 10 位十六进制字符,虽然空间大,但可以通过暴力破解
    • 如果短码有规律,更容易被猜测
  2. 无法防止恶意链接

    • 一旦生成短链接,无法撤销
    • 无法对链接进行审核或黑名单过滤
    • 恶意用户可能生成大量恶意短链接
  3. 信息泄露风险

    • 如果原始 URL 包含敏感信息,MD5 值可能泄露部分信息
    • 虽然 MD5 是单向函数,但通过彩虹表可能反推部分信息

攻击示例

1
2
3
4
5
6
7
// 攻击者可以尝试枚举短码
func bruteForceAttack() {
for i := 0; i < 1000000; i++ {
shortCode := generateShortCode(fmt.Sprintf("https://example.com/%d", i))
// 尝试访问短链接,可能获取到敏感信息
}
}

4. 功能局限性

  • 无法统计访问量:没有存储,无法记录短链接的访问次数
  • 无法自定义短码:短码完全由算法决定,用户无法自定义
  • 无法批量管理:无法查询某个用户生成的所有短链接
  • 无法实现高级功能:如访问地域统计、设备统计等

适用场景

  1. 临时分享:不需要长期保存的临时链接分享
  2. 内部系统:企业内部系统,对安全性要求不高
  3. 原型验证:快速验证短链接功能,不需要完整功能
  4. 低流量场景:访问量小,冲突概率低

方案二:雪花算法生成方案

设计思路

方案二采用雪花算法(Snowflake Algorithm)生成唯一的 ID,然后将其转换为 10 位短码。这是一种有状态、需要存储的设计方案。

实现原理

1. 雪花算法简介

雪花算法是 Twitter 开源的分布式 ID 生成算法,生成的 ID 是 64 位整数,包含以下部分:

1
2
3
4
5
6
64 位 ID 结构:
+--------+--------+--------+--------+--------+--------+
| 1 bit | 41 bit | 5 bit | 5 bit | 12 bit | 4 bit |
+--------+--------+--------+--------+--------+--------+
| 符号位 | 时间戳 | 机器ID | 数据中心ID | 序列号 | 保留位 |
+--------+--------+--------+--------+--------+--------+

2. 短码生成流程

1
原始 URL → 雪花算法生成 ID → 转换为 10 位短码 → 存储映射关系

3. 具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
package main

import (
"database/sql"
"encoding/base62"
"fmt"
"time"
)

// 雪花算法 ID 生成器
type Snowflake struct {
machineID int64
datacenterID int64
sequence int64
lastTimestamp int64
}

const (
// 时间戳起始点(2020-01-01 00:00:00)
epoch int64 = 1577836800000

// 各部分位数
machineIDBits = 5
datacenterIDBits = 5
sequenceBits = 12

// 最大值
maxMachineID = -1 ^ (-1 << machineIDBits)
maxDatacenterID = -1 ^ (-1 << datacenterIDBits)
maxSequence = -1 ^ (-1 << sequenceBits)

// 位移
machineIDShift = sequenceBits
datacenterIDShift = sequenceBits + machineIDBits
timestampShift = sequenceBits + machineIDBits + datacenterIDBits
)

func NewSnowflake(machineID, datacenterID int64) *Snowflake {
if machineID > maxMachineID || machineID < 0 {
panic("machineID 超出范围")
}
if datacenterID > maxDatacenterID || datacenterID < 0 {
panic("datacenterID 超出范围")
}
return &Snowflake{
machineID: machineID,
datacenterID: datacenterID,
}
}

func (s *Snowflake) NextID() int64 {
now := time.Now().UnixNano() / 1e6

if now < s.lastTimestamp {
panic("时钟回拨")
}

if now == s.lastTimestamp {
s.sequence = (s.sequence + 1) & maxSequence
if s.sequence == 0 {
now = s.waitNextMillis(s.lastTimestamp)
}
} else {
s.sequence = 0
}

s.lastTimestamp = now

return ((now - epoch) << timestampShift) |
(s.datacenterID << datacenterIDShift) |
(s.machineID << machineIDShift) |
s.sequence
}

func (s *Snowflake) waitNextMillis(lastTimestamp int64) int64 {
now := time.Now().UnixNano() / 1e6
for now <= lastTimestamp {
now = time.Now().UnixNano() / 1e6
}
return now
}

// 将 ID 转换为 10 位短码(使用 Base62 编码)
func idToShortCode(id int64) string {
const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
if id == 0 {
return string(charset[0])
}

var result []byte
for id > 0 {
result = append([]byte{charset[id%62]}, result...)
id /= 62
}

// 补齐到 10 位
for len(result) < 10 {
result = append([]byte{charset[0]}, result...)
}

// 截取或补齐到 10 位
if len(result) > 10 {
return string(result[:10])
}
return string(result)
}

// 存储映射关系
type ShortLinkService struct {
snowflake *Snowflake
db *sql.DB
}

func (s *ShortLinkService) CreateShortLink(originalURL string, expiryDays int) (string, error) {
// 1. 生成唯一 ID
id := s.snowflake.NextID()

// 2. 转换为短码
shortCode := idToShortCode(id)

// 3. 计算过期时间
expiryTime := time.Now().AddDate(0, 0, expiryDays)

// 4. 存储到数据库
_, err := s.db.Exec(
"INSERT INTO short_links (short_code, original_url, created_at, expires_at) VALUES (?, ?, ?, ?)",
shortCode, originalURL, time.Now(), expiryTime,
)
if err != nil {
return "", err
}

return shortCode, nil
}

func (s *ShortLinkService) GetOriginalURL(shortCode string) (string, error) {
var originalURL string
var expiresAt time.Time

err := s.db.QueryRow(
"SELECT original_url, expires_at FROM short_links WHERE short_code = ?",
shortCode,
).Scan(&originalURL, &expiresAt)

if err != nil {
return "", err
}

// 检查是否过期
if time.Now().After(expiresAt) {
return "", fmt.Errorf("短链接已过期")
}

return originalURL, nil
}

4. 数据库设计

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE short_links (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(10) UNIQUE NOT NULL,
original_url TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP,
access_count INT DEFAULT 0,
last_accessed_at TIMESTAMP,
INDEX idx_short_code (short_code),
INDEX idx_expires_at (expires_at)
);

优点分析

1. 控制有效期

实现方式

  • 在数据库表中存储 expires_at 字段
  • 每次查询时检查是否过期
  • 可以设置定时任务清理过期数据

优势

1
2
3
4
// 可以为每个短链接设置不同的有效期
shortCode1, _ := service.CreateShortLink(url1, 7) // 7 天有效
shortCode2, _ := service.CreateShortLink(url2, 30) // 30 天有效
shortCode3, _ := service.CreateShortLink(url3, 365) // 1 年有效

应用场景

  • 营销活动链接:活动结束后自动失效
  • 临时分享链接:设置短期有效期
  • 敏感信息链接:设置较短有效期,提高安全性

2. 不易冲突

唯一性保证

  • 雪花算法生成的 ID 在分布式环境下保证全局唯一
  • 通过时间戳 + 机器 ID + 数据中心 ID + 序列号组合,确保不重复
  • 即使在高并发场景下,也能保证唯一性

冲突概率

  • 理论上冲突概率为 0(在同一毫秒内,同一机器最多生成 4096 个 ID)
  • 即使多台机器同时生成,通过机器 ID 区分,不会冲突

对比方案一

1
2
3
4
5
6
7
方案一:MD5 截取
- 冲突概率:随数据量增加而增加
- 无法避免:相同 URL 必然生成相同短码,不同 URL 可能冲突

方案二:雪花算法
- 冲突概率:接近 0
- 可避免:通过算法设计保证唯一性

3. 不易被攻击

安全性增强

  1. 无法枚举

    • 短码由时间戳和序列号生成,无规律可循
    • 攻击者无法通过猜测获取有效短码
    • 即使知道部分短码,也无法推断其他短码
  2. 访问控制

    • 可以记录访问日志,发现异常访问
    • 可以设置访问频率限制
    • 可以设置 IP 白名单/黑名单
  3. 链接管理

    • 可以主动删除恶意链接
    • 可以设置链接状态(启用/禁用)
    • 可以对链接进行审核

安全功能实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 访问控制
func (s *ShortLinkService) AccessShortLink(shortCode string, clientIP string) (string, error) {
// 1. 检查访问频率
if s.isRateLimited(clientIP) {
return "", fmt.Errorf("访问过于频繁")
}

// 2. 检查 IP 黑名单
if s.isBlacklisted(clientIP) {
return "", fmt.Errorf("IP 已被封禁")
}

// 3. 获取原始 URL
originalURL, err := s.GetOriginalURL(shortCode)
if err != nil {
return "", err
}

// 4. 记录访问日志
s.recordAccess(shortCode, clientIP)

return originalURL, nil
}

4. 功能扩展性强

可实现的扩展功能

  1. 访问统计

    • 记录访问次数、访问时间
    • 统计访问地域、设备类型
    • 生成访问报表
  2. 用户管理

    • 关联用户 ID,查询用户的所有短链接
    • 设置用户级别的访问限制
    • 实现用户权限管理
  3. 自定义短码

    • 允许用户自定义短码(需要检查唯一性)
    • 提供短码建议功能
    • 支持短码别名
  4. 高级功能

    • 密码保护短链接
    • 访问次数限制
    • 地理位置限制
    • 设备类型限制

缺点分析

1. 需要存储介质

存储需求

  1. 数据库存储

    • 需要存储短码与原始 URL 的映射关系
    • 需要存储元数据(创建时间、过期时间、访问统计等)
    • 随着数据量增长,存储成本增加
  2. 缓存需求

    • 为了提高查询性能,通常需要 Redis 缓存
    • 热门短链接需要缓存到内存
    • 增加了系统复杂度

存储成本估算

1
2
3
4
5
6
假设每条记录占用 500 字节:
- 100 万条记录:约 500 MB
- 1 亿条记录:约 50 GB
- 10 亿条记录:约 500 GB

加上索引、缓存等,实际存储需求更大

2. 额外开销

性能开销

  1. 数据库查询

    • 每次访问短链接都需要查询数据库
    • 即使使用缓存,缓存未命中时仍需查询数据库
    • 高并发场景下,数据库压力大
  2. 网络延迟

    • 需要与数据库/缓存进行网络通信
    • 增加了请求响应时间
    • 网络故障会影响服务可用性
  3. 资源消耗

    • 需要维护数据库连接池
    • 需要维护缓存连接
    • 增加了服务器资源消耗

性能对比

1
2
3
4
5
6
7
8
9
方案一(MD5):
- 响应时间:< 1ms(纯计算)
- 吞吐量:极高(无 I/O)
- 资源消耗:低(仅 CPU)

方案二(雪花算法):
- 响应时间:1-10ms(包含数据库查询)
- 吞吐量:受数据库性能限制
- 资源消耗:中等(CPU + 内存 + 网络)

3. 系统复杂度

架构复杂度

  1. 组件依赖

    • 需要数据库(MySQL/PostgreSQL)
    • 需要缓存(Redis)
    • 需要监控和日志系统
  2. 数据一致性

    • 需要保证数据库和缓存的一致性
    • 需要处理缓存穿透、缓存击穿、缓存雪崩
    • 需要数据备份和恢复机制
  3. 运维成本

    • 需要维护数据库
    • 需要监控系统健康状态
    • 需要处理数据迁移、扩容等问题

架构对比

1
2
3
4
5
6
7
方案一架构:
[客户端] → [短链接服务] → [响应]

方案二架构:
[客户端] → [短链接服务] → [Redis 缓存] → [MySQL 数据库]

[监控系统]

优化方案

1. 性能优化

缓存策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 多级缓存
func (s *ShortLinkService) GetOriginalURL(shortCode string) (string, error) {
// 1. L1 缓存:本地缓存(热点数据)
if url, ok := s.localCache.Get(shortCode); ok {
return url.(string), nil
}

// 2. L2 缓存:Redis(分布式缓存)
if url, err := s.redis.Get(shortCode); err == nil {
s.localCache.Set(shortCode, url, 5*time.Minute)
return url, nil
}

// 3. L3 存储:数据库
url, err := s.getFromDB(shortCode)
if err != nil {
return "", err
}

// 回写缓存
s.redis.Set(shortCode, url, 1*time.Hour)
s.localCache.Set(shortCode, url, 5*time.Minute)

return url, nil
}

数据库优化

  • 使用读写分离,提高查询性能
  • 使用分库分表,应对大数据量
  • 使用连接池,减少连接开销
  • 优化索引,提高查询效率

2. 可用性优化

高可用设计

  • 数据库主从复制,实现故障转移
  • Redis 集群,避免单点故障
  • 服务多实例部署,负载均衡
  • 降级策略,缓存故障时直接查数据库

3. 成本优化

存储优化

  • 定期清理过期数据,减少存储空间
  • 冷热数据分离,历史数据归档
  • 使用压缩算法,减少存储空间

适用场景

  1. 生产环境:需要完整功能、高可用性的生产系统
  2. 高并发场景:需要处理大量请求的商业应用
  3. 需要统计:需要访问统计、用户分析等功能
  4. 安全要求高:需要访问控制、链接管理的场景
  5. 长期运营:需要长期维护和扩展的系统

方案对比总结

对比项 方案一:MD5 截取 方案二:雪花算法
存储需求 需要数据库 + 缓存
性能 极高(< 1ms) 较高(1-10ms)
冲突概率 较高 极低(接近 0)
有效期控制 不支持 支持
安全性 较低 较高
功能扩展 受限 丰富
实现复杂度 简单 复杂
运维成本
适用场景 临时、内部系统 生产、商业系统

混合方案建议

在实际生产环境中,可以考虑混合方案,结合两种方案的优点:

  1. 短码生成:使用雪花算法保证唯一性
  2. 缓存策略:热门短链接缓存到 Redis,提高性能
  3. 降级策略:缓存故障时,可以考虑使用 MD5 方案作为降级
  4. 分层设计
    • 免费用户:使用 MD5 方案(无存储,无统计)
    • 付费用户:使用雪花算法方案(完整功能)

总结

  • 方案一(MD5 截取):适合快速原型、临时场景、内部系统,优点是简单高效,缺点是功能受限、安全性低。

  • 方案二(雪花算法):适合生产环境、商业应用,优点是功能完整、安全性高,缺点是复杂度高、需要存储。

选择方案时,需要根据实际业务需求、性能要求、安全要求、成本预算等因素综合考虑。对于大多数商业应用,推荐使用方案二,虽然增加了复杂度,但提供了更好的功能性和可扩展性。


文章作者: djaigo
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 djaigo !
评论
  目录