数据库性能的排查工作,通常从有人提议换一台更大的实例开始,又通常以这样一个发现结束:每次加载页面时,有一条查询都在对 400 万行做顺序扫描。瓶颈从来不是硬件,瓶颈是执行计划。
这个模式重复得足够稳定,值得当作默认假设写下来。当应用很慢而数据库又很忙时,原因几乎总是少数几条具体的查询,而不是整体容量不足;把机器换大,只能把问题掩盖到表再次长大的那一刻为止。
动手改之前,先量。 凭猜测去优化一条查询,正是团队花掉整整一周添加索引、结果写入变慢而读取一点没快的原因。任何数据库都能告诉你哪些语句消耗的总时间最多。从那里开始,修掉排在最前面的那条,然后再量一次。这样反复两三轮,事故通常就结束了。
数据库性能始于找出那条查询
总时间比最坏单次更重要。一条耗时两秒、每天只跑两次的查询无关紧要。一条耗时四十毫秒、每分钟却跑 8,000 次的查询才是你的问题,而且它永远不会出现在阈值设为一秒的慢查询日志里。
在 Postgres 中,pg_stat_statements 扩展统计的正是这些:按归一化后的语句给出调用次数、总时间和平均时间。按总时间排序,罪魁祸首通常就在前三行里。MySQL 通过 performance schema 提供类似的聚合能力。
在断定问题出在查询本身之前,有两件事值得先确认。它是每次都慢,还是只在某些时段慢?后者指向资源争用,而不是执行计划。它是单独执行也慢,还是只有在并发上来时才慢?后者指向锁或连接数上限。
读执行计划,而不是靠猜
拿到语句之后,去问数据库它打算怎么执行。Postgres 通过 EXPLAIN 把这些暴露出来
,其中重要的变体是 EXPLAIN ANALYZE,它会真正执行这条查询,报告的是实测耗时而不是估算值。
在那份输出里,承载了大部分信号的是三样东西。
大表上的顺序扫描。 数据库正在读取每一行。在小表上这既正确又快。在大表上,它意味着你写的那个条件没有可用的索引,或者规划器认为用索引并不划算。
估算行数与实际行数之间的巨大差距。 规划器是依据统计信息挑选策略的,所以当它的估算偏差达到数量级时,它做出的糟糕选择跟你的查询本身毫无关系。统计信息陈旧是常见原因,而且很容易修复。
时间集中在某一个节点上。 执行计划是一棵树,该修的地方就是消耗掉时间的那个节点。优化其他任何位置都不会带来变化。
一看到顺序扫描就想加索引,这种本能往往是对的,但仍值得忍住三十秒,因为索引没有被使用的原因,有时比索引不存在这件事更重要。
为什么索引帮不上忙
存在的索引,未必是被用上的索引。
条件不可索引。 把列包进函数里,或者对列做算术运算,通常会让该列上的索引用不起来,因为索引存的是列的原始值,而不是变换之后的值。把条件改写成让列保持原样,通常就能恢复索引的使用。
复合索引的列顺序错了。 复合索引服务的是使用其前导列的查询。一个先按某列、再按另一列建立的索引,帮不了只按第二列过滤的查询,而人们在这里反复栽跟头。
规划器认为全表扫描更便宜。 如果一条查询要返回表中很大一部分数据,顺序读取确实比在索引里跳来跳去更快。这是正确行为,该修的是让它返回得更少。
统计信息过期。 在批量导入或大批量删除之后,规划器对数据的认识可能严重失真,直到统计信息被重新收集为止。
而且每个索引都有代价。写入必须维护它,它还占用本可以用来缓存数据的内存。一张挂着十五个索引的表,通常有好几个是没人需要的,而每一个都会让每次插入变得更慢。
N+1 问题仍然是最大的单一原因
应用变慢,来自这里的比来自任何执行计划问题的都多,而且它永远不会以慢查询的形式出现,因为单条查询本身都很快。
它的形态很熟悉。取回一份 100 条记录的列表,然后遍历它,为每一条再去取关联数据。结果就是本来一两次就够的地方,产生了 101 次往返。每条查询三毫秒就返回,页面却仍然要花半秒,因为成本在往返次数上,不在实际工作量上。
对象关系映射器让人很容易在不知不觉中写出这种代码,因为读取关联数据看起来像是访问一个属性,而不像一次数据库调用。修法是把关联数据和父集合一起用一条查询加载出来,成熟的 ORM 都支持这么做,而大多数在默认情况下偏偏不这么做。
检测起来并不复杂:数一数每个请求发出多少条查询。如果一个页面发出的查询数量与页面上展示的条目数成正比,那就找到了。当应用在边缘节点上变慢时,这也是最值得优先做的一项检查,正如我们的 Cloudflare Hyperdrive 指南所讲的那样,因为距离越远,每一次往返的代价就越高。
连接与争用
有两个问题看着像慢,其实不是。
连接耗尽。 每个数据库对并发连接数都有上限,而每个连接都要占内存。当应用打开的连接超过连接池允许的数量时,请求就会排队等待可用连接,于是数据库明明闲着,应用却显得很慢。症状是应用侧延迟很高而数据库 CPU 很低,修法是使用连接池,而不是换一台更大的机器。
锁争用。 一个长事务握着锁不放,会把排在它后面的一切都堵住。常见原因是事务被跨越了根本不需要数据库的工作而一直开着,比如中间还夹着一次对另一个服务的 HTTP 调用。请把事务保持得短,并且只圈住真正操作数据库的那部分。
这两种情况都值得尽早排除,因为两者都很容易被误读成查询问题,而且都不是加索引能解决的。
按什么顺序动手
先找出消耗总时间最多的那些语句。对其中最糟的一条跑 EXPLAIN ANALYZE,读清楚时间到底去了哪里。在优化任何东西之前,先看每个请求的查询条数,把 N+1 排除掉。加索引之前先刷新统计信息,因为有时候这就是全部的修复动作。然后再加上能服务该条件的最窄的那个索引,并重新测量。
Mecanik 把这件事作为我们软件开发 工作的一部分,而结果几乎总是一样的:责任在两三条查询身上,修复很小,那台没人买过的更大实例,自始至终都没有必要。
相关文章: API 版本管理:何时该破坏兼容,以及如何不破坏 、2026年如何构建Web应用:英国开发者指南 、密码存储:2026年该用什么 、英国定制软件开发:买家完整指南 。
常见问题
我怎么找出是哪条查询拖慢了应用? 按总时间排序,而不是按最坏单次排序。一条四十毫秒、每分钟跑 8,000 次的查询,代价远高于一条两秒、每天跑两次的查询,而且它永远不会出现在阈值设为一秒的慢查询日志里。在 Postgres 中,pg_stat_statements 按语句聚合调用次数和总时间;MySQL 通过 performance schema 提供同样的能力。
EXPLAIN ANALYZE 的输出里该看什么? 有三样东西承载了大部分信号:大表上的顺序扫描,说明没有可用索引;估算行数与实际行数差距很大,说明规划器在用糟糕的统计信息干活;以及时间集中在计划树的某一个节点上,那里正是该修的地方。优化其他任何节点都不会带来变化。
我的索引为什么没被用上? 通常是四个原因之一。条件把列包进了函数或算术运算里,索引因此不再匹配。复合索引的列顺序不支持这条查询。查询返回的表数据比例足够大,以至于全表扫描确实更便宜。或者在批量导入或删除之后,统计信息已经过期。
什么是 N+1 查询问题? 先取回一份记录列表,然后为其中每一条单独发一次查询去取关联数据,于是在本来一两次就够的地方产生了 101 次往返。它不会出现在慢查询日志里,因为每条查询都很快,成本在往返上。检测方法是数每个请求的查询条数,看它是否与展示的条目数成正比。
换一台更大的数据库服务器能解决慢查询吗? 很少能,而且只是暂时的。当应用很慢而数据库又很忙时,原因几乎总是少数几条具体的查询,而不是容量不足,所以升级配置只能把问题掩盖到表再次长大为止。应用侧延迟高而数据库 CPU 低,通常反而说明连接池已经耗尽,而这不是更多硬件能解决的。
评论