高并发系统设计:从理论到实践
上周五晚上十点半,我正缩在公司工位上疯狂敲键盘,咖啡杯里泡着第三包速溶咖啡。产品经理刚发来消息:“小张,下周三上线新活动,预计流量会翻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 的
bigcache或ristretto) + 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 台就够了。老板看到账单直接给我点了杯瑞幸。
教训与心得
- 别迷信框架:Gin、Echo 很快,但如果你业务逻辑全是 DB 查询,再快的框架也救不了你。
- 监控要早做:Prometheus + Grafana + Loki 三件套,没它们等于盲人开车。
- 压测要真实:用 wrk 或 k6 模拟真实用户行为,别只测单接口。
- Go 很强,但不是银弹:Goroutine 虽好,泄漏了照样 OOM。记得用 pprof 分析内存和 CPU。
- 文档和注释很重要:我接手的旧代码连个缓存过期时间都没注释,差点误删生产数据。
最后说两句
写这篇文章时,我已经连续三天准时下班了——因为系统稳了。运维老哥甚至主动请我喝了杯奶茶,说“最近报警少多了”。
高并发不是玄学,它是一堆细节的堆砌。没有“一键解决”的神器,只有不断压测、观察、优化的耐心。
如果你也在被流量折磨,别慌。先看监控,再查日志,然后一层层剥洋葱。资源有限,但聪明的用法无限。
对了,文中的 Go 示例代码我都整理成了小项目,放在 GitHub 上了(搜 “high-concurrency-go-demo” 就能找到)。配套还有详细的部署教程,包括 Dockerfile、K8s manifests 和 Prometheus 配置——毕竟,我可是那个连 YAML 都要问 Claude 的人,深知新手有多痛苦 😅
共勉。下次大促,希望你也能准时下班。

评论 0