那是去年的“双十一”零点,气温骤降,但服务器机房的热度却在疯狂飙升。

我是当时负责核心交易链路的DBA(数据库管理员)。屏幕上的监控曲线像是一条心电图,在20:59分时还平稳如丝,但在21:00整,随着千万级流量涌入,曲线突然变成了垂直的悬崖——MySQL主库的CPU利用率瞬间飙升至100%,连接数(Connections)爆表,紧接着是海量的 Lock wait timeout exceeded 错误。

那一刻,后台钉钉群里炸了锅:“订单入库失败!”、“库存扣减异常!”、“前端页面白屏!”。

这就是今天要讲的实战故事。我们不谈空洞的理论,直接复盘这场从“慢查询优化”到“架构重构”的生死救援。希望能给正在经历或即将面临高并发挑战的你,提供一份可落地的避坑指南。


第一阶段:紧急止血——定位那个“杀人”的SQL

大促刚开始5分钟,告警就来了。第一时间,我们不能慌,必须像外科医生一样精准切除病灶。

1.1 为什么慢?先看 SHOW PROCESSLIST

登录数据库,第一条命令通常是:

SHOW PROCESSLIST;

你会发现,大量查询处于 Sending dataLocked 状态。其中有一条耗时最长的SQL引起了我的注意:

SELECT * FROM orders 
WHERE user_id = 123456 
ORDER BY create_time DESC 
LIMIT 20;

这条查询看起来人畜无害,对吧?查询用户订单,按时间倒序,取前20条。但在大促期间,orders 表已经膨胀到了 5亿行

1.2 揭开真相:Using filesortFull Table Scan

我们执行 EXPLAIN 看看执行计划:

EXPLAIN SELECT * FROM orders 
WHERE user_id = 123456 
ORDER BY create_time DESC 
LIMIT 20;

结果令人震惊:

id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE orders ALL idx_user_id NULL NULL NULL 500,000,000 Using where; Using filesort

问题在哪里?

  1. 全表扫描(All):虽然 idx_user_id 存在,但MySQL优化器发现,对于热门用户(比如大V或抢购狂魔),命中该索引的数据量可能占总数据的很大比例,于是放弃了索引,直接全表扫描。
  2. 文件排序(Using filesort):即使拿到了数据,还要在内存或磁盘上对5亿行数据进行排序,这简直是灾难。

1.3 紧急优化:覆盖索引 + 延迟关联

既然全表扫描性能差,我们能不能只扫描索引?答案是:覆盖索引(Covering Index)

我们将SQL改写为:

SELECT o.id, o.user_id, o.create_time, o.amount 
FROM orders o
WHERE o.user_id = 123456
ORDER BY o.create_time DESC
LIMIT 20;

但这还不够。关键在于覆盖索引的运用。如果我们的索引包含所有查询字段,MySQL就不需要回表查询主键了。

更进一步的优化方案是 延迟关联(Deferred Join)

SELECT o.* FROM orders o
INNER JOIN (
    SELECT id FROM orders 
    WHERE user_id = 123456 
    ORDER BY create_time DESC 
    LIMIT 20
) tmp ON o.id = tmp.id;

原理是什么?

子查询只返回20个ID,这些ID都在索引 idx_user_id 上。主查询通过这20个ID回表获取完整数据。这样,排序操作只在索引树(B+ Tree)上进行,速度极快,且避免了全表扫描。

效果如何?

优化后,这条SQL的执行时间从 5秒+ 降到了 0.02秒。仅这一项优化,就释放了大量CPU资源,为后续的稳定运行奠定了基础。


第二阶段:架构升级——读写分离,减轻主库压力

解决了最痛的点,我们发现主库的压力依然巨大。毕竟,大促期间90%的请求都是查询(看商品、看订单、看库存),只有10%是写操作(下单、扣库存)。

让主库同时承担读写,显然是资源错配。于是,我们开启了 读写分离 模式。

