从电商大促到秒杀场景MySQL高并发崩溃的3个真实案例详解缓存层设计读写分离主从同步及连接池优化的实战解决方案


先给你讲个真实发生的事。

2019年双11,某头部电商平台的一个秒杀活动,商品单价99元,限量1000件。活动开始前一切正常,点击”立即抢购”按钮时页面正常响应。但就在开抢后第3秒,整个系统突然雪崩——前端页面全部白屏,数据库连接池瞬间耗尽,后端服务器疯狂报警,运维同学连夜爬起来重启服务,最终这场秒杀硬生生中断了15分钟。

事后排查发现,罪魁祸首只有一个:MySQL在秒杀瞬间承受了超过3万QPS的单表查询压力,连接池被打满,主库直接扛不住了。

这不仅仅是那个平台的教训,而是整个电商行业都踩过的坑。今天我把三个真实案例掰开了揉碎了讲给你听,并且把解决方案的代码级细节也铺出来,保证你能直接拿去用。


案例一:千万级DAU平台的”库存超卖+连接耗尽”连环惨剧

事故经过

这是一家日活超过2000万的综合电商平台,2021年618大促期间,他们搞了一场限时秒杀活动。活动商品是热门品牌运动鞋,库存5000件,预计秒杀时段并发峰值达到8万QPS。

系统架构上,他们做了前置的Redis缓存层,理论上缓存能扛住大部分读请求。但问题出在执行层面:

09:59:58  预热阶段:Redis缓存命中率99.2%,一切正常
10:00:00  开抢瞬间:并发请求如潮水般涌来
10:00:02  MySQL主库连接数达到上限(配置的最大连接数3000)
10:00:03  新请求全部无法获取数据库连接,连接池报错
10:00:04  部分请求绕过缓存直接打到MySQL(缓存穿透)
10:00:05  主库CPU使用率飙升至98%,开始拒绝新连接
10:00:08  从库因主从延迟,数据不一致问题暴露
10:00:12  出现库存超卖:原本5000件库存,实际卖出5342件
10:00:15  运维紧急下线主库读写流量,切换至只读模式
10:00:45  重启MySQL后恢复,但已造成大量用户投诉

根因分析

这件事背后其实藏着三个叠加的致命问题,它们像多米诺骨牌一样连锁倒塌:

第一个问题:连接池配置不合理

他们的MySQL连接池配置是这样的:

# Druid连接池配置(事故前的配置)
spring:
  datasource:
    druid:
      initial-size: 50          # 初始连接数太少
      min-idle: 100             # 最小空闲连接太少
      max-active: 3000          # 最大连接数3000,在8万QPS面前杯水车薪
      max-wait: 3000            # 获取连接最大等待时间仅3秒
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 300000

问题很明显:8万QPS意味着每秒钟有8万个请求要处理,即使有缓存层,仍然有大量请求需要读写数据库。3000个连接,每个连接处理一个请求平均耗时100ms,理论最大吞吐量只有30000 QPS,远不足以应对8万QPS的压力。

第二个问题:缓存穿透+缓存雪崩

秒杀商品在开抢前大量预热,但开抢后用户疯狂查询的商品ID(包括大量不存在的ID)穿透到了MySQL:

// 问题代码:没有做缓存穿透防护
public GoodsDTO getGoods(Long goodsId) {
    // 直接从Redis获取
    String cacheKey = "goods:" + goodsId;
    String json = redisTemplate.opsForValue().get(cacheKey);
    if (StringUtils.isNotBlank(json)) {
        return JSON.parseObject(json, GoodsDTO.class);
    }
    // 缓存没命中,直接查数据库
    GoodsDTO goods = goodsMapper.selectById(goodsId);
    if (goods != null) {
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
    }
    return goods;
}

这段代码有两个致命问题:

第一,当缓存中没有数据时,直接查数据库,如果请求的是一个不存在的goodsId,每次都会穿透到MySQL。在秒杀场景下,黑客或恶意脚本会随机生成大量不存在的ID进行查询,造成数据库压力倍增。

第二,缓存雪崩:所有秒杀商品缓存过期时间都是30分钟,如果在同一时刻批量过期,数据库会在瞬间承受所有缓存请求的压力。

第三个问题:库存扣减直接操作数据库

这是最致命的问题。他们的库存扣减逻辑是直接操作MySQL:

// 问题代码:直接数据库操作库存,并发下必然出问题
@Transactional
public boolean deductStock(Long orderId, Long goodsId, int quantity) {
    // 先查库存
    Goods goods = goodsMapper.selectByIdForUpdate(goodsId);
    if (goods.getStock() < quantity) {
        throw new BusinessException("库存不足");
    }
    // 再扣减库存
    int rows = goodsMapper.deductStock(goodsId, quantity);
    if (rows == 0) {
        throw new BusinessException("库存扣减失败,请重试");
    }
    // 创建订单
    Order order = buildOrder(orderId, goodsId, quantity);
    orderMapper.insert(order);
    return true;
}

这里有两个问题:

首先是性能问题SELECT ... FOR UPDATE是行级锁,在高并发下会形成锁竞争,大量事务排队等待锁释放,连接占用时间急剧增加。8万QPS的请求中,只要有一部分走到这里,MySQL的连接数就会瞬间被打满。

其次是超卖问题。虽然用了SELECT ... FOR UPDATE,但在分布式环境下,多个服务实例各自持有连接,如果主从同步延迟导致读到了旧数据,或者锁竞争超时,就会发生超卖。

最终解决方案

事故之后,他们花了一个月时间重构了整个架构,核心改动如下:

1. 分层连接池设计

不再把所有请求打到一个MySQL集群,而是按业务场景拆分:

# 优化后的多数据源配置
spring:
  datasource:
    # 主库:专门处理写操作(库存扣减、订单创建)
    primary:
      druid:
        initial-size: 20
        min-idle: 20
        max-active: 500          # 写库连接数适中,因为写操作本身有限流
        max-wait: 5000
        url: jdbc:mysql://primary-db:3306/ecomm?useSSL=false&serverTimezone=Asia/Shanghai
    # 从库:专门处理读操作(商品信息查询)
    replica:
      druid:
        initial-size: 50
        min-idle: 50
        max-active: 2000          # 读库连接数较大,因为读操作多
        max-wait: 3000
        url: jdbc:mysql://replica-db:3306/ecomm?useSSL=false&serverTimezone=Asia/Shanghai
    # 秒杀专用库:完全隔离,避免影响主业务流程
    seckill:
      druid:
        initial-size: 30
        min-idle: 30
        max-active: 800
        max-wait: 2000
        url: jdbc:mysql://seckill-db:3306/ecomm_seckill?useSSL=false&serverTimezone=Asia/Shanghai

2. 多级缓存+防穿透设计

@Component
public class GoodsCacheService {
    
    private static final Logger log = LoggerFactory.getLogger(GoodsCacheService.class);
    
    @Resource
    private RedisTemplate<String, String> redisTemplate;
    
    @Resource
    private GoodsMapper goodsMapper;
    
    // 布隆过滤器,防止缓存穿透
    private BloomFilter<String> bloomFilter;
    
    // 本地缓存(Caffeine),应对超高热点查询
    private LoadingCache<Long, GoodsDTO> localCache;
    
    @PostConstruct
    public void init() {
        // 布隆过滤器初始化,错误率0.01%
        bloomFilter = BloomFilter.create(
            Funnel.fromString(), 
            1000000,      // 预期插入100万数据
            0.001         // 误判率0.1%
        );
        // 预加载所有秒杀商品ID到布隆过滤器
        List<Long> allGoodsIds = goodsMapper.selectAllSeckillGoodsIds();
        allGoodsIds.forEach(bloomFilter::put);
        
        // Caffeine本地缓存,最大1000条,TTL 10秒
        localCache = CacheBuilder.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.SECONDS)
            .build(new CacheLoader<Long, GoodsDTO>() {
                @Override
                public GoodsDTO load(Long goodsId) {
                    return queryFromDB(goodsId);
                }
            });
    }
    
    /**
     * 查询商品信息,三级缓存兜底
     */
    public GoodsDTO getGoods(Long goodsId) {
        // 第一级:布隆过滤器判断是否存在
        if (!bloomFilter.mightContain(String.valueOf(goodsId))) {
            // 商品不存在,返回空对象缓存,防止穿透
            return EMPTY_GOODS;
        }
        
        // 第二级:本地缓存(Caffeine)
        try {
            return localCache.get(goodsId);
        } catch (ExecutionException e) {
            log.warn("本地缓存加载失败,降级到Redis", e);
        }
        
        // 第三级:Redis缓存
        String cacheKey = "goods:detail:" + goodsId;
        String json = redisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.isNotBlank(json)) {
            return JSON.parseObject(json, GoodsDTO.class);
        }
        
        // 四级:数据库
        GoodsDTO goods = queryFromDB(goodsId);
        if (goods != null) {
            // 设置随机过期时间,防止缓存雪崩
            int expireSeconds = 1800 + new Random().nextInt(300);
            redisTemplate.opsForValue().set(cacheKey, 
                JSON.toJSONString(goods), expireSeconds, TimeUnit.SECONDS);
        } else {
            // 空值也缓存,防止穿透(TTL较短)
            redisTemplate.opsForValue().set(cacheKey, "", 60, TimeUnit.SECONDS);
        }
        return goods;
    }
    
    private GoodsDTO queryFromDB(Long goodsId) {
        GoodsDTO goods = goodsMapper.selectById(goodsId);
        return goods;
    }
}

