某互联网大厂数据库雪崩事故深度复盘 百万并发MySQL高并发处理架构优化实战经验总结

那天是2023年6月18号下午3点,我们公司电商大促活动的流量高峰时段。

突然之间,监控系统疯狂报警,值班人员连滚带爬地冲进会议室,后面跟着一排脸色发白的DBA和后端开发。大屏上,MySQL的QPS曲线从平时的5000直接飙到了80万,然后整个系统像被抽走了灵魂一样,全线瘫痪。

整整27分钟,没有一笔订单能下单成功。

后来复盘发现,这场事故的背后,是一个看起来”人畜无害”的代码改动引发的连锁反应。今天我就把这次事故的前因后果、技术细节、以及后来我们是怎么一步步优化架构的经验,毫无保留地分享出来。

事故的”导火索”

事情起源于一个很普通的代码需求——给商品详情页加一个”浏览次数”字段。

最初的需求只是:用户每次打开商品页,浏览次数+1,存到数据库。开发同学写了一段代码,大概长这样:

// 问题代码:每次页面访问都直接写库
public void increaseViewCount(Long productId) {
    String sql = "UPDATE product_view_count SET view_count = view_count + 1 
                  WHERE product_id = ?";
    jdbcTemplate.update(sql, productId);
}

这段代码本身看起来没问题,逻辑也很清晰。问题出在实际运行时的流量规模上。

那天是大促首日,某个头部主播在直播间推荐了这款产品,单小时流量峰值达到了平时的50倍。而这段代码,每一次HTTP请求都会触发一次数据库写操作

按照当时的流量模型:

指标 平时 事故当日峰值
QPS 5,000 800,000+
数据库写入QPS 500 800,000+
单条SQL平均耗时 2ms 350ms+
活跃连接数 200 5,000+(打满)

一个看似无害的”写一行数据”,在百万级并发面前,变成了压垮骆驼的最后一根稻草。

雪崩是怎么发生的

第一阶段:连接池被耗尽

MySQL默认的连接数配置是151个(这个默认值在业界经常被吐槽)。当80万QPS同时涌入,每个请求都要建立数据库连接,连接池在3秒内被打满。

# 事故后的连接池配置对比
# 事故前(错误配置)
spring:
  datasource:
    hikari:
      maximum-pool-size: 10        # 只有10个连接!
      minimum-idle: 5
      connection-timeout: 30000    # 连接超时30秒

# 事故后(优化配置)
spring:
  datasource:
    hikari:
      maximum-pool-size: 200       # 增加到200
      minimum-idle: 50
      connection-timeout: 5000    # 缩短到5秒,快速失败
      max-lifetime: 1800000       # 连接最大存活30分钟
      idle-timeout: 60000         # 空闲连接1分钟回收

连接池打满后,新的请求开始在应用层排队。但排队本身又带来了新问题。

第二阶段:慢查询扩散

被阻塞的请求占用了应用服务器的线程资源,线程池也很快耗尽。这时候,一些原本正常的查询也开始变慢——因为应用服务器的CPU被大量处于”等待数据库响应”状态的线程占满了。

时间线(事故第0-27分钟):

0:00  流量开始激增,普通查询开始变慢
0:47  连接池打满,新请求全部等待
2:15  应用服务器线程池耗尽,开始报错
3:00  第一个服务实例OOM(内存溢出)
5:30  级联故障扩散到第二个实例
8:00  整个商品服务集群宕机
12:00 数据库CPU使用率100%,所有查询全部超时
27:00 运维团队手动重启所有服务,系统恢复

第三阶段:缓存击穿放大问题

这次事故还有一个关键因素:Redis缓存被”击穿”了。

浏览次数这个字段,本来是有缓存的。但由于这次流量暴增,大量请求同时miss缓存,然后同时去请求数据库。这就是经典的”缓存击穿”问题。

// 事故前:存在缓存击穿风险
public int getAndIncreaseViewCount(Long productId) {
    // 1. 先查缓存
    Integer count = redisTemplate.opsForValue().get("view_count:" + productId);
    
    // 2. 缓存没有?直接去查数据库
    if (count == null) {
        count = dbService.getViewCount(productId);  // 大量请求同时到这里
        // 然后写数据库
        dbService.increaseViewCount(productId);
        redisTemplate.opsForValue().set("view_count:" + productId, count, 30, TimeUnit.MINUTES);
    }
    
    return count;
}

多个请求同时穿过缓存,同时打到数据库,放大了问题的严重性。

事故后的架构优化实战

这次事故之后,我们花了整整三个月,对整条架构链路做了系统性的重构。以下是我们实际落地的一些方案,每一条都是血泪换来的经验。

