高并发系统设计:从理论到实践(一个北漂码农的血泪复盘)

Star收藏家
2025-12-19 16:52
阅读 1195

上个月,我终于在北京五环外交了首付。房贷月供比我的发际线退得还快——每月一万多,雷打不动。为了不被银行催收电话吵醒,我只能继续在新东家“卷”下去。

入职这家电商中厂刚俩月,就赶上大促压测。产品经理一句“咱们双11要冲百万 QPS”,差点没把我送走。更离谱的是,他补了一句:“后端用 Go 写吧,听说性能好。” 我心里苦笑:哥,性能不是靠语言,是靠架构和无数个深夜 debug 的命堆出来的啊。

事情是怎么开始的?

我们有个“商品热度排行榜”功能,原本是用 Python + Redis 简单实现的:每来一个用户访问商品页,就 incr 一下对应 key。每天凌晨跑个脚本聚合数据,生成 Top 100 推给前端。

听起来很稳?直到上周五晚上九点,运维突然在钉钉群里 @ 全体成员:“线上 Redis CPU 100%,集群快崩了!”

我火速 SSH 上去一看,redis-cli monitor 输出刷屏速度比我敲键盘还快——全是 INCR hot:product:12345。原来是爬虫大军盯上了我们的商品接口。有些友商的爬虫甚至伪造 User-Agent,伪装成正常用户疯狂刷量。

那一刻,我真的想砸电脑。但想到下个月房贷,深吸一口气:得,重构吧。

第一轮:Go 重写 + 异步队列

既然老板钦定了 Go,那就上呗。Go 的 goroutine 和 channel 天然适合高并发场景,我心里其实也挺期待。

我第一版方案很简单:

  • 前端请求进来,先做基础风控(IP 限流、UA 检查)
  • 合法请求直接扔进内存 Channel
  • 起 10 个 worker goroutine 从 Channel 里消费,批量写入 Redis
// 简化版伪代码
var eventChan = make(chan Event, 10000)

func recordHot(productId string) {
    if !isCrawler(r.UserAgent) && !isOverLimit(r.IP) {
        eventChan <- Event{ProductID: productId, Timestamp: time.Now()}
    }
}

// 启动消费者
for i := 0; i < 10; i++ {
    go func() {
        batch := []Event{}
        ticker := time.NewTicker(100 * time.Millisecond)
        for {
            select {
            case e := <-eventChan:
                batch = append(batch, e)
                if len(batch) >= 100 {
                    flushToRedis(batch)
                    batch = nil
                }
            case <-ticker.C:
                if len(batch) > 0 {
                    flushToRedis(batch)
                    batch = nil
                }
            }
        }
    }()
}

本地压测跑得飞起,QPS 轻松破万。我美滋滋地提测,心想这波稳了。

结果上线第二天,又炸了。

坑点 1:内存 Channel 不是银弹
当突发流量超过 1w/s 时,Channel 很快写满,recordHot 函数直接阻塞,整个 HTTP 服务雪崩。我忘了 Go 的 Channel 是无缓冲 or 固定缓冲的,背不住洪峰。

坑点 2:Redis 批量写还是太重
虽然改成批量写,但每个 incr 操作还是要走网络、序列化、持久化。高峰时段 Redis 还是扛不住。

这时候,我突然想起大学操作系统课讲的“写合并”(write combining)和“延迟提交”——能不能把热点数据先缓存在本地,定时 merge 再刷出去?

第二轮:本地缓存 + 定时合并

我引入了一个简单的 LRU 本地计数器:

type LocalCounter struct {
    mu sync.RWMutex
    counts map[string]int64
}

func (lc *LocalCounter) Incr(key string) {
    lc.mu.Lock()
    defer lc.mu.Unlock()
    lc.counts[key]++
}

func (lc *LocalCounter) Flush() map[string]int64 {
    lc.mu.Lock()
    defer lc.mu.Unlock()
    snapshot := make(map[string]int64)
    for k, v := range lc.counts {
        snapshot[k] = v
        delete(lc.counts, k) // 清空
    }
    return snapshot
}

然后每 500ms 启动一个 goroutine,把 LocalCounter 的快照 merge 到 Redis:

// Redis 使用 HINCRBY 批量更新 hash
for product, delta := range counter.Flush() {
    redisClient.HIncrBy("hot_products_daily", product, delta)
}

这招果然奏效!Redis 的写压力骤降 90%。因为原来每秒几万次 incr,现在变成每秒几百次 HINCRBY,而且 key 数量大大减少(很多 product 没人看,根本不会进缓存)。

但新问题来了:如果服务挂了,本地计数就丢了

产品经理听说后,幽幽地说:“那双11要是丢数据,我们排名掉下来,你负责吗?”

我:……

最终方案:Kafka 中转 + 幂等消费

为了保证数据不丢,我咬牙上了 Kafka(虽然我们小团队之前从没用过)。流程改成:

HTTP 请求 → [风控] → 发送消息到 Kafka → Go 消费者组批量写 Redis

关键点:

  • Kafka 消息带 trace_id,保证幂等(同一次请求不重复计数)
  • 消费者组多实例部署,自动负载均衡
  • Redis 写入失败时,消息可重试

至于那些烦人的爬虫?我们在 Nginx 层加了规则:

  • 限制单 IP 每秒最多 10 次 /product/* 请求
  • UA 包含 "bot"、"spider" 直接 403
  • 异常 IP 自动加入黑名单(用 Redis SET + TTL 实现)

有趣的是,我们还发现有些“正常用户”其实是内部爬虫——比如运营同学用 Python 脚本批量抓商品数据做分析。我只好给他们开白名单接口,顺手教他们用 time.sleep(0.5),别把生产环境当测试床。

性能对比 & 教训总结

方案 QPS(峰值) Redis CPU 数据可靠性 开发复杂度
Python + 实时 incr ~3k 80%+
Go + Channel 异步 ~8k 60% 中(可能丢)
Go + 本地缓存 ~20k 15% 低(进程挂就丢)
Go + Kafka + 批量 ~50k+ <10%

最终,我们在预演大促时轻松扛住了 45k QPS,Redis 负载稳如老狗。最重要的是——没丢数据

几句掏心窝子的话

  1. 别迷信语言:Go 确实快,但 Python 配合好架构也能扛。关键是理解瓶颈在哪。
  2. 爬虫防不胜防:永远假设你的接口会被滥用。风控前置,比事后救火强一百倍。
  3. 本地缓存是把双刃剑:提升性能的同时,引入了状态和数据丢失风险。用之前先问自己:能接受丢多少?
  4. Kafka 不是玩具:虽然它解决了可靠性问题,但运维成本飙升。小团队慎用,除非真有高可靠需求。
  5. 和产品沟通要“翻译”:他说“要高性能”,其实是“别让老板在大屏前看到系统崩”。你要给他确定性,而不是技术参数。

写完这篇博客,已经是凌晨两点。窗外北京的夜色沉沉,楼下的便利店还亮着灯。我知道,明天还有新的需求、新的 bug、新的 deadline 在等着我。

但至少今天,我没让系统崩掉,房贷暂时保住了。

(完)

P.S. 有朋友问为啥不用 ClickHouse 或 Flink 做实时数仓?答:我们团队就三个后端,运维一个 Kafka 已经在求爷爷告奶奶了……理想很丰满,现实要还贷啊兄弟们。

评论 0

最热最新
暂无评论
Star收藏家Lv.1
0
影响力
0
文章
0
粉丝