高并发系统设计:从理论到实践(一个被线上事故逼出来的教程)

TypeScript守夜人
2025-12-18 14:53
阅读 1474

作者注:我是 GitHub Copilot 付费用户,用了快两年。日常主力是 VSCode,插件装得比头发还多(虽然发际线也在配合)。在当前公司干了三年多,最近开始琢磨跳槽的事儿——不是因为工资低(好吧,其实也有点),主要是想看看外面的世界有没有更酷的高并发场景可以折腾。顺便,我是个喜欢钻底层原理的“杠精”,看到 synchronized 就想翻 JVM 源码。


上周五晚上十点半,我正用 Copilot 自动补全一段 React 组件代码(是的,前端我也要碰,别问,问就是“全栈”),手机突然疯狂震动。运维群炸了:

“【P0 级告警】订单服务 CPU 100%,接口超时率 87%!”

我一口冰美式差点喷到屏幕上。又是双十二预热?这才十一月初啊!点开监控一看,好家伙,QPS 瞬间飙到 12w+,数据库连接池直接打满,Redis 响应延迟从 1ms 飙到 500ms。产品经理还在群里@我:“亲,能不能先扛住?明天上午有个大客户演示……”

那一刻,我真的想砸电脑。

但冷静下来一想——这不就是我一直想啃的高并发实战吗?理论看了不少,《高性能MySQL》《Designing Data-Intensive Applications》都翻烂了,可纸上谈兵终究不如真刀真枪干一场。于是,我决定复盘这次事故,并把整个优化过程写成这篇带血泪教训的项目案例剖析。如果你也在做 Java 后端,或者前端同学好奇后端怎么扛流量,这篇应该能帮到你。


背景:一个看似普通的秒杀活动

我们公司做的是一套 SaaS 电商中台,客户可以在后台配置“限时折扣”、“库存抢购”等活动。原本这类功能走的是常规下单流程:前端 → Nginx → Spring Boot API → MySQL + Redis。

问题出在活动入口页面。前端用的是 React,每次进入活动页会调用 /api/activity/detail/{id} 获取商品信息、库存、倒计时等。正常情况下 QPS 不过几百,但一旦有网红带货,瞬时流量直接把后端干趴。

最致命的是:这个接口没做任何缓存穿透/击穿防护,而且每次请求都会查数据库库存!

// 伪代码:原始接口
@GetMapping("/detail/{id}")
public ActivityDetail getDetail(@PathVariable Long id) {
    // 直接查 DB!连 Redis 都没走!
    return activityService.queryById(id);
}

是的,你没看错。上线前测试只跑了 100 并发,QA 说“没问题”,结果生产环境一上大流量,MySQL 的 InnoDB Buffer Pool 直接被刷爆,磁盘 IO 打满,连锁反应导致整个订单链路雪崩。


第一步:紧急止血 —— 缓存 + 限流

事故当晚,我和运维兄弟通宵搞了三件事:

  1. Nginx 层加突发限流
    先用 limit_req 把 QPS 控死在 5000,避免服务彻底挂掉。

    http {
        limit_req_zone $binary_remote_addr zone=api:10m rate=500r/s;
        
        server {
            location /api/activity/ {
                limit_req zone=api burst=2000 nodelay;
                proxy_pass http://backend;
            }
        }
    }
    
  2. 接口层加 Redis 缓存
    把活动详情缓存 5 秒(业务允许短暂不一致),Key 设为 activity:detail:{id}

  3. 数据库连接池扩容
    HikariCP 从默认 10 改到 100,虽然治标不治本,但至少让服务能喘口气。

搞定后,凌晨三点,接口终于恢复。但我知道,这只是临时方案——缓存过期瞬间还是可能被打穿,而且 5 秒缓存对用户体验也不友好。


第二步:重构核心逻辑 —— 三层防护体系

周末两天,我拉着前端同学一起重做了整个活动页架构。目标:扛住 10w+ QPS,且保证数据一致性

架构图(脑内版):

前端 (React) 
  ↓ (HTTP)
CDN + Nginx (静态资源 + 动态接口分离)
  ↓
Spring Boot 集群 (Java 17 + Spring Cloud)
  ├── 库存服务 (独立部署)
  ├── 活动服务 (带多级缓存)
  └── 订单服务 (异步化)
  ↓
Redis Cluster (缓存 + 分布式锁)
  ↓
MySQL (分库分表 + 读写分离)

关键点 1:前端做“智能降级”

以前前端一进页面就狂刷 /detail,现在改成了:

  • 首屏数据走 SSR(服务端渲染),由 Nginx 直接返回 HTML 片段(含基础活动信息)
  • 倒计时、实时库存等动态数据,通过 WebSocket 推送(减少轮询)
  • 如果后端响应慢,前端自动展示“加载中”兜底图,并提示“稍后再试”

前端同事吐槽:“你们后端总算知道前端也能帮忙扛流量了?” 我回他:“不然你以为 P0 事故谁背锅?”

关键点 2:Java 服务端多级缓存

我们搞了个三层缓存策略:

缓存层级 存储介质 TTL 用途
L1 Caffeine (本地) 1s 防止 Redis 热点 Key
L2 Redis Cluster 10s 主缓存,支持集群
L3 MySQL - 最终一致性

