乐于分享
好东西不私藏

软件测试面试:慢SQL原因

软件测试面试:慢SQL原因
这篇文章面试,它又要凉了里面第10题面试题。这道题是在问完软件测试面试:ClickHouse自身什么情况会变慢之后紧接着问的,连着两个问题有异曲同工之妙,答案很相似。现在回过头来看,当时面试官应该是想引导我回答(友好的面试官啊)。

以下内容有AI辅助整理,介意的话,勿看。


一、先说ClickHouse

1. ORDER BY 跟查询条件对着干

先看一个例子。某个表,如果建表语句是这样的,然后业务方经常按 event_type 查,会有什么问题?

-- 建表时的排序键ORDER BY (timestamp, user_id, event_type)-- 业务方的查询SELECT count() FROM events WHERE event_type = 'login'

ClickHouse 的索引不是 B+树,是稀疏索引。它会把你写入的数据按 ORDER BY 的列顺序排序,然后每 8192 行记录一个"标记"。查询的时候,必须先匹配 ORDER BY 的第一列,才能用到索引。

上面的例子,ORDER BY 的第一列是 timestamp,但查询条件里根本没有 timestamp——只有 event_type。那 ClickHouse 就傻了:我不知道 timestamp 的范围,没法用索引定位,只能把所有数据扫一遍。

怎么改?把高频查的列放前面:

ORDER BY (event_type, timestamp, user_id)

同一个查询,改了之后可能只扫几千个颗粒(granule),而不是全表几百万行。效果就是几十秒变成零点几秒。

联合索引/排序键的原理都一样——查询条件不命中第一列,后面的列全白瞎。


2. 分区太大或太小都不行

一个按天分区的表,单天 5 亿条日志。业务方查"最近一周的数据":

SELECT count() FROM logsWHERE log_date BETWEEN '2025-07-05' AND '2025-07-11'

看起来带了时间条件没错,但每一天是一个分区,分区里面有 5 亿行。就算只扫 7 个分区,加起来也要扫 35 亿行。毛估估也得跑几十秒。

反过来,如果按小时分区呢?一天的日志数据变成 24 个分区,一个月就 720 个分区。分区粒度过细也有问题——ClickHouse 记录每个分区的元数据,分区太多了,光是"确定要查哪些分区"就要花不少时间。可以理解成要在一千个文件夹里找一个文件,光翻目录都翻半天。

建议:日志类数据按天分区基本够用;业务数据(日增百万级)按月。


3. 后台有人在搞事情——Mutation 和 Merge

你正查着数据,感觉忽然比平时慢了好几倍。一查 system.mutations:

SELECT database, table, command, parts_to_do, is_doneFROM system.mutationsWHERE is_done = 0

结果有一条:

database | table | command | parts_to_do | is_done---------|------------|----------------------------------|-------------|--------mydb | events | DELETE WHERE id % 100 = 0 | 1500 | 0

什么意思?有人在跑一个 DELETE,需要重写 1500 个 part 文件。每个 part 是一个独立的数据片段,重写一个 part 就是要把它全读出来,删掉符合条件的行,再写回去。1500 个 part 正在排队等处理,磁盘 IO 被占得七七八八。查询跟它抢磁盘,快不了。

另外还有一个场景——高频小写入。

想象一下:应用每秒调几百次 INSERT,每次都只写几行。每次 INSERT 都会在磁盘上生成一个新的 part 文件。一分钟后可能已经有几千个细碎的 part 了。

ClickHouse 后台有个 merge 线程,专门负责把这些小 part 合并成大 part。但 merge 要是跟不上写入速度,积压就越来越严重。这会导致两个问题:一是查询时要打开几千个小文件,文件句柄不够用;二是 merge 本身大量读写磁盘,相当耗资源。

一个典型的飙慢路径就是:小批量高频写入 → part 碎片堆积 → merge 满载 → 查询被挤压


4. 内存不够,落到磁盘跑

举个 GROUP BY 的例子:

SELECT user_id, count() FROM events GROUP BY user_id

假设 events 表有 5 亿行,user_id 的取值有几千万种。GROUP BY 要维护一个 hash 表,把每个 user_id 对应到一个计数器的内存地址。

ClickHouse 给单个查询分配的内存是有上限的(参数叫 max_bytes_before_external_group_by)。如果内存装不下这个 hash 表了,怎么办?它不会报错让你重来,而是把装不下的部分先临时写进磁盘,等你继续查的时候再从磁盘读回来。

内存在跑就是毫秒级的事。一旦开始读写临时文件,性能直接断崖式下跌。就好像:本来在桌子上摊开作业写,桌子不够大了,开始蹲在地上写,还得不停站起来去桌上拿这拿那。

怎么看有没有溢写:去 system.query_log 看,如果查询执行时间很长但没报 OOM,而且 peak_memory_usage 摸到配置的上限——大概在偷偷走磁盘。

ORDER BY、JOIN、DISTINCT 同理,内存满了就溢写,溢写就变慢。


5. 分布式集群——查询被"放大"了

连的是一个分布式表,这个表下面挂了 4 个分片(shard),每个分片上有一张本地表存实际数据。然后跑了一条:

SELECT count() FROM distributed_events

这条查询的实际执行路径是:发起节点收到请求,把 SELECT count() 原封不动地发到 4 个分片上去执行。4 个分片各自扫完自己的本地表,返回一个数字。发起节点再把 4 个数字加起来给你。

数据量小的时候没事。但如果每个分片都存了几亿行,4 个分片同时全表扫,磁盘 IO 直接叠加——这个就叫"查询放大"。

怎么缓解:带分区键条件去查,或者用本地表直接查单个分片。

还有一个坑是 GLOBAL IN:

SELECT * FROM distributed_AWHERE user_id GLOBAL IN (SELECT user_id FROM distributed_B WHERE ...)

子查询的结果要先从各节点汇总到发起节点,再分发回各节点。如果子查询的结果集有几百万条……发起节点内存直接炸了。


二、关系型数据库

再看下MySQL这类关系型数据库,作为扩展。

1. 索引——看不见不代表它没失效

很多人觉得"加了索引就应该快"。但索引用不上的场景比较多:

对索引列做了函数

SELECT * FROM orders WHERE DATE(create_time) = '2025-07-11'

create_time 列上明明有索引。但是用 DATE() 函数把它包了一层。MySQL 不认识 DATE(create_time) 等于什么,它必须把每一行的 create_time 都算一遍 DATE(),才能过滤。索引全程没用。

改成这样就走了:

SELECT * FROM ordersWHERE create_time >= '2025-07-11 00:00:00' AND create_time < '2025-07-12 00:00:00'

类型对不上——隐式转换

手机号字段 phone 是 varchar(20),你的查询写成:

SELECT * FROM users WHERE phone = 13800138000

字符串列查数字——MySQL 内部会把 phone 列的值逐行转成数字再去比较。索引又废了。

前缀模糊

SELECT * FROM articles WHERE title LIKE '%面试%'

B+树索引是从左往右匹配的。% 在最前面,MySQL 不知道从哪开始。除非上全文索引。


2. 分页翻到后面越翻越慢

翻页功能,前几页秒开,翻到一百多页开始转圈。原因:

SELECT * FROM orders ORDER BY id LIMIT 100000010

MySQL 想要第 100 万行开始的 10 条数据。但它没有"直接跳到第 100 万行"的能力,只能从第 1 行开始数,数到 100 万行,丢掉,再取 10 条。相当于为了拿 10 条记录,扫了 100 万条。


3. 锁——不是查询慢,是被人堵住了

-- 事务A(不提交)BEGIN;UPDATE inventory SET stock = stock - 1 WHERE product_id = 12345;-- 跑去接水了……10分钟没回来-- 事务BSELECT * FROM inventory WHERE product_id = 12345 FOR UPDATE;-- 干等着,因为事务A拿着这行的排他锁

从监控上看,事务B的查询跑了 5 分钟还没完。但它不是真的在执行——它就是在排队等锁。这种"慢"用 EXPLAIN 分析是看不出来的。

长事务是锁竞争的温床。开了一个事务,中间写了几行没提交,后面的人全排着。


三、其他非关系型数据库

看看两个常见的NoSQL。

MongoDB —— 文档嵌套是双刃剑

假设把用户和它的订单存成一个文档:

{ ”user_id”: ”U001”, ”name”: ”张三”, ”orders”: [ { ”order_id”: ”O001”, ”amount”: 100, ... }, { ”order_id”: ”O002”, ”amount”: 200, ... }, // ... 这个用户买了几万单,全塞在这个数组里 ]}

现在要查"所有用户中,有没有一笔订单金额是 999 元"。数据库必须把每个用户的整个文档(包括那个几万条订单的巨型数组)全部加载到内存里,再一条一条翻订单。一个用户就一个巨型文档,扫完全表,加载的数据量可能是真正需要的几百倍。

这就是"数据模型和查询方式不匹配"——存的时候想着"订单归用户管",但查的时候是按订单维度查的,模型就对不上了。


Redis——单线程怕重活

Redis 是单线程模型,所有命令排队处理。

KEYS order:*

假设有 100 万个 key。KEYS 会遍历整个 key 空间进行正则匹配。执行期间,其他所有请求全部排队干等。生产环境跑这个命令,基本等同于自己给自己挂了一个几秒甚至十几秒的阻塞。

还有一种情况。一个 Hash 结构:

HSET user:info:10086 field1 val1 field2 val2 ... field5000000 val5000000

然后某天执行:

HGETALL user:info:10086

500 万个 field 的一次性拉取,网络传输加上内存复制,服务在这期间没法响应其他请求。

用 SCAN 替代 KEYS,用 HSCAN 替代 HGETALL 分批拿——思路一样,都是化整为零。


四、面试时怎么答

AI说了这么多,现场不可能全背啊。我的建议,也准备以后这样做:

第一步:接住语境

如果前面一直在聊你的项目,就说项目中用到的对应数据库,然后其他类型的数据库有不一样的地方,再展开说。比如我应该:"我的项目中主要用到ClickHouse,刚才我们也一直在聊这个数据库,慢SQL可能会是以下几种情况(总结性说): 

1、ORDER BY 跟查询条件对着干;

2、分区设置不合理,太大或太小都不行;

3、后台在抢资源,Mutation 和 Merge;

4、内存不够了,落到磁盘跑;

5.、分布式集群,查询被"放大"了。"

第二步:挑两个有印象最深刻的

比如ClickHouse, 索引排序键和后台 mutation,各举一个能讲清楚的例子:"我们线上遇到过,ORDER BY 把 timestamp 放第一列,但大多数查询是按 event_type 查的,索引用不上,几十秒起步。还有就是有同事跑了一条 DELETE,后台重写了几千个 parts,那段时间全表的查询都慢了。"

第三步:补充知道的其他数据库类型

一笔带过其他类型的数据库,面试官感兴趣的话,会追问细节。最后加一句排查思路,体现下实战经验。

"其实除了ClickHouse,产品中还经常用到PostgreSQL,像关系型数据库,我了解到的,索引失效和深分页是比较常见的慢sql原因。NoSQL 的话,模型和查询不匹配会导致慢sql,比如 MongoDB 文档嵌套太深。一般排查的思路都可以归结为——先看是不是索引/键的问题,再看是不是别人在抢资源。"

End.希望有帮助。