那天下午,监控突然炸了。
核心服务在短时间内批量报
QueryTimeoutException。我打开日志一看,满屏都是同一类错误:三个数字一模一样:active 16,maxActive 16,runningSqlCount 16。连接池被打满了,一个不剩。
更诡异的是,报错的 SQL 五花八门:查用户订阅的 COUNT、查红点状态的 SELECT、查用户标签的查询……看起来毫无关联的业务全部超时。
出问题了。但问题不在这些报错的 SQL。
1. 先找谁占住了连接
连接池只有 16 个连接。既然全部被占,那一定有人在占着不放。
我把每条超时异常的 Cause 链追到底,发现真正占住连接的,始终是同一条 SQL:
一条配置查询,把 16 个连接全部霸占。其余所有业务查询排队等 5 秒后超时,形成连锁故障。
这就怪了。配置查询按理说应该是毫秒级返回的东西,怎么会把连接占住不还?
先还原一下调用链路。故障涉及两个服务:
正常流程不复杂:上游消费 MQ 消息,先查本地缓存,命中直接返回;没命中就 RPC 调下游查库,再写缓存。
看起来挺合理。但就是这个看起来合理的链路,藏了三个问题。

图 1:调用链路与三个隐藏问题。
2. 第一个问题:查询根本没走索引
我先去看了
config_table 的索引结构:索引 | 列 |
PRIMARY KEY | id |
UNIQUE KEY | (code, category_id) |
表上有个联合唯一索引
(code, category_id)。但查询条件是 WHERE category_id = ?,没有带 code。根据 MySQL 最左前缀原则,这个联合索引的最左列是
code。只查 category_id 不走索引,每次都是全表扫描。表数据量小的时候,全表扫描也无所谓,几毫秒就过了。但数据量涨上去以后,一条本应毫秒级返回的查询变成了秒级操作。而这期间,数据库连接一直被占用。
不过到这里我还有一个疑问:就算全表扫描要几秒钟,那也就占一个连接吧?为什么 16 个连接全部被同一类 SQL 占满?

图 2:联合索引 (code, category_id),跳过 code 直接查 category_id 不走索引。
3. 第二个问题:SELECT * 拉了大字段
我接着看了下表结构。
config_table 里有一个 TEXT 类型的 detail 字段,存的是大段 JSON 配置,动辄几十 KB。而查询代码是这样的:
没有用
.select() 限定列,默认查全部字段。全表扫描,加上每次拖几十 KB 的 TEXT 大字段,单条查询比正常慢了不止一倍。慢上加慢。
但这也说不通。慢 SQL 再慢,如果并发量不高,也不至于把整个连接池打满。一定还有东西在放大并发。
4. 第三个问题:缓存击穿
继续往上追,我看到了上游服务的缓存代码:
看到
getIfPresent() + 手动 put() 这个组合,我心里咯噔了一下。这个写法不是原子操作。15 分钟 TTL 到期那一瞬间,50 个线程同时走到
getIfPresent(),全部拿到 null。然后全部穿透到 RPC,全部打到数据库。而且上游有多个实例,每个实例的 Caffeine 缓存独立,过期时间接近。也就是说多个实例几乎同时发生缓存失效,压力被进一步放大。
到这里,故障的全貌就清楚了。

图 3:缓存击穿——TTL 到期瞬间,所有线程同时穿透到数据库。
5. 把时间线拼起来
三个问题串在一起,时间线是这样的:
不到 5 秒,一条配置查询搞瘫了所有业务。
这三个问题,单独拿出哪一个都不算致命。索引缺失让查询变慢,SELECT * 让查询更慢,缓存击穿让慢查询并发暴增。三者叠加,一次普通的缓存过期就变成了全站雪崩。

图 4:从缓存到期到全站雪崩,不到 5 秒。
6. 怎么修
P0 止血三件事:
第一,加索引。 这是最根本的一刀。
全表扫描变索引查找,查询时间从秒级降到毫秒级。单条查询不慢了,后面的问题就算还在,影响面也大幅缩小。
第二,修缓存击穿。
Caffeine.get(key, loader) 对同一个 key 内部加锁,只有一个线程执行加载逻辑,其余线程等结果。彻底消除惊群效应。第三,限定查询列。 不查不需要的 TEXT 大字段。
P1 加固两件事。一是连接池
maxActive 从 16 调到 50,提高容错水位。二是下游服务也加一层缓存——配置数据变化极低频,不应该每次 RPC 都查库。7. 回过头看
这次复盘让我重新理解了"防御性编程"这五个字。
以前我觉得防御性编程就是处理好异常、写好单元测试、加好降级逻辑。但这次的问题,没有一个环节是"异常"的——索引没报错,SQL 没报错,缓存没报错,连接池也没报错。每个组件都在按设计工作,但合在一起,炸了。
防御性编程真正的含义,不是处理已知的异常,而是假设每个环节都可能出问题,然后确保问题不会扩散。

图 5:三个单独看都不致命的问题,叠加在一起就是全站雪崩。
具体到这次,我觉得有几条值得记住:
- 写查询前先看索引。 不是写完再补,是写之前就确认能走什么索引。联合索引的列顺序,决定了你的查询能不能用上。
- SELECT * 在有 TEXT/BLOB 字段的表上是定时炸弹。 尤其是通过 RPC 传输的场景,大字段的代价每一跳都在放大。
getIfPresent+put在高并发下等于没缓存。 用get(key, loader)或者分布式锁,别自己手动作缓存加载。
- 连接池是所有业务共享的资源。 一条慢 SQL 可以拖垮整个服务。多实例本地缓存不共享,N 个实例就是 N 倍穿透压力。
故障的本质很少是单一原因。往往是几个"不大"的问题,在某个时间点刚好凑到一起,一个引爆另一个。你能做的不是消灭所有问题——那不可能。你能做的是,别让一个小问题有扩散成全站雪崩的机会。
- 作者:Yibin
- 链接:https://yibin.dev/article/37960b50-99a4-80a3-b385-deff53e32068
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章







