深夜两点,监控报警群里突然炸开了锅。线上订单服务响应时间从200毫秒飙到了8秒,甚至出现了大量超时错误。DBA老张冲进会议室的时候,服务器负载已经红灯爆表。这不是演习,这是典型的MySQL在高并发场景下的“猝死”现场。
我是见过太多这种案例的。很多开发者在设计系统时,只关注了正常流量下的性能,却忽略了极端并发情况下的各种隐患。今天我就把这套实战排障经验毫无保留地分享出来,帮你避开这些坑。
先搞清楚:你的MySQL到底是在哪里“卡死”的
当系统出现高并发卡死时,不要急于重启服务或修改配置。第一步永远是诊断。我需要你理解,MySQL卡死通常表现为三种形态:查询缓慢、连接数耗尽、或者完全无响应。
让我用一个真实的场景来说明。去年某电商平台大促期间,他们的MySQL服务器突然无法接受新的连接。运维团队第一反应是加大连接数上限,结果问题更加严重。为什么?因为他们没有搞清楚根本原因。
正确的诊断思路应该像医生看病一样,层层递进:
首先检查系统资源。用top或htop命令看看CPU使用率,用free -m查看内存情况,用df -h检查磁盘空间。如果磁盘IO等待很高,那可能是I/O瓶颈;如果CPU全满,可能是复杂查询或锁竞争导致的。
其次查看MySQL的状态。执行SHOW PROCESSLIST;可以看到当前所有连接的执行情况。如果你发现大量查询处于Locked状态,那很可能是死锁或行锁竞争;如果有很多Sending data状态的查询,可能是全表扫描导致的。
再然后检查等待事件。在MySQL 5.7及以上版本,可以使用Performance Schema来查看线程等待信息。执行以下SQL:
SELECT EVENT_NAME, COUNT_STAR, SUM_TIMER_WAIT/1000000000000 AS wait_seconds
FROM performance_schema.events_wait_summary_by_instance
WHERE SUM_TIMER_WAIT > 0
ORDER BY SUM_TIMER_WAIT DESC;
这个查询能告诉你线程主要在等待什么资源。如果是wait/io/file/innodb/innodb_data,说明是I/O问题;如果是wait/lock/table/sql/hdl,说明是表锁竞争;如果是wait/synch/mutex/innodb/lock_struct,那就是行锁问题。
最后,别忘了查看错误日志。MySQL的错误日志通常位于/var/log/mysql/error.log或/var/lib/mysql/hostname.err。里面会有详细的锁等待超时、死锁检测等信息。
缓存击穿:当热点数据突然失效时的连锁反应
缓存击穿是并发场景下最常见的问题之一。想象一下这个场景:某个商品ID对应的缓存键突然过期,而此刻正好有上千个请求同时查询这个商品。这些请求发现缓存中没有数据,于是全部转向数据库。数据库瞬间承受巨大压力,响应变慢甚至宕机。
这不是理论推演,而是我亲眼见过多次的真实事故。某社交平台的热门帖子就是典型的缓存击穿受害者。当一个大V发布了一条爆款内容,浏览量瞬间飙升。缓存中的帖子数据过期后,所有用户请求直接打到数据库,导致数据库CPU利用率达到100%。
解决缓存击穿,核心思路是“不让请求直达数据库”。
第一种方案是永不过期缓存。对于热点数据,我们可以设置缓存永不失效。当数据需要更新时,采用异步更新策略,先更新数据库,再删除缓存。这样即使缓存中的数据不是最新的,也不会导致大量请求打到数据库。
// 伪代码示例:热点数据缓存策略
public Article getHotArticle(long articleId) {
// 先从缓存获取
Article article = cache.get(articleId);
if (article != null) {
return article;
}
// 缓存未命中,使用分布式锁保证只有一个请求查询数据库
String lockKey = "lock:article:" + articleId;
boolean locked = redis.setnx(lockKey, "1", 10); // 设置10秒过期
if (locked) {
try {
// 双重检查,避免重复查询
article = cache.get(articleId);
if (article == null) {
article = database.query(articleId);
if (article != null) {
// 设置缓存,永不过期
cache.set(articleId, article, CacheOption.NEVER_EXPIRE);
}
}
} finally {
redis.delete(lockKey);
}
} else {
// 其他请求等待并重试
Thread.sleep(50);
return getHotArticle(articleId);
}
return article;
}
第二种方案是热点数据提前预热。在系统低峰期或数据更新时,主动将热点数据加载到缓存中,并设置合理的过期时间。这样当高并发来临时,缓存已经有数据可用。
第三种方案是布隆过滤器。对于明显不存在的查询,可以使用布隆过滤器快速判断。如果布隆过滤器说这个数据不存在,那大概率真的不存在,可以直接返回空结果,避免查询数据库。
# 使用布隆过滤器防止缓存击穿
from pybloom_live import ScalableBloomFilter
# 创建布隆过滤器,设置误判率为0.01
bloom_filter = ScalableBloomFilter(initial_capacity=1000000,
error_rate=0.01,
mode=ScalableBloomFilter.LARGE_SET_GROWTH)
# 将所有可能查询的article_id加入布隆过滤器
for article_id in all_article_ids:
bloom_filter.add(article_id)
def get_article_safe(article_id):
# 先用布隆过滤器快速判断
if article_id not in bloom_filter:
return None # 数据肯定不存在,直接返回
# 查询缓存
article = cache.get(article_id)
if article:
return article
# 缓存未命中,查询数据库
article = database.query(article_id)
if article:
cache.set(article_id, article, expire_time=3600)
return article
死锁频发:多线程竞争下的“握手困难”
死锁是MySQL高并发场景下的另一个常见杀手。当两个或多个事务互相等待对方释放锁时,就会形成死锁。MySQL有死锁检测机制,会自动选择一个事务回滚,但这会影响系统性能。
我记得有一次处理一个电商系统的死锁问题。用户在提交订单时,系统频繁报错“Deadlock found when trying to get lock”。经过详细分析,发现是因为订单表和库存表之间的锁竞争导致的。
死锁产生的根本原因是锁的获取顺序不一致。
假设有两个事务:
- 事务1:先锁定订单表,再锁定库存表
- 事务2:先锁定库存表,再锁定订单表
当这两个事务同时执行时,就可能形成死锁:事务1持有订单锁等待库存锁,事务2持有库存锁等待订单锁。
解决死锁的第一步是避免死锁的发生。
最实用的方法是统一锁的获取顺序。所有事务都应该按照相同的顺序获取锁,比如总是先锁定订单表,再锁定库存表。这样就不会形成循环等待。
-- 错误的写法:不同事务锁的顺序不一致
-- 事务1
UPDATE orders SET status = 'PAID' WHERE order_id = 1;
UPDATE inventory SET stock = stock - 1 WHERE item_id = 100;
-- 事务2(可能并发执行)
UPDATE inventory SET stock = stock - 1 WHERE item_id = 100;
UPDATE orders SET status = 'PAID' WHERE order_id = 1;
-- 正确的写法:统一锁的顺序
-- 所有事务都先锁定订单,再锁定库存
UPDATE orders SET status = 'PAID' WHERE order_id = 1;
UPDATE inventory SET stock = stock - 1 WHERE item_id = 100;
第二步是减少锁的持有时间。
尽可能让事务快速执行,减少持锁时间。把不必要的操作移出事务范围。比如,查询操作不应该放在事务中,只有写操作才需要事务。
-- 错误的写法:在事务中执行不必要的查询
BEGIN;
SELECT * FROM users WHERE id = 1; -- 这个查询不需要锁
UPDATE orders SET status = 'PAID' WHERE order_id = 1;
COMMIT;
-- 正确的写法:只对有写操作的事务加锁
SELECT * FROM users WHERE id = 1; -- 在事务外执行
BEGIN;
UPDATE orders SET status = 'PAID' WHERE order_id = 1;
COMMIT;
第三步是使用合适的隔离级别。
MySQL默认的隔离级别是REPEATABLE READ,这个级别会使用Next-Key Lock,可能会锁定更多的范围。对于不需要强一致性的场景,可以考虑使用READ COMMITTED隔离级别,它能减少锁的范围。
-- 查看当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
-- 设置会话级别的隔离级别为READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
行锁竞争:高并发下的“排队现象”
除了缓存击穿和死锁,行锁竞争也是高并发场景下的常见问题。当多个事务同时更新同一行数据时,后面的事务需要等待前面的事务释放锁。如果等待时间过长,就会导致请求堆积,系统响应变慢。
行锁竞争的本质是热点数据的并发更新。
比如一个计数器,多个用户同时点击“点赞”按钮,每个点击都需要更新同一行的点赞数。这种场景下,行锁竞争会非常严重。
解决行锁竞争的方法有多种:
方法一:批量更新,减少锁的粒度。
不要每次点击都更新数据库,而是先更新内存中的计数器,然后定期批量写入数据库。
// 使用内存计数器,定期批量更新
public class LikeCounter {
private Map<Long, Integer> localCounts = new ConcurrentHashMap<>();
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
public void incrementLike(long articleId) {
localCounts.merge(articleId, 1, Integer::sum);
}
public void flush() {
if (localCounts.isEmpty()) {
return;
}
// 批量更新数据库
Map<Long, Integer> countsToFlush = new HashMap<>(localCounts);
localCounts.clear();
String sql = "UPDATE articles SET likes = likes + ? WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
for (Map.Entry<Long, Integer> entry : countsToFlush.entrySet()) {
stmt.setInt(1, entry.getValue());
stmt.setLong(2, entry.getKey());
stmt.addBatch();
}
stmt.executeBatch();
} catch (SQLException e) {
// 处理异常,可能需要重新flush
localCounts.putAll(countsToFlush);
}
}
}
方法二:使用分布式锁控制更新顺序。
对于必须实时更新的场景,可以使用分布式锁来控制更新顺序,避免行锁竞争。
# 使用Redis分布式锁
import redis
import time
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def like_article(article_id, user_id):
lock_key = f"like_lock:{article_id}"
lock_value = f"{user_id}:{time.time()}"
# 尝试获取锁,设置1秒过期时间
acquired = redis_client.set(lock_key, lock_value, nx=True, ex=1)
if not acquired:
# 获取锁失败,说明有其他用户正在点赞,排队重试
time.sleep(0.01)
return like_article(article_id, user_id)
try:
# 执行业务逻辑
# 1. 检查是否已经点赞
if redis_client.sismember(f"liked:{article_id}", user_id):
return False # 已经点过赞
# 2. 更新点赞数
conn = get_database_connection()
try:
conn.execute("UPDATE articles SET likes = likes + 1 WHERE id = %s", (article_id,))
# 记录用户点赞
redis_client.sadd(f"liked:{article_id}", user_id)
conn.commit()
finally:
conn.close()
return True
finally:
# 释放锁
redis_client.delete(lock_key)
方法三:读写分离,减轻主库压力。
对于点赞这种读多写少的场景,可以考虑使用读写分离。主库负责写操作,从库负责读操作。这样可以把查询压力分散到多个数据库实例上。
-- 主库执行写操作
INSERT INTO article_likes (article_id, user_id, create_time)
VALUES (100, 12345, NOW());
-- 从库执行读操作(查询点赞数)
SELECT COUNT(*) FROM article_likes WHERE article_id = 100;
连接数耗尽:当所有“电话线”都被占满时
连接数耗尽是另一个常见的高并发问题。MySQL的每个连接都会占用一定的内存资源,当连接数超过限制时,新的连接请求就会被拒绝。
连接数耗尽通常有两个原因:
一是连接池配置不当,连接数设置过小;二是存在长事务或慢查询,连接被长时间占用无法释放。
解决连接数耗尽的问题,需要从多个方面入手:
首先,合理配置连接池。
现代应用通常使用连接池来管理数据库连接。连接池的大小应该根据业务需求和服务器资源来设置。一般来说,连接池大小可以设置为CPU核数的2倍加上磁盘数。
# Spring Boot连接池配置示例
spring:
datasource:
hikari:
maximum-pool-size: 20 # 最大连接数,根据CPU核数调整
minimum-idle: 5 # 最小空闲连接数
idle-timeout: 30000 # 空闲连接超时时间(毫秒)
max-lifetime: 1800000 # 连接最大生命周期(毫秒)
connection-timeout: 30000 # 获取连接超时时间(毫秒)
leak-detection-threshold: 60000 # 连接泄漏检测阈值(毫秒)
其次,优化查询,减少连接占用时间。
慢查询会长时间占用连接,导致连接池耗尽。应该通过SQL优化、添加索引等方式来提高查询效率。
-- 查看慢查询日志
SHOW VARIABLES LIKE 'slow_query_log%';
-- 设置慢查询阈值
SET GLOBAL long_query_time = 2;
-- 分析慢查询
EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND status = 'PAID';
-- 添加合适的索引
ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);
再次,使用连接泄漏检测。
连接池通常提供连接泄漏检测功能,可以及时发现未正确关闭的连接。
// HikariCP连接泄漏检测配置
HikariConfig config = new HikariConfig();
config.setDataSourceClassName("com.mysql.cj.jdbc.MysqlDataSource");
config.addDataSourceProperty("serverName", "localhost");
config.addDataSourceProperty("port", "3306");
config.addDataSourceProperty("databaseName", "mydb");
config.setLeakDetectionThreshold(60000); // 60秒未归还的连接视为泄漏
锁等待超时:当“排队”变成“拥堵”
锁等待超时是MySQL高并发场景下的另一个常见问题。当一个事务等待锁的时间超过阈值时,MySQL会返回锁等待超时错误。
锁等待超时通常发生在以下场景:
一个长事务持有锁,而其他短事务需要等待这个锁才能继续执行。如果长事务执行时间很长,短事务就会不断超时。
解决锁等待超时的问题,可以从以下几个方面入手:
首先,避免长事务。
长事务会长时间持有锁,影响其他事务的执行。应该尽量缩短事务的执行时间,把不必要的操作移出事务范围。
-- 错误的写法:长事务
BEGIN;
-- 执行一些复杂的业务逻辑,耗时很长
SELECT * FROM orders WHERE user_id = 1;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
-- 又执行一些其他操作
SELECT * FROM products WHERE id = 1;
COMMIT;
-- 正确的写法:拆分为多个短事务
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
COMMIT;
BEGIN;
UPDATE orders SET status = 'PAID' WHERE user_id = 1;
COMMIT;
其次,调整锁等待超时时间。
如果业务确实需要较长的锁等待时间,可以适当调整锁等待超时参数。
-- 查看当前锁等待超时设置
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 设置锁等待超时时间(单位:秒)
SET GLOBAL innodb_lock_wait_timeout = 50;
-- 设置会话级别的锁等待超时
SET SESSION innodb_lock_wait_timeout = 30;
再次,使用非阻塞锁。
MySQL支持非阻塞锁,可以使用SELECT ... LOCK IN SHARE MODE或SELECT ... FOR UPDATE NOWAIT。NOWAIT选项会在获取锁失败时立即返回错误,而不是等待。
-- 使用NOWAIT,获取锁失败立即返回
SELECT * FROM orders WHERE id = 1 FOR UPDATE NOWAIT;
-- 使用WAIT参数,指定等待时间
SELECT * FROM orders WHERE id = 1 FOR UPDATE WAIT 5;
全面监控:防患于未然的最佳策略
解决高并发卡死问题,最重要的不是事后排障,而是事前预防。建立完善的监控系统,可以及时发现潜在问题,避免事故发生。
监控体系应该包括以下几个层面:
基础设施监控: 监控CPU、内存、磁盘、网络等资源使用情况。当资源使用率达到阈值时,及时告警。
”`bash
