☰
数据库性能优化:连接池、长事务与N+1查询程序侧规范
2026/10/14 4:41:15 网站建设 项目流程

上周三晚上十一点,我刚躺下,手机就开始震。监控平台弹出一堆告警:一个内部运营后台的数据库 CPU 直接从 30% 冲到 90%,慢查询数量翻了十几倍。我第一反应是某个暴力 SQL 又上线了,赶紧抓了慢日志出来看,结果很意外——慢日志里全是平时秒回的小查询,只是每一条都多等了 100 到 300 毫秒。真正的问题最后在连接池监控图上暴露了:几十个后台任务同时启动,池子里连接不够,线程互相抢,程序里又有很多循环小查询,一叠加,数据库自然就忙不过来了。

这个场景做开发和运维的同事应该都熟悉:SQL 优化了,索引也加了,架构上也没大毛病,但性能还是不达标。这时候往往该往程序操作习惯上找原因了。这篇是数据库性能优化系列的第三篇,前两篇分别聊了索引与 SQL 写法优化、库表与架构层面的优化手段。这篇换个视角,不写 SQL,也不谈如何加索引,专门说程序端会影响数据库性能的操作习惯:连接怎么建、事务怎么管、查询怎么循环、写入怎么批量、取数怎么分页、哪些压力根本不该落到数据库上。对后端开发来说可以直接落进代码,对运维同学来说也可以照着去排查系统里的同类问题。

1. 连接池参数不是默认值就够:程序访问数据库的第一道瓶颈

1.1 建立一条数据库连接为什么这么贵

很多程序刚跑起来的时候性能都挺好,连接池默认配置也能撑住,但一到高峰期就开始出问题。这其实不能说连接池“默认值不对”,而是连接建立这件事本身的成本比很多人想象的高得多。

新建一条数据库连接,不只是 TCP 三次握手那么简单。客户端和服务端还要完成 协议握手、身份认证、会话状态初始化 这些环节;如果启用了加密传输或者需要加载权限信息,额外开销会更大。对数据库来说,每一条连接都要占用线程、内存和会话资源。问题是程序里每新建一条连接,这一整套流程就要重来一遍,而且连接被 close 之后,TCP 层还会残留一批 TIME_WAIT 状态。所以在高并发场景下,频繁建连不是“慢一点”这么简单,而是会让数据库的 CPU 大量消耗在握手和资源初始化上,真正执行 SQL 的算力反而被挤掉了。

连接池解决的就是“复用”的问题。提前建好一批连接放在池子里,程序需要的时候拿一条,用完再还回去,这样建连成本被摊薄到整个生命周期里。但池子本身也有参数要调,参数设置不合理时会出现两种极端:一种是池子太小,请求全堵在取连接这一步;另一种是池子太大,数据库被大量空置连接拖垮。

1.2 连接池参数应该按什么思路去调

以常见连接池框架的配置项命名习惯为例,几个关键参数的含义和调整思路大致如下:

参数含义调整思路
maximumPoolSize连接池允许的最大连接数根据数据库规格、单连接开销和业务并发估算,不要放太大
minimumIdle池中保持的最小空闲连接数按业务平峰期的稳定流量来设定,避免高峰时突发建连
connectionTimeout从池中获取连接的等待超时一般设 500 到 1000 毫秒,让失败快速暴露而不是无限等
maxLifetime连接的最大存活时间建议小于中间网络设备对空闲连接的回收时间,通常 30 分钟左右
validationTimeout连接有效性校验的超时时间只对拿出来的连接做轻量校验,不建议每次请求都做重量级检测

一个比较常见的配置示例大致是这样:

maximumPoolSize = 50 minimumIdle = 10 connectionTimeout = 1000 maxLifetime = 1800000 validationTimeout = 3000

这里要强调一下,不要照抄参数值,因为每个系统的数据库规格、业务量、平均 SQL 耗时都不一样。我习惯的做法是先看数据库侧能承受的连接上限,再结合应用侧的并发量来算。假设数据库最大连接数是 200,应用里通常同时可能有 80 个线程在执行 SQL,那池子设 80 到 100 就够了,再放大对吞吐没有帮助,反而会增加数据库侧的上下文切换。反过来,如果池子只有 10 而业务峰值需要 40 个并发,那就不是调池子的问题了,是要么限制业务并发,要么提升数据库规格。

