高并发系统设计:从理论到实践
大家好,我是小李,一个刚入职新公司还在试用期的后端开发。说“刚入职”可能有点误导——其实我在上一家公司干了三年多,眼看着项目越来越臃肿、技术栈越来越老,再加上去年双11期间系统崩了三次,我终于下定决心换个环境。现在这家新公司主打AI+电商,节奏快得飞起,但氛围意外地不错,至少没人半夜三点在群里@你“这个需求很简单”。
最近团队在搞一个高并发商品秒杀模块,产品经理拍着胸脯说“就几千人抢,稳得很”,结果压力测试一跑,直接502了。领导看我简历上写了点Go经验(其实是自学的),就让我牵头搞架构设计。说实话,当时心里慌得一批——我哪懂什么高并发啊,顶多在B站看了几个教程,连Redis都没正经上过生产。
但转念一想:这不就是跳槽前最该掌握的核心技能吗?于是硬着头皮上了,边学边干,还厚着脸皮蹭了隔壁组的技术分享会。今天这篇博客,就是把踩过的坑、熬过的夜、查过的文档,全倒出来,希望能帮到和我一样“被赶鸭子上架”的兄弟们。
事情是怎么搞砸的?
事情起源于上周五下午四点。产品同学突然拉了个会:“下周三上线新品首发,预计峰值QPS 5000。”我一听,心都凉了半截——我们现在的服务是单体架构,MySQL直连,缓存?不存在的。更离谱的是,他们居然打算用原来的下单接口直接改!我当场就想问:“你是觉得服务器电费太便宜了吗?”
果然,第一次压测(JMeter模拟2000并发)不到30秒,数据库CPU飙到100%,API响应时间从200ms直接干到8秒,最后整个Pod OOM killed。运维大哥在群里发了个“😅”,我知道,他在憋笑。
那一刻我真想砸电脑。但冷静下来想想,问题其实很典型:
- 热点数据集中:所有请求都打到同一个商品ID
- 数据库成为瓶颈:每次都要查库存、写订单
- 缺乏流量控制:恶意刷单或突发流量直接冲垮系统
于是,我给自己定了个小目标:用Go重写核心链路,扛住5000 QPS,不宕机、不超卖、不背锅。
架构怎么搭?别整那些花里胡哨的
很多人一提高并发,张口就是“微服务+Kafka+ES+Redis Cluster”。拜托,我们是个初创团队,人力有限,能跑起来再说。我画了个极简版架构图(脑子里的),核心就三点:
- 前置缓存:把库存放到Redis,用原子操作扣减
- 异步削峰:下单请求先入队列,后端慢慢消费
- 限流熔断:防止雪崩,保护下游
说白了,就是“能缓存的绝不查库,能异步的绝不同步,能拒绝的绝不硬扛”。
缓存层:Redis + Lua 脚本防超卖
库存扣减是最容易出问题的地方。如果用“读库存 → 判断 >0 → 扣减”这种三步走,高并发下必超卖。解决方案?Lua脚本!
// stock.lua
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1
end
return 0
在Go里调用:
func DeductStock(ctx context.Context, client *redis.Client, skuId string) (bool, error) {
script := redis.NewScript(`
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1
end
return 0
`)
result, err := script.Run(ctx, client, []string{"stock:" + skuId}).Result()
if err != nil {
return false, err
}
return result.(int64) == 1, nil
}
这段脚本保证了原子性,哪怕1000个请求同时进来,也只会扣成功一次。亲测有效——上次压测到8000 QPS,库存一分不差。
小贴士:记得给Redis key设置过期时间,不然活动结束库存锁死,运营小姐姐会追着你砍。
异步处理:用Channel当消息队列?
一开始我想直接上Kafka,但运维说“集群还没申请下来”。情急之下,我灵机一动:Go不是有goroutine和channel吗?临时拿它当内存队列用用?
type OrderRequest struct {
UserID string
SkuID string
// ...
}
var orderChan = make(chan OrderRequest, 10000) // 有缓冲channel,防爆
func init() {
// 启动消费者
go func() {
for req := range orderChan {
// 真正写DB、发MQ、记日志...
processOrder(req)
}
}()
}
func PlaceOrderHandler(w http.ResponseWriter, r *http.Request) {
var req OrderRequest
json.NewDecoder(r.Body).Decode(&req)
// 先扣库存(Redis Lua)
success, _ := DeductStock(r.Context(), redisClient, req.SkuID)
if !success {
http.Error(w, "库存不足", http.StatusTooManyRequests)
return
}
// 入队
select {
case orderChan <- req:
w.WriteHeader(http.StatusAccepted)
default:
// 队列满,直接拒绝
http.Error(w, "系统繁忙,请稍后再试", http.StatusServiceUnavailable)
}
}
看起来挺美?但上线第二天就翻车了——服务重启,channel里的请求全丢了!用户付了钱没下单,客服电话被打爆。
教训:内存队列不能用于关键业务!赶紧切到RabbitMQ(运维终于批了)。不过这段代码帮我撑过了最危险的48小时,也算立功了。
限流:别让流量把你家门踹烂
再好的系统也怕洪水猛兽。我们用了golang.org/x/time/rate做令牌桶限流:
var limiter = rate.NewLimiter(rate.Every(time.Second/100), 200) // 每秒100个,突发200
func RateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "请求太频繁", http.StatusTooManyRequests)
return
}
next(w, r)
}
}
配合Nginx层的limit_req,双重保险。实测效果:哪怕压测工具疯狂打,后端服务稳如老狗。
数据库和接口设计:少即是多
很多人喜欢在接口里返回一堆字段,前端说“以后可能用”。结果呢?网络传输慢、序列化耗时、DB索引臃肿。这次我学乖了:
- 接口只返回必要字段:下单成功就返
{"code":200, "msg":"ok"} - 数据库拆表:订单主表 + 订单扩展表,热点字段单独索引
- 写操作去关联:不连表、不触发器、不外键(别骂,生产环境真香)
另外,所有写接口都加了幂等性校验——用request_id做唯一键,防止重复提交。产品经理说“用户不会点两次”,但现实是:他点了十次,因为页面卡住了。
效果如何?数据说话
经过两周折腾(包括三个通宵),最终压测结果如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| P99 延迟 | 8200 ms | 120 ms |
| 错误率 | 35% | 0.2% |
| DB CPU | 100% | 25% |
| 最大QPS | ~800 | 6500+ |
上线当天,虽然流量只来了3000 QPS(产品又高估了),但系统稳得一批。运维大哥破天荒在群里夸了一句:“这次没半夜叫我,不错。”
最后一点碎碎念
写这篇文章的时候,我试用期还剩两周。说实话,要不是这次项目逼我深入高并发,我可能还在写CRUD。现在不仅对系统设计有了手感,连跳槽简历都能多写一行“主导高并发秒杀系统重构”。
如果你也在试用期焦虑、想跳槽但技术卡壳,我的建议是:主动接难题,哪怕是硬着头皮。公司愿意让你碰核心系统,说明信任你;搞砸了,最多背锅(但通常不会开除);搞成了,就是你的资本。
顺便安利下我们公司的技术分享会——每周五下午茶时间,有人讲AI模型部署,有人聊Go性能调优,还有人吐槽产品需求。氛围真的好,不像某些公司,技术分享=领导讲话。
好了,就写这么多。代码细节我放GitHub了(私信我拿链接),欢迎交流。下次要是系统再崩,可能我就真砸电脑了 😅

评论 0