实战!如何将 Spring Boot 应用优化到 100 万请求 / 秒

来了解一下我用来将一个Spring Boot应用从每秒处理5万次请求扩展到每秒处理100万次请求的确切技术吧。我会分享我发现的那些令人惊讶的性能瓶颈、产生了重大影响的响应式编程模式,以及带来巨大性能提升的配置调整。

去年,我们团队面临着一个看似不可能完成的挑战:我们的Spring Boot应用需要将处理的流量提升20倍,从每秒5万次请求提升到惊人的每秒100万次。而我们只有三个月的时间来完成这个任务,并且硬件预算有限,我不确定我们是否能够成功。

剧透一下:我们做到了。我们的应用现在能够轻松处理每秒120万次请求的峰值负载,响应时间在100毫秒以内,而且运行所需的基础设施成本与之前大致相同。

在本指南中,我将详细介绍我们是如何实现这一目标的,分享我们发现的真正的性能瓶颈、带来最大改变的优化措施,以及我们在此过程中获得的令人惊讶的经验教训。

测量起始点 ⏱️

在进行任何更改之前,我先建立了清晰的性能基线。这一步是必不可少的;如果不知道起点,就无法衡量进展,也无法确定最大的改进空间。

以下是我们最初的性能指标:

// 初始性能指标
最大吞吐量:每秒50,000次请求
平均响应时间:350毫秒
第95百分位响应时间:850毫秒
峰值时的CPU利用率:85%-95%
内存使用量:可用堆内存的75%
数据库连接:经常达到最大连接池大小(100)
线程池饱和:频繁出现线程池耗尽的情况

我使用了多种工具来收集这些指标:

• JMeter:用于进行负载测试并确定基本的吞吐量数值

• Micrometer + Prometheus + Grafana:用于实时监控和可视化

• JProfiler:用于深入分析代码中的热点区域

• 火焰图:用于识别CPU密集型方法

有了这些基线指标,我就可以确定优化的优先级,并衡量优化措施的效果。

发现真正的性能瓶颈 🔍

最初的性能分析揭示了几个值得关注的性能瓶颈:

1. 线程池饱和:默认的Tomcat连接器达到了其极限

2. 数据库连接争用:HikariCP的配置没有针对我们的工作负载进行优化

3. 序列化效率低下:Jackson在请求/响应处理过程中消耗了大量的CPU资源

4. 阻塞式I/O操作:尤其是在调用外部服务时

5. 内存压力:过多的对象创建导致频繁的垃圾回收暂停

让我们系统地解决这些问题。

响应式编程:改变游戏规则的关键 ⚡

最具影响力的改变是采用了Spring WebFlux的响应式编程。这并不是简单的替换,而是需要重新思考我们构建应用的方式。

我首先确定了那些具有大量I/O操作的服务:

// 之前:阻塞式实现
@Service
public class ProductService {
    @Autowired
    private ProductRepository repository;
    
    public Product getProductById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

并将它们转换为响应式实现:

// 之后:响应式实现
@Service
public class ProductService {
    @Autowired
    private ReactiveProductRepository repository;
    
    public Mono<Product> getProductById(Long id) {
        return repository.findById(id)
                .switchIfEmpty(Mono.error(new ProductNotFoundException(id)));
    }
}

控制器也相应地进行了更新:

// 之前:传统的Spring MVC控制器
@RestController
@RequestMapping("/api/products")
public class ProductController {
    @Autowired
    private ProductService service;
    
    @GetMapping("/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable Long id) {
        return ResponseEntity.ok(service.getProductById(id));
    }
}
// 之后:WebFlux响应式控制器
@RestController
@RequestMapping("/api/products")
public class ProductController {
    @Autowired
    private ProductService service;
    