一、读写分离 + 分库分表

首先解决的是”所有写请求打到一台数据库”的问题。

读写分离是最基础的一步。我们将写操作和读操作分离到不同的实例上,读请求走从库,写请求走主库。

# 读写分离配置示例
spring:
  datasource:
    dynamic:
      datasource:
        master:
          url: jdbc:mysql://10.0.1.10:3306/shop_db?useSSL=false
          username: root
          password: ${DB_PASSWORD}
        slave_1:
          url: jdbc:mysql://10.0.1.11:3306/shop_db?useSSL=false
          username: root
          password: ${DB_PASSWORD}
        slave_2:
          url: jdbc:mysql://10.0.1.12:3306/shop_db?useSSL=false
          username: root
          password: ${DB_PASSWORD}
      # 写操作走master,读操作负载均衡到slave
      p6spy: true
      hikari:
        maximum-pool-size: 100

但读写分离还不够。我们的商品表数据量已经达到了单表2亿行的级别,即便是读请求,单表查询的性能也急剧下降。

所以我们引入了ShardingSphere做分库分表

// 分库分表策略配置
@Bean
public ShardingSphereDataSource shardingSphereDataSource() {
    // 按product_id取模分16个库,每个库64张表
    ShardingSphereRuleConfiguration config = new ShardingSphereRuleConfiguration();
    
    // 分片策略
    Map<String, Object> shardingProps = new HashMap<>();
    shardingProps.put("product_id", "product_id % 16");  // 16个库
    shardingProps.put("view_count", "view_count % 64");   // 64张表
    
    // 数据源分布
    List<String> masterDatasources = Arrays.asList(
        "ds_0", "ds_1", "ds_2", "ds_3", "ds_4", "ds_5", 
        "ds_6", "ds_7", "ds_8", "ds_9", "ds_10", "ds_11",
        "ds_12", "ds_13", "ds_14", "ds_15"
    );
    
    config.setShardingRules(buildShardingRules(shardingProps, masterDatasources));
    return new ShardingSphereDataSource(config);
}

分库分表之后,单个数据库实例的写入压力从80万QPS降到了约12,500 QPS,这是完全可以承受的范围。

二、浏览次数:从”写数据库”到”写消息队列”

这是本次事故最核心的优化点。

浏览次数本质上是一个统计型数据,对实时一致性要求并不高。 我们完全可以不用每次请求都写数据库,而是通过消息队列异步批量写入。

// 优化后的浏览次数更新方案
@Component
public class ViewCountService {
    
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 浏览次数:先更新Redis,异步批量写入MySQL
     * 采用Redis原子操作 + 定时批量同步策略
     */
    public void increaseViewCount(Long productId) {
        // 1. Redis做原子计数(INC命令是原子的,性能极高)
        String cacheKey = "view_count:" + productId;
        redisTemplate.opsForValue().increment(cacheKey, 1);
        
        // 2. 发送消息到Kafka,由消费者异步写库
        // 这里用延迟队列,避免瞬时流量打爆DB
        ViewCountMessage message = new ViewCountMessage(productId, System.currentTimeMillis());
        kafkaTemplate.send("view-count-topic", String.valueOf(productId), 
                           JSON.toJSONString(message));
    }
    
    /**
     * Redis预热:服务启动时从DB加载浏览次数到Redis
     */
    @PostConstruct
    public void warmupViewCountCache() {
        List<Product> products = productMapper.selectAllActiveProducts();
        for (Product product : products) {
            String cacheKey = "view_count:" + product.getProductId();
            redisTemplate.opsForValue().set(cacheKey, 
                String.valueOf(product.getViewCount()), 
                2, TimeUnit.HOURS);
        }
    }
}

// Kafka消费者:批量写入数据库
@Component
public class ViewCountConsumer {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    /**
     * 批量写入策略:每500条或每2秒,执行一次批量INSERT
     * 这样即使Kafka收到80万条消息,实际写库只有约1600次批量操作
     */
    @KafkaListener(topics = "view-count-topic", groupId = "view-count-group")
    public void consume(String message) {
        batchBuffer.add(JSON.parseObject(message, ViewCountMessage.class));
        
        // 达到批量阈值或超时,执行写入
        if (batchBuffer.size() >= 500 || 
            System.currentTimeMillis() - lastWriteTime > 2000) {
            flushToDatabase();
        }
    }
    