1.3 连接耗尽让慢日志充满小查询的排查过程

回到开头那个事故。当时慢日志里的单条 SQL 全部都是毫秒级的小查询,但耗时都被拖到了几百毫秒。直觉告诉我这不是 SQL 本身的问题,于是先看数据库的最大连接数和当前活跃连接数,同时让应用侧打印连接池的日志,重点看 activeCount、idleCount 和等待获取连接的时间。

结果一出来就清楚了:连接池最大只有 10,而当时有 30 个后台任务线程同时在跑,每个任务线程都需要反复从池子里获取连接做“先查后写”。因为任务代码把大量耗时的业务操作和数据库操作全都塞在一个事务里,连接被事务长时间占着不归还,后面的线程只能排队等待获取连接。等线程数一多,等待超时开始报错,部分线程就重试,重试又进一步增加压力,数据库 CPU 就这么被打上去了。

这个案例后来做了三处改动:把连接池最大连接调到 60,同时限制了后台任务的并发数;把事务范围缩小,只让连接在真正读写期间被占用;把循环里的小查询改成批量查询。改动之后 CPU 直接回落到 30% 以下。

2. 事务边界没控住,整个数据库陪你一起等锁

2.1 长事务为什么是性能优化里一定要排查的隐形雷

很多人对事务的理解停留在“要么全部成功、要么全部失败”这个层面,但事务在数据库内部还有另一层作用:它决定了锁和版本数据的生命周期。只要事务不提交,它持有的行锁、间隙锁就不会释放,别的会话要改同一行就只能排队等待。与此同时,事务开启后产生的版本数据也不能立即清理,因为数据库要保留旧版本供其它事务做一致性读。

后果就是,一个事务如果拖了几十秒甚至几分钟,旁边的所有写操作都在等它手里的锁,而读操作也可能因为版本链变长而变慢。更麻烦的是,这种影响往往是横跨多张表、多个业务的。比如你在一个事务里更新了订单表,顺便调用了外部接口卡了 5 秒,这 5 秒内所有想更新同一批订单的线程都会堆在锁等待上。数据库 CPU 可能没有明显上升,但业务响应时长会噌噌往上涨。

2.2 几个让事务无意识变长的程序写法

做后端时间长了,基本一眼就能看出哪些代码会让事务失控,常见的有这么几类:

  • 在事务里调用外部 HTTP 接口、远程 RPC 或者等待消息返回;
  • 在事务里 sleep、做复杂的本机计算、解析文件、生成报表;
  • 一个事务里循环几千上万条数据做逐条写操作,中途甚至不打印进度,也不区分提交点;
  • 用声明式事务时方法体过大,一个接口方法从头到尾都包着事务,哪怕里面大部分是读操作;
  • 事务内部嵌套事务,使用时传播行为配置错误,导致整个调用链都挂在同一把事务上。

事务里调外部接口是特别常见的问题。外部接口的响应时间完全不可控,一旦对方出现抖动,数据库事务就被动拉长,锁也一直拿着不放。我见过最夸张的一次,一个订单状态更新接口在事务里调了支付渠道查询,对方超时了 15 秒,结果数据库里相关的订单行集体锁了十几秒,下游所有渠道的更新全被堵死。

2.3 让事务变得更短更轻的具体做法

指导思想很简单:事务只包住必须保证一致性的写操作,读操作、外部调用、耗时计算全部放到事务外面去。

具体操作上,建议做四个调整。第一,把外部调用从事务代码里挪出去,用先本地标记状态、等外部结果返回后再提交事务的方式处理;第二,如果事务里有大量循环写,要给每批次设置独立的提交点,不要让整个循环变成一个巨型事务;第三,对于只读的查询链路,可以显式开启只读事务,告诉数据库我这一段的开启只为读,不需要准备写锁和回滚方案;第四,给事务设置合理的超时时间和锁等待超时,宁可快速失败也不无限阻塞下去。

