某互联网大厂数据库雪崩事故深度复盘 百万并发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的峰值流量,大促期间零事故。那次雪崩事故虽然痛苦,但确实让我们团队的技术水平上了一个台阶。
如果你也在构建高并发系统,希望这些经验能帮到你。有什么具体问题,欢迎评论区交流。