    private void flushToDatabase() {
        if (batchBuffer.isEmpty()) return;
        
        // 批量UPSERT:INSERT ... ON DUPLICATE KEY UPDATE
        String sql = "INSERT INTO product_view_count (product_id, view_count, updated_at) " +
                     "VALUES (?, ?, NOW()) " +
                     "ON DUPLICATE KEY UPDATE view_count = view_count + VALUES(view_count)";
        
        List<Object[]> batchArgs = batchBuffer.stream()
            .map(msg -> new Object[]{msg.getProductId(), 1L})
            .collect(Collectors.toList());
        
        jdbcTemplate.batchUpdate(sql, batchArgs);
        batchBuffer.clear();
        lastWriteTime = System.currentTimeMillis();
        
        log.info("批量写入浏览次数: {} 条", batchArgs.size());
    }
}

这个方案的核心思想是:用Redis的原子操作承接瞬时高并发,用Kafka做流量削峰,用批量写入降低数据库压力。最终数据库的写入QPS从80万降到了个位数级别的批量操作。

三、缓存架构的全面升级

除了浏览次数,整个缓存体系我们也做了一波重构。

分级缓存策略

第一层:本地缓存(Caffeine)
  └─ 热点数据:TTL 30秒,最大10,000条
  └─ 命中率目标:80%+

第二层:分布式缓存(Redis Cluster)
  └─ 普通热点数据:TTL 5分钟
  └─ 大促商品:延长到30分钟

第三层:数据库
  └─ 最终数据来源
  └─ 写穿透:所有写操作先更新缓存,再异步刷DB
// 多级缓存实现
@Component
public class MultiLevelCacheService {
    
    // 第一层:本地缓存(应用节点内)
    private final Cache<String, Object> localCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(30, TimeUnit.SECONDS)
        .build();
    
    // 第二层:Redis缓存
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 获取商品详情:先本地缓存 -> 再Redis -> 最后DB
     */
    public Product getProductDetail(Long productId) {
        String key = "product:" + productId;
        
        // 1. 查本地缓存
        Object local = localCache.getIfPresent(key);
        if (local != null) {
            return (Product) local;
        }
        
        // 2. 查Redis
        Object redis = redisTemplate.opsForValue().get(key);
        if (redis != null) {
            localCache.put(key, redis);  // 回种到本地缓存
            return (Product) redis;
        }
        
        // 3. 查数据库(缓存穿透保护:空值也缓存)
        Product product = productMapper.selectById(productId);
        if (product != null) {
            redisTemplate.opsForValue().set(key, product, 5, TimeUnit.MINUTES);
            localCache.put(key, product);
        } else {
            // 缓存空值,防止恶意攻击导致穿透
            redisTemplate.opsForValue().set(key, "{}", 60, TimeUnit.SECONDS);
        }
        
        return product;
    }
    
    /**
     * 缓存预热:提前加载大促商品到缓存
     */
    public void preheatPromotionProducts(List<Long> productIds) {
        List<Product> products = productMapper.selectByIds(productIds);
        for (Product product : products) {
            String key = "product:" + product.getProductId();
            // 本地缓存
            localCache.put(key, product);
            // Redis缓存(大促期间延长TTL)
            redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
        }
        log.info("大促商品缓存预热完成: {} 个", products.size());
    }
}

四、限流与熔断:给系统装上”保险丝”

光有缓存和异步还不够,我们还需要在入口层做限流,在故障发生时做熔断降级

令牌桶限流算法

// 基于Redis + Lua的分布式令牌桶限流
@Component
public class RateLimiterService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 令牌桶限流:每个product_id独立限流
     * 
     * @param productId 商品ID
     * @param permits  允许的速率(每秒多少请求)
     * @return true=允许通过,false=限流
     */
    public boolean tryAcquire(Long productId, int permits) {
        String key = "rate_limit:" + productId;
        
        // Lua脚本保证原子性
        String luaScript = 
            "local capacity = tonumber(redis.call('GET', KEYS[1] .. ':capacity')) or 100\n" +
            "local tokens = tonumber(redis.call('GET', KEYS[1] .. ':tokens')) or capacity\n" +
            "local now = tonumber(redis.call('GET', KEYS[1] .. ':now')) or 0\n" +
            "local interval = 1000000  -- 1秒的微秒数\n" +
            "local current = redis.call('TIME')\n" +
            "local timestamp = current[1] * 1000000 + current[2]\n" +
            "local delta = (timestamp - now) / 1000000\n" +
            "tokens = math.min(capacity, tokens + delta * tonumber(ARGV[1]))\n" +
            "if tokens >= 1 then\n" +
            "  tokens = tokens - 1\n" +
            "  redis.call('SET', KEYS[1] .. ':tokens', tokens)\n" +
            "  redis.call('SET', KEYS[1] .. ':now', timestamp)\n" +
            "  return 1\n" +
            "else\n" +
            "  return 0\n" +
            "end";
        
        Long result = (Long) redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(key),
            String.valueOf(permits)
        );
        
        return Boolean.TRUE.equals(result);
    }
}