要排查线上有没有长事务,可以用下面这条语句,在 MySQL 里通过系统库直接看:

select trx_id, trx_state, trx_started, round(TIMESTAMPDIFF(SECOND, trx_started, now())) as trx_age_seconds, trx_query from information_schema.innodb_trx order by trx_started asc;

如果一个事务的 trx_age_seconds 持续在几十秒以上,那基本就可以确认代码里有长事务了。结合应用日志找出事务相关的线程 ID,再顺藤摸瓜找到是哪段业务逻辑把事务拖住的。这个过程几乎每次都能命中前面说的几类问题。

3. 循环里的参数化查询:N+1 是程序侧最典型的拖垮库的操作

3.1 1 + N 次查询是怎么在代码里悄悄长出来的

N+1 问题用大白话说就是:先查一次主表数据拿到 N 条记录,然后再循环每条记录去查一遍关联表,总共执行 1 + N 条 SQL。N 小的时候看不出什么问题,但 N 是几百几千的时候,程序对数据库发起的请求量就被放大了 N 倍。

最典型的场景是 ORM 的懒加载机制。程序只查了一个列表,但在遍历列表时访问了某个关联属性,框架就会自动为每一条记录执行一次额外的查询。日志里会看到一堆只有条件值不同的相似 SQL,这就是一条一条的 N+1。

下面这种写法可以说是反面教材里的典型:

// 伪代码示例:先查 100 条订单,再循环查每条订单的明细 List<Order> orders = orderMapper.selectByFilter(filter); for (Order order : orders) { List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId()); order.setItems(items); }

如果订单量是 500,这个代码就会产生 501 次数据库查询。改成批量聚合之后,只需要两条查询:

List<Order> orders = orderMapper.selectByFilter(filter); List<Long> orderIds = orders.stream().map(Order::getId).toList(); Map<Long, List<OrderItem>> itemMap = orderItemMapper .selectByOrderIds(orderIds) .stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); orders.forEach(o -> o.setItems(itemMap.getOrDefault(o.getId(), new ArrayList<>())));

3.2 数据库为什么经不起这样“高频小考”

一次两条 SQL 和一次五百条 SQL,区别远不止是数量。每一次 SQL 都要经历网络往返、解析、优化、执行、返回结果的完整过程。N+1 产生的额外代价里,最贵的往往不是数据库执行那几下,而是大量重复的请求在网络和解析环节消耗的资源。

举一个容易理解的类比:这就好比你去办事大厅,把你想要的清单一次性递给窗口,跟你在窗口前面排队刷了 500 次磁条,虽然最终处理内容一样,但给系统带来的压力天差地别。

到了高并发场景,N+1 会被进一步放大。单个请求多出几百条 SQL,同时在线人数乘上去之后,数据库的 QPS 就变成一个很夸张的数字。而这些 SQL 又大多是简单查询,数据库端大量 CPU 花在解析和上下文切换上,真正有价值的查询反而被延缓了。所以线上排查时发现某个接口的数据库 QPS 奇高,但慢日志里看不到大查询,第一反应就应该是看看有没有 N+1。

3.3 三种修复方式,以及各自的取舍

修复 N+1 主要有三种思路。

第一种是批量 IN 查询,把 N 次查询合并成一次。上面例子里的做法就是这种,最常用也最容易理解。要注意的是 IN 里的元素数量不能太大,几千上万的 IN 条件本身也会成为大 SQL,建议拆成每 500 到 1000 个一批去查。

第二种是用 JOIN 一次查出主表和关联表的数据,应用侧再按主键拼装。这种适合一对一或者关联数据量小的场景,一对多时要小心结果集膨胀,一条主记录如果对应几十条明细,JOIN 出来的行数会被放大,传输和内存开销反而得不偿失。

第三种是在应用层做缓存,把频繁查询的配置类数据、字典类数据放在内存里,循环里直接读内存而不是查数据库。这是对 N+1 的“降维打击”,适合读多写少且允许短暂不统一的场景。

3.4 怎么一伸手就抓到 N+1

N+1 定位起来不难,难的是很多人平时没留意。我常用的方法就两个。