2.1 读写分离的核心逻辑

用户请求 --> 网关/中间件 --> 判断SQL类型
                          /          \
                   SELECT请求      INSERT/UPDATE/DELETE
                      |                   |
                从库(Slave)          主库(Master)

2.2 遇到的坑:主从延迟

读写分离最容易遇到的问题就是 主从延迟(Replication Lag)

大促期间,写流量巨大,主库写入速度快,而从库同步稍慢。如果用户在主库刚下单,立刻去查询订单状态,可能会查到旧数据。

解决方案:

  1. 关键写操作强制走主库:对于“查订单状态”这类对实时性要求极高的场景,中间件配置规则,确保写入后的短暂时间内(如500ms),查询请求路由到主库。
  2. 优化从库硬件:将从库的硬件配置提升,使用更快的磁盘(SSD),加速同步速度。
  3. 监控延迟:实时监控 Seconds_Behind_Master,一旦延迟超过阈值,自动告警并暂时将所有读流量切回主库,保证数据一致性。

2.3 代码层面的实现示例(Java + MyBatis)

在实际项目中,我们通常通过动态数据源来实现读写分离。以下是一个简化的配置示例:

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource masterDataSource() {
        // 配置主库连接
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://master-db:3306/db_order");
        config.setUsername("root");
        config.setPassword("password");
        return new HikariDataSource(config);
    }

    @Bean
    public DataSource slaveDataSource() {
        // 配置从库连接
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://slave-db:3306/db_order");
        config.setUsername("root");
        config.setPassword("password");
        return new HikariDataSource(config);
    }

    @Bean
    @Primary
    public DynamicDataSource dynamicDataSource(
            @Qualifier("masterDataSource") DataSource master,
            @Qualifier("slaveDataSource") DataSource slave) {
        
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceType.MASTER, master);
        targetDataSources.put(DataSourceType.SLAVE, slave);
        
        DynamicDataSource dataSource = new DynamicDataSource();
        dataSource.setTargetDataSources(targetDataSources);
        dataSource.setDefaultTargetDataSource(master); // 默认走主库
        return dataSource;
    }
}

通过AOP切面,根据方法名(selectquery开头)自动路由到从库,其他操作路由到主库。


第三阶段:终极方案——分库分表,解决单点瓶颈

读写分离之后,主库的性能瓶颈暂时缓解了,但新问题又出现了:表数据量持续增长,单表查询效率依然低下;单库连接数达到上限(默认151,调整后也有限)。

这时,我们不得不面对最复杂、风险最高,但也最彻底的方案—— 分库分表

3.1 为什么分库分表?

想象一下,你有一本电话簿,有10亿条记录。如果它在一本书里,你很难快速找到。但如果把它分成100本小册子,每本1000万条,查找速度会快得多。

  • 分表:将一个大表拆分成多个小表(如 orders_0orders_99)。
  • 分库:将多个表拆分到不同的数据库实例,甚至不同的服务器。

3.2 分片策略:如何选择Sharding Key?

分库分表的核心是 分片键(Sharding Key) 的选择。对于订单系统,常见的分片策略有:

  1. user_id 取模

    • orders_0user_id % 10 == 0
    • orders_1user_id % 10 == 1
    • 优点:同一用户的所有订单都在同一个库,查询用户订单极快。
    • 缺点:热点用户(如大V)的数据集中在一台服务器,造成 热点倾斜
  2. order_id 取模

    • orders_0order_id % 10 == 0
    • 优点:数据均匀分布,避免热点。
    • 缺点:查询用户订单需要扫描所有分片,性能差。

我们的选择

考虑到电商场景中,“查询用户订单”是高频操作,而“全局订单搜索”频率较低且可用ES(Elasticsearch)解决,我们选择了 user_id 分片,并引入了 哈希+取模 的混合策略,同时配合 热点账号隔离 技术,将大V用户的订单单独存放,避免挤占普通用户资源。

