这是一篇实战文,不是知识点罗列。目标是从零写出一个能扛 10 万并发的短链服务,把 Go 的并发、Redis 缓存、MySQL 存储、压测验证串成一条完整的线。每一步都给出可运行的代码和验证方法。
一、先说清楚要做什么
短链服务就三件事:把长网址变成一个短码存起来、访问短码时 302 跳回原址、统计点击。听起来简单,但「10 万并发」这个约束会让每个设计决策都变重:
- 读多写少:1 万条短链可能有 100 万次访问,读写比约 100:1 → 必须缓存
- 热点聚集:少数短链占大部分流量 → 缓存命中率决定成败
- 短码生成要防冲突:并发插入时两个请求可能生成同一短码
- 跳转必须快:用户点链接等 300ms 就开始不耐烦,重定向链路不能有多次 DB 查询
| 指标 | 目标值 | 怎么测 |
|---|---|---|
| 跳转 P99 延迟 | < 10ms | wrk / k6 压测 |
| 单机 QPS | > 5 万 | wrk 持续压 30 秒 |
| 缓存命中率 | > 95% | Redis INFO stats |
| 短码冲突率 | 0(靠唯一索引兜底) | 并发插入测试 |
二、整体架构:三层就够了
不要一上来就微服务。短链服务的瓶颈在「跳转路径」,不在服务拆分。三层结构最合适:
- HTTP 层:Go 标准库 net/http(Go 1.22+ 的路由增强足够用,不必上框架)
- 缓存层:Redis 存 `短码 → 长网址` 映射,TTL 24 小时
- 存储层:MySQL 存全量映射 + 点击计数
请求流程:
跳转请求 GET /{code}
↓
Redis GET code
├─ 命中 → 直接 302(不碰数据库)
└─ 未命中 → 查 MySQL → 回填 Redis → 302
↓
异步累加点击(不阻塞响应)三、短码怎么生成(这是第一个坑)
3.1 方案对比
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 自增 ID + Base62 | 无冲突、短、可预测长度 | ID 连续时可被枚举 | 内部系统 |
| 随机 6 位 Base62 | 不可枚举 | 需查重(并发下要重试) | 对外的短链 |
| 哈希(MD5 取前 6 位) | 同一长链得同一短码(天然去重) | 有碰撞,仍要查重 | 需要幂等的场景 |
本项目要对外开放,选随机 6 位 Base62:62^6 ≈ 568 亿种组合,碰撞概率极低,且不可枚举。
3.2 生成代码
package shortener
import (
"crypto/rand"
"math/big"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// NewCode 生成 6 位 Base62 随机短码。
// 用 crypto/rand 而非 math/rand:后者在多协程下需要锁,且种子可预测。
func NewCode(n int) (string, error) {
max := big.NewInt(int64(len(alphabet)))
b := make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
b[i] = alphabet[idx.Int64()]
}
return string(b), nil
}3.3 冲突怎么处理
不要在应用层「先查再插」——这是并发下的经典竞态。正确做法是依赖数据库唯一索引,插入失败就重试:
// Save 插入短链;若短码已存在则重新生成,最多重试 5 次。
func (s *Store) Save(ctx context.Context, longURL string) (string, error) {
for i := 0; i < 5; i++ {
code, err := NewCode(6)
if err != nil {
return "", err
}
_, err = s.db.ExecContext(ctx,
`INSERT INTO links (code, long_url) VALUES (?, ?)`, code, longURL)
if err == nil {
return code, nil
}
if isDupKey(err) { // 唯一索引冲突,换一个重试
continue
}
return "", err
}
return "", ErrTooManyRetries
}- 不要用 `INSERT … ON DUPLICATE KEY UPDATE` 掩盖冲突:那会把「短码被占用」变成「覆盖别人的短链」,静默的严重 bug
- `isDupKey` 判断 MySQL 错误码 1062,不同驱动写法不同(go-sql-driver 用 `*mysql.MySQLError`)
- 5 次重试的失败率约 (已用短码数 / 568亿)^5,实际可忽略
四、数据库设计
CREATE TABLE links (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(8) NOT NULL,
long_url TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
clicks BIGINT UNSIGNED DEFAULT 0,
UNIQUE KEY uk_code (code), -- 冲突靠它兜底
KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三个设计要点:
- code 用 VARCHAR(8) 而非 CHAR(6):留升级空间(未来可能扩到 7-8 位),且 VARCHAR 对变长更友好
- clicks 存在 MySQL 而不是只在 Redis:Redis 可能丢数据,点击数是业务数据不能丢
- long_url 用 TEXT:URL 理论上可长达 2000+ 字符,VARCHAR(255) 会截断
五、跳转路径:缓存怎么用才对
5.1 缓存穿透与击穿
两个常见问题,处理方式不同:
| 问题 | 表现 | 解法 |
|---|---|---|
| 穿透 | 查不存在的短码,每次都打到 DB | 缓存空值(短 TTL,如 60 秒) |
| 击穿 | 热点短码缓存刚过期,瞬间大量请求打到 DB | singleflight 合并同一 key 的查询 |
// Resolve 查短码对应的长网址,带缓存与 singleflight 防击穿。
func (s *Service) Resolve(ctx context.Context, code string) (string, error) {
// 1. 查 Redis
if v, err := s.rdb.Get(ctx, "link:"+code).Result(); err == nil {
if v == "" {
return "", ErrNotFound // 空值缓存:直接返回,不查库
}
return v, nil
}
// 2. 未命中:用 singleflight 合并并发请求,只让一个打 DB
v, err, _ := s.sf.Do(code, func() (interface{}, error) {
url, err := s.store.GetByCode(ctx, code)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 缓存空值 60 秒,防穿透
s.rdb.Set(ctx, "link:"+code, "", 60*time.Second)
return "", ErrNotFound
}
return "", err
}
s.rdb.Set(ctx, "link:"+code, url, 24*time.Hour)
return url, nil
})
if err != nil {
return "", err
}
return v.(string), nil
}5.2 singleflight 为什么关键
缓存过期的那一刻,如果有 1000 个并发请求同时打进来,没有 singleflight 就是 1000 次 DB 查询;有了它只有 1 次,其余 999 个等结果复用。这是 Redis 缓存的标准配套。
go get golang.org/x/sync/singleflight六、点击统计:不要阻塞跳转
跳转响应必须在 10ms 内返回,所以点击数不能同步写 MySQL。做法是异步累加:
// 跳转 handler:只做重定向,点击计数丢给后台
func (h *Handler) Redirect(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code") // Go 1.22+ 路由参数
url, err := h.svc.Resolve(r.Context(), code)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
// 异步计数:非阻塞,失败也不影响跳转
h.clicks.Incr(code)
w.Header().Set("Location", url)
w.WriteHeader(http.StatusFound) // 302
}
// Incr 把点击事件放入内存缓冲,后台批量刷入 Redis
type Counter struct {
ch chan string
}
func (c *Counter) Incr(code string) {
select {
case c.ch <- code: // 缓冲区没满就丢进去
default: // 满了直接丢弃,绝不阻塞跳转
}
}
// 后台每 1 秒把缓冲批量写 Redis
func (c *Counter) run(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
buf := make(map[string]int64)
for {
select {
case code := <-c.ch:
buf[code]++
case <-ticker.C:
if len(buf) == 0 { continue }
pipe := c.rdb.Pipeline()
for code, n := range buf {
pipe.HIncrBy(ctx, "clicks", code, n)
}
pipe.Exec(ctx)
buf = make(map[string]int64)
case <-ctx.Done():
return
}
}
}- `select` 的 default 分支是刻意的:缓冲区满时丢点击数,而不是阻塞用户跳转。点击数少记几百次不影响业务,跳转变慢直接影响体验
- Redis 的点击累积到一定量(比如 1000)再批量写 MySQL,避免高频写库
- 这个设计在极端流量下会丢数据,要精确计数就得用消息队列(Kafka),但那是另一个复杂度级别
七、压测:怎么验证真的能扛 10 万
7.1 压测工具选 wrk
# 安装
apt install wrk # 或 brew install wrk
1. 压测:12 线程 400 连接,持续 30 秒
wrk -t12 -c400 -d30s --latency http://127.0.0.1:8080/abc123
1. 关注这三行
1. Requests/sec: 51234.56 ← QPS
1. Latency P99: 8.21ms ← 尾延迟
1. Socket errors: 0 ← 不能有错误7.2 分阶段定位瓶颈
| 阶段 | 现象 | 瓶颈在哪 | 怎么改 |
|---|---|---|---|
| QPS 卡在 1 万 | CPU 打满、DB 连接数满 | 缓存没命中 | 查 Redis 命中率,补预热 |
| QPS 卡在 3 万 | Redis CPU 高 | Redis 单线程 | 加 Pipeline 或本地缓存 |
| QPS 卡在 5 万 | Go 进程 GC 频繁 | 对象分配多 | 用 sync.Pool 复用、减少字符串拼接 |
| QPS 上不去且 CPU 不满 | 连接数/文件描述符限制 | 内核参数 | 调 ulimit -n、somaxconn |
7.3 本地缓存的最后一层
单机 Redis 到 3-5 万 QPS 就到顶了(单线程)。要再往上,加一层进程内缓存:
// 进程内 LRU 缓存,挡住 Redis 之前的一层
import "github.com/hashicorp/golang-lru/v2"
type TwoLevel struct {
local *lru.Cache[string, string]
rdb *redis.Client
}
func (t *TwoLevel) Get(ctx context.Context, code string) (string, error) {
if v, ok := t.local.Get(code); ok {
return v, nil // 本地命中,零网络开销
}
v, err := t.rdb.Get(ctx, "link:"+code).Result()
if err == nil {
t.local.Add(code, v) // 回填本地
return v, nil
}
return "", err
}- 本地缓存容量要小(比如 10 万条),否则内存吃不住
- 多实例部署时本地缓存会有一致性问题——短链数据基本不变,这个延迟可接受
- TTL 要短(比如 5 分钟),容忍脏数据但不长期错
八、部署与真实数字
# docker-compose.yml(压测环境)
services:
app:
build: .
ports: ["8080:8080"]
environment:
- REDIS_ADDR=redis:6379
- MYSQL_DSN=app:pwd@tcp(mysql:3306)/short?parseTime=true
deploy:
resources:
limits: { cpus: "2", memory: 1G }
redis:
image: redis:7.4-alpine
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: pwd
MYSQL_DATABASE: short在 2C1.8G 的机器上实测(与本站服务器同规格):
| 配置 | QPS | P99 | 说明 |
|---|---|---|---|
| 只用 MySQL(无缓存) | 约 1200 | 38ms | 瓶颈在 DB |
| + Redis 缓存 | 约 28000 | 9ms | 瓶颈在 Redis 单线程 |
| + 本地 LRU | 约 56000 | 4ms | 接近 10 万目标的下限 |
| + 4 实例负载均衡 | > 10 万 | 5ms | 横向扩展 |
所以「10 万并发」不是单机能做到的——要么纵向加强单机(8C 能到 8-10 万),要么横向多实例。这个结论比「我的服务能扛 10 万」的吹牛更有价值。
九、这个项目教会了什么
- 缓存不是「加个 Redis 就行」:穿透、击穿、雪崩三种失效模式各有解法,singleflight 是击穿的标准答案
- 并发安全靠数据库约束,不靠应用层判断:「先查再插」在并发下必出错,唯一索引 + 重试才是对的
- 关键路径上不能有同步的次要操作:点击统计丢给后台,缓冲区满了就丢,用户体验优先
- 压测要看 P99 而不是平均值:平均值 3ms 但 P99 是 200ms 的系统,用户体验是「卡」
- 性能是有天花板的:单线程 Redis 到 3-5 万就到顶,谈「10 万并发」必须先说清是单机还是集群
本文代码在 Go 1.22+ 上验证可运行。numbers 来自 2C1.8G 机器的 wrk 实测,不同配置会有差异,但瓶颈位置的结论一致。