技术探索与实践优化:一个三线城市互联网公司技术负责人的 Spring Boot 调优实录

萧杰
2025-12-15 14:34
阅读 561

大家好,我是小李,坐标上海,目前在一家“名义上三线、实际业务卷成一线”的互联网公司担任后端技术负责人。说白了,就是那种老板嘴上喊着“我们要做下一个字节”,但团队总共不到 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