很多时候我们以为“服务慢”是代码问题、线程问题、Pod 不够用——结果一路扩容、加实例、加线程,延迟还是在那儿。直到把链路压测和 DB 指标一对,你会发现真相很朴素:数据库扛不住了。在微服务里应用层可以水平扩,但数据库天然是共享瓶颈,“每一条不必要的 SQL 都会把问题放大”。
下面我按一个“从最常见、ROI 最高”到“更偏架构层”的顺序,把这套 Spring Boot 场景下的数据库优化清单整理成 9 步。你可以当成排查路径,也可以当成上线前的性能 Checklist。
先立一个第一性原理:DB 是共享瓶颈
微服务架构里你可以很轻松把应用实例从 3 个扩到 30 个,但你很难把数据库也同样线性扩展。于是一个很常见的现象是:
- 应用扩容 → 并发更高
- 并发更高 → SQL 次数/连接争用/锁等待一起上升
- 最后表现出来:吞吐不升反降、P99 飙升、连接池排队
Step 1:先把连接池配“对”,而不是配“大”
Spring Boot 默认用 HikariCP,很多团队长期沿用默认最大连接数 10,然后在高并发下让请求线程排队等连接,吞吐直接塌。
我建议的做法:
- 先看现状:DB CPU、锁等待、慢 SQL、连接数峰值
- 再设池子:按“DB 能承受多少并发连接”来定,而不是按“服务线程数”来定
- 重点关注“连接等待时间”而不是“连接数看起来很大”
示例(思路参考即可):
spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 2000 idle-timeout: 600000 max-lifetime: 1800000⚠️ 关键提醒:连接池不是越大越快。超过 DB 承载只会带来更多上下文切换和锁竞争,整体更慢。
Step 2:干掉 N+1(它是“隐形性能杀手”)
典型场景:查订单列表,然后循环访问订单明细,ORM 每次触发一条 SQL。
List orders = orderRepository.findAll();for (Order o : orders) { o.getItems().size(); // 又是一条 SQL} 100 个订单 → 101 次查询;到了高 RPS 场景直接炸。

修法:
- JPA:JOIN FETCH / EntityGraph
- MyBatis:一次性 join 或 in 查询再组装
- 只要原则不变:把“循环 SQL”变成“批量 SQL”
Step 3:只返回你需要的字段,别“整表搬家”
很多接口慢,不是查询慢,是拉了太多不必要的数据:大 JSON、BLOB、关联对象、冗余字段,最后还要序列化、再传输、再 GC。
用 DTO/Projection 只取必要列:
public record UserDto(Long id, String name) {}@Query("select new com.app.UserDto(u.id, u.name) from User u")List findUsers(); 它带来的收益往往是“连锁反应”:网络 IO ↓、对象分配 ↓、序列化 ↓、GC 压力 ↓。
Step 4:索引不是玄学,用 EXPLAIN 把“猜”变成“证据”
我见过太多“接口突然慢了”的根因,最后就是:缺索引 / 索引没命中 / 查询计划退化。
习惯性做两件事:
- EXPLAIN ANALYZE ...
- 看是否出现 Seq Scan(全表扫描信号之一)
如果你在计划里看到 Seq Scan,意味着数据库在逐行扫描并过滤,通常需要考虑合适索引或改写查询。
Step 5:避免“话痨式微服务”——一次请求串 5 次 DB 往返
坏味道长这样:
- A 查自己 DB
- A 同步调 B → B 查自己 DB
- B 再同步调 C → C 查自己 DB
- 一次请求变成 5 次 DB round trip,延迟指数级上升
优化方向(按优先级):
- 能在本服务一次查完的,不要拆成多服务链路
- 必须跨服务:做 API Composition,但控制同步链深度
- 稳定数据:缓存/本地缓存/只读副本
- 强一致要求不高:事件驱动(最终一致)来换性能与韧性
Step 6:事务边界要短——批处理要“分段提交”
把一个大循环放进一个 @Transactional,最容易带来:
- 长事务 → 锁持有时间长
- 回滚代价大
- 写入吞吐下降
更推荐:
- 按 chunk 分段事务
- 写入走 batch
Spring Boot 配 Hibernate batching 要用正确的属性名:
spring.jpa.properties.hibernate.jdbc.batch_size
Step 7:减少锁竞争——热点行/热点计数器是“扩展性天敌”
例如同一库存行被 500 并发更新:
update inventory set stock = stock - 1 where id = 10;锁队列会把吞吐拖死。
常用解法:
- 乐观锁(版本号)
- 热点拆分(分桶、分片、按业务维度拆行)
- 避免共享计数器(用分段计数/异步汇总)
- 写入改事件驱动,削峰填谷
Step 8:别只盯 API 延迟,要盯“DB 真实瓶颈指标”
我通常会在看板上固定这些:
- DB CPU / load
- active connections(活跃连接数)
- lock wait time(锁等待)
- slow query log(慢查询)
- Hikari connection wait(连接等待时间)
如果连接等待长期 > 100ms,基本可以判定:瓶颈在 DB 或 SQL。
Step 9:缓存要“有边界”,否则就是“慢性事故”
缓存能极大缓解 DB 读压力,但前提是:有 TTL、有最大容量、有失效策略。
例如 Caffeine:
Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(10, TimeUnit.MINUTES) .build();文中给的量级是:稳定读场景缓存能显著降低 DB 负载(可到 60%–80%)。我更保守的说法是:只要缓存命中率做起来,DB 压力下降通常非常明显。
最后一段:微服务的“扩容幻觉”
微服务确实能让团队更独立、发布更快,但它也天然增加网络调用、DB 调用和系统复杂度。如果 DB 访问不优化,扩应用实例往往只是把 DB 压垮得更快。
所以我现在做性能优化会优先问自己一句话:
这一波优化,能不能让“每个请求的 SQL 次数更少、每条 SQL 更快、每次写入锁更短、每次读更可复用”?
能做到这四点,你会发现:很多时候甚至不用扩容,系统就能多扛一倍。
如果你是 Java / 后端 / 全栈开发者,关注我一起走。
本号持续输出:
- Java 核心 & 高并发 & JVM
- Spring / 架构设计 / 实战踩坑
- JavaScript/TS/Vue/大前端
- AI × 编程 / Agent × 工程化
关注我,少踩坑,多走捷径。欢迎点赞 、收藏 ⭐、转发给需要的朋友