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

木木在敲代码
2025-12-16 10:49
阅读 737

去年双11前夜,我蹲在成都办公室的角落里啃着冷掉的冒菜,盯着 Grafana 上那条不断飙升的 QPS 曲线,手心全是汗。那一刻我真想把产品经理拉过来问问:“你确定‘简单加个缓存’就能扛住流量洪峰?”——当然,这只是幻想。现实中我只能默默重启第三个 Redis 实例,祈祷它别再 OOM。

我是京东的一名后端工程师,入行五年,经历过六次大促,从最初的“看监控就腿软”到现在能一边喝冰美式一边淡定地切流量,也算是在高并发这口高压锅里炖熟了。最近团队在重构一个核心交易链路,正好趁这个机会把这几年踩过的坑、攒下的经验整理一下,顺便记录下自己从 Java 老兵向 Rust 新手过渡的一些思考。


一切始于一个“不可能”的需求

事情得从上个月说起。产品那边突然丢过来一个 PRD,说要搞个“秒杀专区”,要求支持 10w+ QPS,响应时间 < 50ms,99.99% 可用性。我当场就笑了:“兄弟,咱们现在主站峰值也就 3w QPS,你这直接翻三倍?数据库怕不是要当场去世。”

但笑归笑,活儿还是得干。毕竟在互联网公司,deadline 是第一生产力。于是我们拉了个小群,名字就叫“别崩就行”,开始了这场高并发攻坚战。


架构不是画出来的,是压测压出来的

很多人以为高并发系统就是堆中间件:Redis、Kafka、Nginx、CDN……但真正的问题从来不在工具本身,而在于怎么组合它们

我们最初的设计很“教科书”:

  • 前端用 JavaScript 发起请求(对,JS 也是高并发链条里的一环!)
  • 网关层限流
  • 服务层查库存、扣减
  • 数据库写订单

结果第一次压测就炸了。MySQL CPU 直接飙到 100%,大量 Deadlock found when trying to get lock 报错刷屏。运维老哥在群里发了个“?”的表情包,我默默回了个“对不起”。

问题出在哪?热点数据 + 行锁竞争。所有请求都在争抢同一行库存记录,数据库成了瓶颈。

缓存不是万能的,但没有缓存是万万不能的

我们立刻加了 Redis 缓存库存。但光缓存还不够——如果缓存失效瞬间涌入大量请求,还是会打穿 DB(这就是经典的“缓存击穿”)。于是我们做了三层防护:

  1. 本地缓存(Caffeine):热点商品 ID 在 JVM 内缓存,减少 Redis 访问
  2. 分布式锁:用 Redis 的 SETNX 控制缓存重建,避免雪崩
  3. 异步队列削峰:非核心操作(如发券、日志)扔进 Kafka,解耦主流程