第一,把 ORM 的 SQL 日志打开,跑一遍接口,然后统计单次请求产生的 SQL 条数。如果一个请求产生了两位数以上的 SQL,就要仔细看是不是循环查询了;如果有几十条 SQL 只有条件值不同,基本可以断定是 N+1。

第二,在监控工具里看这个接口产生的 SQL 耗时分布。如果总耗时里面网络往返和连接池等待占比极高,而单条 SQL 执行时间都很短,也大概率是 N+1。因为 N+1 的特征就是“每条都短,但次数太多”。

4. 写数据要会批量也要会把持批次,否则一条管道被堵死

4.1 一次一条 insert 和一次一万条都很要命

写操作的优化比读操作更容易被忽略。很多人对读操作会敏感,接口一慢就去看 SQL,但写数据时往往是“先循环 insert 再提交事务”这种最朴素的写法,直到某天数据量上来,数据库开始卡顿。

一次一条 insert 的问题是开销重复。每条 insert 都要走一次网络往返、解析、执行,假如一个业务需要插入 10 万条记录,程序就和数据库来回交互 10 万次。哪怕单次 insert 只有零点几毫秒,叠加网络和解析之后总时间也会很惊人。

不过反过来,把 10 万条记录一次性打包成一个 insert 或一个事务也非常危险。单个事务过大,回滚日志会急剧膨胀,持有锁的范围和时间都会增加,一旦中间出问题要回滚,恢复代价同样巨大。而且大批量写操作执行期间,其它事务很可能被堵在锁等待里,整个库的响应时间都会恶化。

4.2 合理的批量大小需要试出来

写操作的优化思路在“批量”和“分批”之间找到平衡点。先把单条循环改成批量写,比如一条 insert 语句插入多行,或者用数据库驱动的原生批处理接口;然后再把批量结果分成多个批次,每个批次单独提交。

批次的粒度没有标准答案,需要根据数据行大小、表结构、数据库负载来调整。我个人的习惯是先从一个比较保守的值开始,比如每条一批包含 200 到 500 行,观察数据库的负载和耗时;如果表现平稳,再逐步加大到 1000 行左右。一批几千行数据如果字段很多,单条 SQL 本身就会变得很大,协议的传输和解析成本也会上升,不见得比小批次快多少。

给一个非常粗略的参考表:

数据量反向做法正向做法
100 到 1000 行循环单条 insert单条多值 insert,一次提交
1 万到 10 万行一个大事务包住所有每 500 到 1000 行一个批次提交
百万级以上无脑循环或一条超大 SQL分批写入,批次间留出间隔,配合进度记录

4.3 分批提交方案和失败恢复

分批提交遇到的一个问题是,如果第 3 批写失败了,前面两批已经提交,数据库里就会出现“部分成功”的状态。很多业务场景其实可以接受这种状态,只要程序里有对应的补偿或重试机制。

我的做法是给批量任务加一个进度游标,每成功一批就把游标记录下来,失败之后从游标位置继续跑,而不是从零开始。如果任务本身要求原子性,那批次的粒度就要和事务绑定,保证每一批内部全部成功或全部回滚。

还有一点容易被忽略:跨多行的批量更新,为了让多个事务尽量并行而不是互相等待,程序里应该先把要更新的主键排序,再分批处理。如果不排序,多个线程同时更新同一组数据的不同顺序,非常容易产生死锁。死锁在数据库层面存在一个致命特性:资源不会自己缓解,需要靠程序重试才恢复。

4.4 批量更新也能顺手降低锁竞争

批量更新本身比批量插入更容易造成锁竞争。更新操作会触发行锁,还会因为二级索引的维护产生额外的写放大。一次 UPDATE 影响的行越多,锁的范围就越大,其它线程需要修改这堆行里的任何一行都要排队。

所以更新大批量数据时,除了分批提交之外,还建议给每个批次之间留一点时间间隔。这个间隔不需要很长,几十毫秒到几百毫秒都行,目的是给其它事务留出插队的窗口,避免一个长事务把一小部分热数据的更新权霸占太久。这种做法看起来有点“笨”,但在实际的高并发场景里非常有效,属于用时间换空间的经典操作。

