Mình có đọc được 1 bài khá hay về cách tối ưu ứng dụng muốn chia sẻ với mọi người về 1 chiến lược để scale 1 ứng dụng spring boot handle 50k requests/s → 1M requests/s. Điểm cốt lõi ở đây là giải quyết bottleneck bằng việc tối ưu hoá thread per request bằng việc sử dụng reactive programming và chỉnh sửa cấu hình để tối ưu hoá performance.
Sau khi áp dụng chiến thuật này , ứng dụng giờ đây thoải mái xử lý tải trọng cao nhất là 1,2 triệu yêu cầu mỗi giây với thời gian phản hồi Sub-100ms, chạy với chi phí cơ sở hạ tầng gần như trước đây.
Chúng ta sẽ đi qua từng phần để hiểu , nhìn chung chiến lược sẽ như sau:
Từ nút thắt ( bottleneck ) → tối ưu hoá bằng các chiến lược rõ ràng ( optimize ) → bài học rút ra (lesson)
Đo lường điểm bắt đầu ⏱️
Trước khi bắt đầu cái gì, phải xác định rõ ràng baseline có những gì. Đây là phần bắt buộc (non-negotiable) mà các bạn bắt buộc phải làm, rõ ràng bạn không thể biết mình thành công hay không nếu không có cột mốc để xác định.
Initial metrics sẽ bao gồm:
Maximum throughput: 50,000 requests/second
Average response time: 350ms
95th percentile response time: 850ms
CPU utilization during peak: 85-95%
Memory usage: 75% of available heap
Database connections: Often reaching max pool size (100)
Thread pool saturation: Frequent thread pool exhaustion
Sử dụng những tools sau để đo lường thực tiễn metrics:
- JMeter: Dùng cho load testing và performance benchmarking ⇒ Gíup hiểu rõ system sẽ hoạt động ra sao dưới tải trọng cụ thể và điểm bottleneck
- Micrometer + Prometheus + Grafana:
- Micrometer: Thu thập metrics cho ứng dụng JVM, thu thập các số liệu như memory use, latency
- Prometheus: Database thời gian dùng để store metrics thu thập
- Grafana: visualize dashboard dùng để biểu thị các số liệu metrics lên những graph tuỳ chỉnh
- Jprofiler: Đo hiệu suất cho ứng dụng java: CPU usage, phân bổ memory ,….
- Flame graph: Trực quan stack traces được thu thập
Những bottlenecks hệ thống đang gặp phải 🔍
Dựa vào những số liệu trên, ta có thể thấy rõ những bottlenecks bao gồm:
- bão hoà threadpool - tomcat connector đạt số lượng tối đa
- Sự tranh chấp connection - HikariCP config không optimize được cho workload
- Jackson consume quá nhiều CPU
- Thread bị chiếm dụng cho hành động I/O lãng phí
- Object tạo lãng phí dẫn đến GC ( Garbage collection ). hoạt động
Bắt đầu optimize
Reactive Programming: Thay đổi cuộc chơi ⚡
Chúng ta sẽ sử dụng reactive programing kết hợp webflux.
Đây là service trước đó
@Service
public class ProductService { @Autowired private ProductRepository repository; public Product getProductById(Long id) { return repository.findById(id) .orElseThrow(() -> new ProductNotFoundException(id)); }
}
Và khi convert chúng dùng Mono:
// AFTER: Reactive implementation
@Service
public class ProductService { @Autowired private ReactiveProductRepository repository; public Mono<Product> getProductById(Long id) { return repository.findById(id) .switchIfEmpty(Mono.error(new ProductNotFoundException(id))); }
}
Tương tự controller
// BEFORE: Traditional Spring MVC controller
@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)); }
}
// AFTER: WebFlux reactive controller
@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()); }
}
Tối ưu hoá database 📊
Cũ sử dụng derived method name là 1 việc kém hiệu quả vì hạn chế về query, câu query khi thêm điều kiện thì phải chỉnh lại name, limit khả năng query
// BEFORE: Using derived method name (inefficient)
List<Order> findByUserIdAndStatusAndCreatedDateBetween( Long userId, OrderStatus status, LocalDate start, LocalDate end);
// AFTER: Optimized query
@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);
Tối ưu hoá N+1 query bằng việc sử dụng BatchSize
Ôn lại chút về N+1 là khi bạn query các data related một cáhc lãng phí. Ví dụ khi bạn tìm 10 quyển sách của 1 tác giả. Rồi từ 10 quyển sách bạn lại query số trang giấy của từng quyển. Vậy là tốn tổng cộng 10 (N) query số trang giấy + 1 query gốc để tìm số quyển sách = N + 1
@Entity
public class Order { // Other fields @OneToMany(mappedBy = "order", fetch = FetchType.EAGER) @BatchSize(size = 30) // Batch fetch order items private Set<OrderItem> items;
}
⇒ Batchsize giúp giải quyết bài toán này bằng cách khi bạn query các entities các entites liên quan ( trong trường hợp này là số trang giấy ) thì nó sẽ group lại các queries liên quan.
Ví dụ:
Example: select page from book where id = 1;
select page from book where id = 2;
select page from book where id = 3;
Thay thế bằng
select page from book where id in (1,2,3);
Lưu ý: batchSize ko phải là eagar loading, nó chỉ là optimize cho lazy loading thôi.
Điều chỉnh connection tuning
Default setting của hikari gây ra tranh chấp kết nối ( connection contention). Sau nhiều lần testing thì mình sẽ được config này:
spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 10 idle-timeout: 30000 connection-timeout: 2000 max-lifetime: 1800000
Thực thi bộ nhớ chiến lược
Thêm redis để tối ưu query frequent data
@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(); }
}
Sau đó thêm vào service ⇒ giúp giảm 70% read-heavy .
@Service
public class ProductService { // Other code @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); }
}
Tối ưu hoá serialize để save CPU 💾
15% CPU time được sử dụng để cho hành vi jackson, thay thế từ jackson ⇒ protocol buffer giúp tối ưu hoá hơn.
@RestController
@RequestMapping("/api/products")
public class ProductController { // Jackson-based endpoint @GetMapping("/{id}") public Mono<ResponseEntity<Product>> getProduct(@PathVariable Long id) { // Original implementation } // Protocol buffer endpoint for high-performance needs @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)); }
}
Điều chỉnh thread pool và connection netty:
spring: reactor: netty: worker: count: 16 # Number of worker threads (2x CPU cores) connection: provider: pool: max-connections: 10000 acquire-timeout: 5000
Nếu. ứng dụng vẫn sử dụng springMVC vs tomcat thì hãy dùng:
server: tomcat: threads: max: 200 min-spare: 20 max-connections: 8192 accept-count: 100 connection-timeout: 2000
Bước cuối: Scale horizontal kết hợp với k8s
Sau khi containerize applicaiton và deploy lên k8s, cần điều chỉnh thông số scale cho phù hợp
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
Chỉnh auto scale dựa trên CPU usage:
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
Config retry , kiểm soát các yêu cầu vô service = service mesh:
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
Kết quả refactor:
// Final Performance Metrics
Maximum throughput: 1,200,000 requests/second
Average response time: 85ms (was 350ms)
95th percentile response time: 120ms (was 850ms)
CPU utilization during peak: 60-70% (was 85-95%)
Memory usage: 50% of available heap (was 75%)
Database queries: Reduced by 70% thanks to caching
Thread efficiency: 10x improvement with reactive programming
Bài học rút ra:
- Measure is everything: Nếu ko có các thông số base và result, ta không thể biết việc optimize có hiệu quả hay không
- Không nên quá phụ thuộc vào reactive nếu bạn không hiểu hoàn toàn về nó, nếu bạn muốn hiểu thêm thì hãy đọc qua: https://viblo.asia/p/mi-hao-hao-va-reactive-programming-an-lien-khong-nghen-khong-cho-n1j4lkeMVwl
- Kiểm soát database chặt chẽ: Caching và derived query optimize
- Chú ý vào configuration: Nhưng configuration không hợp lý sẽ dẫn tới những CPU usage và behavior không đúng
- Về chiến lược config, có thời gian bạn hãy đọc qua: https://www.linkedin.com/pulse/mastering-spring-boot-performance-ultimate-guide-omar-ismail-4psee/
- Đừng scale quá sớm, hãy optimize service 1 cách rõ ràng nhất, sau đó hãy scale
- Test với các real scenario để coi nó hoạt động sao
- Optimize cho 99%: có những phần đã tối ưu hoá tốt nhất rồi, nếu thay đổi mà không mang lại giá trị thiết thực thì hãy giữ nguyên
- Cân bằng giữa complexity và khả năng maintenance
Reference
- https://readmedium.com/en/https:/medium.com/javarevisited/how-i-optimized-a-spring-boot-application-to-handle-1m-requests-second-0cbb2f2823ed
- https://www.linkedin.com/pulse/mastering-spring-boot-performance-ultimate-guide-omar-ismail-4psee/
- https://spring.io/blog/2023/10/16/runtime-efficiency-with-spring