电商大促并发10万订单MySQL崩溃怎么办连接数爆满查询卡死锁表怎么解决缓存失效雪崩如何应对
说实话,这是我见过的最让人头疼的场景之一。去年双11,我朋友的老张做电商,凌晨三点他突然发来一条消息:”完了,MySQL炸了。”我点开他的监控截图,连接数直接飙到5000+,查询队列排队排到了天际,缓存那边更是全面雪崩。那一晚,他们团队所有人都没睡觉。今天我就把这个血泪教训掰开揉碎了讲给你听,不只是给你方案,还要让你真正理解为什么会这样。
先搞清楚敌人是谁:10万并发订单到底意味着什么
你得先有个概念。10万订单不是一下子砸下来的,但大促峰值往往会在某几分钟内集中爆发。想象一下,正常情况下你的数据库每秒钟处理几十笔查询已经很轻松了,但大促期间可能每一秒要扛住上千甚至上万笔查询。
MySQL的默认配置是给普通业务设计的,不是给这种级别的流量准备的。它的连接数默认才151个,每个连接都要占用内存、CPU资源,还有那个著名的max_connections限制,一旦超标,新连接直接报Too many connections错误。你的用户那边就会看到各种奇怪的报错,然后疯狂下单失败,退款投诉蜂拥而至。
连接数爆满:不是简单的”调大参数”就完事了
很多新手一看到连接数满了,第一反应是把max_connections调到10000。听起来很爽,但实际上这是在玩火。为什么?因为每个MySQL连接在建立的时候都要分配内存,包括读缓冲区、写缓冲区、排序缓冲区等等。你可以简单理解成每个连接都是一个”小工人”,工人越多,需要的工具间(内存)就越大。如果你的服务器只有32G内存,硬开10000个连接,可能连接还没开始干活,内存就已经OOM了。
老张他们的服务器是64G内存,当时max_connections被临时调到8000,结果不到十分钟,内存直接打满,MySQL自己把自己kill掉了。
正确的做法是什么?
第一,用连接池。连接池的核心思想是”借出去的工具要还回来”。你不能每个请求都创建一个数据库连接,而是维护一个连接池,让多个请求复用有限的连接。在Java生态里,HikariCP是目前公认性能最好的连接池,配置起来也很简单:
// HikariCP连接池配置示例
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://your-db-host:3306/ecommerce");
config.setUsername("your_user");
config.setPassword("your_password");
// 关键配置项
config.setMaximumPoolSize(50); // 核心:池子里最多50个连接,不要贪多
config.setMinimumIdle(10); // 空闲时保持10个连接
config.setIdleTimeout(30000); // 空闲连接30秒后回收
config.setMaxLifetime(1800000); // 连接最长存活30分钟
config.setConnectionTimeout(30000); // 获取连接超时30秒
config.setLeakDetectionThreshold(60000); // 60秒未归还连接视为泄漏
DataSource dataSource = new HikariDataSource(config);
注意看maximumPoolSize,这里我设的50,不是500。为什么这么保守?因为数据库能同时高效处理的连接数是有限的,你开1000个连接,900个都在排队等待CPU时间片,反而更慢。真正的高并发不是靠连接数堆出来的,而是靠连接的高效复用。
第二,调整MySQL的thread_cache_size。这个参数控制线程缓存数量,默认值是0,意味着每次断开连接后线程就被销毁了,下次又要重新创建。把它调到32到128之间,可以大幅减少连接的创建开销:
-- 查看当前配置
SHOW VARIABLES LIKE 'thread_cache_size';
SHOW VARIABLES LIKE 'max_connections';
-- 动态调整(需要SUPER权限)
SET GLOBAL thread_cache_size = 64;
SET GLOBAL max_connections = 500;
-- 持久化到配置文件my.cnf
[mysqld]
thread_cache_size = 64
max_connections = 500
第三,监控连接来源。连接数爆满有时候不是正常业务流量,而是某个bug导致连接不释放。老张那次排查到最后,发现是一个定时任务在并发环境下没有正确关闭连接,导致连接数缓慢增长直到打满。你可以用这个SQL快速定位问题:
-- 查看所有连接的详细信息
SHOW PROCESSLIST;
-- 或者用更详细的方式
SELECT
id,
user,
host,
db,
command,
time AS 持续时间秒,
state,
info AS 当前执行的SQL
FROM information_schema.processlist
ORDER BY time DESC;
看time字段,如果有连接的持续时间特别长,比如几十秒甚至几分钟还在跑,基本就是问题所在。
查询卡死:大多数情况下是慢查询在拖后腿
查询卡死的原因五花八门,但90%的情况可以归结为两类:慢查询和查询优化器选错了执行计划。
先说慢查询。什么叫慢查询?在电商大促场景下,超过1秒的查询都应该被标记为慢查询。老张当时的问题就是一个商品列表查询,关联了五六个表,没有合适的全覆盖索引,导致MySQL做了大量的文件排序和临时表操作。你可以想象一下,10万用户同时在查商品列表,每个查询都要扫几百万行数据做排序,服务器能不卡死吗?
如何快速定位慢查询?
-- 开启慢查询日志(如果还没开启)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过1秒的查询记录为慢查询
-- 查看慢查询日志文件位置
SHOW VARIABLES LIKE 'slow_query_log_file';
-- 分析慢查询
SELECT
LEFT(query_time, 19) AS 查询时间,
query_time AS 耗时微秒,
lock_time AS 锁等待微秒,
rows_sent AS 返回行数,
rows_examined AS 扫描行数,
db AS 数据库,
LEFT(end_time, 19) AS 结束时间,
LEFT(mdig_user, 32) AS 用户,
LEFT(mdig_host, 64) AS 主机,
sql_text AS 完整SQL
FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 20;
找到慢查询之后,用EXPLAIN分析执行计划:
EXPLAIN SELECT
p.id, p.name, p.price, p.stock,
c.name AS category_name,
s.name AS shop_name
FROM product p
LEFT JOIN category c ON p.category_id = c.id
LEFT JOIN shop s ON p.shop_id = s.id
WHERE p.status = 1
ORDER BY p.sales DESC
LIMIT 20;
看EXPLAIN结果里的几个关键字段:type(连接类型)、key(实际使用的索引)、rows(预估扫描行数)、Extra(额外信息)。如果看到Using filesort或者Using temporary,基本就是性能瓶颈所在。
对于老张那个具体的案例,他的product表有5000万行数据,原来的查询没有对sales字段建索引,导致每次排序都要扫全表。解决办法是加一个联合索引:
-- 添加覆盖索引,避免回表和文件排序
ALTER TABLE product
ADD INDEX idx_status_sales_id (status, sales, id, name, price, stock);
-- 同时优化查询,让它能用到这个索引
SELECT
p.id, p.name, p.price, p.stock,
c.name AS category_name,
s.name AS shop_name
FROM product p
LEFT JOIN category c ON p.category_id = c.id
LEFT JOIN shop s ON p.shop_id = s.id
WHERE p.status = 1
ORDER BY p.sales DESC
LIMIT 20;
加完索引之后,查询耗时从平均3.2秒降到了0.05秒,差了60多倍。
但查询卡死还有另一种情况:查询被锁住了。这时候即使查询本身写得再好,也得等着。
锁表:大促期间的隐形杀手
锁表是最让人头疼的问题之一,因为它往往不是显性的报错,而是查询莫名其妙地卡住,等上几十秒甚至几分钟才出来结果。
MySQL的锁主要分为两类:表级锁和行级锁。InnoDB引擎下主要是行级锁,但某些操作还是会升级为表锁,比如ALTER TABLE、LOCK TABLES,以及某些情况下的大批量UPDATE/DELETE。
老张那次锁表的根源是一个批量更新操作。有一个运营活动需要在凌晨对10万商品的库存进行批量调整,代码里写的是一个循环单条UPDATE:
// 错误的做法:循环单条更新,锁住大量行
for (ProductUpdate update : updates) {
String sql = "UPDATE product SET stock = stock - ? WHERE id = ? AND status = 1";
jdbcTemplate.update(sql, update.getStockChange(), update.getProductId());
}
这段代码看着没什么问题,但问题在于:每个UPDATE都会加行锁,而且事务没有正确控制。当10万条UPDATE同时进行时,锁的范围迅速扩大,最终引发了锁等待链。更糟糕的是,这些UPDATE和正常的商品查询也在竞争同一批行的锁,导致查询全部卡死。
如何发现和解决锁问题?
-- 查看当前锁等待情况
SELECT
r.trx_id AS 等待事务ID,
r.trx_state AS 事务状态,
r.trx_started AS 事务开始时间,
r.trx_query AS 等待的SQL,
b.trx_id AS 持有事务ID,
b.trx_state AS 持有事务状态,
b.trx_query AS 持有锁的SQL
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
这个查询能让你清楚地看到谁在等锁、谁在持锁。找到锁的来源后,处理方式有几种:
第一种,优化批量操作。把循环单条UPDATE改成批量INSERT/UPDATE,或者分批处理:
// 正确做法:分批批量更新
int batchSize = 500;
for (int i = 0; i < updates.size(); i += batchSize) {
List<ProductUpdate> batch = updates.subList(i, Math.min(i + batchSize, updates.size()));
String sql = "UPDATE product p " +
"INNER JOIN (? as u ON p.id = u.id) " +
"SET p.stock = p.stock + u.stock_change " +
"WHERE p.status = 1";
// 使用Spring的batchUpdate,一次SQL搞定
jdbcTemplate.batchUpdate(sql, batch, batch.size(),
(PreparedStatement ps, ProductUpdate update) -> {
ps.setInt(1, update.getStockChange());
ps.setInt(2, update.getProductId());
});
}
第二种,控制事务粒度。尽量缩短事务持续时间,不要在一个大事务里做太多操作。
// 短事务原则
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 5)
public void updateProductStock(Long productId, int stockChange) {
// 只做必要的更新,不要在这里查其他表
productMapper.updateStock(productId, stockChange);
}
第三种,对于读多写少的场景,考虑读写分离和缓存策略,减少直接打到数据库的写操作。
如果锁表问题特别严重,也可以考虑临时调整锁等待超时时间,避免请求无限等待:
-- 查看当前锁等待超时时间(默认50秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 调整为10秒,超时后快速失败而不是卡死
SET GLOBAL innodb_lock_wait_timeout = 10;
超时后失败的请求可以重试,比无限等待要好得多。
缓存失效雪崩:压死数据库的最后一根稻草
数据库扛得住连接数和锁的问题,但缓存一崩,所有流量瞬间打到数据库上,那时候神仙也救不了。
缓存雪崩指的是大量缓存数据在同一时间失效,导致所有请求都打到数据库上。缓存穿透是查询根本不存在的数据,缓存每次都失效,请求直逼数据库。缓存击穿是一个热点key突然失效,并发请求全部打到数据库。
老张那次的雪崩是因为他们的缓存过期时间设置得太统一了。所有商品的缓存都设置了30分钟过期,每到整点的时候,大量key同时失效,查询压力瞬间打到了MySQL上。
如何防止缓存雪崩?
核心思路是:不要让你的缓存key在同一时间批量失效。
// 错误的做法:统一的过期时间
redisTemplate.opsForValue().set("product:" + productId, product, 30, TimeUnit.MINUTES);
// 正确的做法:在基础过期时间上加上随机值
long baseExpire = 30; // 基础30分钟
long randomOffset = ThreadLocalRandom.current().nextLong(0, 15); // 随机0-15分钟
redisTemplate.opsForValue().set(
"product:" + productId,
product,
baseExpire + randomOffset,
TimeUnit.MINUTES
);
这样每个key的过期时间就分散开了,不会集中失效。
但光靠随机过期还不够,还需要建立多级缓存架构和合理的缓存策略。
完整的缓存防护策略:
@Component
public class ProductCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
// 本地缓存作为第一层(Caffeine,毫秒级响应)
private final Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(5000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
/**
* 获取商品信息,多层缓存防护
*/
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// 第一层:本地缓存
Product local = localCache.getIfPresent(cacheKey);
if (local != null) {
return local;
}
// 第二层:Redis缓存
Object redisValue = redisTemplate.opsForValue().get(cacheKey);
if (redisValue != null) {
Product product = deserialize(redisValue);
localCache.put(cacheKey, product);
return product;
}
// 第三层:数据库(注意防穿透)
Product product = productMapper.selectById(productId);
if (product == null) {
// 缓存空值,防止穿透
redisTemplate.opsForValue().set(
cacheKey,
"NULL",
5,
TimeUnit.MINUTES
);
return null;
}
// 设置过期时间,加上随机偏移防止雪崩
long expireTime = 30 + ThreadLocalRandom.current().nextLong(0, 15);
redisTemplate.opsForValue().set(cacheKey, product, expireTime, TimeUnit.MINUTES);
localCache.put(cacheKey, product);
return product;
}
/**
* 热点Key预热:大促前主动加载热点数据到缓存
*/
public void warmupHotProducts(List<Long> hotProductIds) {
for (Long productId : hotProductIds) {
Product product = productMapper.selectById(productId);
if (product != null) {
String cacheKey = "product:" + productId;
redisTemplate.opsForValue().set(
cacheKey,
product,
60 + ThreadLocalRandom.current().nextLong(0, 30),
TimeUnit.MINUTES
);
localCache.put(cacheKey, product);
}
}
}
private Product deserialize(Object value) {
// 反序列化逻辑
return (Product) value;
}
}
这里做了三件事:本地缓存+Redis缓存+数据库兜底,这是现在大厂常用的多级缓存架构。本地缓存能挡住大部分高频访问,Redis挡住中频访问,数据库只做最后兜底。
另外还要特别注意热点Key的问题。大促期间,某个爆款商品的访问量可能是其他商品的几十倍甚至几百倍。如果这个商品的缓存失效,所有请求会同时打到数据库。解决方案是对热点Key做特殊处理:
/**
* 热点Key永不过期策略(实际是用逻辑过期,不是真正的永不过期)
*/
public Product getHotProduct(Long productId) {
String cacheKey = "hot_product:" + productId;
// 先拿缓存
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
// 检查是否是逻辑过期标记
if ("EXPIRED".equals(cached.toString())) {
// 异步重建缓存,不要阻塞请求
rebuildCacheAsync(productId, cacheKey);
return deserializeOldCache(cached);
}
return deserialize(cached);
}
// 缓存不存在,从数据库加载
Product product = productMapper.selectById(productId);
if (product != null) {
// 设置一个较长的过期时间
redisTemplate.opsForValue().set(cacheKey, product, 120, TimeUnit.MINUTES);
}
return product;
}
private void rebuildCacheAsync(Long productId, String cacheKey) {
// 异步线程重建,不影响当前请求
CompletableFuture.runAsync(() -> {
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 120, TimeUnit.MINUTES);
}
});
}
这就是”逻辑过期”的思路:缓存本身不设置物理过期时间,而是用一个特殊的标记来表示数据可能过期了,然后异步去刷新。这样热点Key永远不会突然全部失效。
大促前的全面准备清单
聊了这么多问题,最后给你一个实战清单。老张他们在大促前没有做任何预案,完全是临阵磨枪,所以才那么被动。如果你能提前做好准备,这些问题的发生率会降低90%以上。
架构层面的准备:
请求链路设计:
用户请求 → 负载均衡 → 应用服务 → [本地缓存] → [Redis缓存] → MySQL
↓
监控告警系统
↓
自动熔断降级
这个链路里,缓存是核心防线。数据库不应该直接面对10万并发的冲击。
具体的检查项:
第一,数据库参数优化。大促前跑一次mysqltuner工具,它会分析你的MySQL配置并给出优化建议:
# 安装mysqltuner
wget http://mysqltuner.com/mysqltuner.pl
chmod +x mysqltuner.pl
./mysqltuner.pl
# 根据建议调整my.cnf
[mysqld]
# 连接相关
max_connections = 500
thread_cache_size = 64
# 内存相关(根据服务器内存调整,一般设为内存的50-70%)
innodb_buffer_pool_size = 32G
# 性能相关
innodb_flush_log_at_trx_commit = 2 # 大促期间可以放宽到2,牺牲一点数据安全性换性能
sync_binlog = 0 # 同样,大促期间可以适当放宽
# 查询相关
query_cache_type = 0 # MySQL 8.0已移除,低版本建议关闭
tmp_table_size = 64M
max_heap_table_size = 64M
注意innodb_flush_log_at_trx_commit这个参数。默认值是1,意味着每次事务提交都要刷盘,数据最安全但性能最差。大促期间可以临时调到2,每秒刷一次盘,性能提升明显。活动结束后再改回来。这需要在数据安全和性能之间做取舍,你要根据业务场景决定。
第二,索引全面审查。用pt-query-digest工具分析慢查询日志,找出需要优化的SQL:
# 安装percona toolkit
apt-get install percona-toolkit
# 分析慢查询日志
pt-query-digest /var/log/mysql/slow.log > report.txt
# 查看未使用索引的表
SELECT
TABLE_SCHEMA,
TABLE_NAME,
INDEX_NAME,
SEQ_IN_INDEX,
COLUMN_NAME,
CARDINALITY
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'ecommerce'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
第三,压力测试。在正式大促前,用JMeter或者wrk对你的系统做压力测试,找到瓶颈点:
# 用wrk做简单的API压力测试
wrk -t12 -c400 -d30s http://your-api-host/product/list
# 输出示例:
# Running 30s test @ http://your-api-host/product/list
# 12 threads and 400 connections
# Thread Stats Avg Stdev Max +/- Stdev
# Latency 125.32ms 45.21ms 523.45ms 89.23%
# Req/Sec 312.50 45.32 890.00 78.45%
# 112500 requests in 30.05s, 89.45MB read
看到Latency(延迟)和Req/Sec(每秒请求数),就能知道你的系统能扛多少。
第四,监控告警。没有监控的大促就是在裸奔。确保你有这几样东西:
# 监控指标清单
监控项:
- MySQL连接数(超过80%阈值告警)
- QPS/TPS(突增或突降告警)
- 慢查询数量(每分钟超过10条告警)
- 锁等待数量(有等待即告警)
- 缓存命中率(低于90%告警)
- 磁盘IO使用率(超过80%告警)
- 应用层RT(平均响应时间超过500ms告警)
用Prometheus+Grafana这套组合拳来监控,性价比最高。Prometheus负责采集和存储,Grafana负责可视化,Alertmanager负责告警推送。
第五,预案和降级策略。这是最重要但也最容易被忽视的一点。你要提前想好:如果MySQL真的扛不住了,怎么办?
/**
* 熔断降级示例(基于Resilience4j)
*/
@CircuitBreaker(
name = "productService",
fallbackMethod = "getProductFallback"
)
@Retry(name = "productService")
public Product getProductWithProtection(Long productId) {
return productCacheService.getProduct(productId);
}
// 降级方法:返回缓存中的旧数据或者默认值
public Product getProductFallback(Long productId, Exception e) {
log.warn("产品服务降级,productId: {}", productId, e);
// 尝试从本地缓存获取
String cacheKey = "product:" + productId;
Product local = localCache.getIfPresent(cacheKey);
if (local != null) {
return local; // 返回旧数据,比报错好
}
// 最后兜底:返回一个默认产品或提示页
return Product.defaultProduct();
}
熔断降级不是丢脸的事,是大促期间保护系统不彻底崩溃的必要手段。宁可返回旧数据,也不能让系统直接崩掉。
最后说几句心里话
老张他们那次大促,虽然 MySQL 崩了,但后续处理得当,最终把损失控制在了可接受的范围内。事后他们做了一整套的预案和架构升级,第二年大促的时候,系统稳如泰山。
我想说的是,这些问题不是不可解决的,关键在于提前准备和深刻理解。每个参数的调整背后都有权衡,每次架构的升级都要结合实际业务场景。没有银弹,只有最适合你的方案。
现在你对MySQL在大促场景下可能遇到的问题有了比较全面的了解。连接数、慢查询、锁表、缓存雪崩,每一条都是实打实的经验。把这些知识用起来,下次大促你也能从容应对。记住,好的架构不是一蹴而就的,而是在一次次的实战中打磨出来的。