5. 分页取数不是加个 limit 就完事:大偏移分页谁都扛不住

5.1 深翻页为什么这么慢

分页查询几乎是每个系统都有的功能,但绝大多数程序写分页时都默认用 LIMIT offset, count 这种写法。数据量小的时候无所谓,一旦数据量到了百万级别,用户往后翻到第十万条数据时,程序就会执行类似这样的查询:

select id, name, status from t_order order by id desc limit 100000, 20;

这条 SQL 看起来只是取 20 条,但数据库内部的执行方式是:先按照 order by 排序,然后从第一行开始扫描到第 100019 行,最后把前 100000 行丢弃,只返回最后的 20 行。这意味着前面 10 万行的读取、排序、传输全都白干了,而且数据量越大,浪费越严重。

更麻烦的是,深翻页通常不是只执行一次。后台任务如果遍历整张表做导出或数据修复,用 offset 分页翻到几万页,每一页都会重复扫描之前所有页的数据,总耗时呈指数级上升,数据库的磁盘 IO 也会被打满。

5.2 用游标式分页替代偏移量分页

对于需要“按顺序翻页”的场景,最有效的替代方案是游标式分页,也叫 keyset 分页。思路很简单:不跳着数行,而是记住上一页最后一条数据的某个唯一键,下一页从这条数据后面继续取。

假设按主键排序,下一页查询就是这样:

select id, name, status from t_order where id > ?last_max_id order by id asc limit 20;

数据库可以直接利用主键索引定位到 last_max_id 那条记录,然后往后取 20 条,完全不需要扫描前面那些已经被翻过的数据。即使翻到第十万页,查询性能也不会明显下降。

如果业务要求按创建时间排序,那需要建一个(create_time, id)的联合索引,然后下一页用where (create_time, id) > (?, ?)来定位。这里的核心是,排序条件里要么带一个唯一字段,要么用联合唯一组合,否则翻页过程中容易出现数据重复或遗漏。

5.3 后台导出和大批量取数的姿势要换

除了交互式分页,后台导出场景也值得优化。很多程序用 offset 分页一页一页取,然后处理完再取下一页,这种写法的性能瓶颈和大偏移分页完全一样。更合适的方式是先按 id 范围或者时间范围切成多个子任务,每个子任务独立取数,最后汇总。

对于特别大的结果集,还可以用服务端游标或流式查询,让数据库一边产生数据一边由程序消费,避免一次性把几百万行数据全部加载到内存里。流式查询需要注意连接占用和事务边界,取数过程中连接要保持住,所以更建议配合分批提交的方式,边取边释放。

还有一个看起来简单但常被忽略的优化:SELECT 后面不要带 *,只取真正要用的字段。少传几个大字段,网络传输和数据库回表压力都会明显降低,尤其是存在 text、blob 这类大对象时,效果立竿见影。

6. 程序里提前分流:缓存与异步让数据库少接活

6.1 缓存放的位置比选型更重要

很多性能问题到了最后,真正的解法不是让数据库跑得更快,而是让数据库干脆不用处理那么多请求。读多写少的场景,在程序里加缓存是性价比最高的方式。

缓存放哪个层面要分情况。单机应用可以用本地内存缓存,速度快、实现简单,但多实例部署时每个实例各缓存一份,数据一致性需要自己控制。分布式缓存适合多实例共享,但在缓存命中时多了一次网络访问,延迟比本地缓存高一些。实际项目中往往是两级配合:第一级本地缓存,第二级分布式缓存,数据库兜底。

缓存里的数据更新策略比选型更重要。程序写数据库之后,一定要在事务提交成功之后再更新缓存或删除缓存,不能在事务还没提交时就把新值写进缓存。否则一旦事务回滚,缓存里残留的就是错误数据。

6.2 穿透、击穿、雪崩各有各的防法

缓存并不总是可靠,几个常见问题如果没处理,数据库反而会被打得更惨。

缓存穿透是指请求的数据在缓存和数据库里都不存在,每次请求都直接打到数据库。解决思路是缓存空值或默认值,同时在程序入口做一些参数校验,把明显不合理的请求直接拦截掉。

