综合设计题:用 Gin 设计高并发短链服务(要点完整)
一句话结论:核心链路是“创建:长链→发号→落库→写缓存;跳转:短码→查缓存→命中 302 / 未命中回源→302”。完整方案要覆盖:发号(全局唯一、防枚举、高可用)、存储(DB 持久化 + Redis 缓存,读多写少)、跳转语义(302 而非 301,便于统计与下线)、缓存三防(穿透/击穿/雪崩)、幂等与去重、统计异步化、安全治理(恶意 URL、限流、过期回收)与监控。Gin 侧就是三组路由 + 中间件:跳转页、API(鉴权+限流)、健康检查,服务本身无状态可水平扩展。
总体架构与模块
| 模块 | 方案要点 |
|---|---|
| 入口 | nginx → Gin 集群(无状态,短码→长链映射不落本地) |
| 发号 | 号段模式/雪花发号器,base62 编码成 6~8 位短码;预取号段减少 DB 压力 |
| 去重 | 长链哈希查表复用旧码;DB 短码唯一索引兜底并发 |
| 存储 | MySQL:short_code、long_url、过期时间,short_code 建唯一索引 |
| 缓存 | Redis:short→long,cache-aside;布隆过滤防非法码穿透;单飞防击穿 |
| 跳转 | GET /:code 命中即 302 Location;301 会被浏览器缓存,无法统计与下线 |
| 治理 | 创建接口限流/黑白名单(防恶意短链与枚举)、过期懒删+定期清理、QPS 与命中率监控 |
Gin 路由与核心代码
r.GET("/:code", Redirect) // 跳转:缓存→回源→302
api := r.Group("/api/v1", AuthMiddleware(), RateLimit())
api.POST("/urls", CreateShortURL) // 创建:校验→发号→落库→写缓存
api.GET("/urls/:code/stats", GetStats) // 统计:异步打点后查询
// 读链路:缓存优先 + 防击穿(singleflight 简写示意)
func Redirect(c *gin.Context) {
code := c.Param("code")
long, err := cache.GetLong(c, code)
if err == cache.Miss {
long, err = dao.FindLong(code) // 回源 DB
if err == nil { cache.Set(c, code, long, ttl) } // 回填
}
if err != nil { c.JSON(404, ...); return }
go statAsync(code) // 点击数异步落库/队列
c.Redirect(302, long)
}
高并发要点
- 读多写少:Redis 命中率是生命线;超热点短链可加进程内本地缓存(注意一致性 TTL 要短)。
- 发号器预取号段(一次 1 万缓存在内存),写接口不每次打 DB;号段用完再取。
- 统计异步化:PV/UV 走消息队列或批量落库,别阻塞 302 主链路。
- 防御:短码用大随机空间 + 限流,防遍历枚举抓全量;创建接口校验目标 URL(防钓鱼跳转)。
- 容量参考:6 位 base62 空间约 62^6≈568 亿,正常业务 6~7 位足够,还嫌短可加位。
追问记忆点
- 追问:为什么 302 不用 301?——301 被浏览器/代理缓存后不再回源,点击统计与“下架链接”都会失效。
- 追问:短码直接用长链哈希行不行?——碰撞/长度不可控;正规做法是发号器保证唯一,哈希只用于去重判断。
- 追问:为什么用 62 进制不用 base64?——62=26+26+10,全是 URL 安全字符;base64 含 +/= 需要转义。
- 追问:发号器挂了怎么办?——号段分段预取到多实例,单点故障只影响写、不影响读(缓存+DB 还在)。
- 记忆点:发号 base62 唯一、DB 持久化、Redis 缓存三防、302 跳转、统计异步、限流与 URL 风控、监控命中率。