有一次周五下午两点十九分,我盯着监控屏上的曲线,数据库 CPU 从 12% 一路飙到 97%,连接数打满,订单接口的响应时间从 80ms 涨到三秒开外。群里已经开始刷“怎么回事”“要不要重启”“谁动线上配置了”。这种场面大多数后端和 DBA 都不陌生:平时好好的系统,一到业务高峰就变脸,慢查询、锁等待、连接耗尽一套组合拳下来,服务直接“半死不活”。这篇文章就把这件事讲透——高峰期数据库扛不住的根因是什么,现场怎么排障,哪些操作能立刻止血,哪些优化能治本,以及怎么通过压测和容量规划把“扛不住”变成“早就知道扛不住”。
1. 高峰期“扛不住”的第一现场:先搞清楚瓶颈长什么样
1.1 数据库告警时的典型症状
先说说“扛不住”长什么样。不同业务形态、不同数据库版本,表现可能不一样,但核心症状比较集中,我按出现频率排一下。
- 连接数打满:应用日志里出现类似“Too many connections”的报错,新请求拿不到数据库连接,直接失败。这个最常见,而且最容易引起恐慌——因为看起来像数据库挂了。
- CPU 使用率飙升:用户态 CPU 高,说明 SQL 本身在大量计算;系统态 CPU 高,说明线程切换、锁竞争、IO 等待导致的上下文切换太频繁。
- 慢查询数量激增:平时慢查询日志里一天只有几条,高峰时每分钟几百条,而且某些 SQL 的单次执行时间比平时慢了一个数量级。
- 锁等待和死锁变多:
show engine innodb status里频繁出现锁等待信息,应用日志报 Lock wait timeout exceeded。 - 磁盘 IO 延迟变大:
iostat看到await达到几十甚至上百毫秒,说明磁盘在读上已经忙不过来了,一般伴随大量的全表扫描或临时表落盘。 - 内存不足或 Swap 占用:InnoDB Buffer Pool 命中率下降,大量查询直接走磁盘,响应时间随之恶化。
这些症状往往一起出现,但总有一个是“起点”。比如先是一条糟糕 SQL 打满 CPU,然后查询全部变慢、事务持有锁的时间变长、连接不释放、连接池耗尽,最后表现为“数据库挂了”。只看表象去重启或扩容,大概率治标不治本,重启之后下个高峰照样崩。
1.2 一次 CPU 打满的快速定位记录
我用一个实际见过的场景举例。某个系统午高峰突然接口超时,监控显示 CPU 99%,连接池被占满。当时第一件事不是看监控大屏,而是直接登录数据库执行了下面这条命令:
SHOW FULL PROCESSLIST;结果基本可以分成三类:大量 SELECT 处于Sending data状态、个别 UPDATE 处于Updating状态、还有几个连接卡在Waiting for table metadata lock。再配合慢查询日志,找到了一条统计类 SQL——join 了 8 张表,其中一张核心业务表没走索引,单次执行花了 4 秒,高峰期被调用了几百次,直接把 CPU 吃满。
注意一个细节:那条 SQL 平时跑 1 秒多,虽然不理想,但业务量小的时候感觉不出来;一旦并发上来,每条 SQL 多出的 2~3 秒就会把数据库的处理能力全部拖住。这引出一个关键问题:为什么很多性能问题平时不暴露,非要等到高峰才爆发?下一部分讲底层逻辑。
2. 为什么平时没事、一到高峰就崩:性能衰减的底层逻辑
2.1 排队效应和雪崩是怎么发生的
数据库本质上是“请求-处理-返回”的排队系统。假设数据库每秒最多处理 500 个请求,平时业务每秒只来 300 个,多余的处理能力全部空闲,每请求平均响应时间很短。高峰期每秒来了 600 个请求,超出的 100 个就只能在队列里等着。关键在于,排在后面的请求等待时间并不只是“100 个请求的处理时间”这么简单——前面每个请求的处理时间在负载升高时也会变长,比如锁竞争、IO 排队、CPU 上下文切换,于是队列越来越长,最终形成一个恶性循环:负载越高等得越久,等得越久连接占用越久,连接占用越久新请求越进不来。
你可以把它理解成餐饮门店排队:平时翻台快,等位五分钟;周末客流翻倍,但每桌吃饭时间也从三十分钟拉长到一个小时,等位人数就会指数级上涨。数据库也是这样,到达率超过处理能力的瞬间,响应时间不是线性增长,而是像滚雪球一样恶化。
这里有个判断点:所谓“扛不住”,往往不是数据库“完全不干活”,而是处理能力被排队消耗殆尽,有效吞吐降到了业务需要以下。所以扩容机器只能缓解一部分问题——如果根因是某一条 SQL 本身消耗过大,加再多机器也挡不住那一条 SQL 把资源吃光。
2.2 慢查询的放大效应
“一条慢 SQL 拖垮整个库”在高峰期特别常见。我见过一个真实案例:某统计报表功能新上线,一条 SQL 要全表扫描近百万行,单次执行约 2 秒,平时每天只被手动打开几次,大家都没注意。大促那天这个报表被加入了大屏自动刷新,每 5 秒跑一次,全表扫描频繁触发,直接把数据库 CPU 打满,核心交易接口跟着超时。
这里的关键是“放大效应”:慢 SQL 占用的不只是它自己的执行时间,还包括连接资源、内存临时表、磁盘 IO、锁。当它把 CPU 打满时,原本 10ms 能执行完的快查询也被迫排队,表现为“什么都没改,系统突然就慢了”。更隐蔽的是,某些场景下慢查询不直接出现在业务接口的调用链路上,而是来自定时任务、报表系统、数据同步任务,排查时要留意这些“隐性消费者”。
2.3 锁竞争在并发场景下被成倍放大
锁是数据库并发控制的核心机制,平时事务执行快,锁的持有时间以毫秒计,锁竞争几乎无感。但高峰期事务变慢之后,锁的持有时间被拉长,比如一个 UPDATE 本来 5ms 能提交,因为前面排队、IO 变慢,变成了 50ms,那后续针对同一行数据的更新都要多等 45ms。如果有大量热点行的并发更新,就会形成一个明显的“锁等待队列”,表现就是活跃会话数很高、但吞吐量上不去、CPU 也未必高,因为大家都在等待锁而不是在计算。
最常见的坑是热点行更新,典型场景就是库存扣减:所有人都在 update 同一件商品的库存记录,行锁天然串行,并发再高也只能一个个过。这种问题靠加索引、加缓存都未必有效,需要从业务设计层面去解决,比如库存分片、异步扣减、Redis 预扣。后面我会专门讲。
2.4 连接数并非越高越好
很多团队遇到数据库变慢,第一反应是把连接池调大,觉得“连接多了吞吐肯定上来”。但这里有一个常见的反直觉现象:连接数的增加不会线性提升吞吐,超过某个阈值后吞吐反而下降。
原因不复杂。每个数据库连接都要占用内存(线程栈、排序缓冲区、临时表空间等),MySQL 为例,每个连接在内部还可能有独立的排序和临时表资源。更重要的是,连接多了以后,线程上下文切换成了 CPU 的主要开销,大量 CPU 时间被花在“切换线程”而不是“执行 SQL”上。另一个问题在于应用侧:如果应用线程池也开得很大,几千个线程同时去数据库抢连接,请求队列和线程切换会形成双重压力,数据库连接层反而成了最脆弱的地方。
所以连接池不是越大越好,合理阈值需要结合压测数据和数据库配置去定。这也解释了为什么某些系统一到大促就“调连接池、加机器、重启”,三件套做完还是崩——方向就搞反了。
3. 排障方法论:从指标到根因的诊断路线
3.1 五个必看的核心指标
真实排障时,先看哪些指标最容易判断问题在哪一层?我按优先级整理如下。
| 指标 | 怎么查 | 说明 |
|---|---|---|
| CPU 使用率 | top / dstat | 区分用户态与系统态,用户态高偏 SQL 计算,系统态高偏连接/上下文切换 |
| 活跃线程数 | show processlist | 观察有多少会话真正在执行 SQL,而不是睡眠 |
| 连接数 | show global status like 'Threads_connected' | 和 max_connections 对比,看是否逼近上限 |
| 慢查询数量 | show global status like 'Slow_queries' | 和基线对比,看是否激增 |
| 锁等待与临时表 | show engine innodb status | 看锁等待、事务堆积、临时表使用情况 |
这五个指标要结合起来看,不能单看某一个。比如 CPU 不高但接口很慢,大概率是锁等待或者 IO 瓶颈;连接数快满了但 CPU 很低,大概率是连接被大量空闲会话占住,或者存在“获取连接后不释放”的应用代码问题。
3.2 抓取当前会话与慢查询
排障现场第一件事永远是看当前正在干什么。我在 1.2 节里提到过SHOW FULL PROCESSLIST,这里补充一些使用细节。
-- 查看当前所有连接的状态分布 SELECT command, state, count(*) FROM information_schema.processlist GROUP BY command, state; -- 按执行时间倒序看最“拖”的会话 SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist ORDER BY time DESC LIMIT 20;第二句最重要。执行时间最长的会话,不一定就是问题根源,但大概率是“被卡住”的代表。比如大量会话卡在Waiting for table metadata lock,说明有人持有了一张表的元数据锁,通常是有 DDL 操作长时间未提交;大量会话卡在Sending data,说明确实有慢查询在执行;大量会话卡在Updating,则要看是不是锁等待。
慢查询日志也要提前配置好。生产环境建议开启以下参数:
slow_query_log = ON long_query_time = 1 log_queries_not_using_indexes = ONlong_query_time可以按业务情况调整,一般 1 秒是合理起点,如果业务还不算大,也可以先调到 0.5 秒抓一段时间的全量慢查询,分析归类之后再改回来。需要注意,开启慢查询日志本身也有性能开销,但以现在的硬件水平看,这笔开销远小于问题本身的代价,建议默认开启。
3.3 读懂执行计划,判断索引是否走对
定位到具体 SQL 后,用EXPLAIN看执行计划。重点看三列:type、key、rows。
type是访问类型,从好到差大致是system→const→eq_ref→ref→range→index→ALL。看到ALL,说明全表扫描,优先怀疑索引缺失。key指示实际使用的索引,如果为 NULL,就是没走索引。rows是优化器预估需要扫描的行数,实际扫描行数比预期大很多,或者rows达到几十万上百万,就该检查索引设计和数据分布了。
另外还要看Extra列,出现Using filesort说明排序走外部文件,可能引发临时表落盘;出现Using temporary说明用了临时表,大概率会造成磁盘 IO 压力;出现Using index是好的信号,说明覆盖索引生效。这里有一个重要提醒:rows是预估值,可能因为统计信息过旧而严重失准,这时候可以执行ANALYZE TABLE刷新统计信息,再看执行计划。
4. 优化实战:先救火,再治本
4.1 能立刻止血的临时手段
线上已经出问题了,第一步永远是止血,而不是马上改架构。以下手段按效率排序。
- 杀掉最耗资源的会话。通过
SHOW FULL PROCESSLIST找到执行时间特别长、info里能看到明确 SQL 的会话,评估后KILL <id>。注意先确认这不是核心事务,杀错了可能导致业务回滚,我通常在高峰期只杀“非关键路径”的查询类会话。 - 给应用设置合理的超时和熔断。比如数据库连接超时设置短一点,让应用快速失败而不是无限等待;或者对非核心功能做降级处理,先把核心交易的流量保住。
- 临时扩容只读节点。如果读流量是主要压力来源,可以快速给 MySQL 加一个只读从库,把读请求分担过去。前提是主从延迟在可接受范围。
- 临时调低某些参数,比如把
lock_wait_timeout调小,让锁等待的会话更快放弃,而不是长时间挂在库里。
救火手段不要长期保留,理清根因后要及时恢复常规配置。比如杀会话只能在故障期间用,长期依赖杀会话会导致核心事务被误伤。
4.2 缓存和读写分离:把流量挡在数据库外面
真正治本的第一步,是把不该打到数据库上的流量挡在外面。
先看读多写少的场景,这是最常见的优化机会。热点数据上缓存是性价比最高的手段。一个商品详情页,假设每秒有 5000 次查询,其中 90% 都在读同一个商品的信息,不加缓存的时候这 5000 次请求全部打到 MySQL;加一层 Redis 缓存之后,命中率按 90% 算,MySQL 只需要承担 500 次请求,压力直接降一个数量级。缓存失败的场景要设置短过期时间做保护,避免缓存雪崩时流量全量冲回数据库。
再说读写分离。MySQL 主从架构已经非常成熟,业务上按照读写比例拆分流量:写操作走主库,读操作走从库。要注意两个问题:一是主从延迟,如果业务逻辑要求“写完立即读到刚写入的数据”,这种强一致读不能路由到从库;二是从库也不是无限扩展的,每个从库都在消费主库的 binlog 同步数据,如果写入量极大,从库的同步也会成为瓶颈。
4.3 SQL 重写与索引设计的关键细节
在优化完“要不要查数据库”之后,再优化“怎么查数据库”。
索引设计有一些高频踩坑点,我挑几个重点说。首先是最左前缀原则:联合索引(user_id, status, created_at)可以支持user_id=...和user_id=... AND status=...的查询,但不能直接支持status=...的查询。很多同学建了联合索引却命中不了,就是没搞懂这一点。
其次是不要在索引列上做函数运算或隐式类型转换。比如WHERE DATE(created_at) = '2024-01-01',这会让索引失效,应该改写为WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'。隐式类型转换更隐蔽,比如WHERE user_id = '123',如果user_id是数值类型,字符串和数值比较会触发转换,同样导致索引失效。
此外,覆盖索引是一个性价比非常高的优化手段。业务上最常见的查询是“根据某个条件取出某些字段”,如果建立一个覆盖所有查询字段的联合索引,查询可以直接从索引中取数,连回表都省了,磁盘 IO 和内存消耗都会大幅下降。但索引也不是越多越好,每一个索引都会拖慢写入速度、占用额外存储空间,所以要用实际业务查询模式来设计,而不是“把所有字段都建上索引”。
SQL 重写方面,分页查询是一个经典优化点。ORDER BY create_time DESC LIMIT 100000, 20需要扫描 10 万行之后丢弃前面的数据,浪费严重。可以改成:
-- 优化前 SELECT * FROM orders ORDER BY create_time DESC LIMIT 100000, 20; -- 优化后 SELECT * FROM orders WHERE create_time < (SELECT create_time FROM orders ORDER BY create_time DESC LIMIT 99999, 1) ORDER BY create_time DESC LIMIT 20;或者用上一页最后一条记录的排序字段值直接做游标翻页,而不是用偏移量。这里要注意,游标翻页需要一个唯一的、有序的键,实践中最常用的是自增主键或created_at加主键的组合。
4.4 连接池和数据库参数怎么调
连接池配置是很多团队容易忽略又特别容易出错的地方。以应用侧的连接池参数为例,重点关注maximumPoolSize、minimumIdle、connectionTimeout、maxLifetime。
先说一个经验值:maximumPoolSize不建议一味调大。很多项目的配置是“机器的 CPU 核心数 × 2 + 1”,比如 8 核机器设置 17 个连接。这个公式适用于单纯的连接池基准场景,实际业务还要考虑单条 SQL 的耗时、数据库负载、连接占用时间等多种因素。更合理的做法是结合压测:从 10 个连接开始逐步加压,观察 QPS 曲线,找到吞吐不再增长的拐点,再把连接数设置为拐点附近的 80%。
数据库侧的max_connections同理,不能随便设置成几千。每个连接在 MySQL 中大约要占用几百 KB 到几 MB 内存,连接数越大,线程调度开销越大。通常 2C4G 的实例设置个 300~500 是合理的,大型实例可以再往上加,但必须配合监控和压测来定。
再提两个 InnoDB 核心参数。innodb_buffer_pool_size是 InnoDB 缓存表和索引的内存区域,建议设置为可用内存的 60%~75%,但不能超过物理内存导致 swap。调大 buffer pool 之后,查询命中缓存的概率提升,磁盘 IO 压力会明显下降。innodb_flush_log_at_trx_commit这个参数控制事务日志的刷盘策略,设为 1 是最安全的,每个事务提交都 fsync 一次,性能最差;设为 2 表示每秒刷一次,性能更好但断电可能丢数据。对大多数互联网业务来说,设为 2 是常见的折中选择,但金融类、强一致类业务建议维持 1。
需要反复强调:参数调整必须基于监控和压测数据,不要照搬别人的配置,不同业务、不同访问模型下,最优值差异很大。
5. 长期治理:压测、容量规划与预案演练
5.1 容量评估的常用公式
经历过几次“扛不住”之后,我最大的体会是:高峰能不能扛住,在流量还没进来之前就应该知道了。容量规划的计算并不复杂,先把高峰期数据库需要承受的 QPS 算出来。
假设某系统日活 100 万,活跃用户高峰期集中在 2 小时内,人均在这 2 小时内产生 50 次请求,那么峰值请求数粗略计算为:
100万 × 50 / 7200秒 ≈ 6944 QPS落实到数据库的压力还要再打折。通常只有一部分请求会直接查数据库(其余命中缓存或静态资源),假设比例是 15%,则数据库峰值 QPS 约为:
6944 × 15% ≈ 1041 QPS再看单实例能扛多少。决定这个数字最靠谱的方法是压测:用一个和生产环境相同规格、相同数据量的实例,跑业务的真实查询模型,观察它在 CPU 安全水位(比如 60%~70%)下能支撑多少 QPS。假设结果是单实例能抗 800 QPS,那么按“峰值需求 1041 QPS + 20% 冗余”计算,就需要至少 2 个实例来处理。这样的估算比拍脑袋定“我们要加四台机器”靠谱得多。
5.2 慢查询治理闭环
慢查询治理不要做成“出了问题才查一次”,要形成一个持续运转的闭环。
第一步是建立基线。至少连续观察一两周的慢查询日志,记录每天慢查询数量、平均耗时、Top SQL 排行,得出这个系统的“正常水平”。第二步是分类治理。针对 Top N 慢 SQL,逐条审查执行计划,判断是缺索引、SQL 写法问题、还是统计信息不准,然后让对应的业务负责人限期优化。第三步是上线验证。优化上线之后,持续观察慢查询日志和监控指标,确认数量和耗时真的降了下来,再把这个 SQL 从待办清单里划掉。
闭环的关键是责任到人。慢查询治理最常见的失败方式是“DBA 发现慢 SQL,发给开发,开发说最近没空,然后就没有然后了”。更好的做法是把慢查询数量和 Top 慢 SQL 作为团队的日常质量指标,每周在例会上过一遍,让每个业务模块对自己的 SQL 负责。
5.3 数据归档与冷热分离
大表是性能的隐形杀手。一张积累了三四年的订单表,可能已经有一亿行数据,即使每条查询都走了索引,单表数据量过大也会让索引层级变深、扫描成本变大,同时增加备份和恢复的时间。这时候该考虑的不是分库分表,而是先做数据归档和冷热分离。
以订单为例,最自然的拆法是按时间维度把历史订单迁移到历史库或归档表,线上只保留最近三个月到半年的数据。迁移过程推荐使用pt-archiver这类工具,按主键分批删除和归档,避免一次性锁大量行导致主库抖动。我遇到过不少团队用简单的DELETE ... WHERE create_time < xxx一次性删几百万行,结果锁表、主从延迟、业务超时一次全来了,教训非常深刻。
分库分表是更进一步的手段,但它会引入分布式事务、跨库 join、全局主键等一系列复杂度。我的建议是:在引入分库分表之前,先把 SQL 优化、缓存、读写分离、数据归档这些手段都用尽,确认数据量的增长趋势和生产实例的硬件天花板之后,再考虑分库分表,否则很可能“拆了更麻烦”。
5.4 监控阈值与故障预案
监控的目的不是“出事了告警”,而是“在出事之前给出信号”。阈值设置要结合历史基线,而不是随便填一个数。比如数据库 CPU 平时稳定在 20%~30%,把告警阈值设成 90% 就太晚了,因为从 30% 飙到 90% 可能只需要几分钟;设成 70% 再结合持续时长告警,就能在性能明显受损之前介入。
故障预案要具体到操作步骤。比如“连接数打满”的预案,应该写明第一步看哪些指标、第二步执行哪条命令、第三步哪些会话可以杀、第四步谁能执行扩容、第五步怎么判断恢复。预案不能停留在文档层面,每个季度最好安排一次演练,让值班同学真正跑一遍流程,否则真出事时手忙脚乱,文档写得再详细也没用。
6. 一个完整复盘:从“扛不住”到 P99 降到 250ms
6.1 故障现场
之前做的一个订单中心系统,某大促预热当日午后流量明显上涨,监控告警开始刷屏:数据库 CPU 从 35% 升到 95%,连接数逼近峰值,部分地区用户反馈下单白屏。查看监控曲线可以发现,午高峰读流量翻了三倍,但其间夹杂了一个定时统计任务和一个新上线的报表功能,这两条线叠加在一起把数据库资源吃满了。
现场的关键指标是这样的:活跃连接数一度超过 200,慢查询数量从每分钟 3 条涨到每分钟 400 条;一条关联 8 张表的统计 SQL 单次执行耗时超过 4 秒,并且被报表页面每 5 秒触发一次;另外某个热门商品的库存记录上,有大量的并发 UPDATE 阻塞在行锁等待上。
6.2 定位过程
我当时的排查顺序可以给大家参考。第一步,SHOW FULL PROCESSLIST看会话分布,发现大量Sending data和一个长期存在的Waiting for table metadata lock;第二步,查看慢查询日志,定位到那条统计 SQL;第三步,用EXPLAIN分析,确认命中了一张百万级业务表的全表扫描;第四步,查information_schema.innodb_trx和锁等待信息,发现热点库存行的锁等待已经积累了几十秒。
这里有个排障上的小经验:同时出现多个问题时,先找“占用资源最大的会话”,再找“等待最久的会话”,前者是原因,后者是结果。一开始我们差点去处理锁等待,但杀掉几个卡住的会话之后发现锁还是不断堆积,根源其实是统计 SQL 把 CPU 打满之后,所有事务都变慢了,锁自然越积越多。
6.3 优化措施与最终结果
处理的顺序是先恢复再优化。第一,杀掉那条统计 SQL 的会话,数据库 CPU 立刻从 95% 降到 60% 左右,接口响应开始恢复;第二,禁止报表系统在高峰期直接查数据库,报表数据改用离线预计算结果替换;第三,针对库存热点行更新,把单行库存拆成多行分片,并引入 Redis 预扣库存;第四,重新设计统计 SQL 的索引,并修正字段类型,避免隐式转换导致的索引失效。
最终结果是这样的:P99 响应时间从故障时的 3000ms 以上降到了 250ms 左右,数据库 CPU 稳定在 30% 上下,慢查询数量归零。从这次故障里提炼出的三条经验,我至今每次做系统评审都会用到:第一,任何新上线的 SQL 都要做执行计划审查,尤其要警惕 join 过多、没有合理条件的统计查询;第二,高峰期最怕的不是流量大,而是流量大叠加定时任务和报表查询,这几种流量要错峰;第三,热点数据的并发更新要从设计上规避,而不是靠调数据库参数硬扛。
最后再分享一个小技巧:每次大促或业务高峰结束后,花半小时把这次故障的监控截图、排查命令、处理过程、参数变更整理成一篇内部复盘文档,下次再做容量评估或者评审新需求时,它能帮你少踩一大半的坑。数据库调优没有银弹,但有了正确的排查思路和长期治理机制,至少不会再被高峰期打一个措手不及。