技术探索与实践优化:一个三线城市互联网公司技术负责人的 Spring Boot 调优实录
大家好,我是小李,坐标上海,目前在一家“名义上三线、实际业务卷成一线”的互联网公司担任后端技术负责人。说白了,就是那种老板嘴上喊着“我们要做下一个字节”,但团队总共不到 20 人、运维就俩实习生、测试还得自己写用例的典型中小厂。
最近半年,我一直在折腾我们核心服务的性能瓶颈问题——尤其是那个用了好几年的 Spring Boot 后端系统。上线三年多,代码从最初几个模块膨胀到上百个接口,数据库连接池动不动就爆满,接口响应时间从 50ms 飙到 800ms+。上周五晚上双十二预热活动刚启动,监控大盘直接红成一片,运维兄弟半夜打电话:“李哥,API 网关挂了,日志全是 Too many connections……”
当时我真的想砸电脑——不是因为 Bug,是因为这锅本来不该背。但转念一想,作为技术负责人,不就是得在这种时候顶上去吗?
所以今天这篇文章,不讲什么高大上的微服务架构、云原生、Service Mesh(咱这小庙也供不起这些大佛),就说说我这半年怎么一边被产品经理催需求、一边抽空搞技术优化的血泪史。如果你也在中小厂摸爬滚打,或许能从中找到共鸣,甚至抄点作业。
起因:一个“看起来很稳”的系统,其实早就摇摇欲坠
我们的主业务系统是典型的单体 Spring Boot 应用(别笑,很多公司都是这样过来的),用的是 Spring Boot 2.6 + MyBatis + MySQL + Redis。初期确实香:开发快、部署简单、文档全。但随着日活从几千涨到三十万,QPS 从几十飙到三千,问题就暴露了。
最致命的不是代码烂(虽然确实有点乱),而是资源管理混乱 + 异步处理缺失 + 缓存策略拍脑袋定的。
比如:
- 所有接口都同步调用第三方支付回调,超时直接拖垮线程池;
- Redis 缓存击穿没做互斥锁,热点商品详情页一刷,DB 直接被打穿;
- 数据库连接池默认 HikariCP,但最大连接数设成了 100,而 MySQL 的
max_connections才 150,其他服务一抢,我们就连不上了。
更离谱的是,有一次产品经理提了个“用户注册送积分”需求,我手下一实习生直接在注册接口里写了段发短信 + 写积分 + 发通知的逻辑,结果注册高峰时段整个服务雪崩。我问他为啥不拆异步?他理直气壮:“Spring Boot 不是自动开线程吗?”
唉,年轻人啊……
第一步:压测 + 监控,先看清敌人是谁
优化不能靠猜。我花了两天搭了一套轻量级监控体系:
- Arthas:线上动态诊断,看线程堆栈、内存占用;
- Micrometer + Prometheus + Grafana:监控 JVM、HTTP 请求耗时、DB 查询次数;
- SkyWalking:链路追踪(虽然有点重,但对我们这种单体应用反而清晰)。
跑了一轮 JMeter 压测(模拟 2000 并发用户),结果触目惊心:
| 指标 | 优化前 | 优化目标 |
|---|---|---|
| P95 响应时间 | 780ms | ≤ 200ms |
| DB 连接使用率 | 95%+ | ≤ 60% |
| GC 频率(Full GC) | 每 10 分钟 1 次 | 每小时 ≤ 1 次 |
| CPU 使用率(峰值) | 90%+ | ≤ 70% |
最夸张的是 /api/order/create 接口——一个下单接口,居然平均调了 12 次数据库!有的是为了查库存,有的是校验优惠券,还有的是记录操作日志。每次查都走完整 MyBatis 流程,连二级缓存都没开。
我当时就想:这哪是写代码,这是在给 DB 喂饭呢。
第二步:代码层优化 —— 别让 Spring Boot 成为你的“舒适区陷阱”
很多人觉得 Spring Boot 开箱即用,但它的“约定优于配置”在高并发下反而会埋雷。我重点干了这几件事:
1. 异步化:把非核心逻辑扔进线程池
以前所有操作都在主线程里干,现在我把短信通知、积分发放、行为日志等非关键路径全部异步化。
// 自定义线程池,避免用 ForkJoinPool.commonPool()(容易被其他任务占满)
@Configuration
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-task-");
executor.setRejectedExecutionHandler(new ThreadPoolTaskExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
然后在 Service 里:
@Service
public class OrderService {
@Async("taskExecutor")
public void sendNotification(Long userId, String orderId) {
// 发短信、推消息等
smsClient.send(userId, "订单创建成功");
}
public Order createOrder(OrderRequest req) {
// 核心下单逻辑
Order order = doCreateOrder(req);
// 非阻塞调用
sendNotification(req.getUserId(), order.getId());
return order;
}
}
踩坑提醒:
@Async方法必须是 public,且不能在本类内部调用(Spring AOP 限制),否则不会生效!我一开始就在同一个类里调,结果异步根本没起作用,还以为线程池配错了。
2. 缓存策略升级:防击穿 + 多级缓存
原来我们只用 Redis 做缓存,key 过期就去查 DB。结果某次大促,某个爆款商品缓存过期,瞬间几百个请求同时打到 DB,直接触发慢查询告警。
解决方案:
- 缓存永不过期 + 后台定时刷新(适用于变化不频繁的数据)
- 加分布式锁防击穿
public Product getProduct(Long id) {
String key = "product:" + id;
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 双重检查 + 分布式锁
String lockKey = "lock:product:" + id;
try {
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (!locked) {
// 等待 50ms 再查一次(简单退避)
Thread.sleep(50);
return getProduct(id);
}
// 真正查 DB
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(key, product, Duration.ofHours(2));
} else {
// 防止缓存穿透:空值也缓存(短 TTL)
redisTemplate.opsForValue().set(key, NULL_PLACEHOLDER, Duration.ofMinutes(5));
}
} catch (Exception e) {
log.error("Load product failed", e);
} finally {
redisTemplate.delete(lockKey);
}
return product;
}
3. 数据库访问优化:批量 + 连接复用
原来很多地方都是循环里查 DB,比如:
// 错误示范!
List<Order> orders = orderMapper.selectAll();
for (Order order : orders) {
User user = userMapper.selectById(order.getUserId()); // N+1 查询
}
改成用 IN 批量查,或者用 MyBatis 的 <foreach>:
// 一次性查所有用户
List<Long> userIds = orders.stream().map(Order::getUserId).collect(Collectors.toList());
Map<Long, User> userMap = userMapper.selectByIds(userIds)
.stream()
.collect(Collectors.toMap(User::getId, u -> u));
同时,调整 HikariCP 参数:
spring:
datasource:
hikari:
maximum-pool-size: 30 # 原来是 100,太高了
minimum-idle: 10
connection-timeout: 3000 # 3秒超时,别让请求卡死
idle-timeout: 300000 # 5分钟空闲回收
max-lifetime: 1800000 # 30分钟强制换连接(防 MySQL 主从切换断连)
第三步:JVM 和 GC 调优 —— 别再用默认参数了!
我们之前一直用默认的 G1GC,但老年代增长太快,Full GC 频繁。通过 Arthas 看到,大量临时对象(比如 DTO、Map)生命周期其实很短,但因为方法栈深,被晋升到了老年代。
解决方案:
- 调整新生代比例
- 减少大对象分配
JVM 参数最终定稿:
-Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:+ExplicitGCInvokesConcurrent \
-Dfile.encoding=UTF-8
关键点:
- 固定堆大小(-Xms = -Xmx),避免运行时扩容抖动;
- 降低 IHOP(InitiatingHeapOccupancyPercent),让 G1 更早开始并发标记;
- 禁用 System.gc() 的 STW 行为(用
-XX:+ExplicitGCInvokesConcurrent)。
上线后 Full GC 从每 10 分钟一次降到几乎消失,P95 响应时间直接砍掉一半。
第四步:Rust 的启发 —— 性能思维比语言更重要
说到这儿,可能有人问:既然这么折腾,为什么不直接上 Go 或 Rust 重写?
我也想过。最近确实在研究 Rust,觉得它的内存安全和零成本抽象很有意思。但现实是:重构成本远高于优化现有系统。我们团队就 5 个后端,3 个还在学 Spring Cloud,哪有精力搞新语言栈?
但 Rust 给我的最大启发是:资源意识。
在 Rust 里,你时刻要思考“这个变量谁拥有?什么时候释放?”。而在 Java 里,我们太依赖 GC,动不动就 new 对象、new List、new Map,完全不管内存压力。
所以我现在带团队 review 代码,会特别关注:
- 是否有不必要的对象创建?
- 能否复用 StringBuilder?
- Stream API 用得是否合理?(有些地方用 for 循环反而更快)
举个例子,原来我们序列化 JSON 用的是 Jackson 默认配置,每次都要反射。后来改成预编译 ObjectMapper + 关闭不必要的特性:
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
return mapper;
}
虽然提升不大,但积少成多。
效果如何?数据说话
经过三个月的迭代优化(中间还穿插了三个紧急需求,真是边跑边修飞机),效果显著:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P95 响应时间 | 780ms | 160ms | ↓ 79% |
| DB 连接峰值 | 95 | 45 | ↓ 53% |
| Full GC 频率 | 6 次/小时 | 0.2 次/小时 | ↓ 97% |
| 服务器数量 | 8 台 | 5 台 | ↓ 37.5% |
最重要的是,双十二当天零故障。运维兄弟终于没在凌晨三点打电话给我,产品经理也没再说“你们技术是不是不行”。
最后一点真心话
作为中小厂的技术负责人,我深知我们没有大厂的资源和容错空间。每一个优化决策,都得在“快速交付”和“系统稳定”之间走钢丝。
Spring Boot 是个好框架,但它不是银弹。真正的性能优化,从来不是换一个框架就能解决的,而是对业务、数据、资源的深度理解。
顺便吐槽一句:下次产品经理再说“就加个小功能,很快吧?”,我准备直接甩他这篇博客链接。
对了,最近还在研究怎么把部分高频读接口用 Rust 写成 FFI 插件(别笑,真在试),如果成功了再来分享。毕竟在上海租房这么贵,省下的服务器钱,够我多喝一个月瑞幸了。
—— 写于一个加班后的深夜,公司楼下全家便利店

评论 0