    @GetMapping("/{id}")
    public Mono<ResponseEntity<Product>> getProduct(@PathVariable Long id) {
        return service.getProductById(id)
            .map(ResponseEntity::ok)
            .defaultIfEmpty(ResponseEntity.notFound().build());
    }
}

仅这一改变就通过更高效地利用线程使我们的吞吐量提高了一倍。WebFlux不是每个请求对应一个线程,而是使用少量线程来处理许多并发请求。

数据库优化:隐藏的倍增器 📊

数据库交互是我们接下来最大的性能瓶颈。我采用了三管齐下的方法:

1. 查询优化

我使用Spring Data的@Query注解来替换效率低下的自动生成的查询:

// 之前:使用派生方法名(效率低下)
List<Order> findByUserIdAndStatusAndCreatedDateBetween(
    Long userId, OrderStatus status, LocalDate start, LocalDate end);
// 之后:优化后的查询
@Query("SELECT o FROM Order o WHERE o.userId = :userId " +
       "AND o.status = :status " +
       "AND o.createdDate BETWEEN :start AND :end " +
       "ORDER BY o.createdDate DESC")
List<Order> findUserOrdersInDateRange(
    @Param("userId") Long userId, 
    @Param("status") OrderStatus status,
    @Param("start") LocalDate start, 
    @Param("end") LocalDate end);

我还通过使用Hibernate的@BatchSize注解优化了一个特别有问题的N+1查询:

@Entity
public class Order {
    // 其他字段
    
    @OneToMany(mappedBy = "order", fetch = FetchType.EAGER)
    @BatchSize(size = 30) // 批量获取订单项
    private Set<OrderItem> items;
}

2. 连接池调优

默认的HikariCP设置导致了连接争用。经过大量测试,我确定了以下配置:

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      idle-timeout: 30000
      connection-timeout: 2000
      max-lifetime: 1800000

关键的认识是,连接数并非越多越好;我们发现30个连接是最佳数量,这在不使数据库不堪重负的情况下减少了争用。

3. 实施策略性缓存

我为频繁访问的数据添加了Redis缓存:

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues();
            
        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(cacheConfig)
            .withCacheConfiguration("products", 
                RedisCacheConfiguration.defaultCacheConfig()
                    .entryTtl(Duration.ofMinutes(5)))
            .withCacheConfiguration("categories", 
                RedisCacheConfiguration.defaultCacheConfig()
                    .entryTtl(Duration.ofHours(1)))
            .build();
    }
}

然后将其应用到相应的服务方法中:

@Service
public class ProductService {
    // 其他代码
    
    @Cacheable(value = "products", key = "#id")
    public Mono<Product> getProductById(Long id) {
        return repository.findById(id)
            .switchIfEmpty(Mono.error(new ProductNotFoundException(id)));
    }
    
    @CacheEvict(value = "products", key = "#product.id")
    public Mono<Product> updateProduct(Product product) {
        return repository.save(product);
    }
}

这使得读操作较多的情况下,数据库负载降低了70%。

序列化优化:令人惊喜的CPU节省方案 💾

性能分析显示,15%的CPU时间花在了Jackson序列化上。我切换到了一个更高效的配置:

@Configuration
public class JacksonConfig {
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        
        // 使用afterburner模块以实现更快的序列化
        mapper.registerModule(new AfterburnerModule());
        
        // 仅包含非空值
        mapper.setSerializationInclusion(Include.NON_NULL);
        
        // 禁用我们不需要的功能
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
        
        return mapper;
    }
}

对于我们最注重性能的端点,我用Protocol Buffers替换了Jackson:

syntax = "proto3";
package com.example.proto;

message ProductResponse {
  int64 id = 1;
  string name = 2;
  string description = 3;
  double price = 4;
  int32 inventory = 5;
}
@RestController
@RequestMapping("/api/products")
public class ProductController {
    // 基于Jackson的端点
    @GetMapping("/{id}")
    public Mono<ResponseEntity<Product>> getProduct(@PathVariable Long id) {
        // 原始实现
    }
    
