高并发系统设计:从理论到实践
凌晨两点,窗外只剩路灯还醒着。我合上刚刷完的 LeetCode 第 398 题,顺手把咖啡杯推到键盘旁边——结果打翻了。又得擦键盘,又是周五晚上。作为一个远程办公的独立开发者,自由是真的自由,孤独也是真的孤独。没人催你交周报,但也没人陪你 debug 到天亮。
最近我在准备跳槽,一边接外包项目糊口,一边疯狂补分布式和高并发的知识点。说来惭愧,之前做过的系统顶多支撑几百 QPS,现在面试官张口就是“你们系统怎么扛住十万并发?”、“缓存击穿怎么防?”,搞得我一度怀疑自己是不是在写玩具项目。
上周,一个老客户突然找上门,说他们的电商产品要搞大促,预估流量会冲到平时的 20 倍。他们用的是 PHP + MySQL 的老架构,数据库一压就崩。我本来想婉拒——毕竟这不是我的主栈——但对方开的价格太香,而且……我也确实需要练手。于是咬牙接了下来,顺便把整个重构过程当成自己的“高并发实战教程”。
起手式:别一上来就堆技术
很多人一听到“高并发”,脑子里立刻蹦出 Redis、Kafka、微服务、分库分表……仿佛不把这些词塞满 PPT 就不够高级。但现实很骨感:系统瓶颈往往藏在最不起眼的地方。
这个项目最初的问题其实特别朴素:用户点击“立即购买”后,页面卡死十几秒,最后返回 504。查日志发现,下单接口直接调用了库存校验 + 订单创建 + 支付回调 + 发券逻辑,全在同一个事务里跑。更离谱的是,库存字段居然没加索引!
我跟产品经理对线的时候他说:“我们就是要保证强一致性啊!”
我说:“强一致性没问题,但你这代码是同步阻塞的,用户等 15 秒才告诉你‘库存不足’,体验比裸奔还差。”
所以第一步不是换语言,也不是上消息队列,而是拆流程、异步化、降耦合。我把核心下单路径简化为:
- 前端点击 → 后端校验参数 + 扣减 Redis 库存(原子操作)
- 成功则返回“订单已提交,请等待处理”
- 后台通过消息队列异步落库、发券、通知支付
这样,用户感知的响应时间从 15s 降到 200ms 内。至于最终一致性?用补偿机制兜底就行。产品经理虽然嘴上嘀咕“不够实时”,但看到监控图表上陡降的错误率,也就闭嘴了。
为什么选 Go?
客户原本想让我用 Java 重写,但我直接否了。原因很简单:启动快、内存占用低、goroutine 天然适合 I/O 密集型场景。而且,作为一个 solo 开发者,Go 的工程结构清晰、依赖管理简单,不用花三天配 Maven 环境。
更重要的是,Go 的 net/http 和 context 包已经内置了超时控制、请求取消、连接复用等高并发基础能力。比如这个简单的限流中间件:
func RateLimitMiddleware(next http.HandlerFunc, limit int) http.HandlerFunc {
sem := make(chan struct{}, limit)
return func(w http.ResponseWriter, r *http.Request) {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
next(w, r)
default:
http.Error(w, "Too many requests", http.StatusTooManyRequests)
}
}
}
几十行代码就能挡住突发流量。当然,生产环境我会换成令牌桶或漏桶算法(比如用 golang.org/x/time/rate),但原理一样:先稳住入口,再优化内部。
数据库:别让 MySQL 成为你的拖油瓶
异步化之后,DB 压力还是大。因为所有订单、用户、商品数据都挤在一个库。高峰期 CPU 100%,慢查询日志刷屏。
我做了三件事:
- 读写分离:主库写,从库读。但注意,从库有延迟!所以用户刚下单后刷新页面看不到订单?正常。加个“正在同步”的提示就行。
- 分库分表:订单表按 user_id 哈希分 16 张表。用
sharding-sphere太重,干脆自己写了个轻量路由层(基于database/sql的 wrapper)。 - 冷热分离:三个月前的订单自动归档到另一个库,主库只留热数据。
这里踩了个坑:分表后,跨表聚合查询(比如“统计某天总销售额”)变得巨难。最后妥协方案是用 Flink 实时消费 binlog,写入 OLAP 引擎(ClickHouse),报表走分析库。虽然架构复杂了点,但主交易链路干净了。
| 方案 | QPS(单机) | 延迟(P99) | 运维成本 |
|---|---|---|---|
| 单库单表 | ~300 | >2s | 低 |
| 读写分离 | ~800 | ~800ms | 中 |
| 分库分表 + 缓存 | ~5000+ | <100ms | 高 |
缓存:不是万能药,但没它真不行
Redis 是高并发系统的氧气面罩。但用不好,反而会引发雪崩、穿透、击穿三大经典事故。
- 缓存穿透:恶意查不存在的 ID。解决方案:布隆过滤器 or 空值缓存(带短 TTL)。
- 缓存击穿:热点 key 过期瞬间大量请求打到 DB。用互斥锁(singleflight 模式)重建缓存。
- 缓存雪崩:大量 key 同时过期。给 TTL 加随机偏移(比如 base + rand(0, 300s))。
我在 Go 里封装了一个通用缓存加载器:
var group singleflight.Group
func GetProduct(id string) (*Product, error) {
// 先查缓存
if val := cache.Get(id); val != nil {
return val.(*Product), nil
}
// singleflight 防止并发击穿
v, err, _ := group.Do(id, func() (interface{}, error) {
p, err := db.QueryProduct(id)
if err != nil {
return nil, err
}
cache.Set(id, p, time.Minute*10)
return p, nil
})
if err != nil {
return nil, err
}
return v.(*Product), nil
}
上线后,DB QPS 直接从 8k 降到 300,老板看监控图笑得合不拢嘴。
产品思维:技术是为业务服务的
说实话,很多高并发设计到最后,拼的不是技术深度,而是对业务场景的理解。
比如这个电商产品有个“限时秒杀”功能。一开始我想用 Redis + Lua 保证原子扣库存,但产品经理突然说:“我们要支持‘预约抢购’,用户提前报名,到点统一放量。”
这下原来的模型崩了。因为不能提前扣库存(用户可能不来),又不能到点瞬间开闸(会被刷)。最后方案是:
- 报名阶段:只记录用户 ID 到 Redis Set
- 开抢瞬间:用 cron 触发脚本,将 Set 转为 List,按报名顺序分批放量(每 100ms 放 100 个名额)
- 前端配合:倒计时结束前按钮置灰,结束后轮询“是否中签”
看似绕远路,但避免了瞬时高并发,系统稳如老狗。运维兄弟都说这次大促“连告警都没响”,感动得差点给我发锦旗。
跳槽视角:面试官到底想听什么?
写这篇文章时,我刚面完一家一线大厂。面试官问:“你如何设计一个支持百万并发的消息推送系统?”
我没急着画架构图,而是反问:“推送内容是什么?实时性要求多高?设备在线率多少?失败要不要重试?”
他愣了一下,然后笑了。因为真正的高并发设计,始于约束条件,终于权衡取舍。
你可以用 Kafka 做削峰,用 WebSocket 长连接推,用 Protobuf 压缩包体,用一致性哈希做节点路由……但如果没有业务上下文,这些只是纸上谈兵。
所以,如果你也在准备跳槽,别光刷题。试着把做过的小项目,用“问题 - 假设 - 验证 - 迭代”的逻辑重新梳理一遍。比如:
“当时 QPS 上不去 → 我假设是 DB 瓶颈 → 通过 pt-query-digest 确认慢 SQL → 加索引 + 读写分离 → QPS 提升 3 倍 → 但发现缓存命中率低 → 引入本地缓存 → 最终达标”
这种叙事,比背八股文有力得多。
写在最后
现在这个系统已经平稳跑过大促,峰值 QPS 6200,错误率 < 0.1%。我把它整理成了一份开源教程(GitHub 已上传),包含完整的 Go 代码、Docker 部署脚本、压测报告。名字就叫《从零构建高并发电商后端:一个独立开发者的实战笔记》。
有人说 solo 开发者视野窄,做不了复杂系统。但我觉得,正是孤独逼你思考每一行代码的价值。没有队友帮你擦屁股,你就得把容错、监控、回滚都想清楚。
对了,上周五打翻的咖啡,键盘还没完全干。但没关系,明天又是新的一天——可能又是一个通宵改需求的夜晚。不过至少,现在的我,面对“高并发”三个字,不会再冒冷汗了。
共勉。

评论 0