说实话,第一次遇到 MySQL 扛不住的时候,我盯着监控大屏上那条飙升到 99% 的 CPU 曲线,后背全是冷汗。那时候我才真正明白,并发不是数字游戏,而是资源分配的博弈。
今天咱们不聊那些虚头巴脑的理论,直接上干货。我会结合我踩过的坑和实际生产环境的解决方案,带你一步步把 MySQL 的并发能力压榨到极致。
一、先别急着改代码,先诊断:你的瓶颈到底在哪?
很多开发者一遇到高并发,第一反应就是“上读写分离”或者“加索引”。但在动手之前,你得先搞清楚:你的 MySQL 到底卡在哪里?
1.1 性能瓶颈的四大类型
根据我的经验,高并发场景下的性能瓶颈通常分为四类:
| 瓶颈类型 | 典型表现 | 核心指标 |
|---|---|---|
| CPU 瓶颈 | 查询响应慢,但 IO 等待不高 | load_avg 高,cpu_time 占比高 |
| IO 瓶颈 | 查询慢,磁盘活跃 | iowait 高,physical_reads 多 |
| 锁竞争 | 事务排队,死锁频繁 | lock_wait_time 高,innodb_row_lock_time 大 |
| 连接数瓶颈 | 应用端报 Too many connections |
Threads_connected 接近 max_connections |
1.2 实战诊断工具
使用 Performance Schema 定位慢查询根源
-- 查看当前最耗 CPU 的 Top 10 查询
SELECT
DIGEST_TEXT AS query_template,
COUNT_STAR AS exec_count,
SUM_TIMER_WAIT/1000000000000 AS total_time_sec,
AVG_TIMER_WAIT/1000000000000 AS avg_time_sec,
SUM_ROWS_EXAMINED AS rows_examined,
SUM_ROWS_SENT AS rows_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY total_time_wait DESC
LIMIT 10;
实时监控锁等待情况
-- 查看当前被阻塞的事务
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
经验之谈:我曾经在一个电商促销活动中发现,80% 的延迟不是来自慢查询,而是来自元数据锁(MDL)竞争。当 DBA 在执行
ALTER TABLE时,所有写请求都被阻塞了。所以,永远不要在业务高峰期执行 DDL 操作。
二、架构优化:在高并发场景下,设计决定上限
2.1 分库分表:从概念到落地
分库分表是解决单表数据量过大和并发压力最直接的手段。但我要提醒你:分库分表是双刃剑,它解决了扩展性问题,却带来了分布式事务、跨节点查询等复杂问题。
何时需要分库分表?
- 单表数据量超过 1000 万行
- QPS 持续超过 5000
- 查询响应时间经常超过 500ms
垂直拆分 vs 水平拆分
┌─────────────────────────────────────────────────────────┐
│ 原始架构 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ MySQL 单库 │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐│ │
│ │ │ 订单表 │ │ 用户表 │ │ 商品表 │ │ 日志表 ││ │
│ │ │(10亿行) │ │(5000万行)│ │(2000万行)│ │(100亿行)││ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘│ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 垂直拆分后 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 订单库 │ │ 用户库 │ │ 商品库 │ │
│ │ │ │ │ │ │ │
│ │ ┌────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │ │
│ │ │订单主表│ │ │ │用户主表│ │ │ │商品主表│ │ │
│ │ │订单明细│ │ │ │用户画像│ │ │ │商品详情│ │ │
│ │ └────────┘ │ │ └────────┘ │ │ └────────┘ │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ ┌────────────┐ │
│ │ 日志库 │ │
│ │ (独立存储) │ │
│ └────────────┘ │
└─────────────────────────────────────────────────────────┘
垂直拆分:按业务模块拆分,将不同表放到不同数据库。优点是架构清晰,缺点是如果某个业务模块数据量爆炸,依然需要水平拆分。
水平拆分的核心:分片键的选择
# 伪代码:水平拆分策略示例
class ShardingStrategy:
def __init__(self, db_count=8, table_count=1024):
self.db_count = db_count
self.table_count = table_count
def get_shard(self, order_id):
# 使用 order_id 的最后几位作为分片依据
# 避免使用雪花ID的低位,因为可能不均衡
shard_key = order_id % (self.db_count * self.table_count)
db_index = shard_key // self.table_count
table_index = shard_key % self.table_count
return f"order_db_{db_index}", f"order_{table_index:04d}"
def get_global_query(self, sql, params):
"""
跨分片查询:需要特殊处理
例如:查询所有用户的订单
"""
results = []
for db_idx in range(self.db_count):
for table_idx in range(self.table_count):
db_name = f"order_db_{db_idx}"
table_name = f"order_{table_idx:04d}"
query = f"SELECT * FROM {db_name}.{table_name} WHERE user_id = %s"
result = self.execute_query(query, params)
results.extend(result)
return results
关键洞察:分片键的选择直接决定了查询性能。尽量使用业务查询中必然涉及的字段作为分片键,避免跨分片查询。如果业务上无法避免,可以考虑引入 ES(Elasticsearch) 作为辅助查询引擎。
2.2 缓存架构:Redis 的妙用
高并发场景下,缓存是MySQL的救命稻草。但缓存不是简单地“加个 Redis”那么简单。
缓存穿透、击穿、雪崩的解决方案
┌─────────────────────────────────────────────────────────────┐
│ 缓存问题全景图 │
├──────────────┬──────────────────────────────────────────────┤
│ 缓存穿透 │ 查询不存在的数据,每次都打到 DB │
│ 解决方案 │ ① 缓存空值 ② 布隆过滤器预判 │
├──────────────┼──────────────────────────────────────────────┤
│ 缓存击穿 │ 热点 key 过期,大量请求同时打到 DB │
│ 解决方案 │ ① 永不过期(逻辑过期)② 互斥锁 │
├──────────────┼──────────────────────────────────────────────┤
│ 缓存雪崩 │ 大量 key 同时过期,DB 瞬间压力巨大 │
│ 解决方案 │ ① 随机过期时间 ② 多级缓存 ③ 限流降级 │
└──────────────┴──────────────────────────────────────────────┘
实战:使用布隆过滤器防止缓存穿透
@Component
public class BloomFilterCache {
@Resource
private StringRedisTemplate redisTemplate;
private static final int EXPECTED_INSERTIONS = 10000000; // 预期存储1000万条
private static final double FALSE_PROBABILITY = 0.01; // 误判率1%
/**
* 初始化布隆过滤器
*/
public void initBloomFilter(List<String> userIds) {
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.forName("UTF-8")),
EXPECTED_INSERTIONS,
FALSE_PROBABILITY
);
userIds.forEach(bloomFilter::put);
redisTemplate.opsForValue().set("bloom_filter:user_ids",
serialize(bloomFilter));
}
/**
* 查询前先用布隆过滤器判断
*/
public boolean mightExist(String userId) {
String filterData = redisTemplate.opsForValue().get("bloom_filter:user_ids");
BloomFilter<String> bloomFilter = deserialize(filterData);
return bloomFilter.mightContain(userId);
}
/**
* 获取用户信息
*/
public User getUserInfo(String userId) {
// 1. 先用布隆过滤器判断是否存在
if (!mightExist(userId)) {
return null; // 一定不存在,直接返回
}
// 2. 查询缓存
String cacheKey = "user:" + userId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 3. 缓存不存在,查询DB
User user = userDao.selectById(userId);
if (user != null) {
// 4. 写入缓存,设置过期时间
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(user), 30, TimeUnit.MINUTES);
} else {
// 5. 缓存空值,防止穿透
redisTemplate.opsForValue().set(cacheKey, "", 5, TimeUnit.MINUTES);
}
return user;
}
}
缓存击穿的解决方案:互斥锁
public User getUserWithMutexLock(Long userId) {
String cacheKey = "user:" + userId;
// 1. 从缓存获取
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null && !cached.isEmpty()) {
return JSON.parseObject(cached, User.class);
}
// 2. 缓存不存在,获取互斥锁
String lockKey = "lock:user:" + userId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 3. 双重检查,避免重复查询DB
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null && !cached.isEmpty()) {
return JSON.parseObject(cached, User.class);
}
// 4. 查询DB
User user = userDao.selectById(userId);
// 5. 写入缓存
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(user), 30, TimeUnit.MINUTES);
return user;
} finally {
// 6. 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 7. 获取锁失败,短暂休眠后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getUserWithMutexLock(userId); // 递归重试
}
}
重要提醒:缓存的过期时间不要设成固定值,最好加上一个随机抖动,比如
30分钟 ± 5分钟,这样可以避免大量 key 同时过期导致的缓存雪崩。
三、读写分离:架构演进的必经之路
3.1 读写分离的基本原理
读写分离的核心思想很简单:写操作走主库,读操作走从库。这样可以有效地将读压力分散到多个从库,提升系统的整体吞吐能力。
┌─────────────────────────────────────────────────────────────┐
│ 读写分离架构图 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 写请求 │ │ 读请求 │ │ 读请求 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 代理层/中间件 │ │
│ │ (MyCAT / ShardingSphere / MySQL-Proxy)│ │
│ └──────────────┬──────────────────────────┘ │
│ │ │
│ ┌─────────┼─────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 主库 │ │ 从库1 │ │ 从库2 │ │
│ │(Master)│ │(Slave) │ │(Slave) │ │
│ └────────┘ └────────┘ └────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
3.2 主从同步机制详解
MySQL 的主从复制是基于 binlog 实现的:
主库(Master) 从库(Slave)
│ │
│ 1. 事务执行完成 │
│ 2. 写入 binlog │
├──┼────────────────────────────────────┤
│ │ 3. I/O线程拉取 binlog │
│ │───────────────────────────────────►│
│ │ 4. 写入 relay log │
│ │◄───────────────────────────────────┤
│ │ 5. SQL线程重放 │
│ │───────────────────────────────────►│
│ 6. 数据更新完成 │
同步模式的权衡
| 模式 | 性能 | 数据安全性 | 适用场景 |
|---|---|---|---|
| 异步复制 | 最高 | 最低(可能丢数据) | 对实时性要求高,可接受少量数据丢失 |
| 半同步复制 | 中等 | 高(至少一个从库确认) | 平衡性能和安全性 |
| 全同步复制 | 最低 | 最高(所有从库确认) | 对数据一致性要求极高,性能要求低 |
-- 开启半同步复制
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
-- 主库配置
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 1000; -- 1秒后降级为异步
-- 从库配置
SET GLOBAL rpl_semi_sync_slave_enabled = 1;
-- 重启从库的 I/O 线程使配置生效
STOP SLAVE;
START SLAVE;
3.3 读写分离的常见问题及解决方案
问题1:主从延迟
这是读写分离最大的痛点。当用户刚写完数据,立刻去读,可能就读不到最新数据。
”`java /**
解决主从延迟的方案:强制读主库 */ @Service public class UserService {
@Resource private UserMapper userMapper;
/**
写入后立即读取,强制走主库 */ public User createUserAndRead(User user) { // 1. 写入主库 userMapper.insert(user);
// 2. 强制读主库(通过设置路由标记) DatabaseRouter.forceMaster();
try {
// 3. 读取刚写入的数据 return userMapper.selectById(user.getId());} finally {
// 4. 清除路由标记,恢复正常路由 DatabaseRouter.clear();} }
/**
- 普通查询,走从库 */ public User getUser(Long userId) { return userMapper.selectById(userId);
