说实话,第一次遇到 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);