高并发系统设计:从理论到实践(一个北漂码农的血泪复盘)
上个月,我终于在北京五环外交了首付。房贷月供比我的发际线退得还快——每月一万多,雷打不动。为了不被银行催收电话吵醒,我只能继续在新东家“卷”下去。
入职这家电商中厂刚俩月,就赶上大促压测。产品经理一句“咱们双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 负载稳如老狗。最重要的是——没丢数据。
几句掏心窝子的话
- 别迷信语言:Go 确实快,但 Python 配合好架构也能扛。关键是理解瓶颈在哪。
- 爬虫防不胜防:永远假设你的接口会被滥用。风控前置,比事后救火强一百倍。
- 本地缓存是把双刃剑:提升性能的同时,引入了状态和数据丢失风险。用之前先问自己:能接受丢多少?
- Kafka 不是玩具:虽然它解决了可靠性问题,但运维成本飙升。小团队慎用,除非真有高可靠需求。
- 和产品沟通要“翻译”:他说“要高性能”,其实是“别让老板在大屏前看到系统崩”。你要给他确定性,而不是技术参数。
写完这篇博客,已经是凌晨两点。窗外北京的夜色沉沉,楼下的便利店还亮着灯。我知道,明天还有新的需求、新的 bug、新的 deadline 在等着我。
但至少今天,我没让系统崩掉,房贷暂时保住了。
(完)
P.S. 有朋友问为啥不用 ClickHouse 或 Flink 做实时数仓?答:我们团队就三个后端,运维一个 Kafka 已经在求爷爷告奶奶了……理想很丰满,现实要还贷啊兄弟们。

评论 0