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

一颗后端星球
2025-12-16 13:34
阅读 261

凌晨两点,窗外只剩路灯还醒着。我合上刚刷完的 LeetCode 第 398 题,顺手把咖啡杯推到键盘旁边——结果打翻了。又得擦键盘,又是周五晚上。作为一个远程办公的独立开发者,自由是真的自由,孤独也是真的孤独。没人催你交周报,但也没人陪你 debug 到天亮。

最近我在准备跳槽,一边接外包项目糊口,一边疯狂补分布式和高并发的知识点。说来惭愧,之前做过的系统顶多支撑几百 QPS,现在面试官张口就是“你们系统怎么扛住十万并发?”、“缓存击穿怎么防?”,搞得我一度怀疑自己是不是在写玩具项目。

上周,一个老客户突然找上门,说他们的电商产品要搞大促,预估流量会冲到平时的 20 倍。他们用的是 PHP + MySQL 的老架构,数据库一压就崩。我本来想婉拒——毕竟这不是我的主栈——但对方开的价格太香,而且……我也确实需要练手。于是咬牙接了下来,顺便把整个重构过程当成自己的“高并发实战教程”。


起手式:别一上来就堆技术

很多人一听到“高并发”,脑子里立刻蹦出 Redis、Kafka、微服务、分库分表……仿佛不把这些词塞满 PPT 就不够高级。但现实很骨感:系统瓶颈往往藏在最不起眼的地方

这个项目最初的问题其实特别朴素:用户点击“立即购买”后,页面卡死十几秒,最后返回 504。查日志发现,下单接口直接调用了库存校验 + 订单创建 + 支付回调 + 发券逻辑,全在同一个事务里跑。更离谱的是,库存字段居然没加索引!

我跟产品经理对线的时候他说:“我们就是要保证强一致性啊!”
我说:“强一致性没问题,但你这代码是同步阻塞的,用户等 15 秒才告诉你‘库存不足’,体验比裸奔还差。”

所以第一步不是换语言,也不是上消息队列,而是拆流程、异步化、降耦合。我把核心下单路径简化为:

  1. 前端点击 → 后端校验参数 + 扣减 Redis 库存(原子操作)
  2. 成功则返回“订单已提交,请等待处理”
  3. 后台通过消息队列异步落库、发券、通知支付

这样,用户感知的响应时间从 15s 降到 200ms 内。至于最终一致性?用补偿机制兜底就行。产品经理虽然嘴上嘀咕“不够实时”,但看到监控图表上陡降的错误率,也就闭嘴了。


为什么选 Go?

客户原本想让我用 Java 重写,但我直接否了。原因很简单:启动快、内存占用低、goroutine 天然适合 I/O 密集型场景。而且,作为一个 solo 开发者,Go 的工程结构清晰、依赖管理简单,不用花三天配 Maven 环境。

更重要的是,Go 的 net/httpcontext 包已经内置了超时控制、请求取消、连接复用等高并发基础能力。比如这个简单的限流中间件:

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%,慢查询日志刷屏。

我做了三件事:

  1. 读写分离:主库写,从库读。但注意,从库有延迟!所以用户刚下单后刷新页面看不到订单?正常。加个“正在同步”的提示就行。
  2. 分库分表:订单表按 user_id 哈希分 16 张表。用 sharding-sphere 太重,干脆自己写了个轻量路由层(基于 database/sql 的 wrapper)。
  3. 冷热分离:三个月前的订单自动归档到另一个库,主库只留热数据。

这里踩了个坑:分表后,跨表聚合查询(比如“统计某天总销售额”)变得巨难。最后妥协方案是用 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

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