短链接系统:一个链接的"变身术"

运营工具篇 · 第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 时,短链接系统会默默记录下这些信息:

运营人员能看到的"全景图"

想象一下,你是一个电商运营,刚刚在三个平台投放了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

这就是短链接追踪的价值——让每一分钱都花得明明白白。

3. 灵活,是隐藏技能

长链接一旦发出,就"定型"了。但短链接可以随时更换背后的目标地址。


二、短链接生成算法:从长到短的魔法原理

短链接的核心,就是把"长"变"短"。但怎么变?变完还能找回来吗?这就要用到几种经典的算法。

在深入算法之前,先理解一个核心概念:短链接本质上是一个映射

长URL(原始地址)  ←→  短码(简短标识)  ←→  短链接(完整地址)
https://example.com/very/long/url/...  ←→  "d7C"  ←→  t.cn/d7C

这个映射关系需要满足几个关键要求:

  1. 唯一性:不同长URL不能生成相同的短码
  2. 可逆性:根据短码能找到对应的长URL
  3. 短小性:短码要足够短,5-7个字符最佳
  4. 高效性:生成和查询都要快

接下来,我们看看三种主流方案是如何实现这些要求的。

1. 自增ID + 62进制转换:最经典的方案

这是最常用、最可靠的方案,像给每个长链接发一个"身份证号"。

为什么选62进制?

生活中我们熟悉10进制(0-9)、16进制(0-9和A-F)。那62进制是什么?

62^6 = 568,002,355,84 ≈ 568亿

这意味着,即使每天生成1亿个短链接,也够用15年!

一个生动的比喻

把短链接生成想象成餐厅排号

  1. 自增ID:就像餐厅发号,1号、2号、3号...每个号码唯一且递增
  2. 62进制转换:就像把"第10086号"简写成"A1B",更短更好记
  3. 存储映射:就像餐厅的小本本,记录"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亿足够用几十年。

优缺点分析

2. 哈希算法:分布式的选择

如果你想摆脱对中心ID的依赖,哈希算法是另一个选择。

哈希的直觉理解

哈希就像是一个"数字指纹"生成器:

想象一下,你有一台神奇的机器:

  1. 把"https://example.com/abc"放进去
  2. 机器吐出一个指纹:"a3b2c1"
  3. 下次再放同样的URL,还是得到"a3b2c1"

这就是哈希的核心特性——确定性

为什么哈希算法适合分布式?

自增ID方案需要所有服务器都访问同一个"发号器",这会成为瓶颈。

哈希方案则不同:

服务器A:对URL做哈希 → 得到短码
服务器B:对同样URL做哈希 → 得到相同短码
服务器C:对同样URL做哈希 → 得到相同短码

不需要协调,每台服务器独立工作,结果却一致!这就是哈希的分布式友好特性。

碰撞:哈希的阿喀琉斯之踵

但是,哈希有一个致命问题——碰撞

这就像停车场:

原理详解

对长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位,碰撞概率:

碰撞处理策略

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范围内
}

优缺点分析

3. 雪花算法(Snowflake):分布式ID的王者

如果你的系统需要每秒生成百万级短链接,雪花算法是最优解。

原理详解

雪花算法生成一个64位的整数ID,结构如下:

0 | 00000000000000000000000000000000000000000 | 0000000000 | 000000000000
<table>
<thead><tr>
<th>41位时间戳</th>
<th>10位机器ID</th>
</tr></thead><tbody>
</tbody></table>
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
}

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. 三大核心挑战

短链接的使用模式非常特殊:

这意味着系统设计的重心要放在读性能优化上。

短链接的流量就像"过山车":

每次点击都要记录,数据积累速度惊人:

2. 分层架构设计

为了应对这些挑战,短链接系统通常采用多层缓存+异步处理的架构:

┌────────────────────────────────────────────────────────┐
│                    用户访问流程                        │
└────────────────────────────────────────────────────────┘

第1层:CDN边缘节点(全球分布)
    ↓ 命中率 60-80%
    
第2层:负载均衡 + 本地缓存(进程内)
    ↓ 命中率 10-20%
    
第3层:Redis集群(分布式缓存)
    ↓ 命中率 5-10%
    
第4层:MySQL主库(持久化存储)
    ↓ 最终兜底

第5层:Kafka + ClickHouse(异步日志处理)

打个比方,短链接系统就像一个"问路"服务:

  1. CDN:就像路边的指示牌,常见地点(热门短链接)直接告诉你怎么走
  2. 本地缓存:就像你的手机备忘录,经常去的地方记下来
  3. Redis:就像问路边的便利店老板,大部分地方都知道
  4. MySQL:就像去市政厅查档案,什么偏僻地方都有记录
  5. 异步日志:就像你边走边录音,事后整理成游记

每一层都拦截掉大部分请求,下一层只需要处理"漏网之鱼"。

3. 关键设计决策

决策一:302还是301重定向?

这是一个经常被忽略但很重要的细节。

决策二:同步还是异步记录日志?

用户点击 → 查缓存 → 记录日志到数据库 → 返回302
                   ↑ 这里会拖慢响应
用户点击 → 查缓存 → 发送日志到消息队列 → 返回302
                          ↓
                   后台消费者 → 批量写入数据库

四、存储方案选型:Redis vs MySQL vs 混合架构

短链接系统有两个核心数据:

  1. 映射关系:短码 ↔ 长URL
  2. 点击日志:每次访问的详细信息

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的优势

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]
}

八、总结

短链接系统看似简单——不就是"长变短"吗?

但深入下去,这是一个涉及分布式系统、高并发架构、数据分析、安全风控的综合工程。

核心要点回顾

给运营同学的建议

给技术同学的建议


附录:技术选型参考

开源方案

方案 语言 特点 适用场景
YOURLS PHP 轻量级,易部署 小型项目
Polr PHP 现代UI,功能全 中型项目
Shlink PHP REST API,多域名 企业级
Kutt Node.js 现代架构,API友好 中型项目
Thunder Go 高性能,分布式 大型项目

云服务

服务 特点 价格
Bitly 功能最全,企业级 按量付费
短链接 国内服务,速度快 免费起步
百度短链接 国内免费 免费

自研 vs 购买


短链接,是运营和技术的一个交汇点。它让运营有了数据,让技术有了场景。一个链接的"变身术",背后是系统设计的智慧。

希望这篇文章能让你对短链接系统有一个全面的认识。下次当你看到 t.cn/xxx 时,你会知道,这短短的几个字符背后,藏着怎样的技术世界。


💬 评论 (0)

0/500
排序: