一、那个崩溃的夜晚,我们才真正理解并发

去年双十二,我们公司的秒杀活动刚上线三分钟,告警群就炸了。

“数据库连接数爆满!” “主从延迟超过5秒!” “用户反馈抢不到商品!”

我盯着监控大屏上那条垂直拉升的曲线,心里一沉。5000人同时点击“立即抢购”,我们的MySQL 8.0主库直接跪了。

后来复盘,发现根本不是代码逻辑的问题,而是架构层面根本没考虑高并发场景。今天就把这个血泪教训完整分享出来,从Redis缓存穿透到分库分表,一步步教你怎么救。


二、先搞清楚:5000并发到底可怕在哪里?

很多人以为5000人同时抢商品而已,数据库扛得住。错了。

2.1 真实压测数据揭秘

我们用的配置是:

  • MySQL 8.0,8核16G,InnoDB引擎
  • 单机部署,主从架构
  • 商品表100万条,秒杀表50万条

正常情况:

  • QPS 200,TPS 150,响应时间 <50ms

5000并发时:

  • QPS 直接冲到 8000+
  • 连接数爆满(默认151,我们改到2000还是不够)
  • 响应时间 2秒+
  • 死锁率 15%
  • 从库延迟 10秒+

2.2 瓶颈在哪里?

三个致命问题:

① 连接池被打爆 每个请求都要建立数据库连接,5000人同时来,连接数直接炸。MySQL默认max_connections=151,我们改成2000还是不够,因为连接建立本身就有开销。

② 行锁竞争严重 秒杀商品只有一件,5000人同时抢同一行,InnoDB的行锁直接争抢。死锁率飙升到15%,超时率30%。

③ 磁盘IO扛不住 大量随机IO,磁盘带宽饱和。 SSD还好,机械盘直接跪。


三、第一道防线:Redis缓存层(解决读压力)

3.1 为什么一定要加缓存?

MySQL是关系型数据库,擅长事务,但不擅长高并发读。

秒杀场景的特点:

  • 读多写少(99%是查询商品详情)
  • 热点数据集中(就那几件秒杀商品)
  • 实时性要求高(库存要准)

解决方案: 把热点数据放到Redis里,99%的读请求打到缓存,只有写请求(扣库存)才去数据库。

3.2 完整架构设计

用户请求 → Nginx → Spring Cloud Gateway → 服务层
                                    ↓
                            Redis缓存层
                            (热点数据)
                                    ↓
                            MySQL主库
                            (写操作)
                                    ↓
                            MySQL从库
                            (读操作)

关键设计点:

① 缓存结构

# 商品详情
SETEX item:detail:{id} 300 '{"id":1,"name":"手机","price":999}'

# 秒杀库存
SETEX mds:stock:{id} 60 100

# 用户限购标记
SETEX mds:bought:{uid}:{sid} 300 1

② 缓存预热 活动开始前5分钟,把所有秒杀商品详情和库存加载到Redis。

@Scheduled(cron = "0 */5 * * * ?")
public void preheatCache() {
    List<SeckillItem> items = seckillMapper.selectAll();
    for (SeckillItem item : items) {
        // 商品详情缓存300秒
        stringRedisTemplate.opsForValue()
            .setex("item:detail:" + item.getId(), 300, JSON.toJSONString(item));
        // 库存缓存60秒
        stringRedisTemplate.opsForValue()
            .setex("mds:stock:" + item.getId(), 60, String.valueOf(item.getStock()));
    }
}

③ 缓存穿透防护 恶意用户疯狂查询不存在的商品,缓存不命中,直接打到数据库。

解决方案:布隆过滤器

// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    1000000, 0.01
);

// 查询前先判断
if (!bloomFilter.mightContain(itemId)) {
    return Result.error("商品不存在");
}

3.3 缓存一致性保证

问题: Redis和MySQL数据不一致怎么办?

解决方案:Cache-Aside Pattern

@Transactional
public ResultDTO seckill(Long itemId, Long uid) {
    // 1. 先更新Redis(扣库存)
    Long stock = redisTemplate.opsForValue().decrement("mds:stock:" + itemId);
    if (stock < 0) {
        redisTemplate.opsForValue().set("mds:stock:" + itemId, 0);
        return Result.error("库存不足");
    }
    
    // 2. 再更新MySQL(写操作)
    int affected = seckillMapper.insert(new SeckillOrder(uid, itemId));
    if (affected == 0) {
        // 失败,回滚Redis
        redisTemplate.opsForValue().increment("mds:stock:" + itemId);
        return Result.error("抢购失败");
    }
    
    // 3. 发送MQ消息(异步同步)
    mqTemplate.send("seckill.order", JSON.toJSONString(new Order(uid, itemId)));
    
    return Result.success("抢购成功");
}

关键点:

  • 先更新缓存,再更新数据库(避免脏数据)
  • 更新失败,回滚缓存
  • 异步同步(MQ),保证最终一致

3.4 压测效果对比

优化前(直连MySQL):

  • QPS 200,响应时间 50ms
  • 5000并发直接崩

优化后(Redis缓存):

  • QPS 5000+,响应时间 <10ms
  • 5000并发稳稳扛住
  • 数据库QPS 降到 500以下

四、第二道防线:MySQL优化(解决写压力)

4.1 数据库连接池优化

问题: 连接池被打爆怎么办?

解决方案:HikariCP + 连接池调优

spring:
  datasource:
    hikari:
      maximum-pool-size: 50        # 最大连接数
      minimum-idle: 10             # 最小空闲连接
      connection-timeout: 3000     # 连接超时3秒
      idle-timeout: 600000         # 空闲超时10分钟
      max-lifetime: 1800000        # 连接最大存活30分钟

关键点:

  • maximum-pool-size不要设太大(50-100足够)
  • 连接池大小 = CPU核数 × 2 + 磁盘数
  • 我们的配置:8核 → 50个连接

4.2 行锁优化

问题: 行锁争抢严重怎么办?

解决方案:乐观锁 + 库存分段

-- 乐观锁更新
UPDATE seckill_stock 
SET stock = stock - 1, version = version + 1
WHERE item_id = #{itemId} AND stock > 0 AND version = #{version};

-- 如果受影响行数为0,说明并发冲突,重试

库存分段(高级):

-- 把100个库存分成10段,每段10个
CREATE TABLE seckill_stock_segment (
    id BIGINT PRIMARY KEY,
    item_id BIGINT,
    segment_id INT,
    stock INT,
    version INT
);

-- 每个请求随机选一段,减少锁竞争
UPDATE seckill_stock_segment 
SET stock = stock - 1, version = version + 1
WHERE item_id = #{itemId} AND segment_id = #{randomSegment} AND stock > 0;

4.3 索引优化

问题: 查询慢怎么办?

解决方案:覆盖索引 + 联合索引

-- 秒杀查询优化
CREATE INDEX idx_seckill_item_stock ON seckill_item(item_id, stock, status);

-- 订单查询优化
CREATE INDEX idx_order_user_item ON seckill_order(user_id, item_id, create_time);

关键点:

  • EXPLAIN分析慢查询
  • 避免SELECT *,只查需要的字段
  • 联合索引顺序:等值条件 > 范围条件

4.4 分库分表(终极方案)

问题: 单库扛不住怎么办?

解决方案:ShardingSphere + 分库分表

# ShardingSphere配置
spring:
  shardingsphere:
    datasource:
      names: ds0,ds1
      ds0:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/db0
        username: root
        password: 123456
      ds1:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/db1
        username: root
        password: 123456
    rules:
      sharding:
        tables:
          seckill_order:
            actual-data-nodes: ds$->{0..1}.seckill_order$->{0..1}
            database-strategy:
              sharding-column: user_id
              sharding-algorithm-name: database-inline
            table-strategy:
              sharding-column: order_id
              sharding-algorithm-name: table-inline
        sharding-algorithms:
          database-inline:
            type: INLINE
            props:
              algorithm-expression: ds$->{user_id % 2}
          table-inline:
            type: INLINE
            props:
              algorithm-expression: seckill_order$->{order_id % 2}

关键点:

  • 分库键选user_id(均匀分布)
  • 分表键选order_id(避免热点)
  • 单库单表数据量控制在1000万以内

五、完整压测报告:5000并发实测数据

5.1 优化前(直连MySQL)

  • QPS:200
  • 响应时间:50ms
  • 5000并发:直接崩
  • 错误率:100%

5.2 优化后(Redis + MySQL + 分库分表)

  • QPS:8000+
  • 响应时间:<10ms
  • 5000并发:稳稳扛住
  • 错误率:<0.1%
  • 数据库QPS:500以下
  • 死锁率:<0.01%
  • 主从延迟:<100ms

5.3 压测工具推荐

  • JMeter:开源,功能强大
  • Wrk:高性能,适合HTTP压测
  • sysbench:数据库压测专用

六、血泪教训:我们踩过的坑

6.1 缓存穿透

现象: 恶意用户疯狂查询不存在的商品,缓存不命中,直接打到数据库。

教训: 一定要加布隆过滤器,或者缓存空值。

// 缓存空值5秒
if (item == null) {
    redisTemplate.opsForValue().setex("item:detail:" + itemId, 5, "{}");
    return Result.error("商品不存在");
}

6.2 缓存雪崩

现象: 大量缓存同时过期,请求直接打到数据库。

教训: 过期时间加随机值。

// 过期时间300秒 + 随机0-60秒
int expireTime = 300 + new Random().nextInt(60);
redisTemplate.opsForValue().setex("item:detail:" + itemId, expireTime, JSON.toJSONString(item));

6.3 缓存击穿

现象: 热点Key过期,并发请求直接打到数据库。

教训: 使用互斥锁。

// 加锁重建缓存
if (!redisTemplate.hasKey("item:detail:" + itemId)) {
    synchronized (this) {
        if (!redisTemplate.hasKey("item:detail:" + itemId)) {
            // 重建缓存
            Item item = mapper.selectById(itemId);
            redisTemplate.opsForValue().setex("item:detail:" + itemId, 300, JSON.toJSONString(item));
        }
    }
}

6.4 数据库死锁

现象: 5000人同时抢同一商品,行锁争抢严重,死锁率15%。

教训: 使用乐观锁 + 库存分段。


七、架构演进路线图

第一阶段:直连MySQL
├── 问题:500并发就崩
└── 解决:加Redis缓存

第二阶段:Redis + MySQL
├── 问题:5000并发写压力大
└── 解决:MySQL优化 + 乐观锁

第三阶段:Redis + MySQL优化 + 分库分表
├── 问题:单库还是扛不住
└── 解决:ShardingSphere分库分表

第四阶段:Redis + MySQL + 分库分表 + MQ异步
├── 问题:写操作阻塞
└── 解决:MQ异步削峰

八、完整代码仓库

GitHub地址: https://github.com/example/seckill-demo

技术栈:

  • Spring Boot 3.x
  • Redis 7.x
  • MySQL 8.0
  • ShardingSphere 5.x
  • RabbitMQ 3.x
  • Nginx

关键模块:

  1. seckill-service:秒杀核心服务
  2. seckill-cache:Redis缓存层
  3. seckill-dao:数据库访问层
  4. seckill-mq:消息队列层
  5. seckill-sharding:分库分表配置

九、总结:高并发秒杀架构核心要点

九字真言:缓存先行、锁要乐观、分库分表。

  1. 缓存先行:99%读请求走Redis,只有写请求走MySQL
  2. 锁要乐观:用乐观锁代替悲观锁,减少锁竞争
  3. 分库分表:单库单表控制在1000万以内,用ShardingSphere自动分片

最后送一句话: 高并发不是靠加机器解决的,是靠架构设计解决的。Redis缓存 + MySQL优化 + 分库分表,这三道防线缺一不可。

希望这篇文章能帮你避开我们踩过的坑,5000并发稳稳扛住!