凌晨三点,手机震动把你从梦中惊醒。不是闹钟,是监控报警群里的红色感叹号:“MySQL主库CPU 100%,连接数爆满,业务不可用!”你猛地坐起,心跳加速,脑子里闪过无数个念头:是不是被黑了?是不是代码写了死循环?还是说……数据库真的扛不住了?

别慌。这种场景,对于任何做过中大型项目的后端开发或DBA来说,都像是“成年礼”。今天,我们不讲那些枯燥的理论定义,而是直接切入实战,把这套组合拳拆解开来,让你在面对高并发冲击时,不仅能救火,还能顺便把防火墙砌得更厚。

第一道防线:当MySQL开始“喘气”时,如何快速止血?

首先,我们要明确一点:MySQL崩溃通常不是突然发生的,而是长期积累的结果。 当连接数打满、CPU飙升、IO等待过高时,系统其实已经在发出求救信号了。如果此时你直接重启服务,虽然能暂时恢复,但问题依旧存在,下次还会崩,甚至因为重启导致数据不一致(比如事务未提交就被杀掉了)。

1. 紧急排查:先诊断,再动手

在按下重启键之前,请先花30秒执行以下几条命令,它们能帮你定位真凶:

  • 查看当前连接数和活跃线程:

    SHOW STATUS LIKE 'Threads_connected';
    SHOW FULL PROCESSLIST;
    

    如果在PROCESSLIST中看到大量Sleep状态的连接,说明你的应用端没有正确关闭连接,或者连接池配置不合理。如果看到大量的Query状态且时间很长,那就是慢查询在作祟。

  • 检查锁等待:

    SELECT * FROM information_schema.INNODB_TRX;
    SELECT * FROM performance_schema.data_locks;
    

    高并发下最常见的“卡死”原因往往是死锁长事务持有锁。如果某个事务持有了行锁超过几分钟,后续的所有请求都会被阻塞,直到超时。这时候,你需要果断KILL掉那个阻塞源头的事务ID。

  • 系统资源监控: 使用tophtop看CPU和内存,使用iostat -x 1看磁盘IO。如果%util接近100%,说明磁盘IO瓶颈已经出现,这时候无论怎么优化SQL都无济于事,必须考虑升级硬件或引入缓存。

2. 临时急救措施

如果确认是流量突增导致的,除了Kill掉异常连接,还可以采取以下临时措施:

  • 限流与降级: 在网关层或应用层进行限流,拒绝非核心业务的请求。比如,大促期间,关闭评论、点赞等非核心功能,只保留下单和支付。
  • 调整参数(谨慎操作): 如果是因为max_connections太小,可以临时调大:SET GLOBAL max_connections = 2000;。但这只是治标,根本解决需要优化连接池。

第二道关卡:读写分离,让主库“轻装上阵”

很多团队一听到高并发,第一反应就是加机器。但加机器之前,得先看看数据库的负载结构。MySQL的主库既要处理写入(INSERT/UPDATE/DELETE),又要处理读取(SELECT),这在写多读少的场景下没问题,但在典型的互联网业务中(通常是读多写少,比例可能达到10:1甚至更高),主库的压力会被大量无效的读取请求拖垮。

1. 为什么需要读写分离?

想象一下,一个繁忙的餐厅,厨师(主库)正在拼命炒菜(写数据),如果顾客(读取请求)一直围着厨房问“好了没”,厨师肯定没法专心炒菜。读写分离的本质,就是把“问好了没”的人分流到服务员(从库)那里去,让厨师专注于做菜。

2. 架构设计与实战

最经典的读写分离架构是:1个主库 + N个从库

  • 主库(Master): 负责所有写操作和部分强一致性读操作。
  • 从库(Slave): 通过Binlog同步主库的数据,负责所有的读操作。

代码层面的实现

在Java应用中,我们通常使用Spring Boot配合MyBatis或JPA来实现动态数据源路由。这里展示一个简单的基于注解的动态数据源切换思路:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {
    DataSourceType value() default DataSourceType.MASTER;
}

public enum DataSourceType {
    MASTER, SLAVE_1, SLAVE_2
}

// 动态数据源上下文
public class DataSourceContextHolder {
    private static final ThreadLocal<DataSourceType> contextHolder = new ThreadLocal<>();

    public static void setDataSourceType(DataSourceType type) {
        contextHolder.set(type);
    }

    public static DataSourceType getDataSourceType() {
        return contextHolder.get();
    }

    public static void clearDataSourceType() {
        contextHolder.remove();
    }
}