    // 满足高性能需求的Protocol Buffer端点
    @GetMapping("/{id}/proto")
    public Mono<ResponseEntity<byte[]>> getProductProto(@PathVariable Long id) {
        return service.getProductById(id)
            .map(product -> ProductResponse.newBuilder()
                .setId(product.getId())
                .setName(product.getName())
                .setDescription(product.getDescription())
                .setPrice(product.getPrice())
                .setInventory(product.getInventory())
                .build().toByteArray())
            .map(bytes -> ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(bytes));
    }
}

这一改变使序列化的CPU使用率降低了80%,并使响应大小减少了30%。

线程池和连接调优:配置的魔力 🧰

使用WebFlux时,我们需要调整Netty的事件循环设置:

spring:
  reactor:
    netty:
      worker:
        count: 16  # 工作线程数(为CPU核心数的2倍)
      connection:
        provider:
          pool:
            max-connections: 10000
            acquire-timeout: 5000

对于我们应用中仍在使用Spring MVC的部分,我调整了Tomcat连接器的配置:

server:
  tomcat:
    threads:
      max: 200
      min-spare: 20
    max-connections: 8192
    accept-count: 100
    connection-timeout: 2000

这些设置使我们能够用更少的资源处理更多的并发连接。

使用Kubernetes进行水平扩展:最后的冲刺 🚢

为了达到每秒100万次请求的目标,我们需要进行水平扩展。我将我们的应用容器化,并部署到了Kubernetes上。

FROM openjdk:17-slim
COPY target/myapp.jar app.jar
ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+ParallelRefProcEnabled"
ENTRYPOINT exec java $JAVA_OPTS -jar /app.jar

然后根据CPU利用率配置了自动扩展:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myapp-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  minReplicas: 5
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

我们还使用Istio实现了服务网格功能,以实现更好的流量管理:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: myapp-vs
spec:
  hosts:
  - myapp-service
  http:
  - route:
    - destination:
        host: myapp-service
    retries:
      attempts: 3
      perTryTimeout: 2s
    timeout: 5s

这使我们能够高效地处理流量高峰,同时保持系统的弹性。

测量结果:成果证明 📈

经过所有的优化后,我们的指标有了显著的提升:

// 最终性能指标
最大吞吐量:每秒1,200,000次请求
平均响应时间:85毫秒(之前是350毫秒)
第95百分位响应时间:120毫秒(之前是850毫秒)
峰值时的CPU利用率:60%-70%(之前是85%-95%)
内存使用量:可用堆内存的50%(之前是75%)
数据库查询次数:由于缓存,减少了70%
线程效率:通过响应式编程提高了10倍

最令人满意的结果是什么呢?在我们的黑色星期五促销活动期间,系统轻松地处理了每秒120万次请求,没有发出任何警报,没有出现任何停机时间,只有满意的客户。

关键经验教训 💡

1. 测量至关重要:如果没有进行适当的性能分析,我可能会优化错误的地方。

2. 响应式编程并非总是更好:在某些情况下,使用Spring MVC更合理,我们采用了混合方法,保留了一些使用Spring MVC的端点。

3. 数据库通常是性能瓶颈:缓存和查询优化带来了一些最大的性能提升。

4. 配置很重要:我们的许多性能提升仅仅来自于对默认配置的调整。

5. 不要过早进行扩展:我们先对应用进行了优化,然后再进行水平扩展,这节省了大量的基础设施成本。

6. 使用真实场景进行测试:我们最初使用合成测试的基准测试结果与生产环境中的模式不匹配,导致了错误的优化方向。

7. 为99%的情况进行优化:有些端点无法进一步优化,但它们只占我们流量的1%,所以我们将重点放在了其他地方。

8. 平衡复杂性和可维护性:一些潜在的优化措施被否决了,因为它们会使代码库变得过于复杂而难以维护。

性能优化不是要找到一个神奇的解决方案;而是要系统地识别和解决整个系统中的性能瓶颈。使用Spring Boot,其功能就在那里;你只需要知道该调整哪些参数。

你在使用Spring应用时面临着哪些性能挑战呢?在评论中分享你的想法吧!

 

请登录后发表评论