高并发系统设计:从理论到实践
上周五晚上十一点,我正瘫在沙发上刷招聘网站,一边啃着冷掉的外卖披萨,一边纠结要不要跳槽。最近公司项目压得喘不过气,双11刚过,系统又崩了一次——这次是因为有个爬虫团伙疯狂请求我们的商品详情页,QPS 直接飙到 8w+,数据库连接池直接干爆。
说实话,我现在有点迷茫。远程办公虽然自由,但天天在家对着屏幕写代码,连个能吐槽的人都没有。今天这篇博客,算是给自己整理思路,也顺便记录下这段时间踩过的坑。毕竟嘛,万一真跳槽了,简历上也能多写点“高并发系统设计经验”(狗头保命)。
起因:不是我想搞高并发,是爬虫逼的
事情得从去年双11说起。我们团队维护一个电商中台,平时 QPS 也就几千,结果那天凌晨两点,监控报警像疯了一样响。查日志发现,大量请求来自几个固定 IP 段,User-Agent 还是空的——典型的恶意爬虫行为。
更离谱的是,这些请求全打在 /product/detail/{id} 接口上,而且参数 id 是连续递增的,明显是在批量扫商品数据。我们的接口没做任何限流,缓存穿透也没防,结果 Redis 被打穿,MySQL 主库 CPU 直接 100%,整个服务雪崩。
当时我真的想砸电脑。产品经理还发消息问:“用户反馈打不开页面,是不是后端又挂了?” 我回了个“是”,然后默默打开了《Go 并发编程实战》PDF。
架构调整:从单体到分层防御
冷静下来后,我和运维、测试开了个紧急会议(其实就是在 Slack 上扯了两小时)。我们决定重构整个接口链路,核心思路就一条:别让请求轻易碰到数据库。
第一层:Nginx + Lua 做 IP 限流
最简单粗暴的方式,先在网关层把坏流量拦住。我们在 Nginx 里加了 Lua 脚本,基于 IP 做令牌桶限流:
http {
lua_shared_dict limit_req_store 100m;
init_by_lua_block {
require "resty.core"
}
server {
location /product/detail/ {
access_by_lua_block {
local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("limit_req_store", 10, 20)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a limit_req object: ", err)
return ngx.exit(500)
end
local key = ngx.var.remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
ngx.log(ngx.WARN, "request rejected by limit_req: ", key)
return ngx.exit(429)
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
}
proxy_pass http://backend;
}
}
}
这招对普通爬虫效果拔群。IP 被限后直接返回 429,连 Go 服务都碰不到。
第二层:Go 服务内做熔断 & 缓存
我们用的是 Go 写的微服务,框架选了 Gin。针对缓存穿透问题,我加了两层防护:
- 空值缓存:如果查不到商品,就缓存一个
{},TTL 设短一点(比如 60s) - 布隆过滤器:用 Redis 的 Bitmap 实现简易版布隆过滤器,提前拦截无效 ID
关键代码片段:
func GetProductDetail(id string) (*Product, error) {
// 先查布隆过滤器
exists, err := bloomFilter.Exists(ctx, id)
if err != nil {
log.Warnf("bloom filter check failed: %v", err)
// 容错:过滤器挂了不能阻塞主流程
} else if !exists {
return nil, ErrProductNotFound // 直接返回,不查 DB
}
// 查 Redis
var p Product
if err := redis.Get(ctx, "product:"+id, &p); err == nil {
return &p, nil
}
// 缓存未命中,查 DB
p, err := db.QueryProduct(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 空值缓存,防穿透
redis.SetEx(ctx, "product:"+id, "{}", 60*time.Second)
return nil, ErrProductNotFound
}
return nil, err
}
// 回填缓存
redis.SetEx(ctx, "product:"+id, p, 24*time.Hour)
return &p, nil
}
吐槽一句:测试同学一开始说“布隆过滤器有误判,会影响用户体验”。我说大哥,误判只是把合法请求当成非法,最多多查一次 DB;但不加的话,系统直接崩,哪个更影响体验?
第三层:数据库读写分离 + 分库分表
虽然前面两层已经挡住 99% 的恶意流量,但我们还是做了兜底。MySQL 主库只处理写请求,读请求全部走从库。另外,商品表按 id % 64 分了 64 个物理表,避免单表过大。
关于区块链?别急,它真的有用
你可能会问:标题里提到的“区块链”去哪了?是不是为了凑关键词硬塞?
还真不是。我们最近在调研一个新需求:商品溯源。用户扫描二维码,能看到这个商品从原料到上架的完整流转记录。这种场景下,数据一旦写入就不能篡改,天然适合区块链。
但我们没用公链(太慢太贵),而是基于 Hyperledger Fabric 搭了个私有链。Go 服务在写入商品信息时,会同时向区块链提交一笔交易:
func SubmitToBlockchain(productID string, traceData TraceInfo) error {
// 序列化数据
payload, _ := json.Marshal(traceData)
// 调用 Fabric SDK 提交
_, err := fabricClient.SubmitTransaction(
"tracecc", // chaincode 名
"recordTrace", // function
productID,
string(payload),
)
return err
}
有趣的是,区块链在这里反而成了性能瓶颈。Fabric 的吞吐量大概就 3k TPS,远低于我们的业务峰值。所以我们做了异步写入:先落 Kafka,再由消费者慢慢同步到链上。这样既保证了不可篡改性,又不影响主流程性能。
性能对比:优化前后数据说话
折腾了两周,效果立竿见影。下面是压测数据(用 wrk 模拟 1w 并发):
| 指标 | 优化前 | 优化后 |
|---|---|---|
| P99 延迟 | 2800ms | 85ms |
| 错误率 | 42% | 0.3% |
| DB CPU | 98% | 23% |
| Redis 命中率 | 61% | 98.7% |
最爽的是,上周又有爬虫来扫,结果人家自己 IP 被封了,我们系统纹丝不动。运维兄弟终于不用半夜被 PagerDuty 叫醒了。
跳槽?先把手头事做好
写到这里,突然觉得跳槽这事没那么急了。虽然现在公司加班多、需求乱,但至少让我接触到了真实的高并发场景——这玩意光看书是学不会的。上次面试某大厂,面试官问“怎么防缓存穿透”,我能直接掏出线上案例讲半小时,比背八股文强多了。
当然,该学的技术还得学。最近在研究 Go 的 context 和 sync.Pool 在高并发下的最佳实践,还顺手给团队封装了个通用限流中间件。代码已开源(内部 GitLab),欢迎 PR(虽然可能没人看 😂)。
最后送大家一句我贴在显示器边上的话:
“系统不会因为你写了‘高可用’三个字就真的高可用。”
共勉。

评论 0