// AOP切面,自动切换数据源
@Aspect
@Component
public class DynamicDataSourceAspect {

    @Around("@annotation(dataSource)")
    public Object around(ProceedingJoinPoint point, DataSource dataSource) throws Throwable {
        try {
            // 根据注解值设置数据源类型,默认走主库
            DataSourceContextHolder.setDataSourceType(dataSource.value());
            return point.proceed();
        } finally {
            // 清理ThreadLocal,防止内存泄漏和脏数据
            DataSourceContextHolder.clearDataSourceType();
        }
    }
}

在实际使用时,你可以在Service层的方法上加上@DataSource(DataSourceType.SLAVE_1),框架就会自动将查询路由到指定的从库。

3. 避坑指南:主从延迟

这是读写分离最大的坑。主库写入成功后,数据同步到从库需要时间(毫秒级到秒级不等)。如果用户刚注册完账号,立刻去登录,结果发现“用户不存在”,这就是因为读请求落在了还没同步数据的从库上。

解决方案:

  1. 强制读主库: 对于涉及资金、库存、用户权限等强一致性要求的读操作,强制路由到主库。可以通过上述的@DataSource(DataSourceType.MASTER)实现。
  2. 容忍短暂不一致: 对于非核心数据(如文章浏览量、点赞数),允许短暂的延迟,接受“读旧数据”的体验。
  3. 优化同步机制: 确保网络通畅,从库IO线程正常,必要时使用半同步复制(Semi-sync Replication)来保证至少有一个从库同步成功才返回写入成功,但这会牺牲一定的写入性能。

第三道护城河:Redis缓存,挡在前面的是它

如果说读写分离是分流,那么Redis就是真正的“缓冲垫”。在高并发场景下,数据库永远扛不住直接打到它的请求。90%以上的热点数据访问,都应该由缓存来处理。

1. 缓存架构选型

常见的有Cache-Aside Pattern(旁路缓存模式):

  1. 读请求:先查缓存,命中则返回;未命中则查数据库,写入缓存,再返回。
  2. 写请求:先更新数据库,再删除缓存(注意是删除,不是更新)。

2. 为什么是“删除”而不是“更新”缓存?

这是一个经典面试题,也是实战中的关键决策。

  • 更新缓存: 如果多个线程同时更新同一个Key,可能会产生竞态条件,导致缓存数据不一致。而且,更新操作本身也有成本。
  • 删除缓存: 下次读的时候,自然会根据最新的数据库数据重新加载。虽然这可能导致短暂的“缓存击穿”或“一致性窗口期”,但通过合理的过期时间和重试机制,完全可以接受。

3. 代码实战:带互斥锁的缓存重建

为了防止缓存击穿(即热点Key过期瞬间,大量请求打到数据库),我们需要引入互斥锁:

public String getData(String key) {
    // 1. 查缓存
    String value = redisTemplate.opsForValue().get(key);
    if (value != null) {
        return value;
    }

    // 2. 缓存为空,尝试获取分布式锁
    String lockKey = "lock:" + key;
    boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 3. 双重检查,避免其他线程已经重建了缓存
            value = redisTemplate.opsForValue().get(key);
            if (value != null) {
                return value;
            }
            
            // 4. 查数据库
            value = queryFromDB(key);
            
            // 5. 写入缓存,设置过期时间
            redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
            return value;
        } finally {
            // 6. 释放锁
            redisTemplate.delete(lockKey);
        }
    } else {
        // 7. 获取锁失败,休眠后重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getData(key); // 递归重试
    }
}

4. 缓存常见陷阱

  • 缓存穿透: 查询根本不存在的数据。
    • 解法: 布隆过滤器(Bloom Filter)拦截;或者缓存空对象(设置短过期时间)。
  • 缓存雪崩: 大量Key在同一时间过期。
    • 解法: 过期时间加上随机值(如base_time + random(1-5) minutes)。
  • 缓存击穿: 热点Key过期瞬间,大量请求涌入。
    • 解法: 如上所述的互斥锁方案,或者使用逻辑过期(不过期,在后台异步重建)。

第四道内功:索引调优,让SQL跑得飞快

即使有了缓存和读写分离,最终落到数据库的查询依然需要高效。索引是MySQL提升查询性能的利器,但用错了,它就是累赘。

1. 索引失效的常见场景