3.3 分库分表的实战挑战

分库分表不是银弹,它带来了一系列复杂性:

挑战1:跨库查询

用户可能要求“查询所有订单”,这涉及100个分库。我们通常 不允许 这种全库扫描,或者通过 ES倒排索引 来解决。对于跨库统计(如“今天总销售额”),我们采用 T+1离线计算实时计数表 的方式,避免实时聚合带来的性能压力。

挑战2:分布式主键

MySQL的 AUTO_INCREMENT 在分库环境下无法保证全局唯一。我们采用了 雪花算法(Snowflake) 生成全局唯一ID。

public class SnowflakeIdWorker {

    private long workerId;
    private long datacenterId;
    private long sequence = 0L;

    public SnowflakeIdWorker(long workerId, long datacenterId) {
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    public synchronized long nextId() {
        long timestamp = timeGen();

        if (timestamp < lastTimestamp) {
            throw new RuntimeException("Clock moved backwards. Refusing to generate id");
        }

        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = timestamp;
        return ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }
}

挑战3:数据迁移

如果线上已经跑了一段时间,数据量不大不小,怎么迁移?

我们使用了 双写+历史数据迁移 的方案:

  1. 新表结构建立,通过Canal监听MySQL Binlog。
  2. 代码层面双写:同时写入老表和新分片表。
  3. 后台任务历史数据分批迁移。
  4. 验证数据一致性后,切换读流量到新表。
  5. 关闭双写,下线老表。

这个过程需要精细的测试和灰度发布,绝不能一次性全量切换。


第四阶段:监控与运维——看不见的防线

技术优化只是第一步,持续的监控和运维保障才是系统稳定的基石。

4.1 关键监控指标

  • QPS/TPS:每秒查询/事务数,衡量系统负载。
  • 连接数:当前连接数/最大连接数,防止连接池耗尽。
  • 慢查询数量:实时监控慢查询日志,及时优化。
  • 主从延迟:确保数据一致性。
  • 锁等待:检测死锁和长时间锁表。

4.2 应急预案

无论优化得多好,意外总会发生。我们制定了详细的应急预案:

  • 场景A:主库CPU 100%

    • 立即Kill掉耗时最长的非关键查询(如复杂的报表统计)。
    • 临时提升主库配置(如果云厂商支持)。
    • 强制将读流量切到从库。
  • 场景B:数据库宕机

    • 自动故障转移(Failover)到备用主库。
    • 如果备用库也失败,启动降级方案:返回缓存数据(如Redis),并提示用户“系统繁忙”。
  • 场景C:慢查询堆积

    • 启用限流,保护核心交易链路。
    • 暂停非核心功能(如积分查询、历史订单导出)。

结语:没有银弹,只有最适合的方案

回顾这场大促,我们从SQL慢查询优化入手,逐步升级到读写分离,最终实施分库分表。每一步都不是孤立的,而是层层递进、相互支撑的。

  • SQL优化 是基础,成本最低,见效最快,必须优先处理。
  • 读写分离 是常态,能有效分流,但需解决延迟问题。
  • 分库分表 是终极手段,能突破单点瓶颈,但复杂度极高,需谨慎评估。

对于大多数中小型企业,读写分离 + 合理的索引设计 + 缓存(Redis) 往往就能应对90%的场景。只有当数据量真正达到亿级,且业务增长不可遏制时,才需要考虑分库分表。

最后,我想提醒每一位工程师:架构设计没有最好的,只有最合适的。 不要为了分库而分库,也不要忽视基础的SQL优化。每一次大促,都是对系统架构的一次考验,也是对工程师能力的一次锤炼。

希望这篇实录能为你带来一些启发。如果你们正在经历类似的问题,欢迎在评论区交流,我们一起探讨更优的解决方案。毕竟,代码是冷的,但解决问题的热情是热的。