Go 短链接服务的实现
作者:白话机器学习
本篇讲解Go短链接服务设计,实现base62和雪花算法两种短码生成,Redis缓存热点URL加速跳转,布隆过滤器防止缓存穿透,分库分表支撑海量数据,分享短码冲突导致跳转错误的踩坑经验,对比base62、雪花算法、自增ID三种短码方案。
开篇故事
去年我们给营销部门做了短链接服务,把长推广URL转成短码发短信。上线第一天发了50万条短信,陆续收到用户反馈说点开短链接跳转到了别人的页面。营销部门炸了,短链接跳错意味着用户看到别人的广告,投放费用全白花。
排查发现是短码冲突。我们用的base62算法把自增ID转成短码,生成短码时没有做唯一性校验。并发场景下两个不同的长URL拿到了同一个自增ID,转出来的短码一样,后写的覆盖了先写的,先写的用户跳转到了后写的页面。
这次我把短链接服务的设计写清楚,重点讲短码生成怎么做防冲突。
一、短码生成算法
短码是把长URL映射成6到7位字符。主流方案有两种,base62编码和雪花算法。base62用0-9、a-z、A-Z共62个字符表示数字,把自增ID编码成短字符串。雪花算法生成全局唯一ID,再编码成短码。
package shorturl
import (
"errors"
"fmt"
"sync"
"time"
)
// Base62Generator base62短码生成器
// 把自增ID转成base62编码
type Base62Generator struct {
mu sync.Mutex
counter int64 // 自增计数器
}
// 字符集: 0-9, a-z, A-Z
const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// NewBase62Generator 创建生成器
// start: 起始计数器值,多实例时不同实例用不同起始值
func NewBase62Generator(start int64) *Base62Generator {
return &Base62Generator{counter: start}
}
// Generate 生成短码
// 把自增ID转成base62字符串
func (g *Base62Generator) Generate() (string, error) {
g.mu.Lock()
defer g.mu.Unlock()
g.counter++
num := g.counter
if num <= 0 {
return "", errors.New("计数器溢出")
}
// 数字转base62字符串
return encodeBase62(num), nil
}
// encodeBase62 数字转base62编码
func encodeBase62(num int64) string {
if num == 0 {
return "0"
}
result := make([]byte, 0, 8)
base := int64(len(charset))
for num > 0 {
// 取余数作为字符索引
result = append(result, charset[num%base])
num /= base
}
// 反转字符串
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return string(result)
}
// SnowflakeGenerator 雪花算法生成器
// 生成全局唯一ID,不依赖自增计数器
type SnowflakeGenerator struct {
mu sync.Mutex
workerID int64 // 工作节点ID
sequence int64 // 序列号
lastStamp int64 // 上次时间戳
}
const (
workerIDBits = 10 // 工作节点ID位数
sequenceBits = 12 // 序列号位数
maxWorkerID = -1 ^ (-1 << workerIDBits)
maxSequence = -1 ^ (-1 << sequenceBits)
// 时间戳左移22位(workerID+sequence位数)
timeShift = workerIDBits + sequenceBits
workerShift = sequenceBits
)
// NewSnowflakeGenerator 创建雪花生成器
// workerID: 节点ID,多实例必须不同
func NewSnowflakeGenerator(workerID int64) (*SnowflakeGenerator, error) {
if workerID < 0 || workerID > maxWorkerID {
return nil, fmt.Errorf("workerID超出范围: %d", workerID)
}
return &SnowflakeGenerator{
workerID: workerID,
lastStamp: time.Now().UnixMilli(),
}, nil
}
// Generate 生成雪花ID再转base62短码
func (s *SnowflakeGenerator) Generate() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UnixMilli()
if now == s.lastStamp {
// 同一毫秒内,序列号递增
s.sequence = (s.sequence + 1) & maxSequence
if s.sequence == 0 {
// 序列号用完,等到下一毫秒
for now <= s.lastStamp {
now = time.Now().UnixMilli()
}
}
} else {
s.sequence = 0
}
s.lastStamp = now
// 拼接: 时间戳 + workerID + 序列号
id := (now << timeShift) |
(s.workerID << workerShift) |
s.sequence
return encodeBase62(id), nil
}
base62简单直接,6位编码能表示620亿个短码(62的6次方)。雪花算法不依赖自增计数器,多实例各自生成不冲突,但短码位数更多(因为ID值大)。
二、Redis缓存与布隆过滤器
短链接服务是读多写少,每次跳转都要查长URL。全走数据库,数据库扛不住。用Redis缓存热点URL,大部分请求直接命中缓存。但缓存有个问题,不存在的短码也会穿透到数据库,恶意请求能打挂数据库。布隆过滤器挡在缓存前面,先判断短码是否存在。
package shorturl
import (
"context"
"errors"
"github.com/redis/go-redis/v9"
)
// ShortURLService 短链接服务
type ShortURLService struct {
client *redis.Client
bloom *BloomFilter // 布隆过滤器
gen *SnowflakeGenerator
}
// NewShortURLService 创建服务
func NewShortURLService(client *redis.Client, gen *SnowflakeGenerator) *ShortURLService {
return &ShortURLService{
client: client,
// 布隆过滤器: 容量1亿,误判率0.01%
bloom: NewBloomFilter(100000000, 0.0001),
gen: gen,
}
}
// Create 创建短链接
func (s *ShortURLService) Create(ctx context.Context, longURL string) (string, error) {
// 生成短码
code, err := s.gen.Generate()
if err != nil {
return "", err
}
// 写入Redis缓存
key := "shorturl:" + code
if err := s.client.Set(ctx, key, longURL, 0).Err(); err != nil {
return "", err
}
// 写入布隆过滤器
s.bloom.Add(code)
// 异步写入数据库(省略)
return code, nil
}
// Resolve 解析短链接,返回长URL
func (s *ShortURLService) Resolve(ctx context.Context, code string) (string, error) {
// 1. 布隆过滤器先判断短码是否存在
if !s.bloom.Exists(code) {
// 一定不存在,直接返回,不查缓存和DB
return "", errors.New("短链接不存在")
}
// 2. 查Redis缓存
key := "shorturl:" + code
longURL, err := s.client.Get(ctx, key).Result()
if err == nil {
return longURL, nil // 缓存命中
}
if err != redis.Nil {
return "", err // Redis异常
}
// 3. 缓存未命中,查数据库(省略DAO调用)
// 4. 数据库查到后回写缓存
// 5. 数据库也没有,返回不存在
return "", errors.New("短链接不存在")
}
// BloomFilter 布隆过滤器
// 用多个hash函数判断元素是否可能存在
type BloomFilter struct {
bits []uint64 // 位数组
size uint // 位数组大小
hashNum uint // hash函数个数
}
// NewBloomFilter 创建布隆过滤器
// n: 预期元素数量, p: 误判率
func NewBloomFilter(n int, p float64) *BloomFilter {
// 计算位数组大小和hash函数个数
// 公式: m = -n*ln(p) / (ln2)^2, k = m/n * ln2
size := uint(float64(n) * 1.44 / 0.693) // 简化计算
hashNum := uint(7) // 经验值
wordCount := (size + 63) / 64
return &BloomFilter{
bits: make([]uint64, wordCount),
size: size,
hashNum: hashNum,
}
}
// Add 添加元素到布隆过滤器
func (b *BloomFilter) Add(key string) {
for i := uint(0); i < b.hashNum; i++ {
// 用不同种子计算多个hash值
pos := b.hash(key, i)
wordIdx := pos / 64
bitIdx := pos % 64
b.bits[wordIdx] |= 1 << bitIdx
}
}
// Exists 判断元素是否存在
// 返回true: 可能存在(有误判)
// 返回false: 一定不存在
func (b *BloomFilter) Exists(key string) bool {
for i := uint(0); i < b.hashNum; i++ {
pos := b.hash(key, i)
wordIdx := pos / 64
bitIdx := pos % 64
if b.bits[wordIdx]&(1<<bitIdx) == 0 {
return false // 任意一位为0,一定不存在
}
}
return true // 所有位都为1,可能存在
}
// hash 简单hash函数,用不同种子区分
func (b *BloomFilter) hash(key string, seed uint) uint {
var h uint = 0
for _, c := range key {
h = h*131 + uint(c) + seed
}
return h % b.size
}
布隆过滤器的特点是判断不存在就一定不存在,判断存在有误判率。短链接场景正好需要这个特性,不存在的短码直接挡掉,存在的再查缓存和数据库。
三、踩坑经验:短码冲突导致跳转错误
开篇提到的短码冲突问题,根因是base62生成器用自增ID,多实例部署时各自维护自己的计数器,两个实例的计数器可能同时到同一个值。生成的短码一样,后写的覆盖先写的。
修复方案有三个要点。第一,短码生成后做唯一性校验,冲突了重新生成。第二,多实例用雪花算法替代自增ID,天生不冲突。第三,数据库层面加唯一索引兜底。
package shorturl
import (
"context"
"errors"
"github.com/redis/go-redis/v9"
)
// SafeShortURLService 带冲突检测的短链接服务
type SafeShortURLService struct {
client *redis.Client
gen *SnowflakeGenerator // 用雪花算法避免冲突
}
// NewSafeShortURLService 创建安全短链接服务
func NewSafeShortURLService(client *redis.Client, gen *SnowflakeGenerator) *SafeShortURLService {
return &SafeShortURLService{client: client, gen: gen}
}
// CreateWithCheck 创建短链接,带冲突检测
// 生成短码后检查是否已存在,冲突则重试
func (s *SafeShortURLService) CreateWithCheck(
ctx context.Context, longURL string,
) (string, error) {
maxRetry := 3
for i := 0; i < maxRetry; i++ {
// 生成短码
code, err := s.gen.Generate()
if err != nil {
continue
}
key := "shorturl:" + code
// SETNX: 只在key不存在时设置
// 返回true说明短码可用,false说明已被占用
ok, err := s.client.SetNX(ctx, key, longURL, 0).Result()
if err != nil {
return "", err
}
if ok {
// 设置成功,短码唯一
return code, nil
}
// 短码冲突,重试生成
}
return "", errors.New("短码生成冲突,重试次数用尽")
}
SETNX天然适合做冲突检测。短码存在就设置失败,不存在才成功。雪花算法加SETNX双重保证,冲突概率降到几乎为零。再配合数据库唯一索引,三层防线确保短码不冲突。
四、对比分析
| 短码方案 | 冲突风险 | 性能 | 多实例 | 短码长度 |
|---|---|---|---|---|
| base62自增 | 高 | 极高 | 不支持 | 6位 |
| 雪花算法 | 极低 | 高 | 支持 | 8位 |
| MD5哈希 | 中 | 高 | 支持 | 6位(截断) |
| UUID | 无 | 中 | 支持 | 20位+ |
base62自增最短最快,但多实例会冲突。雪花算法多实例不冲突,短码稍长但可接受。MD5哈希固定长度但截断后有冲突风险。UUID完全不冲突但太长不适合做短链接。综合看雪花算法加SETNX校验是最佳方案。
总结
短链接服务的核心是短码生成和缓存加速。base62自增简单但多实例冲突,雪花算法天生不冲突是首选。布隆过滤器挡在不存在的短码前面,防止缓存穿透打挂数据库。短码冲突必须三层防护: 雪花算法避免生成冲突,SETNX检查运行时冲突,唯一索引兜底数据库冲突。分库分表支撑海量数据,按短码首字母分表均衡分布。下一篇我们聊分布式任务调度平台。
到此这篇关于Go 短链接服务的实现的文章就介绍到这了,更多相关Go 短链接服务内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