核心代码(简化版):

@Service
public class ActivityCacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private final Cache<Long, ActivityDetail> localCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .build();

    public ActivityDetail getDetail(Long id) {
        // L1: 本地缓存
        ActivityDetail detail = localCache.getIfPresent(id);
        if (detail != null) return detail;

        // L2: Redis
        String redisKey = "activity:detail:" + id;
        detail = (ActivityDetail) redisTemplate.opsForValue().get(redisKey);
        if (detail != null) {
            localCache.put(id, detail); // 回填 L1
            return detail;
        }

        // L3: DB + 写回缓存
        detail = dbQuery(id);
        if (detail != null) {
            // 双写:先写 Redis,再写本地
            redisTemplate.opsForValue().set(redisKey, detail, 10, TimeUnit.SECONDS);
            localCache.put(id, detail);
        }
        return detail;
    }
}

注意:这里没用 @Cacheable,因为 Spring Cache 不支持多级。自己手撸虽然累点,但可控性高。

关键点 3:库存扣减 —— 异步 + 分段锁

最怕的其实是超卖。之前直接 UPDATE stock SET count = count - 1 WHERE id = ? AND count > 0,高并发下 MySQL 行锁竞争严重。

新方案:

  1. Redis 预扣库存:活动开始前,把库存加载到 Redis Hash,如 stock:{activityId} -> {skuId: 100}
  2. Lua 脚本原子扣减
    local stock = redis.call('HGET', KEYS[1], ARGV[1])
    if tonumber(stock) > 0 then
        redis.call('HINCRBY', KEYS[1], ARGV[1], -1)
        return 1
    end
    return 0
    
  3. 异步落库:扣减成功后,发 MQ 消息,由消费者异步更新 MySQL 库存(最终一致性)

这样,99% 的请求在 Redis 层就完成了,DB 压力骤降。


第三步:压测与调优 —— 别信理论,信数据

重构完不能直接上生产!我们用 JMeter + Arthas 做了全链路压测。

  • 模拟 15w QPS 持续 10 分钟
  • 监控指标:CPU、内存、GC、RT、错误率

踩坑记录

  1. Caffeine 本地缓存 OOM
    初始设 maximumSize=100_000,结果 Full GC 频繁。后来降到 10_000,配合短 TTL,稳了。

  2. Redis Pipeline 未启用
    批量查询时没用 pipeline,网络 RT 太高。改成 redisTemplate.executePipelined() 后,吞吐提升 3 倍。

  3. 前端 WebSocket 连接数爆炸
    每个用户一个连接,Nginx 默认 worker_connections=1024 不够用。调到 65535,并启用 reuseport

最终压测结果(单机 4C8G):

指标 优化前 优化后
最大 QPS ~800 28,000
平均 RT (ms) 1200+ 35
错误率 40%+ 0.02%
CPU 使用率 100% 65%

心得体会:高并发不是银弹,而是组合拳

这次事故让我彻底明白:没有“万能架构”,只有“合适场景”

  • 如果你是初创公司,用户量小,别一上来就搞分库分表,先把缓存和限流做好。
  • 如果是金融场景,强一致性优先,宁可牺牲性能也要保证数据正确。
  • 如果是电商秒杀,最终一致性 + 异步化才是王道。

另外,前端真的能帮你扛很多流量!别再把所有压力都甩给后端了。像我们这次,SSR + WebSocket + 智能降级,直接砍掉了 60% 的无效请求。

最后,Copilot 在这次重构中帮了大忙——自动生成 Lua 脚本、JMeter 配置、甚至 Nginx 规则。虽然它偶尔会写出 while(true) 死循环(别问我怎么知道的),但整体效率提升至少 30%。付费?值!


写在最后:关于跳槽

这次事故后,老板拍着我肩膀说:“小张啊,公司离不开你。”
我笑了笑,默默更新了简历。

不是因为怨气,而是我发现:在舒适区待久了,技术会生锈。高并发这种硬核活儿,一年可能就遇到一两次。我想去一个每天都在和流量搏斗的地方,比如某宝、某东、某音……

如果你也在准备跳槽,建议你:
✅ 动手做一个高并发小项目(哪怕只是模拟)
✅ 搞懂缓存、消息队列、分布式锁的底层原理
✅ 学会用监控工具(Arthas、Prometheus、SkyWalking)

毕竟,面试官问“你怎么设计秒杀系统”时,你总不能只背八股文吧?


附:关键配置速查表

组件 参数 建议值 说明
HikariCP maximumPoolSize 50~100 根据 DB 连接数上限调整
Redis maxmemory 80% 物理内存 避免 OOM
Nginx worker_connections 65535 高并发需调大
JVM -Xmx 4g~8g 避免频繁 GC
Caffeine maximumSize 10_000 本地缓存不宜过大

好了,这篇带血泪的高并发实战就到这里。如果对你有帮助,欢迎点赞、转发,或者在评论区吐槽你的 P0 事故——程序员的痛,只有程序员懂

下次见!(希望是在新公司的工位上 😎)

评论 0

最热最新
暂无评论
TypeScript守夜人Lv.1
0
影响力
0
文章
0
粉丝