高并发系统设计:从理论到实践
去年考研出分那天,我盯着屏幕看了整整十分钟——没过线。
那一刻,我脑子里不是“完了”,而是“终于不用再背政治了”。
毕竟,在学校里跟着导师搞开源项目都快两年了,天天读 Kubernetes 源码、折腾 etcd、给 CNCF 项目提 PR,早就把自己当半个工程师了。结果一纸成绩告诉我:学术这条路,暂时走不通。
但生活总得继续。投了几轮简历后,我幸运地进了现在这家做电商 SaaS 的创业公司。团队不大,10 个人不到,前后端全栈混搭,老板兼产品,CTO 兼运维,而我?勉强算个“后端主力”(其实就是人少,锅多)。
上周五晚上 11 点,我正刷着 Go 夜报,钉钉突然弹出一条消息:
产品经理:“兄弟,双 11 快到了,我们那个‘秒杀活动’模块,用户反馈说点进去就卡死……你看看是不是后端扛不住?”
我翻了个白眼——这需求半年前就埋下了雷。当初为了赶上线,用最朴素的单机 Redis + MySQL 写了个“伪高并发”接口,连限流都没加。现在日活涨了 5 倍,不崩才怪。
于是,这篇《高并发系统设计:从理论到实践》,与其说是技术分享,不如说是我的“血泪复盘日记”。
问题来了:一个“简单”的秒杀接口,怎么就崩了?
我们的产品是一个面向中小商家的营销工具平台,运营同事会配置各种促销活动,比如“9.9 元抢购 iPhone”。前端页面一上线,流量瞬间打满。
最初的架构长这样:
用户 → Nginx → 单体 Go 服务 → MySQL(主从) + Redis(单点)
乍看没问题,对吧?但实测发现:
- 并发 500+ 时,MySQL CPU 直接飙到 90%
- Redis 频繁出现
LOADING Redis is loading the dataset in memory(因为主从同步阻塞) - 更离谱的是,有一次库存超卖了 3 倍——因为没加分布式锁,多个请求同时读到库存为 1,都扣成功了
我当时真的想砸电脑。这哪是高并发?这分明是“高并发笑话”。
重构思路:别再拿玩具当武器
痛定思痛,我和 CTO(也就是我们组唯一的“老人”)开了个紧急会议。他说了一句话让我醍醐灌顶:
“高并发不是堆机器,是用正确的姿势拆解问题。”
于是我们定了几个原则:
- 读写分离:查询走缓存,写入异步化
- 削峰填谷:用消息队列缓冲瞬时流量
- 防重放 & 防刷:用户维度限流 + Token 机制
- 兜底策略:降级开关、熔断、告警全覆盖
最关键的是——别让数据库直面用户请求。
第一步:把“下单”变成“预约”
我们引入了一个“预扣库存 + 异步创建订单”的流程:
- 用户点击“抢购”,前端先请求
/api/v1/seckill/token获取一个一次性 Token(带用户 ID 和活动 ID) - 拿着 Token 调用
/api/v1/seckill/submit,服务端只做三件事:- 校验 Token 是否有效(Redis SETNX)
- 扣减 Redis 中的库存(
DECR) - 如果成功,往 Kafka 发一条消息:
{user_id, activity_id, token}
- 后台消费者监听 Kafka,异步写 MySQL、发通知、更新运营报表
这样,核心链路从“同步写 DB”变成了“内存操作 + 消息投递”,RT(响应时间)从 800ms 降到 30ms。
// Go 伪代码:秒杀提交接口
func SubmitSeckill(c *gin.Context) {
var req SeckillRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid params"})
return
}
// 1. 校验 token(防重放)
key := fmt.Sprintf("seckill:token:%s", req.Token)
exists, err := redisClient.Exists(ctx, key).Result()
if err != nil || exists == 0 {
c.JSON(400, gin.H{"error": "invalid or expired token"})
return
}
// 2. 扣减库存(原子操作)
stockKey := fmt.Sprintf("seckill:stock:%d", req.ActivityID)
newStock, err := redisClient.Decr(ctx, stockKey).Result()
if err != nil {
log.Errorf("decr stock failed: %v", err)
c.JSON(500, gin.H{"error": "system busy"})
return
}
if newStock < 0 {
// 库存不足,回滚(这里其实应该用 Lua 脚本保证原子性)
redisClient.Incr(ctx, stockKey)
c.JSON(400, gin.H{"error": "sold out"})
return
}
// 3. 发送消息到 Kafka
msg := SeckillEvent{
UserID: req.UserID,
ActivityID: req.ActivityID,
Token: req.Token,
}
if err := kafkaProducer.Send(msg); err != nil {
// 这里要记录失败日志,后续补偿
log.Errorf("send to kafka failed: %v", err)
c.JSON(500, gin.H{"error": "please retry"})
return
}
// 4. 删除 token(防止重复提交)
redisClient.Del(ctx, key)
c.JSON(200, gin.H{"code": "success"})
}
💡 小贴士:上面的
DECR + 判断 <0其实有竞态风险!生产环境必须用 Lua 脚本封装成原子操作。我第一次上线就栽在这儿,被运营同事追着问“为什么库存变负数了”。
运营视角:高并发不只是技术问题
很多人以为高并发是后端的事,其实产品和运营才是源头。
举个例子:运营同事为了“制造紧迫感”,把倒计时设成每秒刷新一次库存。结果前端每秒发 10w 请求查库存,直接把 Redis 打挂。
后来我们和产品开会,定了两条规矩:
- 库存展示只在用户进入页面时查一次,后续用本地缓存
- 倒计时结束前 5 秒,前端才允许点击“抢购”按钮
另外,我们给运营后台加了“压测开关”:上线前可以模拟 10 倍流量跑一遍,看系统会不会崩。这功能上线后,CTO 说他晚上能睡整觉了。
安全意识:别让“高并发”变成“高危”
高并发系统最容易被忽略的就是安全。比如:
- 恶意用户用脚本刷 Token 接口,耗尽 Redis 内存
- Kafka 消息被篡改,导致订单金额错误
- 数据库慢查询拖垮整个集群
我们的对策:
- Token 接口加限流:基于 user_id + IP 的滑动窗口限流(用 Go 的
golang.org/x/time/rate) - 消息签名:Kafka 消息体加 HMAC-SHA256 签名,消费者校验
- SQL 审计:所有写操作走预编译,禁止动态拼接;慢查询自动告警
// 限流中间件示例
var limiter = rate.NewLimiter(rate.Every(100*time.Millisecond), 5) // 每 100ms 允许 5 次
func RateLimitMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !limiter.Allow() {
c.JSON(429, gin.H{"error": "too many requests"})
c.Abort()
return
}
c.Next()
}
}
效果对比:从“崩了”到“稳了”
经过两周重构(中间熬了三个通宵),我们在预演环境跑了压力测试:
| 指标 | 旧架构 | 新架构 |
|---|---|---|
| 最大 QPS | ~800 | ~12,000 |
| P99 延迟 | 1200ms | 45ms |
| 库存超卖 | 是 | 否 |
| DB CPU 峰值 | 95% | 30% |
| 错误率 | 18% | 0.2% |
双 11 当天,系统扛住了峰值 8k QPS,运营同事发群里:“这次居然没崩?你们是不是偷偷加服务器了?”
我回:“加了,加的是脑子。”
最后一点碎碎念
作为一个考研失败转行的“野生程序员”,我最大的感悟是:高并发不是玄学,而是工程细节的堆砌。
你不需要一开始就设计出淘宝级架构,但你得知道:
- 缓存怎么用才不穿透
- 消息队列如何保证不丢
- 分布式锁到底锁什么
- 什么时候该降级,什么时候该熔断
更重要的是,和产品、运营保持沟通。他们不是“提需求的怪物”,而是帮你理解业务场景的关键伙伴。
现在,我已经开始研究 Service Mesh 和 eBPF 了——毕竟,下次大促可能就是黑五了。而我,再也不想在周五晚上看到“系统崩了”的钉钉消息了。
(完)
附:踩过的坑清单
- 别用
time.Sleep做重试,用指数退避- Redis 主从切换时,客户端要支持自动重连
- Kafka 分区数别设太少,否则消费者拉不满
- 日志一定要结构化,ELK 查起来才快
- 周五下班前,别 merge 任何“就改一行”的代码

评论 0