
数据库性能优化做到第三篇前面聊了不少环境参数和架构层面的调整但说句实在话很多团队在实践里会发现费了半天劲调好的库上线没几天性能又回去了。问题通常不出在数据库身上而是出在程序怎么操作数据库这件事上。同一个业务功能换个写法数据库的负载可能差出一个数量级。这一篇就专门讲程序操作层面的优化把那些藏在业务代码里的性能杀手一个个揪出来。1. 程序操作优化到底在优化什么先说个我经常遇到的场景某业务上线后数据库CPU飙到90%DBA查了半天没发现慢查询所有SQL都是毫秒级但数据库就是累得不行。最后看监控发现应用层每秒发起了上万次SQL请求每个都很快合在一起就把数据库拖垮了。这就是典型的程序操作问题SQL本身不慢但操作次数太多、操作方式太蠢把数据库的资源耗尽了。1.1 三篇优化的边界在哪里前面两篇如果做过的话大概能理清这个逻辑第一篇环境配置优化解决的是数据库“底子”问题比如内存分配、磁盘IO能力、系统参数。第二篇是数据结构优化解决的是数据“存储”问题比如索引设计、表结构拆分。这一篇程序操作优化解决的是代码“怎么写”的问题。三者的关系很像做菜环境配置是厨房设备数据结构是食材处理程序操作就是烹饪手法。设备再好、食材再新鲜手法不对照样做不出好菜。我见过太多环境配得漂漂亮亮、索引加得满满的库最后死在业务代码的一堆糟糕操作上非常可惜。1.2 从数据库视角看程序的“坏习惯”站在数据库的角度去看看程序代码能发现很多平时不太注意的坏习惯而这些习惯每一条都在实实在在消耗性能。对话太多程序跟数据库之间每一轮交互都有网络开销和解析开销。一个循环里发几百条SQL等于来回跑了几百趟。拿得太多明明只需要几条数据却把整表几万行拉到内存里再慢慢筛。干得太碎能一次批量完成的事情拆成几十次小操作每次都有固定开销。锁得太久事务里夹带外部调用、耗时计算把锁攥在手里不放别的会话只能排队等。查得太狠同样的数据在同一个请求里反复查询多次白白浪费数据库的查询能力。这些坏习惯单看都不致命但叠加在一起数据库的资源就这么被悄悄吃掉了。而且这类问题有个特点——它们不会在开发环境暴露。开发环境数据量小、并发低怎么写都很快。等上了生产数据量一大并发一高量变引发质变问题就集中爆发了。所以在程序阶段就把这些习惯改掉收益往往比调任何数据库参数都来得直接。接下来逐个拆解核心的优化手段。2. 连接与交互层面的基础优化很多性能问题其实在程序还没发出第一条SQL之前就已经埋下了。连接管理这块儿看着基础实际上坑特别多而且属于“不报错但是拖后腿”的典型。2.1 连接池该怎么配相信现在不会有团队还在用“每次请求新建连接”的写法了连接池是标配。但连接池的参数怎么设很多人是跟着感觉走的。我见过某系统的连接池初始大小设了5最大设了500结果一上线连接数瞬间飙到几百数据库的压力直接拉满。连接池的核心参数是maximumPoolSize最大连接数这个值不是越大越好而是要够用且不浪费。一个经验法则是最大连接数约等于数据库CPU核数乘以2到4再配合上合理的等待超时。为什么这么算因为每个连接背后都对应一个数据库会话会话是要吃内存和CPU的。连得太多数据库光维护会话就累得够呛真正执行SQL的资源反而被挤占了。线程数方面也需要注意。假设应用服务器是8核业务主要是短事务线程池设16到32比较合适。线程不是越多越好超过一定量后CPU都在做上下文切换实际干活的时间反而少了。连接池还有个容易被忽略的点连接泄漏。代码里开了连接没关连接池的连接被借走不还时间一长池子里的连接全被借光新请求只能排队等超时。排查方式也简单监控连接池的ActiveCount如果这个值长期接近maximumPoolSize八成有泄漏。2.2 减少不必要的网络往返程序跟数据库的每一次交互网络往返是固定成本。哪怕内网延迟只有0.1毫秒一万次交互就是1秒多。在一些高并发场景下这一秒可能就是压垮系统的稻草。减少往返的常见招数有这么几个批量提交替代单条提交。插入100条数据单条提交是100次往返批量提交可能只需要2到3次。一次取数替代循环取数。在代码里先查出ID列表再用IN查询一次拉回全部数据而不是循环里一条条查。合并相似查询。一个页面要展示订单和订单明细两条SQL能合并的尽量合并或者用一条关联查询替代两条独立查询。还有一个现代数据库都支持的特性服务端预处理语句。让数据库预编译SQL模板后续执行只传参数省去重复解析的开销。不过要注意不是所有框架默认都开了这个功能有些ORM框架的预处理其实是模拟出来的并没有真正走到数据库端的预处理通道。顺便提一下游标的使用。很多人不知道ORM框架里用fetchSize指定一次抓取的行数对大数据量遍历查询特别重要。如果不设置某些数据库驱动会一次性把所有结果都加载到内存里几百万行数据直接撑爆应用内存。设置一个合理的fetchSize比如500让程序一边处理一边取数内存占用就能稳稳控制在低位。3. 核心优化SQL与集合操作的实战拆解前面那些连接层面的优化属于热身真正决定程序操作效率高低的还是SQL本身怎么组织、数据怎么取。这块儿我觉得是程序操作优化里最值得花时间的部分。3.1 N1查询的识别与改造N1查询是ORM框架最容易引发的一类性能灾难。它的典型样貌是这样的# 错误示范N1查询 orders get_all_orders() # 1次查询查出N条订单 for order in orders: customer order.customer_id # 每次都触发一次额外查询 process(customer)这段代码干了N1次查询1次查订单再按订单数N次查客户。假设有1000个订单就是1001次查询。当并发用户多了以后数据库的负载会跟滚雪球一样越来越大。改成批量取数后查询次数从N1降到了2# 正确姿势批量查询替代循环查询 orders get_all_orders() customer_ids {order.customer_id for order in orders} customers get_customers_by_ids(customer_ids) # 1次IN查询查出所有客户 customer_map {c.id: c for c in customers} for order in orders: customer customer_map[order.customer_id] process(customer)ORM框架里对应的就是懒加载改成关联抓取。比如某个框架里查询订单时用joinedload把客户信息一次性关联查出来或者用selectinload分两条SQL批量把客户查出来效果都差不多。尤其要注意循环嵌套里不要调用ORM的懒加载属性这是N1查询的重灾区。我自己的排查习惯是在开发环境打开SQL日志输出跑一遍核心接口看看日志里有没有循环触发SQL的痕迹。如果发现一个接口连发了上百条SQL基本就是N1了。3.2 批量操作的合理姿势批量操作是另一个容易出问题的地方。以更新库存为例业务上有100个商品的库存要更新有两种做法// 错误示范逐条更新 for (Product p : products) { UPDATE product SET stock ? WHERE id ?; } // 正确示范批量更新 UPDATE product SET stock CASE id WHEN 1 THEN 10 WHEN 2 THEN 20 WHEN 3 THEN 30 END WHERE id IN (1, 2, 3);逐条更新走100次网络往返批量更新只需1次或者几次效率差距是几十倍的量级。批量插入也是同理用JDBC的addBatch配合executeBatch或者用框架的批量保存接口别循环里单条插入。但批量操作也有个度的问题。批量太大生成的SQL语句过长数据库解析反而吃力还可能占满网络带宽。以多批插入为例每批500到1000条比较合适。更新语句因为CASE WHEN结构会拉长SQL每批200到300条就差不多了。实测下来分批提交比一把梭全量提交要稳很多大数据量下总体耗时反而更短。3.3 查询字段与数据读取的瘦身思路还有一个不起眼但影响很大的习惯查询时把不需要的字段也查出来。有些代码习惯直接SELECT *把表里所有字段拉回来。如果表有30个字段业务只用其中5个剩下的25个字段就是纯浪费——浪费网络传输、浪费数据库IO、浪费应用内存。举个我碰到过的例子某报表接口只用了订单表的id和status两个字段但因为写了SELECT *每次查询都要多传几兆字节的数据接口响应时间从80毫秒涨到400毫秒。改成只查需要的字段后响应立刻降回100毫秒以内。数据读取方面避免在应用内存里做大集合运算。把几百万行数据全拉到内存再用stream自己过滤是数据库最怕的事情之一。数据库天生就是干过滤和聚合的把条件写进WHERE把聚合交给SQL只把结果集拿回应用这才是正确的分工。3.4 复合索引与SQL写法的联动优化程序操作优化和索引设计往往是分不开的因为SQL写法直接决定了索引能不能用上。很多查询慢不是索引没建好而是SQL写出来就注定走不了索引。最典型的几个坑索引列上套函数WHERE DATE(create_time) 2025-01-01索引就废了。改成WHERE create_time 2025-01-01 AND create_time 2025-01-02就能走索引。隐式类型转换字符串列跟数字比较某些数据库会放弃索引。该传字符串就别偷懒省引号。前导通配符LIKE %abc用不上索引这是老生常谈了。真要搜后缀考虑反向存储或者搜索引擎方案。还有个容易被忽略的点写SQL时要注意让数据库能利用上“覆盖索引”。如果查询只需要索引里的列数据库可以直接从索引叶子节点拿数据不用回表查数据行性能提升非常可观。这就意味着建索引时除了考虑WHERE条件还要看看SELECT字段能不能一并覆盖。对这些经验体会比较深的原因是我调过很多性能问题最后根因都是SQL写法让索引没发挥应有的作用。改一句SQL效果比加十个索引都明显。4. 事务边界与锁竞争的把控事务这块儿写不好对性能的影响是隐性的——单看每个事务都不慢但并发一高锁等待、死锁轮番上阵整体吞吐量就下来了。4.1 事务粒度与锁持有时间事务的原则很简单短、小、快。短指持续时间短小指影响的数据量小快指执行尽量快。但很多代码在这一点上都有出格的动作。一个最常见的问题事务里夹带远程调用。Transactional public void processOrder(Order order) { updateOrderStatus(order); callPaymentService(order.getId()); // 耗时300ms的远程调用事务还没提交锁一直攥着 updateInventory(order.getProductId()); }这个事务在等远程服务响应的时候数据库里的行锁、间隙锁全都不能释放。其他事务想更新相关行只能排队等着。一个远程调用300毫秒在低并发场景没感觉在高并发下直接就是性能瓶颈。正确做法是把远程调用挪到事务外面public void processOrder(Order order) { updateOrderStatus(order); // 事务里只做数据库操作快速提交 callPaymentService(order.getId()); // 事务外调用远程服务不占用数据库锁 updateInventory(order.getProductId()); }这个改动看似不大对并发能力的改善是质变。我的建议是事务方法里不要有任何IO操作、远程调用、耗时计算如果确实需要这些从外面调用。4.2 隔离级别与读取策略的取舍隔离级别也是一个需要仔细掂量的地方。有些系统为了“保险起见”把隔离级别设为可串行化并发能力直接砍半。大部分业务场景下读已提交或者可重复读就足够了要用到可串行化的场景真的少之又少。更实际的优化方向是把一部分读操作改成走读库或者用快照读隔离写操作。这取决于数据库的部署架构有主从的话把报表、统计、异步任务这些读流量分流到只读副本上主库的负载能下降不少。还有个跟锁相关的操作技巧大批量更新时不要一次性UPDATE几万行这个操作会锁住大量数据。分批更新比如每批500行、间隔几十毫秒看起来慢了实际上对其他业务的干扰小得多整体的稳定性反而更好。这里尤其想提醒一点排查死锁问题时别光看数据库的错误日志要看程序的事务边界。我调过好几个死锁问题最后发现都是同一个根因——事务里多写了几个无关的查询把锁的范围扩大了导致本来不该冲突的两个事务相互等待。把事务里的无关查询挪出去死锁就消失了。4.3 乐观锁与悲观锁的选择锁策略的选择也很影响性能。库存扣减这类高竞争场景很多团队习惯用悲观锁也就是SELECT FOR UPDATE。这在写冲突很频繁的场景是合理的但在读多写少的场景就是过度设计。读多写少的场景更适合乐观锁用版本号或者时间戳实现。实现方式也简单给表加一个version字段更新时带上版本条件影响行数为0就说明版本冲突重试即可。UPDATE product SET stock stock - 1, version version 1 WHERE id 100 AND version 5;这句SQL如果影响了1行说明更新成功如果影响了0行说明version变了需要重新读取重试。这种方式不持有锁并发能力比悲观锁高得多。在争用不激烈的场景下乐观锁是更好的选择。5. 常见问题与排查技巧实录平时积累了一些实际运维排查问题的经验这里整理成速查列表供遇到类似情况时有个参考方向。5.1 高频问题速查表现象可能原因排查方向核心对策接口慢但SQL单条都快数据库往返次数过多打开SQL日志统计单次请求的SQL条数批量取数合并查询减少循环内的SQLCPU高但无慢查询短SQL高频执行抓取数据库会话频率统计从应用层限流或降频改写程序逻辑连接池被占满连接泄漏或池太小检查ActiveCount曲线与错误日志修复泄漏调整池参设置连接生存时间并发一高就死锁事务持锁过久或锁范围过大抓死锁日志分析事务边界缩短事务减少事务内查询隔离级别降档批量操作反而变慢批量过大或SQL过长测不同批量的耗时曲线调整批量大小通常500到1000比较合适分页查询越来越慢深分页导致的随机访问开销EXPLAIN看执行计划改游标分页或延迟关联或基于主键定位分页这个速查表基本覆盖了程序操作优化里最常见的坑。值得多说一句的是排查时别只盯着数据库侧很多时候问题恰恰出在应用代码逻辑上。5.2 一个慢接口的完整排查案例有一回某管理后台的订单导出接口在数据量上来后越来越慢从原来的3秒慢慢涨到了20多秒。乍一看以为是数据库出问题了A同事发来问题的时候先查了慢查询日志发现有几条同步数据的大查询确实慢。但深挖后发现接口本身的问题更明显。第一层接口循环查了200多次数据库。原代码是遍历订单列表每次拿订单号去查明细表。改成批量IN查询后查询次数从200多次降到了3次。第二层导出用的懒加载字段触发了隐藏的N1。因为ORM默认懒加载导出时访问关联对象又触发了一轮查询。改成关联抓取后这个问题也解决了。第三层深分页问题。导出一开始用传统的LIMIT/OFFSET方式翻到几百页后查询越来越慢。改成基于游标的分页方式在WHERE里用上次最后一条记录的ID做条件后每一页的查询时间都稳定在几十毫秒。三层优化做完接口耗时从20多秒降到了1秒左右。整个过程没有改任何数据库配置也没有动索引纯粹是程序操作层面的调整。5.3 回归验证与性能预算管理优化做完之后验证环节不能省。最基本的做法是准备一套和生产数据量同量级的测试环境跑一轮压测对比优化前后的吞吐量、响应时间、数据库负载曲线。数据量差别太大的测试没多大意义几万行和几百万行的查询表现完全不同。我个人的经验是每次优化都建立“性能预算”的概念。给每个核心接口设定一个SQL次数上限和耗时预算比如“单请求数据库交互不超过10次、总耗时不超过100毫秒”。在开发阶段就用SQL日志做一次静态检查超预算的接口直接打回重构。这套机制的效果比上线后反复救火好得多。另一个建议是建立慢日志和生产监控的联动。程序操作优化是个持续过程业务代码一直在变新写的代码随时可能引入新的性能问题。只有监控能告诉你“哪里又变慢了”代码评审才能拦住“哪里快变慢了”。两者结合起来数据库性能才能长期保持稳定。综合来看数据库性能优化进行到现在我对程序操作优化有个很深的体会这一个层面的优化是纯“代码层面”的改进不需要申请新的服务器资源不需要给数据库加内存更不需要高级的调参技巧——它要求的是开发人员对数据库工作方式的理解和尊重。花一周时间把核心链路的程序操作捋一遍通常比花同样时间调数据库参数带来的收益更大也更持久。