短链接系统:一个链接的"变身术"
运营工具篇 · 第5篇
你有没有想过,为什么你在微信群里看到的活动链接是 t.cn/Axxx,而不是一串长得离谱的URL?
为什么运营人员发的推广链接,能精准知道"谁点击了、从哪来的、用了什么设备"?
今天,我们来聊聊这个看似简单、实则暗藏玄机的运营基础设施——短链接系统。
一、短链接的价值:不只是"短"那么简单
1. 短,是刚需
想象一下,你要在微博发一条推广文案,结果原始链接长这样:
https://www.example.com/campaign/2026/spring-festival?utm_source=wechat&utm_medium=social&utm_campaign=spring2026
换成短链接:
t.cn/spr26
- 节省空间:短信、微博等有字数限制的场景
- 提升体验:链接不会"喧宾夺主"
- 便于传播:用户更愿意复制分享
2. 追踪,才是核心
短链接的真正威力,在于数据追踪。如果把短链接比作一个"侦探",那它不仅能帮你找到目的地,还能记录沿途的一切线索。
一个点击的"履历表"
当用户点击 t.cn/spr26 时,短链接系统会默默记录下这些信息:
- 点击时间:精确到毫秒,用于分析访问高峰
- IP地址:用于判断地理位置和防刷
- User-Agent:包含设备类型、操作系统、浏览器等信息
- 来源渠道:用户是从微信、微博还是抖音来的?
- 推广人员:是哪个销售/运营分享的链接?
- 营销活动:属于哪个campaign,便于ROI计算
- 地理位置:通过IP解析出国家、省份、城市
- 设备类型:PC、手机还是平板
- 新老用户:第一次点击还是回访用户
运营人员能看到的"全景图"
想象一下,你是一个电商运营,刚刚在三个平台投放了618大促活动:
微信朋友圈:t.cn/wx618 (带了1000个点击)
微博热搜:t.cn/wb618 (带了5000个点击)
抖音达人:t.cn/dy618 (带了3000个点击)
三天后,你打开后台看到这样的数据:
| 渠道 | 点击量 | 独立访客 | 转化率 | 订单数 | ROI |
|---|---|---|---|---|---|
| 微信 | 1,000 | 800 | 2.5% | 25 | 3.2 |
| 微博 | 5,000 | 3,500 | 1.2% | 60 | 1.8 |
| 抖音 | 3,000 | 2,800 | 3.5% | 105 | 5.1 |
- 微博点击最多,但转化率低(可能是围观群众)
- 抖音点击不是最多,但转化率最高(目标用户精准)
- 微信转化率中等,但ROI不错(私域流量信任度高)
- 加大抖音投放预算
- 优化微博落地页
- 维护微信私域用户
这就是短链接追踪的价值——让每一分钱都花得明明白白。
3. 灵活,是隐藏技能
长链接一旦发出,就"定型"了。但短链接可以随时更换背后的目标地址。
- 活动页面临时改版,短链接指向新地址
- A/B测试,同一短链接根据条件跳转不同页面
- 紧急下线,短链接改为跳转到公告页
二、短链接生成算法:从长到短的魔法原理
短链接的核心,就是把"长"变"短"。但怎么变?变完还能找回来吗?这就要用到几种经典的算法。
在深入算法之前,先理解一个核心概念:短链接本质上是一个映射。
长URL(原始地址) ←→ 短码(简短标识) ←→ 短链接(完整地址)
https://example.com/very/long/url/... ←→ "d7C" ←→ t.cn/d7C
这个映射关系需要满足几个关键要求:
- 唯一性:不同长URL不能生成相同的短码
- 可逆性:根据短码能找到对应的长URL
- 短小性:短码要足够短,5-7个字符最佳
- 高效性:生成和查询都要快
接下来,我们看看三种主流方案是如何实现这些要求的。
1. 自增ID + 62进制转换:最经典的方案
这是最常用、最可靠的方案,像给每个长链接发一个"身份证号"。
为什么选62进制?
生活中我们熟悉10进制(0-9)、16进制(0-9和A-F)。那62进制是什么?
- 数字:0-9(10个)
- 小写字母:a-z(26个)
- 大写字母:A-Z(26个)
- 总计:10 + 26 + 26 = 62个字符
- 如果加入特殊字符(如@、#、$),用户复制时容易出错
- 如果只用小写字母+数字,62进制变成36进制,6位只能表示21亿,不够用
62^6 = 568,002,355,84 ≈ 568亿
这意味着,即使每天生成1亿个短链接,也够用15年!
一个生动的比喻
把短链接生成想象成餐厅排号:
- 自增ID:就像餐厅发号,1号、2号、3号...每个号码唯一且递增
- 62进制转换:就像把"第10086号"简写成"A1B",更短更好记
- 存储映射:就像餐厅的小本本,记录"A1B号顾客点了什么菜"
当顾客(用户)报出"A1B"时,服务员(短链接系统)查小本本(数据库),就能知道要点什么菜(跳转到哪个长URL)。
原理详解
每个长链接存入数据库时,获得一个唯一的、递增的数字ID:
https://example.com/a → ID = 1
https://example.com/b → ID = 2
https://example.com/c → ID = 3
...
为什么是62进制?因为 a-z(26个)+ A-Z(26个)+ 0-9(10个)= 62个字符。
转换规则和10进制转2进制一样,只是基数变成了62:
// 62进制字符表
const base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func encodeBase62(num int64) string {
if num == 0 {
return "0"
}
result := ""
for num > 0 {
remainder := num % 62
result = string(base62Chars[remainder]) + result
num = num / 62
}
return result
}
func decodeBase62(shortCode string) int64 {
result := int64(0)
for _, char := range shortCode {
result = result * 62 + int64(strings.Index(base62Chars, string(char)))
}
return result
}
ID = 1 → "1"
ID = 62 → "10"
ID = 12345 → "d7C"
ID = 1000000000 → "15FTGg" // 10亿也只要6个字符!
6位62进制能表示多少个链接?
62^6 = 568,002,355,84 ≈ 568亿
够用吗?全球每天产生的短链接数量级在几亿,568亿足够用几十年。
优缺点分析
- 短码唯一性100%保证
- 长度可控且递增
- 实现简单,易于理解
- 支持反向解码(短码→ID)
- 依赖中心化ID生成器(数据库自增或分布式ID服务)
- ID可预测,可能被爬虫遍历
- 单点瓶颈风险
2. 哈希算法:分布式的选择
如果你想摆脱对中心ID的依赖,哈希算法是另一个选择。
哈希的直觉理解
哈希就像是一个"数字指纹"生成器:
- 输入:任意长度的URL
- 输出:固定长度的"指纹"
- 特点:相同的输入永远产生相同的输出
想象一下,你有一台神奇的机器:
- 把"https://example.com/abc"放进去
- 机器吐出一个指纹:"a3b2c1"
- 下次再放同样的URL,还是得到"a3b2c1"
这就是哈希的核心特性——确定性。
为什么哈希算法适合分布式?
自增ID方案需要所有服务器都访问同一个"发号器",这会成为瓶颈。
哈希方案则不同:
服务器A:对URL做哈希 → 得到短码
服务器B:对同样URL做哈希 → 得到相同短码
服务器C:对同样URL做哈希 → 得到相同短码
不需要协调,每台服务器独立工作,结果却一致!这就是哈希的分布式友好特性。
碰撞:哈希的阿喀琉斯之踵
但是,哈希有一个致命问题——碰撞。
- 23个人中,有50%概率两人同一天生日
- 同样,短码空间不够大时,碰撞概率会快速上升
- 6位16进制短码 = 16^6 ≈ 1677万个可能值
- 存入5000个链接后,碰撞概率约为 0.07%
- 存入50000个链接后,碰撞概率约为 7%
这就像停车场:
- 停车场有100个车位
- 第1辆车随便停
- 第50辆车,有一半车位已被占
- 第100辆车,必须找空位
原理详解
对长URL做哈希运算,取结果的一部分作为短码:
import (
"crypto/md5"
"encoding/hex"
)
func generateShortCode(url string, length int) string {
// MD5哈希
hasher := md5.New()
hasher.Write([]byte(url))
hash := hex.EncodeToString(hasher.Sum(nil))
// 取前N位作为短码
return hash[:length]
}
MD5产生128位哈希值,我们只取前6-8位,碰撞概率:
- 6位16进制:16^6 ≈ 1677万,碰撞概率较高
- 8位16进制:16^8 ≈ 42亿,仍然不够安全
碰撞处理策略
func generateWithRetry(url string, maxRetry int) (string, error) {
for i := 0; i < maxRetry; i++ {
// 加盐哈希
salted := url + strconv.Itoa(i)
shortCode := generateShortCode(salted, 6)
// 检查是否已存在
existing, _ := db.Get(shortCode)
if existing == "" {
// 保存并返回
db.Set(shortCode, url)
return shortCode, nil
}
if existing == url {
// 相同URL,直接返回已有短码
return shortCode, nil
}
// 碰撞了,继续重试
}
return "", errors.New("max retry exceeded")
}
MurmurHash是一种非加密哈希,速度快且分布均匀:
import "github.com/spaolacci/murmur3"
func doubleHash(url string) string {
// 两次哈希,降低碰撞概率
h1 := murmur3.Sum32([]byte(url))
h2 := murmur3.Sum32WithSeed([]byte(url), h1)
// 组合成6位短码
combined := (uint64(h1) << 32) | uint64(h2)
return encodeBase62(int64(combined % 56800235584)) // 限制在62^6范围内
}
优缺点分析
- 不依赖中心ID生成器
- 相同URL生成相同短码(天然去重)
- 分布式友好
- 存在碰撞可能,需要处理
- 短码长度固定
- 无法反向解码,必须查表
3. 雪花算法(Snowflake):分布式ID的王者
如果你的系统需要每秒生成百万级短链接,雪花算法是最优解。
原理详解
雪花算法生成一个64位的整数ID,结构如下:
0 | 00000000000000000000000000000000000000000 | 0000000000 | 000000000000
<table>
<thead><tr>
<th>41位时间戳</th>
<th>10位机器ID</th>
</tr></thead><tbody>
</tbody></table>
- 1位符号位:始终为0
- 41位时间戳:毫秒级,可用约69年
- 10位机器ID:支持1024台机器
- 12位序列号:每毫秒可生成4096个ID
type Snowflake struct {
machineID int64
sequence int64
lastTime int64
}
const (
epoch = int64(1704067200000) // 2024-01-01 00:00:00
machineBits = 10
sequenceBits = 12
machineMax = -1 ^ (-1 << machineBits)
sequenceMax = -1 ^ (-1 << sequenceBits)
machineShift = sequenceBits
timeShift = sequenceBits + machineBits
)
func (s *Snowflake) Generate() int64 {
now := time.Now().UnixMilli()
if now == s.lastTime {
s.sequence = (s.sequence + 1) & sequenceMax
if s.sequence == 0 {
// 等待下一毫秒
for now <= s.lastTime {
now = time.Now().UnixMilli()
}
}
} else {
s.sequence = 0
}
s.lastTime = now
return (now-epoch)<<timeShift | s.machineID<<machineShift | s.sequence
}
- 单机每秒可生成400万+ ID
- 分布式部署可线性扩展
4. 工程上的混合方案
实际生产环境,往往是多种方案的组合:
1. 中心服务分配ID段(如:10000-20000)给各应用服务器
2. 服务器在本地ID段内自增生成
3. ID段用完后再申请
1. 用雪花算法生成64位ID
2. 转成62进制短码
3. 结合哈希去重
func generateShortCode(url string) string {
// 先查缓存,相同URL直接返回
if cached := cache.Get(url); cached != "" {
return cached
}
// 雪花ID生成
id := snowflake.Generate()
// Base62编码
shortCode := encodeBase62(id)
// 保存映射关系
db.Set(shortCode, url)
cache.Set(url, shortCode, 24*time.Hour)
return shortCode
}
三、短链接系统的架构设计
在深入存储方案之前,我们需要先理解短链接系统的整体架构。一个成熟的短链接系统,通常要应对三个核心挑战:
1. 三大核心挑战
短链接的使用模式非常特殊:
- 生成频率:每秒几十到几百次(运营人员创建链接)
- 访问频率:每秒几千到几十万次(用户点击链接)
- 读写比例:通常在100:1到1000:1之间
这意味着系统设计的重心要放在读性能优化上。
短链接的流量就像"过山车":
- 正常时段:平稳,几千QPS
- 营销活动:暴增,瞬间几十万QPS
- 爆款文章:不可控,可能百万QPS
每次点击都要记录,数据积累速度惊人:
- 1天1000万点击 → 一个月3亿条记录
- 每条记录约500字节 → 一个月150GB数据
- 一年下来就是1.8TB,这还只是日志数据
2. 分层架构设计
为了应对这些挑战,短链接系统通常采用多层缓存+异步处理的架构:
┌────────────────────────────────────────────────────────┐
│ 用户访问流程 │
└────────────────────────────────────────────────────────┘
第1层:CDN边缘节点(全球分布)
↓ 命中率 60-80%
第2层:负载均衡 + 本地缓存(进程内)
↓ 命中率 10-20%
第3层:Redis集群(分布式缓存)
↓ 命中率 5-10%
第4层:MySQL主库(持久化存储)
↓ 最终兜底
第5层:Kafka + ClickHouse(异步日志处理)
打个比方,短链接系统就像一个"问路"服务:
- CDN:就像路边的指示牌,常见地点(热门短链接)直接告诉你怎么走
- 本地缓存:就像你的手机备忘录,经常去的地方记下来
- Redis:就像问路边的便利店老板,大部分地方都知道
- MySQL:就像去市政厅查档案,什么偏僻地方都有记录
- 异步日志:就像你边走边录音,事后整理成游记
每一层都拦截掉大部分请求,下一层只需要处理"漏网之鱼"。
3. 关键设计决策
决策一:302还是301重定向?
这是一个经常被忽略但很重要的细节。
- 浏览器会缓存重定向结果
- 后续访问直接跳转,不再请求短链接服务器
- 优点:性能好,减少服务器压力
- 缺点:无法追踪后续点击,无法修改目标地址
- 浏览器每次都请求短链接服务器
- 优点:每次都能追踪,可以随时修改目标
- 缺点:服务器压力大
- 需要追踪的营销链接 → 用302(大多数场景)
- 不需要追踪的静态链接 → 用301
决策二:同步还是异步记录日志?
用户点击 → 查缓存 → 记录日志到数据库 → 返回302
↑ 这里会拖慢响应
用户点击 → 查缓存 → 发送日志到消息队列 → 返回302
↓
后台消费者 → 批量写入数据库
- 同步:响应时间 50-100ms
- 异步:响应时间 5-10ms
四、存储方案选型:Redis vs MySQL vs 混合架构
短链接系统有两个核心数据:
- 映射关系:短码 ↔ 长URL
- 点击日志:每次访问的详细信息
1. Redis:读多写少场景的王者
短链接访问是典型的"读多写少"——生成一次,访问千万次。
Redis方案架构
┌─────────────┐
│ 客户端 │
└──────┬──────┘
▼
┌─────────────────────────────────┐
│ Redis Cluster (3主3从) │
│ ┌─────────┐ ┌─────────┐ │
│ │ short: │ │ short: │ │
│ │ d7C→url │ │ x9K→url │ │
│ └─────────┘ └─────────┘ │
└─────────────────────────────────┘
▼ (异步写入)
┌─────────────────────────────────┐
│ MySQL (持久化存储) │
└─────────────────────────────────┘
数据结构设计
SET short:d7C "https://example.com/long-url"
GET short:d7C
TTL short:d7C # 支持过期时间
HSET short:d7C url "https://example.com/long-url"
HSET short:d7C created_at "2026-02-28 10:00:00"
HSET short:d7C clicks 0
HSET short:d7C owner "user_123"
HINCRBY short:d7C clicks 1 # 原子计数
SET short:d7C "https://example.com/long-url" EX 2592000 # 30天过期
Redis的优势
- 性能:单机QPS可达10万+
- 过期策略:内置TTL,自动清理
- 原子操作:INCR、HINCRBY等
- 持久化:RDB+AOF双重保障
Redis的局限
- 内存成本:数据量大时成本高
- 数据安全:极端情况下可能丢数据
- 容量限制:单实例内存有限
2. MySQL:可靠性的基石
虽然Redis快,但MySQL是数据持久化的根本保障。
表结构设计
-- 短链接映射表
CREATE TABLE short_urls (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(10) NOT NULL UNIQUE,
long_url VARCHAR(2048) NOT NULL,
owner_id VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expired_at TIMESTAMP NULL,
status TINYINT DEFAULT 1, -- 1:正常 0:禁用
INDEX idx_short_code (short_code),
INDEX idx_owner_id (owner_id),
INDEX idx_expired_at (expired_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 点击日志表(分表)
CREATE TABLE click_logs_202602 (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
short_code VARCHAR(10) NOT NULL,
click_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip VARCHAR(45),
user_agent VARCHAR(500),
referer VARCHAR(500),
device_type TINYINT, -- 1:PC 2:Mobile 3:Tablet
os_type TINYINT, -- 1:iOS 2:Android 3:Windows 4:Mac 5:Other
channel VARCHAR(50),
INDEX idx_short_code_time (short_code, click_time),
INDEX idx_click_time (click_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
分表分库策略
当点击日志表超过5000万行,就该考虑分表了:
click_logs_202601
click_logs_202602
click_logs_202603
...
db_0: short_code hash % 4 == 0
db_1: short_code hash % 4 == 1
db_2: short_code hash % 4 == 2
db_3: short_code hash % 4 == 3
3. 混合架构:最佳实践
生产环境通常采用多级存储:
┌──────────────────────────────────────────────────────┐
│ 访问流程 │
└──────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ 本地缓存 │ ← 热点短链接(1%的链接占80%流量)
│ (进程内) │ TTL: 1分钟
└────────┬────────┘
│ miss
▼
┌─────────────────┐
│ Redis集群 │ ← 全量活跃短链接
│ │ TTL: 7天(无访问自动清理)
└────────┬────────┘
│ miss
▼
┌─────────────────┐
│ MySQL主库 │ ← 持久化存储
│ │ 永久保存
└─────────────────┘
缓存更新策略
func getLongURL(shortCode string) (string, error) {
// 1. 查本地缓存
if url := localCache.Get(shortCode); url != "" {
return url, nil
}
// 2. 查Redis
url, err := redis.Get("short:" + shortCode).Result()
if err == nil {
// 回填本地缓存
localCache.Set(shortCode, url, time.Minute)
return url, nil
}
// 3. 查MySQL
var record ShortURL
if err := db.Where("short_code = ?", shortCode).First(&record).Error; err != nil {
return "", err
}
// 4. 回填Redis
redis.Set("short:"+shortCode, record.LongURL, 7*24*time.Hour)
// 5. 回填本地缓存
localCache.Set(shortCode, record.LongURL, time.Minute)
return record.LongURL, nil
}
四、高并发架构设计:应对百万QPS
短链接系统的一大挑战是:流量不可预测。一篇爆款文章,可能在几分钟内带来百万访问。
1. 整体架构
┌─────────────────┐
│ DNS解析 │
└────────┬────────┘
│
┌────────▼────────┐
│ CDN边缘节点 │ ← 全球分布,就近访问
└────────┬────────┘
│
┌────────▼────────┐
│ 负载均衡 │ ← LVS + Nginx
│ (多机房) │
└────────┬────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
┌───────▼───────┐ ┌────────▼────────┐ ┌───────▼───────┐
│ 短链服务 A │ │ 短链服务 B │ │ 短链服务 C │
│ (北京机房) │ │ (上海机房) │ │ (广州机房) │
└───────┬───────┘ └────────┬────────┘ └───────┬───────┘
│ │ │
└──────────────────────┼──────────────────────┘
│
┌────────▼────────┐
│ Redis集群 │ ← 3主3从
└────────┬────────┘
│
┌────────▼────────┐
│ MySQL集群 │ ← 1主2从
└────────┬────────┘
│
┌────────▼────────┐
│ Kafka队列 │ ← 异步日志处理
└────────┬────────┘
│
┌────────▼────────┐
│ 数据分析 │
│ (ClickHouse) │
└─────────────────┘
2. 核心优化策略
策略一:边缘计算,CDN直接302
最激进的优化:让CDN直接返回302重定向,请求根本不回源站。
1. 预热:将热门短链接推送到CDN边缘节点
2. 访问:用户请求到CDN,CDN直接返回302
3. 记录:CDN异步上报访问日志
# Nginx配置示例
location ~ ^/s/([a-zA-Z0-9]+)$ {
set $short_code $1;
# 先查本地缓存
set $long_url "";
access_by_lua_block {
local cache = ngx.shared.shorturl_cache
local url = cache:get(ngx.var.short_code)
if url then
ngx.var.long_url = url
end
}
# 缓存命中直接302
if ($long_url != "") {
return 302 $long_url;
}
# 未命中回源
proxy_pass http://backend;
}
策略二:异步化一切
短链接访问的完整流程:
1. 查缓存 → 2. 返回302 → 3. 记录日志 → 4. 更新统计
但用户只关心第2步。第3、4步完全可以异步:
func handleShortURL(c *gin.Context) {
shortCode := c.Param("code")
// 1. 获取长链接(快速路径)
longURL, err := getLongURL(shortCode)
if err != nil {
c.String(404, "Link not found")
return
}
// 2. 立即返回302(不等待日志写入)
c.Redirect(302, longURL)
// 3. 异步记录日志(关键优化!)
go func() {
logEntry := ClickLog{
ShortCode: shortCode,
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
Referer: c.GetHeader("Referer"),
Time: time.Now(),
}
// 发送到Kafka,不等待确认
kafkaProducer.SendAsync("click_logs", logEntry)
}()
}
策略三:批量写入,合并IO
日志写入不一定要一条条来,可以攒一批:
type LogBuffer struct {
buffer []ClickLog
mu sync.Mutex
ticker *time.Ticker
}
func (lb *LogBuffer) Add(log ClickLog) {
lb.mu.Lock()
lb.buffer = append(lb.buffer, log)
if len(lb.buffer) >= 1000 { // 满1000条立即写入
lb.flush()
}
lb.mu.Unlock()
}
func (lb *LogBuffer) flush() {
if len(lb.buffer) == 0 {
return
}
// 批量插入
db.CreateInBatches(lb.buffer, 1000)
lb.buffer = lb.buffer[:0]
}
func (lb *LogBuffer) Start() {
lb.ticker = time.NewTicker(5 * time.Second)
for range lb.ticker.C {
lb.mu.Lock()
lb.flush()
lb.mu.Unlock()
}
}
3. 容灾设计
多机房部署
┌─────────────────────┐
│ 全局DNS (GTM) │
└──────────┬──────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ 北京机房 │ │ 上海机房 │ │ 广州机房 │
│ │ │ │ │ │
│ 30% 流量 │ │ 40% 流量 │ │ 30% 流量 │
└───────────────┘ └───────────────┘ └───────────────┘
降级策略
func getLongURLWithFallback(shortCode string) string {
// 正常路径:本地缓存 → Redis → MySQL
// 降级路径1:Redis挂了
url, err := redis.Get("short:" + shortCode).Result()
if err == redis.Nil {
return "" // 真的不存在
}
if err != nil {
// Redis错误,降级直接查MySQL
log.Warn("Redis down, fallback to MySQL")
var record ShortURL
db.Where("short_code = ?", shortCode).First(&record)
return record.LongURL
}
return url
}
// 降级路径2:MySQL也挂了
func getLongURLEmergency(shortCode string) string {
// 从本地缓存或静态文件读取
return emergencyCache.Get(shortCode)
}
五、过期策略:让短链接"寿终正寝"
短链接不是永久的。营销活动有结束时间,临时分享有过期需求。
1. 过期策略设计
时间过期
-- 创建时设置过期时间
INSERT INTO short_urls (short_code, long_url, expired_at)
VALUES ('abc123', 'https://example.com/campaign', '2026-03-31 23:59:59');
// 访问时检查过期
func getLongURL(shortCode string) (string, error) {
var record ShortURL
if err := db.Where("short_code = ?", shortCode).First(&record).Error; err != nil {
return "", err
}
if record.ExpiredAt != nil && record.ExpiredAt.Before(time.Now()) {
return "", errors.New("link expired")
}
return record.LongURL, nil
}
访问过期
有些场景是"没人访问就过期":
// 每次访问更新最后访问时间
func updateLastAccess(shortCode string) {
redis.Expire("short:"+shortCode, 30*24*time.Hour) // 30天无访问过期
}
2. 过期清理机制
// 创建时设置TTL
redis.Set("short:"+shortCode, longURL, 30*24*time.Hour)
func cleanExpiredLinks() {
for {
time.Sleep(time.Hour)
// 分批删除过期链接
result := db.Where("expired_at < ?", time.Now()).
Limit(1000).
Delete(&ShortURL{})
log.Info("Cleaned expired links", "count", result.RowsAffected)
}
}
// 软删除(标记为过期)
db.Model(&ShortURL{}).
Where("expired_at < ?", time.Now()).
Update("status", 0)
// 定期归档到历史表
func archiveOldLinks() {
// 1. 复制到归档表
db.Exec(`
INSERT INTO short_urls_archive
SELECT * FROM short_urls
WHERE status = 0 AND updated_at < ?
`, time.Now().AddDate(0, -3, 0)) // 3个月前过期的
// 2. 删除原表数据
db.Exec(`
DELETE FROM short_urls
WHERE status = 0 AND updated_at < ?
`, time.Now().AddDate(0, -3, 0))
}
六、统计分析功能:让数据会说话
短链接的真正价值在于数据。让我们设计一个完整的统计分析系统。
1. 数据采集层
每次点击采集的信息:
type ClickEvent struct {
// 基础信息
ShortCode string `json:"short_code"`
ClickTime time.Time `json:"click_time"`
// 来源信息
IP string `json:"ip"`
Referer string `json:"referer"`
UserAgent string `json:"user_agent"`
// 解析后的信息
DeviceType string `json:"device_type"` // PC/Mobile/Tablet
OSType string `json:"os_type"` // iOS/Android/Windows/Mac
Browser string `json:"browser"` // Chrome/Safari/Firefox
Country string `json:"country"` // 解析IP得到
City string `json:"city"`
// 渠道信息
Channel string `json:"channel"` // wechat/weibo/douyin
Campaign string `json:"campaign"` // 营销活动标识
}
// User-Agent解析
func parseUserAgent(ua string) (deviceType, osType, browser string) {
// 使用开源库解析
parser := uaparser.New(ua)
return parser.DeviceType, parser.OS, parser.Browser
}
// IP地理位置解析
func parseLocation(ip string) (country, city string) {
// 使用MaxMind GeoIP库
record, _ := geoip.City(net.ParseIP(ip))
return record.Country.Names["zh-CN"], record.City.Names["zh-CN"]
}
2. 数据存储层
ClickHouse:OLAP分析利器
对于海量日志分析,ClickHouse是最佳选择:
CREATE TABLE click_events (
short_code String,
click_time DateTime,
ip String,
device_type String,
os_type String,
browser String,
country String,
city String,
channel String,
campaign String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(click_time)
ORDER BY (short_code, click_time);
- 列式存储,聚合查询极快
- 支持亿级数据秒级响应
- 压缩比高,存储成本低
- 支持实时写入
3. 聚合统计层
实时统计(Redis)
// 今日点击数
redis.Incr("stats:today:clicks:" + shortCode)
// 按小时统计
hourKey := time.Now().Format("2006-01-02-15")
redis.Incr("stats:hourly:" + shortCode + ":" + hourKey)
// 按渠道统计
redis.HIncrBy("stats:channel:"+shortCode, channel, 1)
// 按设备统计
redis.HIncrBy("stats:device:"+shortCode, deviceType, 1)
离线统计(定时任务)
func aggregateDailyStats() {
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
// 从ClickHouse查询聚合
stats := clickhouse.Query(`
SELECT
short_code,
count(*) as total_clicks,
uniqExact(ip) as unique_visitors,
countIf(device_type = 'Mobile') as mobile_clicks,
countIf(device_type = 'PC') as pc_clicks
FROM click_events
WHERE toDate(click_time) = ?
GROUP BY short_code
`, yesterday)
// 写入汇总表
for _, stat := range stats {
db.Create(&DailyStat{
ShortCode: stat.ShortCode,
Date: yesterday,
TotalClicks: stat.TotalClicks,
UniqueVisitors: stat.UniqueVisitors,
MobileClicks: stat.MobileClicks,
PCClicks: stat.PCClicks,
})
}
}
4. 报表展示层
API设计
// GET /api/stats/overview?code=abc123
func getStatsOverview(c *gin.Context) {
shortCode := c.Query("code")
overview := StatsOverview{
TotalClicks: redis.Get("stats:total:" + shortCode).Val(),
TodayClicks: redis.Get("stats:today:clicks:" + shortCode).Val(),
UniqueVisitors: redis.PFCount("stats:uv:" + shortCode).Val(),
}
c.JSON(200, overview)
}
// GET /api/stats/trend?code=abc123&days=7
func getStatsTrend(c *gin.Context) {
shortCode := c.Query("code")
days := c.Query("days")
// 从ClickHouse查询趋势
trend := clickhouse.Query(`
SELECT
toDate(click_time) as date,
count(*) as clicks
FROM click_events
WHERE short_code = ?
AND click_time >= today() - ?
GROUP BY date
ORDER BY date
`, shortCode, days)
c.JSON(200, trend)
}
七、安全与防滥用:守护短链接的"门禁"
短链接是公开的服务,很容易成为攻击目标。
1. 风险分析
- 生成短链接指向钓鱼网站
- 传播病毒、木马
- 批量生成大量短链接
- 消耗系统资源,影响正常用户
- 人工或机器刷点击量
- 影响数据统计准确性
- 短链接可能包含敏感信息
- 被遍历猜测可能导致信息泄露
2. 防护措施
链接安全检测
func checkURLSafety(url string) error {
// 1. 域名白名单检查
domain := extractDomain(url)
if !whitelist.Contains(domain) {
return errors.New("domain not in whitelist")
}
// 2. 调用安全服务(如Google Safe Browsing)
if safeBrowsing.IsUnsafe(url) {
return errors.New("unsafe URL detected")
}
// 3. 内容安全检测(爬取页面检查)
content, _ := http.Get(url)
if containsMaliciousContent(content) {
return errors.New("malicious content detected")
}
return nil
}
频率限制
// 基于令牌桶的限流
type RateLimiter struct {
limiters sync.Map
}
func (rl *RateLimiter) Allow(key string, rate int, burst int) bool {
limiter, _ := rl.limiters.LoadOrStore(key, rate.NewLimiter(rate, burst))
return limiter.(*rate.Limiter).Allow()
}
// 使用
func createShortURL(c *gin.Context) {
userID := c.GetString("user_id")
// 每个用户每分钟最多10次
if !rateLimiter.Allow("create:"+userID, rate.Limit(10/60), 10) {
c.JSON(429, gin.H{"error": "rate limit exceeded"})
return
}
// 正常创建逻辑
}
防刷机制
type AntiCheat struct {
redis *redis.Client
}
func (ac *AntiCheat) CheckClick(shortCode, ip string) bool {
// 1. IP黑名单检查
if ac.redis.SIsMember("blacklist:ip", ip).Val() {
return false
}
// 2. 同一IP短时间内点击次数限制
key := "click:" + shortCode + ":" + ip
count := ac.redis.Incr(key).Val()
if count == 1 {
ac.redis.Expire(key, time.Minute)
}
if count > 100 { // 1分钟内同一IP点击超过100次
// 加入黑名单
ac.redis.SAdd("blacklist:ip", ip)
return false
}
// 3. 短链接总点击速率检查
totalKey := "click:total:" + shortCode
total := ac.redis.Incr(totalKey).Val()
if total == 1 {
ac.redis.Expire(totalKey, time.Second)
}
if total > 1000 { // 每秒超过1000次点击
log.Warn("Suspicious traffic", "short_code", shortCode)
// 可以触发告警或自动禁用
}
return true
}
短码防猜测
// 不要用纯自增ID,容易被遍历
// ❌ 错误:abc123, abc124, abc125...
// 方案1:ID混淆
func obfuscateID(id int64) string {
// 使用位反转或异或
obfuscated := id ^ 0x5A5A5A5A
return encodeBase62(obfuscated)
}
// 方案2:加入随机成分
func generateSecureCode(id int64) string {
// ID + 随机数
random := rand.Int63n(1000)
combined := id*1000 + random
return encodeBase62(combined)
}
// 方案3:使用UUID的一部分
func generateUUIDCode() string {
uuid := uuid.New()
return encodeBase62(int64(uuid.ID()))[:6]
}
八、总结
短链接系统看似简单——不就是"长变短"吗?
但深入下去,这是一个涉及分布式系统、高并发架构、数据分析、安全风控的综合工程。
核心要点回顾
- 不只是缩短URL,更是数据追踪和灵活管理的工具
- 短是表象,追踪是核心,灵活是加分项
- 自增ID+62进制:简单可靠,工业界主流
- 哈希算法:分布式友好,需要处理碰撞
- 雪花算法:高性能分布式ID,适合海量场景
- 工程实践:混合使用,取长补短
- Redis:读多写少场景的首选,缓存为王
- MySQL:持久化保障,可靠性的基石
- 混合架构:本地缓存 → Redis → MySQL,三级防护
- 边缘计算:CDN直接302,减少回源
- 异步化:日志记录不阻塞主流程
- 批量写入:合并IO,提升吞吐
- 多机房部署:异地容灾,就近服务
- 时间过期:明确的生命周期
- 访问过期:无人问津自动清理
- 软删除+归档:数据安全与存储成本的平衡
- 采集层:User-Agent解析、IP定位、渠道识别
- 存储层:ClickHouse处理海量日志
- 聚合层:Redis实时统计 + 定时离线聚合
- 展示层:API提供多维报表
- 链接检测:白名单 + 安全服务
- 频率限制:令牌桶限流
- 防刷机制:IP黑名单 + 异常检测
- 短码混淆:防止遍历猜测
给运营同学的建议
- ✅ 选择短链接服务时,重点关注数据追踪能力
- ✅ 每个渠道用独立的短链接,方便后续分析
- ✅ 定期查看点击数据,优化投放策略
- ✅ 设置合理的过期时间,避免"僵尸链接"
- ⚠️ 注意保护短链接不被恶意刷量
给技术同学的建议
- ✅ 缓存是性能优化的第一选择
- ✅ 数据量大时,异步处理和冷热分离是必选项
- ✅ 安全防护要前置,不要等问题出现再补救
- ✅ 做好监控告警,流量异常能第一时间发现
- ✅ 文档先行,把架构设计想清楚再动手
附录:技术选型参考
开源方案
| 方案 | 语言 | 特点 | 适用场景 |
|---|---|---|---|
| YOURLS | PHP | 轻量级,易部署 | 小型项目 |
| Polr | PHP | 现代UI,功能全 | 中型项目 |
| Shlink | PHP | REST API,多域名 | 企业级 |
| Kutt | Node.js | 现代架构,API友好 | 中型项目 |
| Thunder | Go | 高性能,分布式 | 大型项目 |
云服务
| 服务 | 特点 | 价格 |
|---|---|---|
| Bitly | 功能最全,企业级 | 按量付费 |
| 短链接 | 国内服务,速度快 | 免费起步 |
| 百度短链接 | 国内免费 | 免费 |
自研 vs 购买
- 有特殊定制需求
- 数据安全要求高
- 量大(成本考虑)
- 有技术团队维护
- 快速上线需求
- 量小(免费版够用)
- 无技术团队
- 非核心业务
短链接,是运营和技术的一个交汇点。它让运营有了数据,让技术有了场景。一个链接的"变身术",背后是系统设计的智慧。
希望这篇文章能让你对短链接系统有一个全面的认识。下次当你看到 t.cn/xxx 时,你会知道,这短短的几个字符背后,藏着怎样的技术世界。
💬 评论 (0)