
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再编码成短码。packageshorturlimport(errorsfmtsynctime)// Base62Generator base62短码生成器// 把自增ID转成base62编码typeBase62Generatorstruct{mu sync.Mutex counterint64// 自增计数器}// 字符集: 0-9, a-z, A-Zconstcharset0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ// NewBase62Generator 创建生成器// start: 起始计数器值多实例时不同实例用不同起始值funcNewBase62Generator(startint64)*Base62Generator{returnBase62Generator{counter:start}}// Generate 生成短码// 把自增ID转成base62字符串func(g*Base62Generator)Generate()(string,error){g.mu.Lock()deferg.mu.Unlock()g.counternum:g.counterifnum0{return,errors.New(计数器溢出)}// 数字转base62字符串returnencodeBase62(num),nil}// encodeBase62 数字转base62编码funcencodeBase62(numint64)string{ifnum0{return0}result:make([]byte,0,8)base:int64(len(charset))fornum0{// 取余数作为字符索引resultappend(result,charset[num%base])num/base}// 反转字符串fori,j:0,len(result)-1;ij;i,ji1,j-1{result[i],result[j]result[j],result[i]}returnstring(result)}// SnowflakeGenerator 雪花算法生成器// 生成全局唯一ID不依赖自增计数器typeSnowflakeGeneratorstruct{mu sync.Mutex workerIDint64// 工作节点IDsequenceint64// 序列号lastStampint64// 上次时间戳}const(workerIDBits10// 工作节点ID位数sequenceBits12// 序列号位数maxWorkerID-1^(-1workerIDBits)maxSequence-1^(-1sequenceBits)// 时间戳左移22位(workerIDsequence位数)timeShiftworkerIDBitssequenceBits workerShiftsequenceBits)// NewSnowflakeGenerator 创建雪花生成器// workerID: 节点ID多实例必须不同funcNewSnowflakeGenerator(workerIDint64)(*SnowflakeGenerator,error){ifworkerID0||workerIDmaxWorkerID{returnnil,fmt.Errorf(workerID超出范围: %d,workerID)}returnSnowflakeGenerator{workerID:workerID,lastStamp:time.Now().UnixMilli(),},nil}// Generate 生成雪花ID再转base62短码func(s*SnowflakeGenerator)Generate()(string,error){s.mu.Lock()defers.mu.Unlock()now:time.Now().UnixMilli()ifnows.lastStamp{// 同一毫秒内序列号递增s.sequence(s.sequence1)maxSequenceifs.sequence0{// 序列号用完等到下一毫秒fornows.lastStamp{nowtime.Now().UnixMilli()}}}else{s.sequence0}s.lastStampnow// 拼接: 时间戳 workerID 序列号id:(nowtimeShift)|(s.workerIDworkerShift)|s.sequencereturnencodeBase62(id),nil}base62简单直接6位编码能表示620亿个短码(62的6次方)。雪花算法不依赖自增计数器多实例各自生成不冲突但短码位数更多(因为ID值大)。二、Redis缓存与布隆过滤器短链接服务是读多写少每次跳转都要查长URL。全走数据库数据库扛不住。用Redis缓存热点URL大部分请求直接命中缓存。但缓存有个问题不存在的短码也会穿透到数据库恶意请求能打挂数据库。布隆过滤器挡在缓存前面先判断短码是否存在。packageshorturlimport(contexterrorsgithub.com/redis/go-redis/v9)// ShortURLService 短链接服务typeShortURLServicestruct{client*redis.Client bloom*BloomFilter// 布隆过滤器gen*SnowflakeGenerator}// NewShortURLService 创建服务funcNewShortURLService(client*redis.Client,gen*SnowflakeGenerator)*ShortURLService{returnShortURLService{client:client,// 布隆过滤器: 容量1亿误判率0.01%bloom:NewBloomFilter(100000000,0.0001),gen:gen,}}// Create 创建短链接func(s*ShortURLService)Create(ctx context.Context,longURLstring)(string,error){// 生成短码code,err:s.gen.Generate()iferr!nil{return,err}// 写入Redis缓存key:shorturl:codeiferr:s.client.Set(ctx,key,longURL,0).Err();err!nil{return,err}// 写入布隆过滤器s.bloom.Add(code)// 异步写入数据库(省略)returncode,nil}// Resolve 解析短链接返回长URLfunc(s*ShortURLService)Resolve(ctx context.Context,codestring)(string,error){// 1. 布隆过滤器先判断短码是否存在if!s.bloom.Exists(code){// 一定不存在直接返回不查缓存和DBreturn,errors.New(短链接不存在)}// 2. 查Redis缓存key:shorturl:code longURL,err:s.client.Get(ctx,key).Result()iferrnil{returnlongURL,nil// 缓存命中}iferr!redis.Nil{return,err// Redis异常}// 3. 缓存未命中查数据库(省略DAO调用)// 4. 数据库查到后回写缓存// 5. 数据库也没有返回不存在return,errors.New(短链接不存在)}// BloomFilter 布隆过滤器// 用多个hash函数判断元素是否可能存在typeBloomFilterstruct{bits[]uint64// 位数组sizeuint// 位数组大小hashNumuint// hash函数个数}// NewBloomFilter 创建布隆过滤器// n: 预期元素数量, p: 误判率funcNewBloomFilter(nint,pfloat64)*BloomFilter{// 计算位数组大小和hash函数个数// 公式: m -n*ln(p) / (ln2)^2, k m/n * ln2size:uint(float64(n)*1.44/0.693)// 简化计算hashNum:uint(7)// 经验值wordCount:(size63)/64returnBloomFilter{bits:make([]uint64,wordCount),size:size,hashNum:hashNum,}}// Add 添加元素到布隆过滤器func(b*BloomFilter)Add(keystring){fori:uint(0);ib.hashNum;i{// 用不同种子计算多个hash值pos:b.hash(key,i)wordIdx:pos/64bitIdx:pos%64b.bits[wordIdx]|1bitIdx}}// Exists 判断元素是否存在// 返回true: 可能存在(有误判)// 返回false: 一定不存在func(b*BloomFilter)Exists(keystring)bool{fori:uint(0);ib.hashNum;i{pos:b.hash(key,i)wordIdx:pos/64bitIdx:pos%64ifb.bits[wordIdx](1bitIdx)0{returnfalse// 任意一位为0一定不存在}}returntrue// 所有位都为1可能存在}// hash 简单hash函数用不同种子区分func(b*BloomFilter)hash(keystring,seeduint)uint{varhuint0for_,c:rangekey{hh*131uint(c)seed}returnh%b.size}布隆过滤器的特点是判断不存在就一定不存在判断存在有误判率。短链接场景正好需要这个特性不存在的短码直接挡掉存在的再查缓存和数据库。三、踩坑经验:短码冲突导致跳转错误开篇提到的短码冲突问题根因是base62生成器用自增ID多实例部署时各自维护自己的计数器两个实例的计数器可能同时到同一个值。生成的短码一样后写的覆盖先写的。修复方案有三个要点。第一短码生成后做唯一性校验冲突了重新生成。第二多实例用雪花算法替代自增ID天生不冲突。第三数据库层面加唯一索引兜底。packageshorturlimport(contexterrorsgithub.com/redis/go-redis/v9)// SafeShortURLService 带冲突检测的短链接服务typeSafeShortURLServicestruct{client*redis.Client gen*SnowflakeGenerator// 用雪花算法避免冲突}// NewSafeShortURLService 创建安全短链接服务funcNewSafeShortURLService(client*redis.Client,gen*SnowflakeGenerator)*SafeShortURLService{returnSafeShortURLService{client:client,gen:gen}}// CreateWithCheck 创建短链接带冲突检测// 生成短码后检查是否已存在冲突则重试func(s*SafeShortURLService)CreateWithCheck(ctx context.Context,longURLstring,)(string,error){maxRetry:3fori:0;imaxRetry;i{// 生成短码code,err:s.gen.Generate()iferr!nil{continue}key:shorturl:code// SETNX: 只在key不存在时设置// 返回true说明短码可用false说明已被占用ok,err:s.client.SetNX(ctx,key,longURL,0).Result()iferr!nil{return,err}ifok{// 设置成功短码唯一returncode,nil}// 短码冲突重试生成}return,errors.New(短码生成冲突重试次数用尽)}SETNX天然适合做冲突检测。短码存在就设置失败不存在才成功。雪花算法加SETNX双重保证冲突概率降到几乎为零。再配合数据库唯一索引三层防线确保短码不冲突。四、对比分析短码方案冲突风险性能多实例短码长度base62自增高极高不支持6位雪花算法极低高支持8位MD5哈希中高支持6位(截断)UUID无中支持20位base62自增最短最快但多实例会冲突。雪花算法多实例不冲突短码稍长但可接受。MD5哈希固定长度但截断后有冲突风险。UUID完全不冲突但太长不适合做短链接。综合看雪花算法加SETNX校验是最佳方案。总结短链接服务的核心是短码生成和缓存加速。base62自增简单但多实例冲突雪花算法天生不冲突是首选。布隆过滤器挡在不存在的短码前面防止缓存穿透打挂数据库。短码冲突必须三层防护: 雪花算法避免生成冲突SETNX检查运行时冲突唯一索引兜底数据库冲突。分库分表支撑海量数据按短码首字母分表均衡分布。下一篇我们聊分布式任务调度平台。