那天下午,监控突然炸了。
核心服务在短时间内批量报 QueryTimeoutException。我打开日志一看,满屏都是同一类错误:
三个数字一模一样:active 16,maxActive 16,runningSqlCount 16。连接池被打满了,一个不剩。
更诡异的是,报错的 SQL 五花八门:查用户订阅的 COUNT、查红点状态的 SELECT、查用户标签的查询……看起来毫无关联的业务全部超时。
出问题了。但问题不在这些报错的 SQL。

1. 先找谁占住了连接

连接池只有 16 个连接。既然全部被占,那一定有人在占着不放。
我把每条超时异常的 Cause 链追到底,发现真正占住连接的,始终是同一条 SQL:
一条配置查询,把 16 个连接全部霸占。其余所有业务查询排队等 5 秒后超时,形成连锁故障。
这就怪了。配置查询按理说应该是毫秒级返回的东西,怎么会把连接占住不还?
先还原一下调用链路。故障涉及两个服务:
正常流程不复杂:上游消费 MQ 消息,先查本地缓存,命中直接返回;没命中就 RPC 调下游查库,再写缓存。
看起来挺合理。但就是这个看起来合理的链路,藏了三个问题。
notion image
图 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 占满?
 
notion image
图 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 缓存独立,过期时间接近。也就是说多个实例几乎同时发生缓存失效,压力被进一步放大。
到这里,故障的全貌就清楚了。
notion image
图 3:缓存击穿——TTL 到期瞬间,所有线程同时穿透到数据库。

5. 把时间线拼起来

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

6. 怎么修

P0 止血三件事:
第一,加索引。 这是最根本的一刀。
全表扫描变索引查找,查询时间从秒级降到毫秒级。单条查询不慢了,后面的问题就算还在,影响面也大幅缩小。
第二,修缓存击穿。 Caffeine.get(key, loader) 对同一个 key 内部加锁,只有一个线程执行加载逻辑,其余线程等结果。彻底消除惊群效应。
第三,限定查询列。 不查不需要的 TEXT 大字段。
P1 加固两件事。一是连接池 maxActive 从 16 调到 50,提高容错水位。二是下游服务也加一层缓存——配置数据变化极低频,不应该每次 RPC 都查库。

7. 回过头看

这次复盘让我重新理解了"防御性编程"这五个字。
以前我觉得防御性编程就是处理好异常、写好单元测试、加好降级逻辑。但这次的问题,没有一个环节是"异常"的——索引没报错,SQL 没报错,缓存没报错,连接池也没报错。每个组件都在按设计工作,但合在一起,炸了。
防御性编程真正的含义,不是处理已知的异常,而是假设每个环节都可能出问题,然后确保问题不会扩散。
notion image
图 5:三个单独看都不致命的问题,叠加在一起就是全站雪崩。
具体到这次,我觉得有几条值得记住:
  • 写查询前先看索引。 不是写完再补,是写之前就确认能走什么索引。联合索引的列顺序,决定了你的查询能不能用上。
  • SELECT * 在有 TEXT/BLOB 字段的表上是定时炸弹。 尤其是通过 RPC 传输的场景,大字段的代价每一跳都在放大。
  • getIfPresent + put 在高并发下等于没缓存。 用 get(key, loader) 或者分布式锁,别自己手动作缓存加载。
  • 连接池是所有业务共享的资源。 一条慢 SQL 可以拖垮整个服务。多实例本地缓存不共享,N 个实例就是 N 倍穿透压力。
故障的本质很少是单一原因。往往是几个"不大"的问题,在某个时间点刚好凑到一起,一个引爆另一个。你能做的不是消灭所有问题——那不可能。你能做的是,别让一个小问题有扩散成全站雪崩的机会。
Typora 发布了1.0 版本,你会为它付费吗?AI 生成的代码有 bug,修还是重来?我的三层判断法
Loading...