熔断降级(基于Resilience4j):

// 熔断器配置:数据库故障时自动降级
@Configuration
public class CircuitBreakerConfig {
    
    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        // 熔断器配置
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
            .failureRateThreshold(50)           // 失败率超过50%触发熔断
            .waitDurationInOpenState(Duration.ofSeconds(30))  // 熔断30秒后半开
            .permittedNumberOfCallsInHalfOpenState(5)         // 半开状态允许5个请求测试
            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(20)
            .build();
        
        return CircuitBreakerRegistry.of(config);
    }
    
    /**
     * 带熔断保护的商品查询服务
     */
    @CircuitBreaker(name = "productService", fallbackMethod = "getProductFallback")
    @Retry(name = "productService")
    public Product getProductWithCircuitBreaker(Long productId) {
        return productMapper.selectById(productId);
    }
    
    /**
     * 降级策略:数据库不可用时,返回缓存数据或默认值
     */
    public Product getProductFallback(Long productId, Exception e) {
        log.warn("数据库熔断,使用降级策略查询商品: productId={}", productId, e);
        
        // 尝试从缓存获取
        String cacheKey = "product:" + productId;
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return (Product) cached;
        }
        
        // 缓存也没有,返回默认值
        return buildDefaultProduct(productId);
    }
}

五、监控与告警:让问题”看得见”

最后,我们建立了一套完整的监控体系,确保下次有问题能在第一时间发现。

# Prometheus + Grafana 监控配置
# 关键指标:

# 1. 数据库层面
mysql_global_status_threads_connected    # 当前连接数
mysql_global_status_threads_running      # 活跃连接数
mysql_global_status_questions            # QPS
mysql_global_status_slow_queries         # 慢查询数
mysql_innodb_row_lock_time_avg           # 行锁平均等待时间

# 2. 应用层面
jvm_threads_live                         # 应用线程数
hikaricp_connections_active              # 连接池活跃连接
hikaricp_connections_idle                # 连接池空闲连接
http_server_requests_seconds             # 接口响应时间P99

# 3. 缓存层面
redis_keyspace_hits                      # 缓存命中数
redis_keyspace_misses                    # 缓存未命中数
redis_connected_clients                  # 客户端连接数
redis_used_memory_peak                   # 内存峰值

# 告警规则
groups:
  - name: database_alerts
    rules:
      - alert: MySQL连接数过高
        expr: mysql_global_status_threads_connected > 80% of max_connections
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "MySQL连接数达到{{ $value }},超过阈值80%"
          
      - alert: 数据库慢查询激增
        expr: rate(mysql_global_status_slow_queries[5m]) > 10
        for: 2m
        labels:
          severity: critical
          
      - alert: 缓存命中率过低
        expr: redis_hits / (redis_hits + redis_misses) < 0.7
        for: 5m
        labels:
          severity: warning

Grafana大盘我们做了三层视图:

  • 总览层:QPS、RT、错误率三大核心指标
  • 组件层:MySQL、Redis、Kafka各自的健康状况
  • 链路层:每个接口的调用链追踪(基于SkyWalking)

给开发者的三条血泪建议

事故过去半年了,回头看,有一些东西想分享给大家:

第一,永远不要低估流量的膨胀速度。 那次事故前,我们的设计容量是按照”日常峰值的3倍”来评估的,但主播带货的流量是”日常的50倍”。在设计容量时,宁可过度预估,也不要抱有侥幸心理。

第二,”写数据库”这个动作,永远不要放在热路径上。 浏览次数、点赞数、收藏数这些统计型数据,本质上不需要实时落库。用Redis + 消息队列异步处理,性能可以提升几个数量级。如果业务确实需要实时性,也要考虑批量写入和分片策略。

第三,熔断和限流不是”可选项”,是”必选项”。 任何系统设计都要假设”最坏情况会发生”。当数据库扛不住的时候,系统应该能够”优雅地失败”——返回缓存数据、返回默认值、或者直接告诉用户”稍后再试”,而不是让整个系统崩溃。


现在我们的系统已经能够稳定支撑每秒200万QPS的峰值流量,大促期间零事故。那次雪崩事故虽然痛苦,但确实让我们团队的技术水平上了一个台阶。

如果你也在构建高并发系统,希望这些经验能帮到你。有什么具体问题,欢迎评论区交流。