用 Go 写一个扛住 10 万并发的短链服务

这是一篇实战文,不是知识点罗列。目标是从零写出一个能扛 10 万并发的短链服务,把 Go 的并发、Redis 缓存、MySQL 存储、压测验证串成一条完整的线。每一步都给出可运行的代码和验证方法。

一、先说清楚要做什么

短链服务就三件事:把长网址变成一个短码存起来、访问短码时 302 跳回原址、统计点击。听起来简单,但「10 万并发」这个约束会让每个设计决策都变重:

  • 读多写少:1 万条短链可能有 100 万次访问,读写比约 100:1 → 必须缓存
  • 热点聚集:少数短链占大部分流量 → 缓存命中率决定成败
  • 短码生成要防冲突:并发插入时两个请求可能生成同一短码
  • 跳转必须快:用户点链接等 300ms 就开始不耐烦,重定向链路不能有多次 DB 查询
指标目标值怎么测
跳转 P99 延迟< 10mswrk / 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 秒)
击穿热点短码缓存刚过期,瞬间大量请求打到 DBsingleflight 合并同一 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 的机器上实测(与本站服务器同规格):

配置QPSP99说明
只用 MySQL(无缓存)约 120038ms瓶颈在 DB
+ Redis 缓存约 280009ms瓶颈在 Redis 单线程
+ 本地 LRU约 560004ms接近 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 实测,不同配置会有差异,但瓶颈位置的结论一致。

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