3. 库存扣减改为Redis预扣减+异步落库

这是整个改造的核心,彻底解决了MySQL库存操作的性能瓶颈:

@Service
public class SeckillService {
    
    private static final Logger log = LoggerFactory.getLogger(SeckillService.class);
    
    @Resource
    private StringRedisTemplate redisTemplate;
    
    @Resource
    private OrderMapper orderMapper;
    
    @Resource
    private GoodsMapper goodsMapper;
    
    private static final String SECKILL_STOCK_KEY = "seckill:stock:";
    private static final String SECKILL_ORDER_KEY = "seckill:order:";
    
    /**
     * 秒杀下单接口
     * 核心思路:Redis预扣减库存 + 消息队列异步落库
     */
    public SeckillResult seckill(Long userId, Long goodsId) {
        // 1. 检查用户是否已下单(防重复提交)
        String orderKey = SECKILL_ORDER_KEY + userId + ":" + goodsId;
        Boolean hasOrder = redisTemplate.opsForValue().setIfAbsent(orderKey, "1", 10, TimeUnit.MINUTES);
        if (!Boolean.TRUE.equals(hasOrder)) {
            return SeckillResult.fail("请勿重复下单");
        }
        
        // 2. Redis预扣减库存(原子操作)
        String stockKey = SECKILL_STOCK_KEY + goodsId;
        Long stock = redisTemplate.opsForValue().decrement(stockKey);
        if (stock < 0) {
            // 库存已扣完,恢复计数
            redisTemplate.opsForValue().increment(stockKey);
            return SeckillResult.fail("秒杀结束");
        }
        
        // 3. 扣减成功,提交异步任务到消息队列,由消费者完成数据库操作
        SeckillMessage message = new SeckillMessage(userId, goodsId);
        try {
            rabbitTemplate.convertAndSend("seckill.exchange", "seckill.routing", message);
            return SeckillResult.success("排队中,请等待");
        } catch (Exception e) {
            // 消息发送失败,回滚库存
            redisTemplate.opsForValue().increment(stockKey);
            log.error("秒杀消息发送失败", e);
            return SeckillResult.fail("系统繁忙,请稍后重试");
        }
    }
    
    /**
     * 消息消费者:异步完成库存扣减和订单创建
     */
    @RabbitListener(queues = "seckill.queue")
    public void handleSeckillMessage(SeckillMessage message) {
        Long userId = message.getUserId();
        Long goodsId = message.getGoodsId();
        
        try {
            // 在MySQL中扣减库存(此时并发已大幅降低)
            int rows = goodsMapper.deductStock(goodsId);
            if (rows == 0) {
                // 库存不足,回滚Redis中的扣减
                String stockKey = SECKILL_STOCK_KEY + goodsId;
                redisTemplate.opsForValue().increment(stockKey);
                log.warn("库存扣减失败,goodsId={}", goodsId);
                return;
            }
            
            // 创建订单
            Order order = new Order();
            order.setUserId(userId);
            order.setGoodsId(goodsId);
            order.setCreateTime(new Date());
            orderMapper.insert(order);
            
            // 发送下单成功通知
            sendSuccessNotification(userId, goodsId, order.getId());
            
        } catch (Exception e) {
            log.error("秒杀订单创建失败,回滚库存", e);
            // 异常时回滚Redis库存
            String stockKey = SECKILL_STOCK_KEY + goodsId;
            redisTemplate.opsForValue().increment(stockKey);
            // 发送补偿消息
            sendCompensateMessage(userId, goodsId);
        }
    }
}

同时,MySQL库存表的扣减也做了优化,用CAS乐观锁替代了行锁:

-- 优化后的库存扣减SQL,使用CAS乐观锁
UPDATE goods 
SET stock = stock - 1, version = version + 1 
WHERE id = #{goodsId} 
  AND stock > 0 
  AND version = #{version};
// Java层配合CAS乐观锁
public boolean deductStockCas(Long goodsId, int version) {
    int rows = goodsMapper.deductStockCas(goodsId, version);
    if (rows > 0) {
        return true;
    }
    // 扣减失败,可能是库存不足或版本号冲突
    Goods goods = goodsMapper.selectById(goodsId);
    throw new BusinessException("库存不足或数据冲突,当前库存:" + goods.getStock());
}

案例二:社交电商的”主从延迟导致用户重复下单”事件

事故经过

这是一家主打社交拼团的电商平台,2022年春节期间的拼团活动中,出现了一个非常诡异的问题:同一用户在同一分钟内多次点击”立即拼团”按钮,每次都显示”下单成功”,但实际上只创建了一个订单,用户却收到了两条短信通知,多次扣款。

运维团队一开始以为是短信网关的问题,排查了整整一个晚上无果。后来技术负责人在日志中发现了一个关键信息:用户在第一次下单后,由于支付页面加载较慢(3-5秒),用户以为没成功,又点击了一次。系统却判断为”下单成功”,创建了重复订单。

根因分析

这个问题的根源是读写分离架构下的主从同步延迟

时间线:
T+0ms   用户点击"立即拼团"
T+10ms  请求到达写库(主库),订单创建成功
T+15ms  用户被重定向到订单详情页
T+20ms  订单详情页请求到达读库(从库)
T+200ms 从库同步主库数据(主从延迟200ms)
T+20ms  读库返回"查无此订单"(因为还没同步过来)
T+25ms  前端页面显示"订单查询失败",用户以为没成功
T+30ms  用户再次点击"立即拼团"
T+35ms  写库再次创建订单...(重复下单)

这是一个典型的读写分离延迟导致的业务逻辑问题。更严重的是,他们的系统没有做任何幂等性保护,完全依赖”先查后插”的模式,一旦查到旧数据,就认为是新请求,直接创建订单。

解决方案

1. 强制关键读操作走主库

对于订单创建后的查询,必须强制读主库,不能用从库:

@Service
public class OrderService {
    
    @Resource
    private OrderMapper orderMapper;
    
    /**
     * 下单后立即查询订单状态,强制读主库
     */
    public OrderDTO createOrderAndQuery(Long userId, Long goodsId) {
        // 第一步:创建订单(写主库)
        Order order = createOrder(userId, goodsId);
        
        // 第二步:强制读主库查询,不使用读写分离
        Order createdOrder = orderMapper.selectMasterById(order.getId());
        
        return convertToDTO(createdOrder);
    }
}

MyBatis层面的主从路由实现:

/**
 * 动态数据源路由,支持强制读主库
 */
public class DynamicDataSource extends AbstractRoutingDataSource {
    
    private static final ThreadLocal<Boolean> FORCE_MASTER = new ThreadLocal<>();
    
    /**
     * 设置强制读主库
     */
    public static void setMaster() {
        FORCE_MASTER.set(true);
    }
    
    /**
     * 清除主库强制标记
     */
    public static void clearMaster() {
        FORCE_MASTER.remove();
    }
    
    @Override
    protected Object determineCurrentLookupKey() {
        // 如果设置了强制读主库,或者在写操作中,走主库
        if (Boolean.TRUE.equals(FORCE_MASTER.get())) {
            return "master";
        }
        // 默认走从库(读操作)
        return "slave";
    }
}

AOP切面自动处理主从路由:

@Aspect
@Component
public class DataSourceRoutingAspect {
    
    /**
     * 所有@Master注解的方法强制读主库
     */
    @Around("@annotation(master)")
    public Object aroundMaster(ProceedingJoinPoint point, Master master) throws Throwable {
        DynamicDataSource.setMaster();
        try {
            return point.proceed();
        } finally {
            DynamicDataSource.clearMaster();
        }
    }
    
    /**
     * 所有写操作方法自动走主库
     */
    @Around("execution(* com.example.dao.*.insert*(..)) || " +
            "execution(* com.example.dao.*.update*(..)) || " +
            "execution(* com.example.dao.*.delete*(..))")
    public Object aroundWriteMethod(ProceedingJoinPoint point) throws Throwable {
        DynamicDataSource.setMaster();
        try {
            return point.proceed();
        } finally {
            DynamicDataSource.clearMaster();
        }
    }
}