缓存击穿是指某个热点 key 在过期的一瞬间,大量请求同时发现缓存没有,于是全部冲向数据库。解决办法是热点 key 的过期时间不要设成精确的固定值,或者在重建缓存时加锁,让同一时刻只有一个请求去数据库查数据,其余请求等待缓存重建完成。

缓存雪崩是指大量 key 同时失效,导致数据库瞬时压力剧增。解决办法比较简单:给缓存过期时间加一个随机扰动,让失效时间均匀分布,而不是集中在同一个时间点。

6.3 异步化和合并写,削平写入峰值

对于写入操作,如果业务不要求绝对实时,可以考虑把一部分写入异步化。典型场景是访问统计、操作日志、消息记录这类数据,写入频繁但对一致性要求不高。程序可以先落入内存队列或消息中间件,再由后台任务批量写入数据库,这样数据库的写入 QPS 峰值会被明显削平。

异步化不是没有代价。如果业务需要写入之后立刻读到最新数据,异步链路的不确定性会造成可用性问题。所以做异步化之前,先识别哪些操作真正可以接受短时间的最终一致,不要为了异步而异步,否则后续会填不完的坑。

还有一种“合并写”的思路,适合对同一份数据频繁更新的场景。比如某个计数器,同一时间有大量请求都在给同一个 key 加一,与其每次请求都写一次数据库,不如在程序加一层聚合统计,每几秒把汇总结果写入一次。写次数从百万级降到个位数,就是这个思路的价值。

7. 可以直接抄的检查清单:把程序操作优化变成代码规范

7.1 程序侧优化自查对照表

技术文章写了再多,落地还是靠一份可以照着勾的清单。下面这张表是从我排过的实际问题里提炼出来的,适合在代码评审和性能排查时逐项对照:

检查项怎么看期望表现
连接池参数连接池监控,取连接耗时获取连接 P99 在 50 毫秒以内,空闲连接稳定
循环查询ORM SQL 日志统计条数单请求数据库查询数低于几十条
长事务information_schema.innodb_trx事务时长绝大多数在秒级以下
批量写提交慢日志、事务日志不存在循环单条 insert,批大小在合理范围
大偏移分页慢 SQL、执行计划offset 值不超过 1 万,深翻页用 keyset
缓存命中情况缓存监控与数据库 QPS 对比热点数据读 QPS 明显下降
异步/合并写活跃写入 QPS 与业务 QPS 对比数据库写入峰值下降,不再跟业务峰值成正比

7.2 用监控指标验证优化效果,而不是靠感觉

优化做完有没有效果,不能靠“感觉快了”,要用数据说话。我在每次优化之后都会重点盯三类指标:第一是数据库的 QPS 和 CPU 使用率,第二是连接池的获取等待时间和活跃连接数,第三是业务接口的 P99 响应时长。

这三个指标之间是联动的。如果数据库 CPU 降了但接口响应没改善,说明瓶颈可能在网络或应用代码本身;如果接口响应改善了但数据库压力没降,可能是缓存或异步化起了作用;如果响应和数据库都改善了,那这次优化的方向基本就是对的。

排问题时我习惯抓住一个异常指标往深处挖,而不是看到一个慢日志就急着改 SQL。慢查询只是表象,真正的问题可能藏在连接池、事务、循环查询或者分页逻辑里。

7.3 个人体会:程序操作优化本质上是习惯养成

数据库性能优化做到第三篇,有一个体会越来越深:越到后面,拼的不是高深的技术,而是程序操作的习惯。连接用完一定归还,事务范围一定控制,循环里绝对不查库,批量写一定分批,深翻页一定用游标,这些规则每一条单独拿出来都算不上复杂,难的是让每个开发同学都养成肌肉记忆。

我见过不少系统,配置比文档里推荐的还高,SQL 写得也规范,但就是绕不开 N+1 和长事务这两个老毛病。说白了,工具链可以很快升级,代码习惯的转变却需要靠评审和监控一点点磨出来。建议团队里把这份清单放进代码评审模板里,每次提测前对应着自查一遍,比出了故障再追责要省心得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询