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

敏锐的哲学家
2025-12-14 16:03
阅读 606

作者:某985大三狗,秋招备战中,边听Lo-fi beats边敲代码的重度患者。在组里干了快两年,代码洁癖晚期,看到屎山就手痒。今天这篇不是教科书,是我被现实毒打后吐出来的血泪经验。


去年双11前两周,我正躺在宿舍床上刷LeetCode,突然接到组长电话:“小张,你那个商品详情页接口,QPS刚被压测打爆了,现在前端天天催,运营说大促挂了要背锅,PM已经在会议室拍桌子了。”

我:???

那一刻,我真的想砸电脑。明明本地跑得好好的,怎么一上测试环境就崩?更离谱的是,运维甩给我一行日志:

Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 3000ms.

好家伙,连接池都炸了。而距离上线只剩11天。

事情是怎么走到这一步的?

我们组做的其实是个电商中间件产品,给公司内部多个业务线提供统一的商品展示能力。前端是React + 微前端,后端SpringBoot单体架构(别笑,很多公司还在用),数据库MySQL + Redis缓存。

问题出在流量预估严重失真。产品经理拍脑袋说“这次活动最多5k QPS”,结果灰度放量第一天就冲到了2w+。更要命的是,我们的接口里居然有三次串行DB查询——查商品基础信息、查库存、查促销规则,每个都走主库,没加任何缓存兜底。

前端同学直接在群里@我:“兄弟,用户点一下页面转圈3秒,再点直接502,运营已经在老板面前哭诉了……”

行吧,背锅侠就是我了。


第一步:稳住别浪,先做熔断降级

高并发的第一课不是优化,是别让系统雪崩

我立刻在SpringBoot里集成Sentinel(比Hystrix轻量,配置也简单)。核心思想就一条:快速失败,保护系统

// 商品服务入口处加流控注解
@SentinelResource(value = "productDetail", 
    blockHandler = "handleProductDetailBlock",
    fallback = "fallbackProductDetail")
public ProductVO getProductDetail(Long skuId) {
    // 原有业务逻辑
}

同时,和前端约定了降级策略:

  • 如果缓存命中但DB异常,返回“库存未知”;
  • 如果Redis也挂了,直接返回静态兜底数据(提前从DB dump一份JSON放在Nginx);
  • 接口超时一律设为800ms,超过直接fail fast。

这一招立竿见影。第二天压测,即使DB扛不住,前端至少能展示个“商品信息加载中…稍后再试”,不至于白屏或者502。运营终于闭嘴了。


第二步:缓存,缓存,还是缓存!

我承认,之前的设计太理想主义了。以为“读多写少”就不用缓存,结果被打脸打得啪啪响。

多级缓存架构

我们现在用的是 本地缓存(Caffeine) + Redis + DB 三级结构:

请求 → Caffeine(毫秒级) → Redis(网络RTT ~1ms) → MySQL(~10ms)

关键点在于缓存穿透/击穿/雪崩的防御:

  • 穿透:查不存在的skuId。解决方案:布隆过滤器 + 空值缓存(带短TTL)
  • 击驰:热点商品缓存过期瞬间大量请求打到DB。解决方案:逻辑过期 + 后台异步刷新
  • 雪崩:大量key同时失效。解决方案:随机TTL偏移
// 示例:带逻辑过期的缓存读取
public ProductVO getWithLogicalExpire(Long skuId) {
    String key = "product:" + skuId;
    String json = redisTemplate.opsForValue().get(key);
    
    if (json != null) {
        RedisData redisData = JSON.parseObject(json, RedisData.class);
        ProductVO product = redisData.getData(ProductVO.class);
        // 判断是否逻辑过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            return product; // 未过期,直接返回
        }
        // 已过期,开启后台线程刷新(只允许一个线程刷新)
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                this.saveProductToRedis(skuId, 20 * 60); // 重建缓存
            } catch (Exception e) {
                log.error("缓存重建失败", e);
            }
        });
        return product; // 先返回旧数据
    }
    // 缓存未命中,查DB并回填
    return queryAndCache(skuId);
}

注:CACHE_REBUILD_EXECUTOR 是一个单线程线程池,避免并发重建。


第三步:数据库扛不住?那就别让它扛

我们的MySQL主库在高峰期CPU飙到90%,慢查询日志里全是 SELECT * FROM product_info WHERE sku_id = ?

读写分离 + 分库分表(轻量版)

由于时间紧,没上ShardingSphere这种重型武器,而是用 MyBatis-Plus + 多数据源 实现读写分离:

spring:
  datasource:
    master:
      url: jdbc:mysql://master-db:3306/product
    slave:
      url: jdbc:mysql://slave-db:3306/product

然后自定义注解路由:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {
}

AOP切面判断,带 @ReadOnly 的走从库。商品详情这种纯读接口全标上,主库压力瞬间下降40%。

索引优化:别再 SELECT * 了!

我把所有接口的SQL都捞出来review了一遍,发现几个致命问题:

  • 没有为 sku_id 加唯一索引(虽然它是主键,但有些表用的是自增id,sku_id只是普通字段)
  • 大量 SELECT *,其实前端只需要5个字段
  • 连表查询没限制,一次查出上千条记录

改完后,单次查询从12ms降到1.8ms。有时候性能瓶颈不在架构,在SQL本身。


第四步:和前端、运营对齐“体验优先级”

技术人容易陷入“我要做到完美”的陷阱,但高并发场景下,用户体验 > 数据强一致

我们和前端、产品开了个紧急会,达成共识:

场景 技术方案 用户感知
库存显示 最终一致性(MQ异步更新缓存) “仅剩XX件”可能延迟1-2秒
价格计算 缓存促销规则,每5分钟刷新 大促期间价格不变,可接受
商品图片 CDN + 静态资源预加载 首屏秒开

甚至运营同意:大促期间关闭“实时销量”展示。因为那个功能每次都要count(*),简直是DB杀手。


第五步:压测!监控!复盘!

光改代码不够,必须验证。

我们用 JMeter + Grafana + Prometheus 搭了一套简易监控:

  • JVM内存、GC频率
  • Redis命中率(目标 > 98%)
  • 接口P99延迟(目标 < 500ms)
  • DB连接池使用率

压测策略也很野:模拟真实用户行为,不是单纯发请求。比如:

  • 70% 用户只看商品页不下单
  • 20% 加购但不支付
  • 10% 完成下单

结果发现,加购接口的Redis写操作成了新瓶颈。于是把“购物车”从Redis List改成 Hash 结构,内存占用降了60%,TPS翻倍。


效果如何?

双11当天,峰值QPS达到3.2w,系统稳如老狗:

指标 优化前 优化后
P99延迟 2800ms 320ms
DB CPU 92% 45%
错误率 12% 0.03%
缓存命中率 76% 98.7%

前端同学请我喝了杯瑞幸,PM在周会上夸我“有大局观”(虽然我知道他根本不懂Sentinel是啥)。


给秋招同学的真心话

作为正在投简历的大三狗,我深刻体会到:面试官不关心你会多少框架,只关心你有没有解决过真实问题。

高并发不是背八股文,而是一套系统性思维

  • 先保命(熔断降级)
  • 再提速(缓存+异步)
  • 最后抠细节(SQL、索引、连接池)

我在简历里写“主导高并发商品服务优化,支撑3w+ QPS”,比写“熟悉SpringCloud”有用十倍。

顺便吐槽一句:千万别信产品经理说的“流量不大”。他们眼中的“不大”,可能是你服务器的十倍。


最后的小Tips

  1. 连接池配置别照抄网上的:HikariCP的 maximumPoolSize 要根据DB的max_connections和应用实例数计算,公式:(DB_max_conn - 保留连接) / 实例数
  2. 日志别打太多:高并发下logger.info() 都可能成为瓶颈,用异步日志(logback async)
  3. 上线前必做:混沌工程!哪怕只是手动 kill 一个Redis节点,看系统会不会雪崩

好了,写完这篇已经凌晨两点,耳机里的Lo-fi还在循环。明天还要改另一个“简单需求”——据说只要加个按钮就行。

唉,程序员的命,都是产品经理给的(狗头保命)。

共勉。

评论 0

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