来了解一下我用来将一个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应用时面临着哪些性能挑战呢?在评论中分享你的想法吧!