从凌晨三点的崩溃到系统稳如老狗:我的高并发实战血泪史

贪心没贪够
2026-02-26 11:01
阅读 1580

上周五凌晨三点,我盯着屏幕上疯狂刷屏的 Too many open files 错误日志,一边啃着冷掉的披萨一边想:这破系统是不是跟我有仇?刚入职新公司两个月,就被塞进一个号称“百万级并发”的核心项目,而我这个 Rust 菜鸟连 Arc<Mutex<T>> 都还没玩明白,就得扛起 Go 服务端重构的大旗。

说起来有点惭愧——虽然简历上写着“精通高并发”,但其实之前做的项目顶多也就几万 QPS。这次接手的是我们游戏后台的实时匹配服务,双11活动一开,玩家暴增,旧架构直接原地爆炸。运维大哥在群里@我:“兄弟,你再不搞定,明天产品经理就要拿咖啡泼你了。”


为什么又双叒叕是连接池炸了?

旧系统用的是 Go + Gin + Redis + MySQL 经典四件套,乍看没问题,但细看全是坑。比如:

  • Redis 连接池大小硬编码成 50,高峰期直接打满
  • MySQL 没有读写分离,所有请求都压到主库
  • 接口没做限流熔断,一个恶意刷接口的脚本就能让整个服务雪崩

最离谱的是,他们居然把用户 Embedding 向量(用于智能匹配)直接序列化存到 MySQL 的 TEXT 字段里!每次匹配都要全表扫描 + 反序列化,CPU 直接飙到 90%+。我看了眼代码提交记录,居然是去年实习生写的……好家伙,人走茶凉,锅留千年。

Embedding 在这里不是 AI 那个嵌入向量吗?怎么跑游戏匹配里去了?
是的!我们用了轻量级的用户行为 Embedding(维度 128),配合 Faiss 做近似最近邻搜索,实现“兴趣相近玩家自动组队”。听起来很 fancy,但落地时直接塞数据库,纯属自残。


Windsurf:被逼出来的流量削峰利器

眼看 deadline 逼近(老板说下周必须上线),我和后端组长一合计:得搞个缓冲层。这时候我想起了最近研究的一个开源项目——Windsurf(别搜了,这名字是我瞎编的,真实项目叫什么不重要,关键是思路)。

它的核心思想很简单:用内存队列暂存突发请求,异步批量处理。类似 Kafka 的角色,但更轻量,专为游戏场景设计——毕竟玩家匹配这种操作,延迟容忍度可以到几百毫秒。

于是我们快速搭了个原型:

// 简化版 Windsurf 缓冲队列
type MatchQueue struct {
    mu      sync.RWMutex
    buffer  []*MatchRequest
    maxSize int
}

func (q *MatchQueue) Push(req *MatchRequest) error {
    q.mu.Lock()
    defer q.mu.Unlock()
    if len(q.buffer) >= q.maxSize {
        return errors.New("queue full")
    }
    q.buffer = append(q.buffer, req)
    return nil
}

func (q *MatchQueue) PopBatch(size int) []*MatchRequest {
    q.mu.Lock()
    defer q.mu.Unlock()
    if len(q.buffer) == 0 {
        return nil
    }
    n := min(size, len(q.buffer))
    batch := q.buffer[:n]
    q.buffer = q.buffer[n:]
    return batch
}

然后启一个 goroutine 定时消费:

func (s *Server) startConsumer() {
    ticker := time.NewTicker(100 * time.Millisecond) // 每100ms处理一批
    for range ticker.C {
        batch := s.queue.PopBatch(50)
        if len(batch) == 0 {
            continue
        }
        // 批量查询 Embedding 并匹配
        go s.processBatch(batch)
    }
}

效果立竿见影:QPS 从 3w 稳定撑到 12w,MySQL CPU 从 95% 降到 40%,而且因为批量处理,Embedding 查询效率提升近 5 倍。


Embedding 存储重构:别再用 MySQL 当垃圾桶!

既然提到了 Embedding,那就说说我们怎么把它从 MySQL 里“解救”出来的。

最初方案是迁到 Redis,但 Redis 的 HSET 存 128 维 float 向量太浪费内存(每个 field 都有 key 开销)。后来我们试了 Redis 的 Vector Search 模块(RediSearch),支持 HNSW 索引,查询快得飞起。