// Java 伪代码:带本地缓存 + 分布式锁的库存查询
public Integer getStock(Long skuId) {
    // 先查本地缓存
    Integer stock = localCache.getIfPresent(skuId);
    if (stock != null) return stock;

    // 尝试获取分布式锁
    String lockKey = "lock:stock:" + skuId;
    if (redis.setnx(lockKey, "1", 3)) {
        try {
            // 双重检查
            stock = localCache.getIfPresent(skuId);
            if (stock == null) {
                stock = db.queryStock(skuId); // 查 DB
                redis.setex("stock:" + skuId, 60, stock);
                localCache.put(skuId, stock);
            }
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,短暂等待后重试或降级
        Thread.sleep(10);
        return getStock(skuId);
    }
    return stock;
}

这段代码上线后,DB 压力直接降了 80%。但新问题来了:本地缓存一致性怎么保证?比如库存变了,其他实例的 Caffeine 还是旧值。

我们最后用 Redis 的 Pub/Sub 通知各节点失效本地缓存。虽然有点“重”,但在高并发场景下,一致性比性能更重要(除非你能接受超卖)。


JavaScript 不是旁观者,而是发起者

很多人觉得前端只是“展示层”,但在高并发场景下,前端策略直接影响后端压力

我们的前端同学一开始用的是轮询:“每 100ms 问一次后端能不能下单”。好家伙,10w 用户就是 100w QPS 打过来。我直接在群里喊话:“求你们改成长轮询或者 WebSocket 吧!”

后来他们用上了 Server-Sent Events (SSE),配合后端的库存状态推送,请求量直接砍掉 90%。而且 JS 层还加了“按钮防抖”和“失败重试指数退避”,极大减少了无效请求。

所以说,高并发不是后端一个人的事。前端、网关、服务、DB,每一环都要优化。


数据库:别再用 ORM 一把梭了

在高并发写场景下,Hibernate/MyBatis 这些 ORM 框架的自动 SQL 生成往往不够高效。我们最终对核心表做了手动 SQL 优化 + 分库分表

比如库存扣减,我们不用 UPDATE stock = stock - 1 WHERE id = ?,而是用 CAS(Compare And Set)

UPDATE product_stock 
SET stock = stock - 1 
WHERE sku_id = ? AND stock > 0;

然后通过 JDBC 返回的 affectedRows 判断是否成功。这种方式天然避免超卖,还不需要显式加锁。

另外,我们把订单表按 user_id 哈希分了 64 库,每个库 16 表。虽然 ShardingSphere 配置起来有点反人类,但至少 DB 不再是单点瓶颈。

方案 QPS 平均延迟 超卖风险
原始 ORM + 单表 ~2k 120ms
手动 SQL + CAS ~15k 40ms
+ 分库分表 ~50k+ 35ms

异步化:让系统“喘口气”

同步阻塞是高并发的大敌。我们把能异步的全异步了:

  • 下单成功后,发消息到 Kafka
  • 消费者处理积分、优惠券、通知等
  • 前端通过轮询或 SSE 获取最终状态

这样主链路从 8 个 RPC 调用减少到 2 个,RT 从 200ms 降到 35ms。而且就算下游挂了,也不影响下单。

不过异步也有代价:状态不一致。用户可能看到“下单成功”但没收到券。我们加了补偿任务 + 对账系统,每天凌晨跑一遍,确保最终一致。


最近在折腾 Rust:Java 老兵的新玩具

说个题外话。最近被 Go 和 Rust 刷屏,加上 ChatGPT 写 Rust 代码越来越稳,我也开始学 Rust 了。不是说要抛弃 Java——毕竟 Spring Cloud 生态太香了——但 Rust 的内存安全和零成本抽象,真的适合做高性能网关或中间件。

上周我用 Rust 写了个简单的限流组件,对比 Java 版本,在同样配置下吞吐高了 40%,内存占用少了一半。虽然现在团队主力还是 Java(毕竟五年的屎山不能说扔就扔),但我觉得未来某些边缘服务可以用 Rust 重写。


总结:高并发没有银弹,只有权衡

经过三轮压测 + 两次线上灰度,我们的秒杀系统终于能在 12w QPS 下稳如老狗。回头看,其实没用什么黑科技,就是把缓存、异步、削峰、分片、降级这些老套路组合好。

几点心得送给大家:

  • 别迷信理论:CAP、BASE 很美好,但线上事故往往死于一个没加索引的 SQL
  • 监控要前置:Metrics、Tracing、Logging 三件套必须在开发阶段就集成
  • 压测要真实:用 JMeter 模拟的流量和真实用户行为差很远,最好用影子库+流量回放
  • 留好逃生通道:开关、降级、熔断,关键时刻能保命

最后,感谢我的队友们——特别是那个总在凌晨三点帮我捞日志的运维小哥。也感谢 ChatGPT,上周五晚上我卡在一个 Redis Lua 脚本 bug 里,它三句话就指出了我漏了 KEYSARGV 的区分。

高并发系统设计,说到底不是技术问题,而是工程问题。它考验的不是你会不会写算法,而是你能不能在 deadline 前,用最稳妥的方式,把一堆不完美的组件拼成一个能跑的东西。

就像我们成都人常说的:“慢慢来,比较快。”——前提是,你得先跑起来。

(完)

P.S. 如果你也在备战大促,记得备份冒菜店电话。有时候,一顿热乎的串串比任何架构图都管用。

评论 0

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