2. 全链路幂等性设计

这是解决重复下单的根本方案。每个请求携带唯一业务ID,在Redis中做去重:

@Service
public class SeckillOrderService {
    
    private static final String IDEMPOTENT_KEY_PREFIX = "idempotent:seckill:";
    private static final long IDEMPOTENT_TTL = 24 * 60 * 60; // 24小时
    
    @Resource
    private StringRedisTemplate redisTemplate;
    
    @Resource
    private OrderMapper orderMapper;
    
    /**
     * 幂等性下单
     * @param bizNo 业务唯一号,由调用方生成(建议:userId_goodsId_timestamp)
     */
    public OrderDTO placeOrder(String bizNo, Long userId, Long goodsId) {
        // 1. 幂等性检查:用Redis SET NX实现分布式锁式去重
        String idempotentKey = IDEMPOTENT_KEY_PREFIX + bizNo;
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(idempotentKey, "1", IDEMPOTENT_TTL, TimeUnit.SECONDS);
        
        if (!Boolean.TRUE.equals(success)) {
            // 请求已处理过,查询原有订单返回
            Order existingOrder = orderMapper.selectByBizNo(bizNo);
            if (existingOrder != null) {
                return convertToDTO(existingOrder);
            }
            // 极端情况:幂等标记存在但订单不存在,可能是缓存过期导致
            throw new BusinessException("重复请求,请稍后查询订单状态");
        }
        
        // 2. 创建订单
        Order order = new Order();
        order.setBizNo(bizNo);
        order.setUserId(userId);
        order.setGoodsId(goodsId);
        order.setStatus(OrderStatus.PENDING_PAYMENT);
        order.setCreateTime(new Date());
        orderMapper.insert(order);
        
        // 3. 扣减库存(Redis预扣减已在上一层完成)
        
        return convertToDTO(order);
    }
}

调用方生成业务唯一号的方式:

// 前端请求时携带的唯一标识
String bizNo = userId + "_" + goodsId + "_" + System.currentTimeMillis();

// 或者用UUID(更严格但性能略差)
// String bizNo = UUID.randomUUID().toString().replace("-", "");

3. 主从延迟监控

为了及时发现主从同步延迟问题,他们加了监控:

@Component
public class MasterSlaveLatencyMonitor {
    
    private static final Logger log = LoggerFactory.getLogger(MasterSlaveLatencyMonitor.class);
    
    @Resource
    private DataSource dataSource;
    
    /**
     * 每分钟检测一次主从延迟
     */
    @Scheduled(fixedRate = 60000)
    public void checkReplicationLag() {
        try {
            Connection conn = dataSource.getConnection();
            Statement stmt = conn.createStatement();
            
            // 查询主从延迟(秒)
            ResultSet rs = stmt.executeQuery("SHOW SLAVE STATUS");
            if (rs.next()) {
                long lag = rs.getLong("Seconds_Behind_Master");
                if (lag > 5) {
                    log.warn("主从同步延迟异常:{}秒", lag);
                    // 发送告警
                    alertService.sendAlert("主从同步延迟过高", String.valueOf(lag));
                }
            }
            
            rs.close();
            stmt.close();
            conn.close();
        } catch (Exception e) {
            log.error("主从延迟检测失败", e);
        }
    }
}

案例三:视频电商的”慢查询拖垮整个集群”事件

事故经过

这是一家新兴的视频电商平台,2023年直播带货期间,他们在直播间推广一款新品。主播说”上车”后,流量在30秒内增长了50倍。系统监控显示,MySQL的CPU使用率从平时的15%瞬间飙升至100%,所有请求全部超时。

初步排查发现,有一张表的数据量已经涨到了2.3亿条,但相关的SQL查询却带着ORDER BY create_time DESC LIMIT 20这样的排序分页查询。在2.3亿数据的表上,这样的查询在没有任何覆盖索引的情况下,需要扫描大量数据才能返回结果。

更糟糕的是,主库在执行这个慢查询期间,其他所有请求都被阻塞了。这就是著名的“慢查询锁死整库”现象。

根因分析

这个问题的本质是索引缺失+大表查询+主库负担过重三者的叠加:

1. 慢查询分析

那条要命的SQL大概是这样的:

-- 问题SQL:在2.3亿数据的表上全表扫描排序
SELECT id, user_id, goods_id, price, create_time, status
FROM order_large_table
WHERE user_id = 123456
ORDER BY create_time DESC
LIMIT 0, 20;