但问题来了:RediSearch 社区版不稳定,生产环境不敢上。最后咬牙上了 Milvus Lite(轻量级向量数据库),单机部署,API 简洁,完美适配我们的场景。

数据同步也做了优化:

  • 用户行为更新时,异步推 Embedding 到 Milvus
  • 匹配服务只读 Milvus,彻底和 MySQL 解耦

性能对比(单机测试):

存储方案 单次查询延迟 内存占用 支持 ANN
MySQL TEXT ~85ms
Redis Hash ~12ms
Milvus Lite ~3ms

看到这个数据,我当场给 Milvus 点了个 star——虽然它文档写得像天书,但真香。


Go 并发陷阱:你以为的 goroutine 不是免费的!

别看 Go 的 goroutine 轻量,乱开一样死给你看。初期我图省事,每个匹配请求都开一个 goroutine 去查 Embedding:

// 千万别这么写!
for _, req := range requests {
    go func(r *MatchRequest) {
        result := queryEmbedding(r.UserID)
        sendResult(result)
    }(req)
}

结果高并发下一堆 goroutine 卡在 I/O,内存爆到 8GB,GC 停顿长达 2 秒。运维差点把我拉黑。

后来改用 worker pool 模式,控制并发数:

type WorkerPool struct {
    jobs    chan Job
    workers int
}

func (wp *WorkerPool) Start() {
    for i := 0; i < wp.workers; i++ {
        go func() {
            for job := range wp.jobs {
                job.Process()
            }
        }()
    }
}

把 worker 数限制在 CPU 核数 × 2(经验值),系统瞬间稳如老狗。顺便还加了 context 超时控制,防止单个请求拖垮整个池子。


运维视角:监控比代码更重要

光写好代码不够,你得知道线上到底发生了什么。我们接入了 Prometheus + Grafana,关键指标包括:

  • 队列积压长度(Windsurf buffer size)
  • Embedding 查询 P99 延迟
  • Goroutine 数量
  • MySQL 连接数 / 慢查询

有一次半夜报警,队列积压突增。我爬起来一看,原来是 Milvus 宕机了,但匹配服务没做降级。赶紧加了个 本地缓存兜底策略:当向量服务不可用时,随机匹配(总比卡住强)。

func (s *Server) getMatchCandidates(userID string) ([]string, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

    candidates, err := s.milvusClient.Search(ctx, userID)
    if err != nil {
        // 降级:随机返回在线玩家
        return s.getRandomOnlinePlayers(10), nil
    }
    return candidates, nil
}

产品经理知道后居然夸我“用户体验意识强”……呵,还不是被线上事故逼的。


为什么我又开始看 Rust?

说实话,这次重构让我对 Go 又爱又恨。爱它的生态和开发效率,恨它在内存控制上的“模糊地带”。特别是 Embedding 这种数值计算密集的场景,Go 的 GC 和逃逸分析总让我提心吊胆。

所以最近下班(如果有的话)就啃 Rust。no_std、零拷贝、编译期内存安全……简直是对高并发系统的终极诱惑。虽然现在项目还是 Go 主力,但我偷偷用 Rust 写了个 Embedding 预处理工具,性能比 Python 版快 8 倍,内存占用只有 1/5。

也许下个项目,我能说服老大试试 Rust + WebAssembly 做匹配引擎?至少先从工具链开始渗透(笑)。


最后几句大实话

高并发从来不是靠某个银弹技术解决的,而是无数个细节堆出来的稳定性

  • 别把数据库当万能桶
  • 异步和缓冲是削峰填谷的神技
  • 监控告警是你睡觉的保障
  • 降级兜底不是丢脸,是专业

我现在终于能在凌晨两点前回家了(虽然偶尔还得加班)。上周团建,运维大哥敬我一杯:“兄弟,你那套 Windsurf 架构,我们准备推广到其他服务。” 我笑笑,心里想:这哪是什么架构,分明是被 bug 逼出来的求生本能。

对了,如果你也在被高并发折磨——别慌,你不是一个人。咱们程序员,不就是在一次次崩溃中,把系统从“屎山”炼成“钢铁侠”吗?

(完)

作者注:本文提到的 Windsurf 为内部代号,实际参考了 Disruptor、Kafka Producer Buffer 等设计思想。Embedding 方案已脱敏,具体技术选型请根据业务规模评估。

评论 0

最热最新
暂无评论
贪心没贪够Lv.1
0
影响力
0
文章
0
粉丝