很多开发者以为建了索引就万事大吉,以下情况会导致索引失效:

  • 隐式类型转换: SELECT * FROM users WHERE phone = 13800000000;(phone字段是字符串,传入了数字)。
  • 函数计算: SELECT * FROM orders WHERE DATE(create_time) = '2023-10-01';。应该在应用层处理好时间范围,使用create_time >= '2023-10-01' AND create_time < '2023-10-02'
  • 模糊查询前缀通配符: LIKE '%abc' 会导致全表扫描,而LIKE 'abc%'可以使用索引。
  • OR条件: 如果OR两边的字段没有一个建立索引,整条语句都会失效。

2. 最左前缀原则与联合索引

假设你有一个联合索引(a, b, c)

  • WHERE a=1 AND b=2 -> 使用索引
  • WHERE a=1 AND c=3 -> 只使用a的索引部分
  • WHERE b=2 AND c=3 -> 不使用索引(跳过了最左边的a)

实战建议: 在创建联合索引时,务必将区分度最高的字段放在前面。例如,对于订单表,user_id的区分度远高于status,所以联合索引应该是(user_id, status)而不是(status, user_id)

3. 覆盖索引与回表

什么是回表?当查询的字段不在索引树中时,MySQL需要通过主键聚簇索引再去查一次数据页,这个过程叫回表。

优化技巧: 尽量使用覆盖索引。即SELECT的字段都在索引中。 例如,查询idname,如果建立了(name, id)的索引,就可以直接通过索引树拿到数据,无需回表。

-- 低效:需要回表
EXPLAIN SELECT * FROM users WHERE name = 'Alice';

-- 高效:覆盖索引,假设(name, age)是联合索引
EXPLAIN SELECT name, age FROM users WHERE name = 'Alice';

4. 索引维护

索引不是一劳永逸的。定期使用ANALYZE TABLE更新统计信息,使用OPTIMIZE TABLE整理碎片。对于大表,不要在业务高峰期做ALTER TABLE添加索引,这会锁表,建议使用pt-online-schema-change等工具在线变更。

第五道防线:分库分表,终极解决方案

当单机MySQL连读写分离+缓存都扛不住时,你就进入了海量数据领域。这时候,分库分表(Sharding)是必经之路。

1. 垂直拆分 vs 水平拆分

  • 垂直拆分: 按业务模块拆分。比如把用户库、订单库、商品库分开。这解决了单库表过多的问题,但不能解决单表数据量过大的问题。
  • 水平拆分: 按数据量拆分。比如将orders表拆分成orders_0, orders_1, … orders_N

2. 分片键的选择

分片键(Sharding Key)至关重要。通常选择user_idorder_id

  • 如果按user_id分片,那么查询某个用户的所有订单非常快,因为数据都在同一个分片上。
  • 如果按order_id分片,跨用户查询(如统计某店铺总销量)就会变得非常复杂,需要跨分片聚合。

3. 中间件选型

不要自己造轮子!使用成熟的中间件:

  • ShardingSphere-JDBC: 轻量级,嵌入在应用进程中,性能损耗小,适合大多数场景。
  • ShardingSphere-Proxy: 独立部署的服务端,对应用透明,支持多种语言,适合微服务架构。
  • MyCat: 老牌中间件,功能丰富但维护稍显滞后。

4. 分布式ID生成

分库分表后,全局唯一ID不能再用自增主键。推荐使用:

  • Snowflake算法: Twitter开源,本地生成,高性能,但依赖服务器时钟。
  • Leaf: 美团开源,支持号段模式和Snowflake模式,更可靠。
  • UUID: 简单但不利于索引存储,不建议作为主键。

结语:架构是演进而来的

回顾整个过程,从紧急止血到读写分离,再到缓存、索引优化,最后到分库分表,这并不是一套可以一次性部署好的静态方案,而是一个演进的过程

  1. 初期: 单库单表,做好基础索引优化。
  2. 增长期: 引入Redis缓存,减轻数据库读压力;配置读写分离,分担主库负载。
  3. 爆发期: 单表数据量突破千万,开始垂直拆分业务,引入消息队列削峰填谷。
  4. 海量期: 水平分库分表,引入分布式事务解决方案(如Seata),构建完整的微服务架构。

最后给新手的建议: 不要为了技术而技术。在决定上读写分离之前,先看看你的QPS是多少;在决定上Redis之前,先看看命中率如何;在决定分库分表之前,先看看单表容量是否真的到了瓶颈。

高并发下的稳定性,不仅仅依赖于技术的堆砌,更依赖于对业务流量的精准预判和对系统边界的清晰认知。希望这份指南能帮你在那一个个深夜报警中,从容应对,成为团队中那个“定海神针”般的存在。