那是去年的“双十一”零点,气温骤降,但服务器机房的热度却在疯狂飙升。
我是当时负责核心交易链路的DBA(数据库管理员)。屏幕上的监控曲线像是一条心电图,在20:59分时还平稳如丝,但在21:00整,随着千万级流量涌入,曲线突然变成了垂直的悬崖——MySQL主库的CPU利用率瞬间飙升至100%,连接数(Connections)爆表,紧接着是海量的 Lock wait timeout exceeded 错误。
那一刻,后台钉钉群里炸了锅:“订单入库失败!”、“库存扣减异常!”、“前端页面白屏!”。
这就是今天要讲的实战故事。我们不谈空洞的理论,直接复盘这场从“慢查询优化”到“架构重构”的生死救援。希望能给正在经历或即将面临高并发挑战的你,提供一份可落地的避坑指南。
第一阶段:紧急止血——定位那个“杀人”的SQL
大促刚开始5分钟,告警就来了。第一时间,我们不能慌,必须像外科医生一样精准切除病灶。
1.1 为什么慢?先看 SHOW PROCESSLIST
登录数据库,第一条命令通常是:
SHOW PROCESSLIST;
你会发现,大量查询处于 Sending data 或 Locked 状态。其中有一条耗时最长的SQL引起了我的注意:
SELECT * FROM orders
WHERE user_id = 123456
ORDER BY create_time DESC
LIMIT 20;
这条查询看起来人畜无害,对吧?查询用户订单,按时间倒序,取前20条。但在大促期间,orders 表已经膨胀到了 5亿行。
1.2 揭开真相:Using filesort 和 Full 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 |
问题在哪里?
- 全表扫描(All):虽然
idx_user_id存在,但MySQL优化器发现,对于热门用户(比如大V或抢购狂魔),命中该索引的数据量可能占总数据的很大比例,于是放弃了索引,直接全表扫描。 - 文件排序(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)。
大促期间,写流量巨大,主库写入速度快,而从库同步稍慢。如果用户在主库刚下单,立刻去查询订单状态,可能会查到旧数据。
解决方案:
- 关键写操作强制走主库:对于“查订单状态”这类对实时性要求极高的场景,中间件配置规则,确保写入后的短暂时间内(如500ms),查询请求路由到主库。
- 优化从库硬件:将从库的硬件配置提升,使用更快的磁盘(SSD),加速同步速度。
- 监控延迟:实时监控
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切面,根据方法名(select、query开头)自动路由到从库,其他操作路由到主库。
第三阶段:终极方案——分库分表,解决单点瓶颈
读写分离之后,主库的性能瓶颈暂时缓解了,但新问题又出现了:表数据量持续增长,单表查询效率依然低下;单库连接数达到上限(默认151,调整后也有限)。
这时,我们不得不面对最复杂、风险最高,但也最彻底的方案—— 分库分表。
3.1 为什么分库分表?
想象一下,你有一本电话簿,有10亿条记录。如果它在一本书里,你很难快速找到。但如果把它分成100本小册子,每本1000万条,查找速度会快得多。
- 分表:将一个大表拆分成多个小表(如
orders_0到orders_99)。 - 分库:将多个表拆分到不同的数据库实例,甚至不同的服务器。
3.2 分片策略:如何选择Sharding Key?
分库分表的核心是 分片键(Sharding Key) 的选择。对于订单系统,常见的分片策略有:
按
user_id取模:orders_0:user_id % 10 == 0orders_1:user_id % 10 == 1- …
- 优点:同一用户的所有订单都在同一个库,查询用户订单极快。
- 缺点:热点用户(如大V)的数据集中在一台服务器,造成 热点倾斜。
按
order_id取模:orders_0:order_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:数据迁移
如果线上已经跑了一段时间,数据量不大不小,怎么迁移?
我们使用了 双写+历史数据迁移 的方案:
- 新表结构建立,通过Canal监听MySQL Binlog。
- 代码层面双写:同时写入老表和新分片表。
- 后台任务历史数据分批迁移。
- 验证数据一致性后,切换读流量到新表。
- 关闭双写,下线老表。
这个过程需要精细的测试和灰度发布,绝不能一次性全量切换。
第四阶段:监控与运维——看不见的防线
技术优化只是第一步,持续的监控和运维保障才是系统稳定的基石。
4.1 关键监控指标
- QPS/TPS:每秒查询/事务数,衡量系统负载。
- 连接数:当前连接数/最大连接数,防止连接池耗尽。
- 慢查询数量:实时监控慢查询日志,及时优化。
- 主从延迟:确保数据一致性。
- 锁等待:检测死锁和长时间锁表。
4.2 应急预案
无论优化得多好,意外总会发生。我们制定了详细的应急预案:
场景A:主库CPU 100%
- 立即Kill掉耗时最长的非关键查询(如复杂的报表统计)。
- 临时提升主库配置(如果云厂商支持)。
- 强制将读流量切到从库。
场景B:数据库宕机
- 自动故障转移(Failover)到备用主库。
- 如果备用库也失败,启动降级方案:返回缓存数据(如Redis),并提示用户“系统繁忙”。
场景C:慢查询堆积
- 启用限流,保护核心交易链路。
- 暂停非核心功能(如积分查询、历史订单导出)。
结语:没有银弹,只有最适合的方案
回顾这场大促,我们从SQL慢查询优化入手,逐步升级到读写分离,最终实施分库分表。每一步都不是孤立的,而是层层递进、相互支撑的。
- SQL优化 是基础,成本最低,见效最快,必须优先处理。
- 读写分离 是常态,能有效分流,但需解决延迟问题。
- 分库分表 是终极手段,能突破单点瓶颈,但复杂度极高,需谨慎评估。
对于大多数中小型企业,读写分离 + 合理的索引设计 + 缓存(Redis) 往往就能应对90%的场景。只有当数据量真正达到亿级,且业务增长不可遏制时,才需要考虑分库分表。
最后,我想提醒每一位工程师:架构设计没有最好的,只有最合适的。 不要为了分库而分库,也不要忽视基础的SQL优化。每一次大促,都是对系统架构的一次考验,也是对工程师能力的一次锤炼。
希望这篇实录能为你带来一些启发。如果你们正在经历类似的问题,欢迎在评论区交流,我们一起探讨更优的解决方案。毕竟,代码是冷的,但解决问题的热情是热的。