在2.3亿数据量的表上,即使有user_id的索引,排序操作仍然需要大量的临时表和文件排序(Filesort),这会消耗大量的CPU和I/O资源,并且会占用大量的连接资源。

2. 连接池被慢查询耗尽

当这个慢查询被执行时,它占用的数据库连接会持续很长时间(可能达到数秒)。在连接池中,如果多个慢查询同时执行,连接会迅速被占满,新请求无法获取连接,最终导致整个系统不可用。

慢查询执行时间线:
T+0ms    慢查询开始执行,占用1个连接
T+500ms  又有10个类似的慢查询同时开始
T+1000ms 慢查询仍未完成,连接占用时间持续增加
T+2000ms 连接池3000个连接中已有2800+被慢查询占用
T+2500ms 新请求无法获取连接,全部报错
T+3000ms 慢查询终于完成,但连接池已耗尽,系统雪崩

解决方案

1. 表分区分表

首先对大表进行分表,将2.3亿数据分散到多个小表中:

-- 方案一:按user_id哈希分表(16张表)
CREATE TABLE order_00 (
    id BIGINT NOT NULL AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    goods_id BIGINT NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    create_time DATETIME NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    INDEX idx_user_id (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE order_01 (...) ... -- 同理创建15张

-- 分表规则:order_no % 16
-- 查询时:先计算分表,再路由到对应表
/**
 * 分表路由工具
 */
@Component
public class ShardingUtil {
    
    private static final int TABLE_COUNT = 16;
    
    /**
     * 根据user_id计算分表索引
     */
    public int getTableIndex(Long userId) {
        return Math.abs((int)(userId % TABLE_COUNT));
    }
    
    /**
     * 获取表名
     */
    public String getTableName(Long userId) {
        return "order_" + String.format("%02d", getTableIndex(userId));
    }
}

2. 深度分页优化

对于大表的排序分页查询,不能用传统的LIMIT offset, size,而要用游标分页:

/**
 * 游标分页查询,避免大偏移量的性能问题
 */
public PageResult<OrderDTO> queryOrdersByUserId(Long userId, Long lastId, int size) {
    String tableName = shardingUtil.getTableName(userId);
    
    // 游标分页:基于上一次的最后一条记录的ID
    // 这种方式不受OFFSET大小的影响,性能恒定
    String sql = "SELECT id, user_id, goods_id, price, create_time, status " +
                 "FROM " + tableName + " " +
                 "WHERE user_id = ? AND id < ? " +
                 "ORDER BY create_time DESC, id DESC " +
                 "LIMIT ?";
    
    List<Order> orders = jdbcTemplate.query(sql, 
        new BeanPropertyRowMapper<>(Order.class),
        userId, lastId, size
    );
    
    boolean hasMore = orders.size() == size;
    Long nextCursor = hasMore ? orders.get(orders.size() - 1).getId() : null;
    
    return new PageResult<>(
        orders.stream().map(this::convertToDTO).collect(Collectors.toList()),
        hasMore,
        nextCursor
    );
}

前端调用示例:

// 第一次加载
loadOrders(null, 20);

// 加载更多
function loadMore() {
    if (!hasMore) return;
    loadOrders(lastId, 20);
}

// 注意:不用offset,用游标(lastId)
// 无论翻了多少页,查询性能都保持一致

3. 读写分离+查询降级

对于直播场景,订单查询可以有一定的延迟,允许读从库的延迟数据:

@Service
public class OrderQueryService {
    
    @Resource
    private OrderMapper orderMapper;
    
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 查询用户订单列表
     * 优先读Redis缓存,缓存未命中读从库
     */
    public PageResult<OrderDTO> queryOrders(Long userId, Long lastId, int size) {
        // 1. 先查缓存(直播场景允许少量延迟)
        String cacheKey = "order:list:" + userId + ":" + (lastId != null ? lastId : "init") + ":" + size;
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return (PageResult<OrderDTO>) cached;
        }
        
        // 2. 缓存未命中,读从库(允许有延迟)
        PageResult<OrderDTO> result = orderMapper.queryOrdersFromSlave(userId, lastId, size);
        
        // 3. 写入缓存,TTL 30秒(允许30秒内的数据延迟)
        redisTemplate.opsForValue().set(cacheKey, result, 30, TimeUnit.SECONDS);
        
        return result;
    }
    
    /**
     * 订单创建后,主动清除缓存
     */
    public void invalidateOrderCache(Long userId) {
        // 清除该用户的所有订单列表缓存
        Set<String> keys = redisTemplate.keys("order:list:" + userId + ":*");
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
    }
}

4. 慢查询自动熔断

当检测到慢查询导致连接池压力过大时,自动熔断查询请求,避免拖垮整个数据库:

@Component
public class SlowQueryCircuitBreaker {
    
    private static final Logger log = LoggerFactory.getLogger(SlowQueryCircuitBreaker.class);
    
    @Resource
    private DataSource dataSource;
    
    // 熔断阈值配置
    private static final int MAX_SLOW_QUERIES = 50;      // 最大慢查询数
    private static final int MAX_ACTIVE_CONNECTIONS = 2500; // 最大活跃连接数阈值
    private static final long SLOW_QUERY_THRESHOLD_MS = 2000; // 慢查询阈值(毫秒)
    
    // 滑动窗口计数器
    private final AtomicInteger slowQueryCount = new AtomicInteger(0);
    private final ConcurrentHashMap<Long, Long> slowQueryTimestamps = new ConcurrentHashMap<>();
    
    /**
     * 检查是否应该熔断
     */
    public boolean shouldCircuitBreak() {
        // 条件1:慢查询数量超过阈值
        if (slowQueryCount.get() > MAX_SLOW_QUERIES) {
            return true;
        }
        
        // 条件2:活跃连接数超过阈值
        try {
            Connection conn = dataSource.getConnection();
            Statement stmt = conn.createStatement();
            ResultSet rs = stmt.executeQuery("SHOW STATUS LIKE 'Threads_connected'");
            if (rs.next()) {
                int connected = Integer.parseInt(rs.getString("Value"));
                if (connected > MAX_ACTIVE_CONNECTIONS) {
                    log.warn("活跃连接数过高:{},触发熔断", connected);
                    return true;
                }
            }
            rs.close();
            stmt.close();
            conn.close();
        } catch (Exception e) {
            log.error("连接数检测失败", e);
        }
        
        return false;
    }
    
    /**
     * 记录慢查询
     */
    public void recordSlowQuery() {
        slowQueryCount.incrementAndGet();
        slowQueryTimestamps.put(System.currentTimeMillis(), 1L);
        
        // 5分钟后清理过期记录
        long fiveMinutesAgo = System.currentTimeMillis() - 5 * 60 * 1000;
        slowQueryTimestamps.entrySet().removeIf(
            entry -> entry.getKey() < fiveMinutesAgo
        );
    }
    
    /**
     * 熔断恢复后重置计数
     */
    public void resetAfterRecovery() {
        slowQueryCount.set(0);
        slowQueryTimestamps.clear();
        log.info("熔断计数器已重置");
    }
}

AOP拦截慢查询并自动熔断:

@Aspect
@Component
public class SlowQueryAspect {
    
    private static final Logger log = LoggerFactory.getLogger(SlowQueryAspect.class);
    
    @Resource
    private SlowQueryCircuitBreaker circuitBreaker;
    
    @Around("execution(* com.example.dao.*.*(..))")
    public Object aroundQuery(ProceedingJoinPoint point) throws Throwable {
        long startTime = System.currentTimeMillis();
        
        try {
            // 检查是否需要熔断
            if (circuitBreaker.shouldCircuitBreak()) {
                log.warn("熔断触发,拒绝执行查询:{}", point.getSignature().getName());
                throw new BusinessException("系统繁忙,请稍后重试");
            }
            
            Object result = point.proceed();
            
            long costTime = System.currentTimeMillis() - startTime;
            if (costTime > 2000) {
                // 记录慢查询
                circuitBreaker.recordSlowQuery();
                log.warn("慢查询 detected:method={}, cost={}ms", 
                    point.getSignature().getName(), costTime);
            }
            
            return result;
            
        } catch (Throwable t) {
            long costTime = System.currentTimeMillis() - startTime;
            if (costTime > 2000) {
                circuitBreaker.recordSlowQuery();
            }
            throw t;
        }
    }
}

连接池优化的黄金法则

上面三个案例都用到了连接池优化,这里单独展开讲讲连接池调优的核心要点。

连接池核心参数调优

# 生产环境推荐配置(基于HikariCP,Spring Boot默认)
spring:
  datasource:
    hikari:
      # 连接池最小空闲连接数,建议设置为max-active的30%-50%
      minimum-idle: 100
      # 连接池最大连接数,需要根据业务QPS和平均响应时间计算
      # 公式:max-active = QPS * 平均响应时间(秒) * 1.5
      # 例如:1000 QPS * 0.1秒 * 1.5 = 150
      maximum-pool-size: 200
      # 连接空闲超时时间,建议30分钟
      idle-timeout: 1800000
      # 连接最大生命周期,建议30分钟(防止连接泄漏)
      max-lifetime: 1800000
      # 获取连接超时时间,建议5秒
      connection-timeout: 5000
      # 连接测试查询
      connection-test-query: SELECT 1
      # 自动提交
      auto-commit: true
      # 连接池名称
      pool-name: HikariPool-Ecomm

连接池大小的科学计算方法

很多团队凭经验设置连接池大小,这是不科学的。正确的做法是根据业务QPS平均响应时间来计算:

理论最大吞吐量 = 连接数 / 平均响应时间(秒)

所以:连接数 = QPS × 平均响应时间(秒)

考虑并发和突发因素,乘以安全系数1.5:
max-active = QPS × 平均响应时间 × 1.5

举例:

场景A:秒杀读操作
- QPS: 50000
- 平均响应时间: 50ms (0.05秒,主要是缓存命中+少量DB查询)
- max-active = 50000 × 0.05 × 1.5 = 3750

场景B:订单创建写操作
- QPS: 5000
- 平均响应时间: 200ms (0.2秒,涉及事务+索引写入)
- max-active = 5000 × 0.2 × 1.5 = 1500

场景C:普通商品查询
- QPS: 10000
- 平均响应时间: 30ms (0.03秒,缓存命中率80%)
- max-active = 10000 × 0.03 × 1.5 = 450

根据这个计算结果,就可以给不同的数据源配置合理的连接池大小,而不是盲目地设置一个很大的值。

连接泄漏检测

连接泄漏是连接池问题中最难排查的问题之一。一定要开启连接泄漏检测:

spring:
  datasource:
    hikari:
      # 开启泄漏检测,超过5分钟未归还的连接会被记录并强制关闭
      leak-detection-threshold: 300000  # 5分钟
      # 或者使用Druid
      druid:
        # 开启SQL防火墙
        wall:
          enabled: true
        # 开启监控统计
        stat:
          enabled: true
          slow-sql-millis: 2000  # 慢SQL阈值

实战总结:高并发MySQL架构的完整防护体系

回顾这三个案例,你会发现它们虽然有各自的具体问题,但背后的核心逻辑是相通的:高并发场景下,MySQL绝对不是扛QPS的主力,它应该是最后一道防线。

一个完整的高并发防护体系应该包含以下四层:

┌─────────────────────────────────────────────────────────────┐
│  第一层:CDN + 静态资源缓存(抗住90%的读请求)                  │
│  第二层:Redis多级缓存(本地缓存+分布式缓存,抗住80%的读写)     │
│  第三层:消息队列削峰填谷(把瞬时高峰拉长到平缓的曲线)           │
│  第四层:MySQL(只处理最终一致性数据,QPS控制在合理范围内)       │
└─────────────────────────────────────────────────────────────┘

具体到每个环节的最佳实践:

缓存层:

  • 本地缓存(Caffeine/Guava)处理热点数据,减少Redis压力
  • Redis多级缓存,设置随机过期时间防止雪崩
  • 布隆过滤器防止缓存穿透
  • 空值缓存防止热点Key过期时的穿透

读写分离:

  • 写操作强制走主库
  • 关键读操作(如订单查询)强制走主库或缓存
  • 非关键读操作走从库,允许一定延迟
  • 实时监控主从同步延迟

连接池:

  • 根据QPS和响应时间科学计算连接数
  • 开启泄漏检测和慢查询监控
  • 设置合理的超时时间,避免无限等待

分库分表:

  • 大表及时分片,避免单表数据量过大
  • 深度分页用游标分页替代OFFSET
  • 分表键选择要均匀分布,避免热点

幂等性:

  • 所有写操作都必须有幂等性保障
  • 用Redis SET NX或数据库唯一索引实现
  • 业务唯一号由调用方生成,服务端不做重复校验

这几个案例都是真实发生过的事故,其中的每个细节都凝结了团队的教训。如果你正在构建电商或者高并发的系统,希望这些经验能帮你在设计阶段就避开这些坑。

最后说一句:架构设计没有银弹,最适合你业务的方案才是最好的方案。不要盲目照搬,要结合自己的流量模型、数据规模和团队能力来选择合适的技术栈。有任何具体问题,随时交流。