高并发系统设计:从理论到实践

AIApp
2025-12-13 11:25
阅读 378

上周五晚上十点半,我正缩在公司工位上疯狂敲键盘,咖啡杯里泡着第三包速溶咖啡。产品经理刚发来消息:“小张,下周三上线新活动,预计流量会翻10倍,你看着办哈~”——配了个笑嘻嘻的表情包。那一刻,我真的想把显示器砸了。

入职新公司两个月,前一个月还在熟悉代码库、搞CI/CD流水线、被K8s YAML折磨得怀疑人生。结果第二个月就赶上大促预热,系统压力测试直接报出503错误。运维老哥幽幽地飘过一句:“你们服务扛不住啊,Redis都快打爆了。”

作为一个平时靠ChatGPT和Claude写CRUD的老程序员(别笑,这年头谁不靠AI辅助开发?),我突然意识到:是时候认真搞搞高并发了。不是为了装X,是为了保住饭碗。


事情是怎么崩的?

我们系统是个典型的电商促销后端,前端用 React + TypeScript,后端主力语言是 Go ——对,就是那个编译快、并发强、内存占用低的 Go。但问题来了:虽然用了 Goroutine,但数据库连接池没调优,缓存策略还是“能跑就行”,限流压根没做。

上周压力测试时,QPS 刚冲到 5k,MySQL CPU 直接飙到 98%,API 延迟从 50ms 蹿到 2s+。用户一多,服务雪崩,整个链路瘫痪。运维甩过来一张监控图,红色警报闪得我眼睛疼。

“你们是不是把所有请求都往 DB 打了?”
“……好像是的。”
“兄弟,你这是拿 Ferrari 当拖拉机开啊。”

痛定思痛,我决定重新梳理整个高并发架构。不是照搬教科书,而是结合真实生产环境,一步步优化。以下是我踩坑、填坑、再踩坑后的实战总结。


资源是有限的,省着点用

高并发的核心哲学就一句话:资源永远不够,所以要省着用、错峰用、复用

这里的“资源”包括:CPU、内存、数据库连接、网络带宽、文件描述符……任何一个环节成为瓶颈,系统就会崩。

我们先看几个关键点:

1. 缓存层:Redis 不是万能胶水

以前我以为“加个 Redis 就万事大吉”,结果双11预演时发现:热点 Key 导致单个 Redis 实例 CPU 打满,其他节点闲着没事干。

后来做了三件事:

  • 分片 + 多级缓存:本地缓存(Go 的 bigcacheristretto) + Redis Cluster
  • 缓存击穿防护:用 singleflight 模式避免多个 Goroutine 同时查 DB
  • 过期时间随机化:防止大量 Key 同时失效引发 DB 雪崩
// Go 中使用 singleflight 避免缓存击穿
var group singleflight.Group

func GetProduct(id int64) (*Product, error) {
    // 先查本地缓存(比如 bigcache)
    if val := localCache.Get(fmt.Sprintf("product:%d", id)); val != nil {
        return val.(*Product), nil
    }

    // singleflight 确保同一时间只有一个 goroutine 去查 DB
    result, err, _ := group.Do(fmt.Sprintf("product:%d", id), func() (interface{}, error) {
        // 查 DB
        p, dbErr := db.QueryProduct(id)
        if dbErr != nil {
            return nil, dbErr
        }
        // 写入 Redis + 本地缓存
        redis.SetEX(ctx, fmt.Sprintf("product:%d", id), p, time.Minute*10)
        localCache.Set(fmt.Sprintf("product:%d", id), p, time.Minute*5)
        return p, nil
    })

    if err != nil {
        return nil, err
    }
    return result.(*Product), nil
}

2. 数据库:别让 MySQL 当炮灰

高并发下,DB 是最脆弱的一环。我们做了这些优化:

  • 读写分离:主库写,从库读(Go 用 sql.DB 配置多个数据源)
  • 连接池调优SetMaxOpenConns(100)SetMaxIdleConns(20)SetConnMaxLifetime(30*time.Minute)
  • 批量操作:能批量插入绝不单条循环
  • 索引优化:慢查询日志每周 review,EXPLAIN 成日常

有一次,一个接口没加索引,QPS 1k 时就把从库 IO 打爆了。DBA 在群里@我:“你这 SQL 是拿脚写的吗?” 我默默改完,顺手加了条告警规则。


限流、熔断、降级:系统的“安全气囊”

再好的车也怕高速撞墙。高并发系统必须有“安全机制”。

我们用 Go 实现了基于令牌桶的限流中间件,并集成到 Gin 路由中:

// 基于 uber-go/ratelimit 的简单限流
import "go.uber.org/ratelimit"

var userLimiter = make(map[string]ratelimit.Limiter)

func RateLimitMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        userID := c.GetHeader("X-User-ID")
        if userID == "" {
            c.AbortWithStatusJSON(400, gin.H{"error": "missing user id"})
            return
        }

        limiter, ok := userLimiter[userID]
        if !ok {
            // 每秒最多 10 个请求
            limiter = ratelimit.New(10)
            userLimiter[userID] = limiter
        }

        // Wait 返回等待时间,如果超过阈值可直接拒绝
        if wait := limiter.Take(); wait > time.Second {
            c.AbortWithStatusJSON(429, gin.H{"error": "too many requests"})
            return
        }

        c.Next()
    }
}

此外,我们还配置了 Hystrix 风格的熔断器(Go 有 sony/gobreaker 库),当某个下游服务失败率超过 50%,自动熔断 30 秒,避免级联失败。

至于降级?简单粗暴:非核心功能直接返回 mock 数据。比如“猜你喜欢”模块挂了,就返回空列表,总比整个页面白屏强。


异步化:把同步请求“甩出去”

有些操作不需要实时完成,比如发通知、记日志、更新推荐算法。我们把这些任务扔进 消息队列(Kafka + Go worker)。

前端提交订单 → 后端写 DB + 发 Kafka → 异步服务消费消息 → 发短信、更新库存、记录行为日志。

这样主流程响应时间从 800ms 降到 120ms,用户体验直线上升。

Go 写消费者特别舒服:

func startWorker() {
    for msg := range kafkaConsumer.Messages() {
        go processOrderEvent(msg) // 注意:实际要用 worker pool 避免 goroutine 泛滥
    }
}

不过千万别乱开 Goroutine!曾有个同事在循环里 go func() 处理消息,结果 OOM 被 K8s 杀了三次。后来我们统一用 ants 这个 goroutine pool 库,稳如老狗。


前端也得配合:别光甩锅给后端

作为曾经的全栈(现在只敢碰 JS),我发现很多性能问题其实是前端惹的祸。

比如:用户疯狂点击“抢购”按钮,前端没做防重,后端瞬间收到 10 个相同请求。

解决方案?

  • 前端按钮 disable + loading
  • 请求加唯一 ID(idempotency key)
  • 用 Web Worker 处理复杂计算,别卡主线程

我们甚至还写了个简单的 JavaScript 限流 Hook 给前端团队用:

// React 中防抖 + 请求去重
function useDebouncedSubmit(callback, delay = 300) {
  const timeoutRef = useRef(null);
  const lastSubmitRef = useRef(null);

  const submit = useCallback((data) => {
    // 如果上次提交还没结束,直接返回
    if (lastSubmitRef.current && !lastSubmitRef.current.isFulfilled) {
      return;
    }

    clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => {
      const promise = callback(data);
      lastSubmitRef.current = promise;
      promise.finally(() => {
        lastSubmitRef.current = null;
      });
    }, delay);
  }, [callback, delay]);

  return submit;
}

前端同学一开始嫌麻烦,直到 QA 报了个“重复下单” bug,他们才乖乖加上。


性能对比:优化前后差距有多大?

以下是我们在测试环境模拟 10k QPS 的数据(4 核 8G 云服务器):

指标 优化前 优化后
平均延迟 1850 ms 92 ms
错误率 23% 0.1%
CPU 使用率 95%+ 45%
DB 连接数 300+ 60
内存占用 1.8 GB 600 MB

最夸张的是,原本需要 8 台机器扛住的流量,现在 3 台就够了。老板看到账单直接给我点了杯瑞幸。


教训与心得

  1. 别迷信框架:Gin、Echo 很快,但如果你业务逻辑全是 DB 查询,再快的框架也救不了你。
  2. 监控要早做:Prometheus + Grafana + Loki 三件套,没它们等于盲人开车。
  3. 压测要真实:用 wrk 或 k6 模拟真实用户行为,别只测单接口。
  4. Go 很强,但不是银弹:Goroutine 虽好,泄漏了照样 OOM。记得用 pprof 分析内存和 CPU。
  5. 文档和注释很重要:我接手的旧代码连个缓存过期时间都没注释,差点误删生产数据。

最后说两句

写这篇文章时,我已经连续三天准时下班了——因为系统稳了。运维老哥甚至主动请我喝了杯奶茶,说“最近报警少多了”。

高并发不是玄学,它是一堆细节的堆砌。没有“一键解决”的神器,只有不断压测、观察、优化的耐心。

如果你也在被流量折磨,别慌。先看监控,再查日志,然后一层层剥洋葱。资源有限,但聪明的用法无限

对了,文中的 Go 示例代码我都整理成了小项目,放在 GitHub 上了(搜 “high-concurrency-go-demo” 就能找到)。配套还有详细的部署教程,包括 Dockerfile、K8s manifests 和 Prometheus 配置——毕竟,我可是那个连 YAML 都要问 Claude 的人,深知新手有多痛苦 😅

共勉。下次大促,希望你也能准时下班。

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