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

◆林伟
2025-12-14 12:30
阅读 370

上周五晚上十点半,我盯着电脑屏幕上那条不断刷屏的告警邮件,心里默默问候了产品经理全家——不是真的骂,是那种程序员式“又来了”的无奈苦笑。我们刚上线的新产品“秒杀活动”,在预热期就因为用户涌入量太大直接把后端服务干崩了,数据库 CPU 直接飙到 100%,API 响应时间从 50ms 蹭蹭涨到 5s+。

说实话,作为一个刚入职两个月的新人,本来以为就是个 CRUD 的活儿,结果第一天就被拉进“高可用架构攻坚小组”,领导拍着我肩膀说:“你不是对分布式系统有点研究吗?交给你了。” 😅


我是谁?为啥写这篇?

先简单介绍一下我自己:一个死忠 Vim 党,日常开发基本不用 IDE(JetBrains 系列在我眼里就是“资源吞噬者”),之前折腾过各种 AI 编程工具,Copilot、CodeWhisperer、TabNine 都试过,最后还是选了 Cursor —— 因为它能完美集成进我的终端流,还能用自然语言改代码,不打断心流。

现在在一家做电商 SaaS 的创业公司,团队氛围不错,但 deadline 永远比人命还紧。这次的“高并发”需求,本质上是为了支撑客户侧的一个大促活动,而我们的产品需要提供稳定、低延迟的接口能力。换句话说:产品要卖钱,系统不能崩。

所以,这篇文不是为了炫技,而是记录自己从“纸上谈兵”到“线上救火”的全过程,顺便给和我一样刚入行、被高并发吓到的小白一点参考。


问题来了:为什么一压测就崩?

我们的核心接口其实很简单:用户点击“抢购”按钮 → 后端校验库存 → 扣减库存 → 创建订单。逻辑清晰,代码也不复杂,用的是 Spring Boot + MySQL + Redis 的经典组合。

但一上 JMeter 压测(模拟 1000 并发),立马翻车:

  • 数据库锁竞争激烈UPDATE stock SET count = count - 1 WHERE product_id = ? AND count > 0 这条 SQL,在高并发下直接变成串行执行,MySQL 的 InnoDB 行锁成了瓶颈。
  • 缓存穿透:有些恶意请求传不存在的 product_id,导致大量请求打到 DB。
  • 超卖:虽然加了 count > 0 判断,但在极端并发下,多个线程同时读到 count=1,都以为还有库存,结果全扣成功,超卖了!

当时我真的想砸键盘。产品那边还在群里@我说:“能不能今晚搞定?明天客户就要演示了。” 🤦‍♂️


思路调整:从“能跑”到“扛得住”

痛定思痛,我翻出大学时看过的《Designing Data-Intensive Applications》,结合公司老大的建议,重新梳理了设计原则:

1. 流量分层:别让所有请求都打到数据库

  • 前置限流:用 Nginx 层做 IP + 接口级别的限流(limit_req_zone),防刷。
  • 缓存兜底:Redis 预热商品库存,请求先查缓存,没命中再走 DB(但得防穿透)。
  • 异步削峰:下单动作不直接创建订单,而是发消息到 Kafka,由消费者异步处理。

2. 库存扣减:原子性是底线

最初的 SQL 问题在于“先查后改”不是原子的。解决方案有两个方向:

  • 数据库层面:用 UPDATE ... SET count = count - 1 WHERE product_id = ? AND count >= 1,靠数据库的行锁保证原子性(但性能差)。
  • Redis + Lua 脚本:把库存存在 Redis,用 Lua 脚本保证“读-判断-写”在一个原子操作里完成。

我选了后者。毕竟 Redis 单线程模型天然适合这种场景,而且 QPS 能轻松上万。

-- stock.lua
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
else
    return 0
end

Java 调用:

String script = "..."; // 上面的 Lua 脚本
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = redisTemplate.execute(redisScript, Collections.singletonList("stock:" + productId), "1");
if (result != null && result == 1L) {
    // 扣减成功,发消息创建订单
    kafkaTemplate.send("order-create", orderId);
} else {
    throw new RuntimeException("库存不足");
}

3. 防缓存穿透 & 雪崩

  • 空值缓存:对查不到的商品 ID,也缓存一个空值(TTL 短一点,比如 60s),避免反复查 DB。
  • 随机过期时间:给缓存设置 TTL 时加个随机偏移(比如 300s ± 30s),防止同一时间大批 key 失效导致 DB 压力骤增。

4. 最终一致性:接受短暂不一致

订单创建是异步的,所以前端不能立刻返回“订单成功”,而是返回“排队中”,用户刷新后才能看到结果。产品一开始很抵触,说“用户体验不好”,但解释清楚“要么慢,要么崩”之后,也接受了。


生产环境踩坑实录

你以为改完就完了?Too young.

坑 1:Redis 连接池爆了

压测时发现 Redis 报 ERR max number of clients reached。原来 Spring Boot 默认的 Lettuce 连接池太小,高并发下不够用。

解决:调大连接池配置:

spring:
  redis:
    lettuce:
      pool:
        max-active: 200
        max-wait: -1ms
        max-idle: 50
        min-idle: 10

坑 2:Kafka 消费积压

异步下单后,消费者处理太慢,消息堆积。查日志发现是每次消费都要查 DB、写 DB、调风控服务,太重了。

优化

  • 消费者批量处理(max-poll-records: 50
  • 本地缓存常用数据(比如用户等级、商品信息)
  • 非关键逻辑(如发通知)再拆出去

坑 3:监控缺失

第一次上线没配好监控,出了问题只能靠日志 grep。后来赶紧上了 Prometheus + Grafana,关键指标包括:

指标 说明 告警阈值
http_request_duration_seconds API 延迟 P99 > 500ms
redis_connected_clients Redis 连接数 > 180
kafka_consumer_lag 消息积压 > 1000

效果如何?数据说话

优化前后对比(模拟 2000 并发):

指标 优化前 优化后
成功率 68% 99.8%
平均响应时间 2200ms 85ms
DB CPU 使用率 95% 35%
超卖发生次数 12 次 0 次

双 11 预演当天,系统稳如老狗。产品终于没在群里 @ 我,反而发了个红包 🎉


心得体会:高并发不是玄学

回头看,高并发系统设计其实没那么神秘。核心就几点:

  • 别让数据库当第一道防线
  • 能缓存的绝不查库
  • 能异步的绝不同步
  • 接受最终一致性,别追求强一致(除非真需要)

最重要的是:别信“理论上可行”。一定要压测!一定要监控!一定要有回滚方案!

另外,感谢 Cursor 在我改第 17 版库存逻辑时,帮我自动生成了 Redis Lua 脚本的 Java 封装代码,省了我至少两小时 debug 时间。不然我现在可能还在加班……


给新手的建议

如果你也像我一样,刚接触高并发,别慌。记住:

  1. 先跑通,再优化:MVP 跑起来再说,别一上来就想搞百万 QPS。
  2. 善用工具:JMeter 压测、Arthas 线上诊断、SkyWalking 链路追踪,都是救命稻草。
  3. 多问老鸟:我们组老大一句话点醒我:“高并发的本质是控制流量,不是提升性能。”
  4. 保护自己:上线前写好回滚脚本,半夜报警才有底气睡觉。

最后,送一句我们团队的口头禅:“产品可以改需求,系统不能崩。”

共勉。

评论 0

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