7 sai lầm khiến Spring Boot ứng dụng của bạn chậm kinh khủng
Mục lục
- Mở đầu
- Sai lầm #1: N+1 Query Problem
- Sai lầm #2: Dùng sai FetchType và CascadeType
- Sai lầm #3: Transaction Management sai
- Sai lầm #4: Connection Pool cấu hình sai
- Sai lầm #5: Blocking calls, không dùng Async
- Sai lầm #6: Cache sai cách & Cache Invalidation
- Sai lầm #7: Thiếu Observability và Profiling
- Bonus: Quy trình tối ưu + Checklist
- Phụ lục: Tools & Resources
Mở đầu
API /dashboard/summary response time 12 giây. Production. Urgent.
Code nhìn vào thì clean — DDD đàng hoàng, repository pattern chuẩn, validation layer riêng, Javadoc đầy đủ. Vậy mà 12 giây.
Thêm mấy dòng này vào application.yml để xem chuyện gì đang xảy ra:
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
Restart. Gọi API. Nhìn vào console và đếm bằng grep -c "Hibernate:" app.log.
847 queries. Cho một dashboard trả về data của 50 user.
Đó là lần đầu giải phẫu một Spring Boot app bị bệnh nặng. Và mình học được nhiều hơn trong 3 ngày đó so với 6 tháng trước. Phải đào vào Hibernate internals, đọc source HikariCP, hiểu Spring AOP proxy hoạt động thế nào — không phải vì tò mò, mà vì bắt buộc.
Bài này tổng hợp những gì rút ra từ đó — cộng với nhiều incident khác sau này. Mỗi sai lầm sẽ giải thích tại sao nó xảy ra ở mức cơ chế, cách phát hiện, và cách fix kèm code cụ thể.
Nếu bạn đang viết Spring Boot và chưa từng profiling app, khả năng cao đang dính vài cái trong danh sách này. Spring Boot che khá nhiều complexity phía dưới — Hibernate, connection pool, transaction proxy — đến lúc scale lên thì những thứ đó mới lộ ra.
Sai lầm #1: N+1 Query Problem
Cơ chế
1 query để lấy N entity → N query để lấy data liên quan của từng entity = N+1 query tổng cộng.
Để hiểu tại sao Hibernate làm vậy, cần biết về fetch strategy. Khi bạn define @OneToMany hay @ManyToOne, Hibernate phải quyết định: load entity này xong thì có load luôn entity liên quan không?
Default theo JPA spec:
@OneToMany,@ManyToMany→FetchType.LAZY(không load ngay)@ManyToOne,@OneToOne→FetchType.EAGER(load ngay)
LAZY nghe có vẻ tốt — chỉ load khi cần. Nhưng "khi cần" có thể là trong một vòng loop, trong Jackson serializer, hoặc trong Thymeleaf template — tất cả đều nằm ngoài Hibernate session ban đầu. Lúc đó Hibernate mở thêm query cho từng entity.
Ví dụ
@Entity
public class Order {
@Id
private Long id;
@ManyToOne // EAGER by default
private User user;
@OneToMany(mappedBy = "order") // LAZY by default
private List<OrderItem> items;
}
@Entity
public class OrderItem {
@Id
private Long id;
@ManyToOne // EAGER by default
private Product product;
private int quantity;
}
// Service — trông vô hại
@Service
public class OrderService {
public List<OrderDTO> getActiveOrders() {
List<Order> orders = orderRepository.findByStatus("ACTIVE");
return orders.stream()
.map(order -> {
OrderDTO dto = new OrderDTO();
dto.setUserName(order.getUser().getName()); // Query #2 per order
dto.setItems(order.getItems().stream() // Query #3 per order
.map(item -> {
ItemDTO itemDto = new ItemDTO();
itemDto.setProductName(item.getProduct().getName()); // Query #4 per item
itemDto.setQuantity(item.getQuantity());
return itemDto;
}).collect(Collectors.toList()));
return dto;
}).collect(Collectors.toList());
}
}
50 orders, mỗi order có 5 items:
1 query → SELECT * FROM orders WHERE status = 'ACTIVE'
50 query → SELECT * FROM users WHERE id = ?
50 query → SELECT * FROM order_items WHERE order_id = ?
250 query → SELECT * FROM products WHERE id = ?
Tổng: 351 queries
Cách phát hiện
1. Hibernate SQL logging (dev/staging)
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
Nhìn vào console, đếm số lần cùng một query lặp lại với parameter khác nhau.
2. datasource-proxy để đếm query
// Dependency: net.ttddyy:datasource-proxy
@Bean
public DataSource dataSource(DataSourceProperties properties) {
HikariDataSource ds = properties.initializeDataSourceBean(HikariDataSource.class);
return ProxyDataSourceBuilder.create(ds)
.name("MyDS")
.logQueryBySlf4j(SLF4JLogLevel.DEBUG)
.countQuery()
.build();
}
3. Hibernate Statistics
@Autowired
private EntityManagerFactory emf;
public void printStats() {
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
System.out.println("Query count: " + stats.getQueryExecutionCount());
System.out.println("Total time: " + stats.getQueryExecutionMaxTime());
}
4. Hypersistence Optimizer — commercial, tự động detect N+1 và suggest fix.
Cách fix
Fix #1: JOIN FETCH trong JPQL
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.user " +
"LEFT JOIN FETCH o.items i " +
"LEFT JOIN FETCH i.product " +
"WHERE o.status = :status")
List<Order> findByStatusWithDetails(@Param("status") String status);
}
Một query duy nhất. DISTINCT để tránh duplicate do Cartesian join.
Fix #2: EntityGraph
@Entity
@NamedEntityGraph(
name = "Order.withDetails",
attributeNodes = {
@NamedAttributeNode("user"),
@NamedAttributeNode(value = "items", subgraph = "items-product")
},
subgraphs = {
@NamedSubgraph(name = "items-product",
attributeNodes = @NamedAttributeNode("product"))
}
)
public class Order { ... }
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(value = "Order.withDetails", type = EntityGraph.EntityGraphType.FETCH)
List<Order> findByStatus(String status);
}
Fix #3: Batch loading — khi JOIN FETCH không phù hợp
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 50)
private List<OrderItem> items;
}
// Hoặc config global
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 50
Với @BatchSize(50): thay vì 50 query riêng, Hibernate load items của 50 orders trong một query WHERE order_id IN (1, 2, ..., 50).
Fix #4: Projection — chỉ lấy data cần
public interface OrderSummary {
Long getId();
String getUserName();
BigDecimal getTotalAmount();
}
@Query("SELECT o.id as id, u.name as userName, o.totalAmount as totalAmount " +
"FROM Order o JOIN o.user u WHERE o.status = :status")
List<OrderSummary> findSummaryByStatus(@Param("status") String status);
Một query, chỉ lấy field cần thiết. Không cần JOIN FETCH phức tạp.
Best practice
✅ JOIN FETCH hoặc EntityGraph khi biết trước sẽ access related entities
✅ @BatchSize(50) như một safety net
✅ Prefer DTO/Projection khi chỉ cần subset data
✅ Enable Hibernate statistics trong dev/staging để catch N+1 sớm
❌ Không để @ManyToOne với FetchType.EAGER trên collection
❌ Không access lazy-loaded collection ngoài @Transactional context
Sai lầm #2: Dùng sai FetchType và CascadeType
Cơ chế
Nếu N+1 là triệu chứng, sai FetchType là nguyên nhân gốc. Nhiều người fix N+1 bằng cách đổi sang FetchType.EAGER — đây là một trong những fix tệ nhất có thể làm.
FetchType.EAGER không có nghĩa là "load sẵn để dùng nhanh". Nó có nghĩa là: bất kể query nào, bất kể có cần không, luôn luôn load cái này.
Nếu User có orders là EAGER, mỗi lần query users — kể cả chỉ để lấy username hiển thị trên navbar — Hibernate sẽ kéo toàn bộ order graph cho mỗi user.
CascadeType cũng tương tự — hay bị dùng theo kiểu "thêm ALL vào cho chắc".
Ví dụ
EAGER trap:
// ❌ Anti-pattern phổ biến nhất
@Entity
public class User {
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER) // ❌
private List<Order> orders;
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER) // ❌ Double trouble
private List<Address> addresses;
}
// Khi query users: Hibernate tạo Cartesian product của orders × addresses
// 100 users × 50 orders × 5 addresses = 25,000 rows được fetch
CascadeType.ALL — con dao hai lưỡi:
// ❌
@Entity
public class Department {
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Employee> employees;
}
// Khi xóa Department:
// departmentRepository.deleteById(id);
// → Hibernate xóa TẤT CẢ employees — có thể không phải ý muốn
// Khi merge:
// department.setName("New Name");
// departmentRepository.save(department);
// → Hibernate cũng "merge" tất cả employees dù bạn không sửa gì
MultipleBagFetchException — ít người biết:
// ❌ Throw MultipleBagFetchException
@Query("SELECT o FROM Order o " +
"LEFT JOIN FETCH o.items " +
"LEFT JOIN FETCH o.tags " + // Không thể FETCH hai Bag cùng lúc
"WHERE o.id = :id")
Order findOrderWithDetails(@Param("id") Long id);
Hibernate không thể FETCH JOIN hai collection cùng lúc nếu cả hai là List (Bag). Fix: dùng Set, hoặc tách thành 2 query.
Cách phát hiện
Viết test để catch unexpected queries:
@SpringBootTest
@Transactional
class UserRepositoryTest {
@PersistenceContext
private EntityManager em;
@Test
void findById_shouldNotLoadOrders() {
em.flush();
em.clear();
SessionFactory sf = em.getEntityManagerFactory().unwrap(SessionFactory.class);
sf.getStatistics().setStatisticsEnabled(true);
sf.getStatistics().clear();
userRepository.findById(1L);
long queryCount = sf.getStatistics().getQueryExecutionCount();
assertThat(queryCount).isEqualTo(1);
}
}
Cách fix
FetchType đúng:
// ✅
@Entity
public class User {
@ManyToOne(fetch = FetchType.LAZY)
private Company company;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders;
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
@LazyToOne(LazyToOneOption.NO_PROXY)
private UserProfile profile;
}
CascadeType — chỉ dùng những gì cần:
// ✅ Explicit cascade
@Entity
public class Order {
// Chỉ cascade PERSIST và MERGE, không REMOVE
@OneToMany(mappedBy = "order",
cascade = {CascadeType.PERSIST, CascadeType.MERGE},
orphanRemoval = true)
private List<OrderItem> items;
}
// ✅ CascadeType.ALL chỉ khi child hoàn toàn phụ thuộc parent lifecycle
@Entity
public class Post {
@OneToMany(mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Comment> comments; // Comment không tồn tại nếu không có Post
}
Fix MultipleBagFetchException:
// Option 1: Dùng Set
@Entity
public class Order {
@OneToMany(mappedBy = "order")
private Set<OrderItem> items;
@OneToMany(mappedBy = "order")
private Set<Tag> tags;
}
// Option 2: Tách 2 query
public Order findWithAllDetails(Long id) {
// Query 1: load order + items
Order order = em.createQuery(
"SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id", Order.class)
.setParameter("id", id)
.getSingleResult();
// Query 2: load tags (Hibernate merge vào persistence context)
em.createQuery(
"SELECT o FROM Order o LEFT JOIN FETCH o.tags WHERE o.id = :id", Order.class)
.setParameter("id", id)
.getSingleResult();
return order; // Đã có đủ items và tags
}
Best practice
✅ Mặc định tất cả relationship là LAZY
✅ Chỉ EAGER khi có profiling data chứng minh là tốt hơn
✅ CascadeType.ALL chỉ khi child hoàn toàn phụ thuộc parent lifecycle
✅ Prefer Set over List cho bidirectional relationships
✅ Test query count như test case bình thường
❌ Không dùng FetchType.EAGER để "fix" LazyInitializationException
❌ Không copy-paste CascadeType.ALL mà không hiểu impact
Sai lầm #3: Transaction Management sai
Cơ chế
Hai kiểu lỗi transaction thường thấy — và chúng ngược nhau hoàn toàn: transaction quá to hoặc thiếu readOnly = true.
Transaction quá to:
- Giữ connection pool lâu hơn cần thiết
- Lock database rows lâu → contention tăng
- Nếu có external HTTP call bên trong transaction → connection bị giữ trong suốt thời gian đó
- Persistence context tích lũy nhiều entity → dirty checking nặng hơn
Thiếu @Transactional(readOnly = true):
- Hibernate không tắt dirty checking — process này có cost O(n) với số entity loaded
- Hibernate flush persistence context trước commit dù không có gì thay đổi
- Database không nhận được hint để optimize
@Transactional trên private method:
Đây là gotcha kinh điển của Spring AOP. @Transactional hoạt động qua proxy — khi bạn gọi method từ bên ngoài, Spring intercept và wrap trong transaction. Khi gọi từ cùng class (self-invocation), proxy bị bypass hoàn toàn.
Ví dụ
Transaction bao gồm external call:
// ❌ Connection bị giữ trong suốt HTTP call tới payment gateway
@Service
public class OrderProcessingService {
@Transactional
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// External HTTP call TRONG transaction — nếu mất 3 giây, connection bị lock 3 giây
PaymentResult result = paymentGatewayClient.charge(order.getAmount());
orderRepository.save(order);
}
}
// ❌ Bulk insert không clear persistence context
@Transactional
public void importLargeDataset(List<DataRow> rows) {
for (DataRow row : rows) {
validateAndEnrich(row);
Entity entity = mapper.toEntity(row);
repository.save(entity);
// 10,000 rows → persistence context tích lũy 10,000 entities
// Dirty checking: O(n) với n tăng dần theo từng iteration
}
}
Self-invocation trap:
// ❌ @Transactional trên private method không hoạt động
@Service
public class UserService {
public void updateUserWithHistory(Long userId, UserDTO dto) {
this.saveUserHistory(userId, dto); // Gọi trong cùng class → proxy bị bypass
updateUser(userId, dto);
}
@Transactional // Annotation này bị bỏ qua hoàn toàn
private void saveUserHistory(Long userId, UserDTO dto) {
userHistoryRepository.save(new UserHistory(userId, dto));
}
}
Thiếu readOnly:
// ❌ Read operation không khai báo readOnly
@Transactional
public DashboardData getDashboardData(Long userId) {
// Hibernate vẫn: bắt đầu transaction, load entities, chạy dirty checking,
// flush context dù không có gì để flush
List<Order> orders = orderRepository.findByUserId(userId);
List<Invoice> invoices = invoiceRepository.findByUserId(userId);
return buildDashboard(orders, invoices);
}
Cách phát hiện
Spring Transaction Logging:
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.orm.jpa: DEBUG
Track transaction time với Micrometer:
@Aspect
@Component
public class TransactionMonitoringAspect {
private final MeterRegistry meterRegistry;
@Around("@annotation(transactional)")
public Object monitorTransaction(ProceedingJoinPoint pjp, Transactional transactional) throws Throwable {
Timer.Sample sample = Timer.start(meterRegistry);
try {
return pjp.proceed();
} finally {
sample.stop(Timer.builder("transaction.duration")
.tag("method", pjp.getSignature().getName())
.register(meterRegistry));
}
}
}
p6spy để trace SQL kèm thời gian:
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>
# spy.properties
appender=com.p6spy.engine.spy.appender.Slf4JLogger
customLogMessageFormat=%(currentTime)|%(executionTime)ms|%(category)|%(sqlSingleLine)
Cách fix
Tách external call ra ngoài transaction:
// ✅ External call nằm ngoài, transaction ngắn gọn
@Service
public class OrderProcessingService {
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// External call NGOÀI transaction
PaymentResult result = paymentGatewayClient.charge(order.getAmount());
// Transaction ngắn, chỉ bao DB operations
updateOrderAfterPayment(orderId, result);
}
@Transactional
protected void updateOrderAfterPayment(Long orderId, PaymentResult result) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markAsPaid(result.getTransactionId());
orderRepository.save(order);
}
}
Bulk import với flush + clear:
// ✅
@Transactional
public void importLargeDataset(List<DataRow> rows) {
int batchSize = 50;
for (int i = 0; i < rows.size(); i++) {
em.persist(mapper.toEntity(rows.get(i)));
if (i % batchSize == 0 && i > 0) {
em.flush();
em.clear(); // Xóa persistence context — giải phóng memory
}
}
em.flush();
}
Fix self-invocation:
// ✅ Option 1: Tách ra service riêng
@Service
public class UserService {
@Autowired
private UserHistoryService userHistoryService;
public void updateUserWithHistory(Long userId, UserDTO dto) {
userHistoryService.saveHistory(userId, dto); // Qua proxy → transaction hoạt động
updateUser(userId, dto);
}
}
// ✅ Option 2: Self-inject
@Service
public class UserService {
@Lazy
@Autowired
private UserService self;
public void updateUserWithHistory(Long userId, UserDTO dto) {
self.saveHistory(userId, dto); // Qua proxy
}
@Transactional
public void saveHistory(Long userId, UserDTO dto) {
userHistoryRepository.save(new UserHistory(userId, dto));
}
}
readOnly = true:
// ✅ Default readOnly ở class level, override cho write methods
@Service
@Transactional(readOnly = true)
public class ReportService {
public DashboardData getDashboardData(Long userId) {
// Hibernate: không dirty checking, không flush, DB nhận hint để optimize
List<Order> orders = orderRepository.findByUserId(userId);
List<Invoice> invoices = invoiceRepository.findByUserId(userId);
return buildDashboard(orders, invoices);
}
@Transactional(readOnly = false)
public void updateReport(Long reportId, ReportDTO dto) {
// ...
}
}
Benchmark readOnly vs không readOnly (PostgreSQL 14, Spring Boot 3.1):
| Scenario | Không readOnly | Có readOnly | Cải thiện |
|---|---|---|---|
| Load 1000 entities | ~150ms | ~95ms | ~37% |
| Complex query + mapping | ~320ms | ~180ms | ~44% |
| Simple findById | ~5ms | ~4ms | ~20% |
Best practice
✅ Default @Transactional(readOnly = true) ở class level, override cho write methods
✅ Transaction scope = DB operations only, không bao gồm external calls
✅ flush() + clear() trong bulk operations
✅ Public method mới dùng @Transactional (Spring AOP proxy chỉ intercept public)
❌ Không wrap toàn bộ service method trong transaction "cho chắc"
❌ Không gọi @Transactional method từ cùng class object (self-invocation)
Sai lầm #4: Connection Pool cấu hình sai
Cơ chế
Default config của HikariCP (connection pool mặc định của Spring Boot) được thiết kế để work everywhere — không phải để work best với workload cụ thể của bạn.
Hãy nghĩ connection pool như một bể bơi có số lane cố định. 10 lane (10 connections) mà có 100 người muốn bơi cùng lúc → 90 người đứng đợi. Request của bạn không chậm vì database chậm — mà vì không có connection để dùng.
Ngược lại, 500 lane mà database server chỉ handle được 100 concurrent connections → timeout và database quá tải.
Ví dụ
Default config — không đủ cho production:
spring:
datasource:
hikari:
# maximum-pool-size: 10 ← Default, thường quá ít
# connection-timeout: 30000 ← 30 giây — user đã bỏ đi từ lâu
# idle-timeout: 600000 ← 10 phút
# max-lifetime: 1800000 ← 30 phút
Triệu chứng connection pool exhaustion:
HikariPool-1 - Connection is not available, request timed out after 30000ms
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available
# Metrics cần theo dõi
hikaricp.connections.pending → > 0 là có vấn đề
hikaricp.connections.timeout → > 0 là CRITICAL
PostgreSQL default max_connections = 100. Nếu bạn có 3 instance app × 10 connections + monitoring tools → gần đầy.
-- Kiểm tra
SELECT count(*) FROM pg_stat_activity;
SHOW max_connections;
-- Query đang chạy lâu
SELECT pid, age(clock_timestamp(), query_start), usename, query
FROM pg_stat_activity
WHERE query != '<IDLE>' AND query NOT ILIKE '%pg_stat_activity%'
ORDER BY query_duration DESC;
Cách phát hiện
Actuator + Prometheus:
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
# Grafana queries
hikaricp_connections_pending{pool="HikariPool-1"}
hikaricp_connections_timeout_total{pool="HikariPool-1"}
hikaricp_connections_acquire_seconds_sum / hikaricp_connections_acquire_seconds_count
Load test với k6:
k6 run --vus 50 --duration 30s - <<EOF
import http from 'k6/http';
import { check } from 'k6';
export default function () {
const res = http.get('http://localhost:8080/api/orders');
check(res, { 'status was 200': (r) => r.status === 200 });
}
EOF
Trong khi test chạy, theo dõi hikaricp.connections.pending và hikaricp.connections.timeout.
Cách fix
HikariCP production config:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb?prepareThreshold=5&preparedStatementCacheQueries=256
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20 # Rule of thumb: (CPU cores * 2) + effective spindle count
minimum-idle: 5
connection-timeout: 5000 # Fail fast sau 5 giây (không phải 30!)
idle-timeout: 300000 # 5 phút
max-lifetime: 1200000 # 20 phút (< DB connection timeout)
keepalive-time: 30000 # Ping mỗi 30 giây
connection-test-query: SELECT 1
validation-timeout: 2000
leak-detection-threshold: 10000 # Cảnh báo nếu connection giữ > 10 giây
pool-name: MyApp-DB-Pool
Formula tính pool size:
Pool Size = Tn × (Cm - 1) + 1
Trong đó:
- Tn = số thread tối đa có thể cần DB connection
- Cm = số concurrent connection mỗi thread cần
Ví dụ:
- App có 200 web threads (server.tomcat.threads.max=200)
- Mỗi request cần 1 DB connection tại một thời điểm
- Pool size lý tưởng ≈ 20-50 (KHÔNG phải 200)
Tại sao? Database là bottleneck, không phải connection.
Nhiều connection hơn database handle được → slower, not faster.
PostgreSQL tuning cơ bản:
ALTER SYSTEM SET shared_buffers = '4GB'; -- 25% RAM cho dedicated DB server
ALTER SYSTEM SET work_mem = '64MB'; -- Memory cho sort/hash operations
ALTER SYSTEM SET effective_cache_size = '12GB'; -- 75% RAM (hint cho query planner)
ALTER SYSTEM SET tcp_keepalives_idle = 300;
ALTER SYSTEM SET log_min_duration_statement = '500'; -- Log queries > 500ms
SELECT pg_reload_conf();
Spring JPA properties:
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
batch_versioned_data: true
fetch_size: 50
order_inserts: true
order_updates: true
generate_statistics: false # Tắt ở production
connection:
provider_disables_autocommit: true # Lazy connection acquisition
Best practice
✅ Monitor hikaricp metrics, alert khi connections.pending > 0
✅ maximum-pool-size = (cores × 2) là starting point, tune theo profiling
✅ connection-timeout ≤ 5000ms (fail fast)
✅ leak-detection-threshold để catch connection leaks sớm
✅ JDBC batch_size = 50 cho bulk insert/update
❌ Không để default pool size cho production
❌ Không tăng max_connections của database vô tội vạ
Sai lầm #5: Blocking calls, không dùng Async
Cơ chế
Spring Boot truyền thống dùng thread-per-request model: mỗi HTTP request được xử lý bởi một thread. Khi thread phải chờ I/O (database, HTTP call, file I/O), nó block — nằm im và không làm gì, nhưng vẫn chiếm memory và không thể xử lý request khác.
Các anti-pattern thường gặp:
- Gọi nhiều external service tuần tự khi có thể song song
- Gửi email/notification đồng bộ trong request flow
- Batch processing tuần tự thay vì parallel
Ví dụ
Sequential external calls:
// ❌ 3 calls tuần tự, tổng thời gian = T1 + T2 + T3 ≈ 750ms
@Service
public class ProductDetailService {
public ProductDetailDTO getProductDetail(Long productId) {
Product product = productRepository.findById(productId).orElseThrow(); // 200ms
PriceInfo priceInfo = pricingServiceClient.getPrice(productId); // 300ms
InventoryInfo inventory = warehouseServiceClient.getInventory(productId); // 250ms
return buildDTO(product, priceInfo, inventory);
}
}
Email đồng bộ trong request thread:
// ❌ Request phải đợi email gửi xong (2-3 giây)
@PostMapping("/users/register")
public ResponseEntity<UserDTO> register(@RequestBody RegisterRequest request) {
User user = userService.createUser(request);
emailService.sendWelcomeEmail(user.getEmail(), user.getName()); // blocking!
return ResponseEntity.ok(userMapper.toDTO(user));
}
Cách phát hiện
Dùng distributed tracing (Zipkin/Jaeger) để nhìn vào trace timeline — thấy ngay A → B → C (sequential) hay A, B, C chạy đồng thời.
@Service
public class ProductDetailService {
@NewSpan("product-detail")
public ProductDetailDTO getProductDetail(Long productId) {
// Timeline sẽ hiển thị rõ pattern
}
}
Cách fix
Parallel với CompletableFuture:
// ✅ Parallel execution, tổng thời gian ≈ max(T1, T2, T3) ≈ 300ms
@Service
public class ProductDetailService {
@Async("externalCallExecutor")
public CompletableFuture<PriceInfo> fetchPriceAsync(Long productId) {
return CompletableFuture.completedFuture(pricingServiceClient.getPrice(productId));
}
@Async("externalCallExecutor")
public CompletableFuture<InventoryInfo> fetchInventoryAsync(Long productId) {
return CompletableFuture.completedFuture(warehouseServiceClient.getInventory(productId));
}
public ProductDetailDTO getProductDetail(Long productId) {
Product product = productRepository.findById(productId).orElseThrow();
CompletableFuture<PriceInfo> priceFuture = fetchPriceAsync(productId);
CompletableFuture<InventoryInfo> inventoryFuture = fetchInventoryAsync(productId);
CompletableFuture.allOf(priceFuture, inventoryFuture).join();
return buildDTO(product, priceFuture.join(), inventoryFuture.join());
}
}
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "externalCallExecutor")
public Executor externalCallExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("external-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
Fire-and-forget với @Async:
// ✅ Email gửi async, request trả về ngay
@Service
public class EmailService {
@Async("emailExecutor")
public void sendWelcomeEmail(String email, String name) {
emailProvider.send(email, "Welcome!", buildWelcomeTemplate(name));
}
}
@PostMapping("/users/register")
public ResponseEntity<UserDTO> register(@RequestBody RegisterRequest request) {
User user = userService.createUser(request);
emailService.sendWelcomeEmail(user.getEmail(), user.getName()); // non-blocking
return ResponseEntity.ok(userMapper.toDTO(user));
}
So sánh:
Sequential (❌):
[DB: 200ms] → [PricingAPI: 300ms] → [WarehouseAPI: 250ms]
Total: ~750ms
Parallel (✅):
[DB: 200ms]
[PricingAPI: 300ms] ← đồng thời
[WarehouseAPI: 250ms] ← đồng thời
Total: ~300ms (cải thiện 2.5×)
Reactive (cho I/O-heavy service):
@Service
public class ReactiveProductService {
public Mono<ProductDetailDTO> getProductDetail(Long productId) {
Mono<Product> productMono = Mono.fromCallable(
() -> productRepository.findById(productId).orElseThrow()
).subscribeOn(Schedulers.boundedElastic());
Mono<PriceInfo> priceMono = pricingClient.get()
.uri("/prices/{id}", productId)
.retrieve()
.bodyToMono(PriceInfo.class);
Mono<InventoryInfo> inventoryMono = warehouseClient.get()
.uri("/inventory/{id}", productId)
.retrieve()
.bodyToMono(InventoryInfo.class);
return Mono.zip(productMono, priceMono, inventoryMono)
.map(tuple -> buildDTO(tuple.getT1(), tuple.getT2(), tuple.getT3()));
}
}
Best practice
✅ Parallel execution cho independent external calls
✅ @Async với dedicated thread pool cho background tasks
✅ Timeout cho mọi external call
✅ Circuit breaker (Resilience4j) cho external service calls
❌ Không gọi external service đồng bộ trong request thread nếu không cần
❌ Không dùng một ThreadPoolExecutor chung cho tất cả async operations
❌ Không quên xử lý exception trong CompletableFuture (silent failures)
Sai lầm #6: Cache sai cách & Cache Invalidation
Cơ chế
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Cache đúng cách có thể cải thiện performance 10-100×. Cache sai cách gây data inconsistency, memory leak, và những bug khó reproduce.
Anti-pattern phổ biến:
- Cache không có TTL → memory leak
- Cache invalidation không đầy đủ → stale data
- Cache granularity sai
- Thundering herd khi cache expire đồng loạt
Ví dụ
Không có TTL:
// ❌ Cache grow vô tận
@Cacheable("products")
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}
// Với millions of products → cache không bao giờ được giải phóng
Cache invalidation không đầy đủ:
// ❌ Cache key không nhất quán giữa read và write
@Service
public class CategoryService {
@Cacheable(value = "categories", key = "#id")
public Category getById(Long id) { ... }
@Cacheable(value = "categories", key = "'parent:' + #parentId")
public List<Category> getByParent(Long parentId) { ... }
@CacheEvict(value = "categories", key = "#category.id") // ❌ Chỉ evict key #id
public Category update(Category category) { // Không evict 'parent:X'
return categoryRepository.save(category);
}
}
// Kết quả: getById trả về data mới, getByParent trả về data cũ
Thundering herd:
// ❌ Khi cache expire, 1000 requests đồng thời hit DB cùng lúc
@Cacheable("top-products")
public List<Product> getTopProducts() {
return productRepository.findTop100ByOrderBySalesDesc();
}
Cách fix
Cache với TTL rõ ràng:
// ✅
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())
);
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
cacheConfigs.put("products",
defaultConfig.entryTtl(Duration.ofHours(1)));
cacheConfigs.put("user-sessions",
defaultConfig.entryTtl(Duration.ofMinutes(30)));
cacheConfigs.put("top-products",
defaultConfig.entryTtl(Duration.ofMinutes(5)));
cacheConfigs.put("categories",
defaultConfig.entryTtl(Duration.ofHours(6)));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
Cache invalidation đầy đủ:
// ✅ Evict tất cả cache liên quan
@Service
@CacheConfig(cacheNames = "categories")
public class CategoryService {
@Cacheable(key = "#id")
public Category getById(Long id) { ... }
@Cacheable(key = "'parent:' + #parentId")
public List<Category> getByParent(Long parentId) { ... }
@Caching(evict = {
@CacheEvict(key = "#category.id"),
@CacheEvict(key = "'parent:' + #category.parentId"),
@CacheEvict(key = "'parent:' + #category.oldParentId",
condition = "#category.parentChanged")
})
public Category update(Category category) {
return categoryRepository.save(category);
}
@CacheEvict(allEntries = true)
public void clearAllCache() { }
}
Fix Thundering Herd:
// ✅ Option 1: Probabilistic early expiration
public List<Product> getTopProducts() {
String key = "top-products";
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
// Nếu còn < 30 giây, 20% chance refresh sớm trước khi expire
if (ttl != null && ttl < 30 && random.nextDouble() < 0.2) {
return refreshTopProducts(key);
}
List<Product> cached = (List<Product>) redisTemplate.opsForValue().get(key);
return cached != null ? cached : refreshTopProducts(key);
}
// ✅ Option 2: Distributed lock
public List<Product> getTopProducts() {
String cacheKey = "top-products";
List<Product> cached = getCached(cacheKey);
if (cached != null) return cached;
RLock lock = redissonClient.getLock("lock:" + cacheKey);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
try {
cached = getCached(cacheKey); // Double-check
if (cached != null) return cached;
List<Product> products = productRepository.findTop100ByOrderBySalesDesc();
setCached(cacheKey, products, Duration.ofMinutes(5));
return products;
} finally {
lock.unlock();
}
} else {
return getFallback();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return getFallback();
}
}
Cache warm-up khi startup:
@Component
public class CacheWarmupRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
productCacheService.getTopProducts();
categoryService.getAllCategories();
}
}
Best practice
✅ Mọi cache entry đều có TTL — không có exception
✅ Cache key nhất quán và predictable
✅ Evict tất cả related keys khi data thay đổi
✅ Monitor cache hit rate — target > 80% cho hot cache
✅ Distributed lock cho cache rebuild operation
❌ Không cache mutable data quan trọng mà không có invalidation strategy
❌ Không dùng @Cacheable trên method update/delete
❌ Không assume cache luôn có data — always handle cache miss
Sai lầm #7: Thiếu Observability và Profiling
Cơ chế
Không có instrument đủ tốt → không biết app đang làm gì → khi production chậm, không biết bắt đầu từ đâu.
Observability gồm 3 trụ cột:
- Metrics — What is happening? (CPU, memory, latency, error rate)
- Logs — Why is it happening? (structured logs với context)
- Traces — Where is it happening? (distributed request tracing)
Thiếu bất kỳ trụ nào, bạn debug trong bóng tối. Và đây là sai lầm dễ fix nhất về mặt kỹ thuật — nhưng hay bị trì hoãn vì "chưa cần thiết", cho đến khi production bốc lửa.
Setup cơ bản
Dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Distributed tracing -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
tags:
application: ${spring.application.name}
env: ${spring.profiles.active}
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5, 0.90, 0.95, 0.99
tracing:
sampling:
probability: 0.1 # Sample 10% requests trong production
Custom business metrics:
@Service
public class OrderService {
private final Counter orderCreatedCounter;
private final Counter orderFailedCounter;
private final Timer orderProcessingTimer;
private final DistributionSummary orderAmountSummary;
public OrderService(MeterRegistry meterRegistry) {
this.orderCreatedCounter = Counter.builder("orders.created")
.description("Total orders created")
.register(meterRegistry);
this.orderFailedCounter = Counter.builder("orders.failed")
.description("Total failed order creations")
.register(meterRegistry);
this.orderProcessingTimer = Timer.builder("orders.processing.time")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
this.orderAmountSummary = DistributionSummary.builder("orders.amount")
.baseUnit("USD")
.register(meterRegistry);
}
public Order createOrder(OrderRequest request) {
return orderProcessingTimer.record(() -> {
try {
Order order = processOrderInternal(request);
orderCreatedCounter.increment();
orderAmountSummary.record(order.getTotalAmount().doubleValue());
return order;
} catch (Exception e) {
orderFailedCounter.increment();
throw e;
}
});
}
}
Structured Logging với MDC:
// ✅
@RestController
public class OrderController {
@PostMapping("/orders")
public ResponseEntity<OrderDTO> createOrder(@RequestBody OrderRequest request,
@AuthenticationPrincipal UserDetails user) {
MDC.put("userId", user.getUsername());
MDC.put("requestId", UUID.randomUUID().toString());
MDC.put("operation", "createOrder");
try {
log.info("Creating order for user={}, items={}", user.getUsername(), request.getItems().size());
Order order = orderService.createOrder(request);
log.info("Order created orderId={}, amount={}", order.getId(), order.getTotalAmount());
return ResponseEntity.ok(orderMapper.toDTO(order));
} catch (Exception e) {
log.error("Order creation failed for user={}", user.getUsername(), e);
throw e;
} finally {
MDC.clear();
}
}
}
<!-- logback-spring.xml — JSON output cho ELK/Loki -->
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>userId</includeMdcKeyName>
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
</encoder>
</appender>
<springProfile name="production">
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</springProfile>
</configuration>
CPU profiling với async-profiler:
# Profile 30 giây, output flame graph
./asprof -d 30 -f flamegraph.html $(pgrep -f spring-boot-app)
# Lock contention profiling
./asprof -e lock -d 30 -f locks.html <PID>
JVM Flight Recorder (overhead thấp, dùng được ở production):
jcmd <PID> JFR.start duration=60s filename=recording.jfr
# Mở bằng JDK Mission Control để analyze
Alert rules:
# prometheus-rules.yml
groups:
- name: spring-boot-performance
rules:
- alert: HighAPILatency
expr: histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m])) > 1.0
for: 2m
labels:
severity: warning
- alert: ConnectionPoolNearExhaustion
expr: hikaricp_connections_pending > 5
for: 1m
labels:
severity: critical
- alert: HighErrorRate
expr: rate(http_server_requests_seconds_count{status=~"5.."}[5m]) > 0.1
for: 2m
labels:
severity: critical
Best practice
✅ Metrics, Logs, Traces — implement từ ngày đầu
✅ Structured logging với requestId và userId trong MDC
✅ Custom business metrics (không chỉ technical metrics)
✅ Alert dựa trên percentile (P95, P99), không phải average
✅ Flame graph cho CPU profiling
✅ JVM Flight Recorder cho production profiling
❌ Không log sensitive data (password, credit card)
❌ Không chỉ dựa vào log để debug production
❌ Không alert on average latency — bị outliers mask
Deep Dive: Hibernate Session và Persistence Context
Phần này giải thích nền tảng để hiểu phần lớn các vấn đề performance của JPA/Hibernate.
Persistence Context là gì?
Mỗi @Transactional method hoặc EntityManager session, Hibernate duy trì một Persistence Context — in-memory cache chứa tất cả entities đã load hoặc persist trong session đó.
Persistence Context (trong một transaction):
┌─────────────────────────────────────────┐
│ Entity Cache (First-Level Cache): │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Order{id=1} │ │ Order{id=2} │ │
│ │ (MANAGED) │ │ (MANAGED) │ │
│ └──────────────┘ └──────────────────┘ │
│ │
│ Snapshots (cho dirty checking): │
│ [Bản sao trạng thái ban đầu của mỗi │
│ entity để detect thay đổi khi flush] │
└─────────────────────────────────────────┘
Tại sao ảnh hưởng performance:
-
Memory footprint: Mỗi entity chiếm memory 2 lần — bản thực và snapshot để dirty checking. Load 10,000 entities = 20,000 object instances trong heap.
-
Dirty checking overhead: Trước mỗi flush, Hibernate so sánh state hiện tại của mỗi entity với snapshot — O(n) operation với n là số entities trong session.
-
First-level cache: Gọi
findById(id)hai lần trong cùng transaction → query DB chỉ một lần. Tốt về query count, nhưng nếu DB thay đổi bởi process khác, bạn đọc stale data.
@Transactional
public void demonstrateFirstLevelCache(Long orderId) {
Order order1 = orderRepository.findById(orderId).orElseThrow(); // DB query
Order order2 = orderRepository.findById(orderId).orElseThrow(); // từ cache, không query DB
System.out.println(order1 == order2); // true — cùng instance
// Muốn fresh data:
entityManager.refresh(order1);
}
Flush modes
// FlushMode.AUTO (default):
// Flush trước khi execute query nếu có dirty data liên quan
// → Đảm bảo query thấy changes của cùng transaction
// → Có thể unexpected flush ở nơi không ngờ tới
// FlushMode.COMMIT:
// Chỉ flush khi commit → ít flush hơn, tốt hơn cho performance
// → Query trong cùng transaction có thể không thấy changes
@Transactional
public void processWithFlushControl() {
Session session = entityManager.unwrap(Session.class);
session.setHibernateFlushMode(FlushMode.COMMIT);
// Không có unexpected flush giữa chừng
session.flush(); // Nếu cần flush explicit
}
Detached entities và LazyInitializationException
// ❌ Gây LazyInitializationException
@Service
public class OrderService {
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
// Transaction kết thúc ở đây, Order trở thành DETACHED
}
}
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
Order order = orderService.getOrder(id);
List<OrderItem> items = order.getItems(); // LazyInitializationException!
return mapper.toDTO(order, items);
}
}
// ✅ Map DTO trong transaction trước khi session đóng
@Service
public class OrderService {
@Transactional(readOnly = true)
public OrderDetailDTO getOrderDetail(Long id) {
Order order = orderRepository.findByIdWithItems(id).orElseThrow();
return orderMapper.toDetailDTO(order); // Access lazy data trong transaction
}
}
Về open-in-view: Spring Boot mặc định bật spring.jpa.open-in-view=true — Hibernate session giữ mở trong suốt HTTP request, kể cả sau khi controller method return. Điều này cho phép lazy loading ở bất kỳ đâu, nhưng giữ DB connection lâu hơn cần và che giấu N+1 problems.
# Tắt đi và handle data fetching explicitly
spring.jpa.open-in-view=false
Tối ưu JVM cho Spring Boot
GC algorithms
┌──────────────┬─────────────┬──────────────────────────────┐
│ Algorithm │ Java Version│ Use Case │
├──────────────┼─────────────┼──────────────────────────────┤
│ G1GC │ Java 9+ │ General purpose, balanced │
│ (default) │ │ throughput/latency │
├──────────────┼─────────────┼──────────────────────────────┤
│ ZGC │ Java 11+ │ Low latency (<10ms pause) │
│ │ prod 15+ │ Large heap (>8GB) │
├──────────────┼─────────────┼──────────────────────────────┤
│ Shenandoah │ Java 12+ │ Tương tự ZGC │
│ │ (RedHat) │ Concurrent GC │
├──────────────┼─────────────┼──────────────────────────────┤
│ ParallelGC │ All │ Batch processing │
│ │ │ Throughput > latency │
└──────────────┴─────────────┴──────────────────────────────┘
ZGC cho low-latency API:
java \
-XX:+UseZGC \
-XX:+ZGenerational \ # Java 21+ — cải thiện throughput
-Xms2g \
-Xmx4g \
-XX:+ZUncommit \
-XX:ZUncommitDelay=300 \
-XX:ConcGCThreads=4 \
-Xlog:gc*:gc.log:time,uptime:filecount=5,filesize=20m \
-jar app.jar
Monitor GC:
# gceasy.io — upload gc.log, nhận analysis miễn phí
# Micrometer tự động expose
# jvm.gc.pause — GC pause duration
# jvm.gc.overhead — % time spent in GC
String optimization
// Dùng Enum thay vì String cho status fields
@Entity
public class Order {
@Enumerated(EnumType.STRING)
private OrderStatus status; // Một instance, không phải N String objects
}
public enum OrderStatus {
ACTIVE, PENDING, COMPLETED, CANCELLED
}
Virtual Threads (Java 21+, Spring Boot 3.2+)
Platform Threads vs Virtual Threads
Platform Threads:
- Map 1:1 với OS thread
- ~1MB stack memory mỗi thread
- Thread pool fixed size (thường 200-500)
- Blocking = OS thread bị block = waste
Virtual Threads:
- Quản lý bởi JVM, mount lên carrier threads khi cần
- ~Vài KB memory mỗi thread
- Blocking = virtual thread bị park, carrier thread xử lý việc khác
- Có thể tạo hàng triệu virtual threads
Enable trong Spring Boot 3.2+
spring:
threads:
virtual:
enabled: true
Chỉ vậy thôi. Spring Boot sẽ dùng virtual threads cho Tomcat request handling, @Async operations, và scheduled tasks.
Khi nào virtual threads thật sự giúp ích
Virtual threads giải quyết thread blocking problem — khi thread phải đợi I/O. Với I/O-bound workload và nhiều concurrent request, cải thiện throughput rõ rệt.
Benchmark (Spring Boot 3.3, Java 21, PostgreSQL 14):
Workload: 500 concurrent requests, mỗi request: 1 DB query (50ms) + 1 HTTP call (100ms)
Platform threads (pool 200):
- Throughput: ~180 req/s
- P99 latency: 2,800ms
Virtual threads:
- Throughput: ~420 req/s (+133%)
- P99 latency: 650ms (-77%)
Cạm bẫy
synchronized block pin virtual thread:
// ❌ synchronized block pin virtual thread vào carrier thread
public synchronized void processWithLegacyLock() {
dbConnection.execute(query); // Virtual thread bị pin!
}
// ✅ Dùng ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public void processWithModernLock() {
lock.lock();
try {
dbConnection.execute(query); // Virtual thread có thể unmount khi đợi
} finally {
lock.unlock();
}
}
ThreadLocal với virtual threads:
// ThreadLocal vẫn hoạt động với virtual threads
// Vấn đề là khi reuse thread, ThreadLocal có thể có stale value
// Virtual threads per task executor: mỗi task có fresh thread → ít vấn đề hơn
// ScopedValue (Java 21+) — replacement tốt hơn
static final ScopedValue<RequestContext> REQUEST_CONTEXT = ScopedValue.newInstance();
ScopedValue.where(REQUEST_CONTEXT, new RequestContext(userId))
.run(() -> {
processRequest(); // REQUEST_CONTEXT.get() available ở đây
});
Connection pool vẫn là bottleneck:
# Virtual threads không magic away database connection limits
spring:
datasource:
hikari:
maximum-pool-size: 50 # Có thể tăng vì virtual threads không waste memory khi đợi
Case Study: Từ 12 giây xuống 180ms
Quay lại API 847 queries lúc đầu. Cụ thể những gì đã làm:
Bước 1: Fix N+1 với JOIN FETCH
// Before: 847 queries
@Query("SELECT u FROM User u WHERE u.active = true")
List<User> findActiveUsers();
// After: 3 queries
@Query("SELECT DISTINCT u FROM User u " +
"LEFT JOIN FETCH u.orders o " +
"LEFT JOIN FETCH o.items i " +
"LEFT JOIN FETCH i.product " +
"WHERE u.active = true")
List<User> findActiveUsersWithDetails();
Kết quả: 847 queries → 3 queries. Response time: 12s → 2.1s.
Bước 2: Thêm @Transactional(readOnly = true)
@Transactional(readOnly = true)
public List<DashboardUserDTO> getDashboardData() { ... }
Kết quả: 2.1s → 1.8s (dirty checking eliminated).
Bước 3: DTO Projection thay vì full entity
// Chỉ cần: userName, email, orderCount, totalAmount
@Query("SELECT new com.example.dto.UserSummaryDTO(" +
"u.id, u.name, u.email, COUNT(o), SUM(o.totalAmount)) " +
"FROM User u LEFT JOIN u.orders o " +
"WHERE u.active = true " +
"GROUP BY u.id, u.name, u.email")
List<UserSummaryDTO> findActiveUserSummaries();
Kết quả: 1.8s → 0.8s.
Bước 4: Cache result (acceptable stale 30 giây)
@Cacheable(value = "dashboard-summary", key = "#userId")
@Transactional(readOnly = true)
public DashboardDTO getDashboard(Long userId) {
return buildDashboard(userId);
}
Lần đầu (cache miss): 0.8s. Các lần sau (cache hit): ~2ms.
Kết quả:
Before: 12,000ms (847 queries, full entity, no cache)
After: 180ms (3 queries, DTO projection, 30s cache)
Cải thiện: 98.5%
Điểm quan trọng: không có bước nào là "magic". Mỗi bước dựa trên profiling data, fix đúng bottleneck theo thứ tự impact.
Anti-patterns hay gặp trong Code Review
Entity inject service dependency:
// ❌
@Entity
public class Order {
@Autowired
private PricingService pricingService; // ĐỪNG LÀM VẬY
public BigDecimal getEffectivePrice() {
return pricingService.calculatePrice(this);
}
}
// ✅ Entity là pure data container
@Entity
public class Order {
private BigDecimal basePrice;
private BigDecimal discountAmount;
public BigDecimal getEffectivePrice() {
return basePrice.subtract(discountAmount); // Simple logic, no external dep
}
}
Return entity trực tiếp từ controller:
// ❌ Expose internal structure, Jackson infinite recursion, security risk
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return orderRepository.findById(id).orElseThrow();
}
// ✅
@GetMapping("/orders/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
return orderMapper.toDTO(orderService.getOrderById(id));
}
Try-catch rải khắp controller:
// ❌ Duplicate exception handling
@GetMapping("/orders/{id}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable Long id) {
try {
return ResponseEntity.ok(orderMapper.toDTO(orderService.getOrderById(id)));
} catch (OrderNotFoundException e) {
return ResponseEntity.notFound().build();
}
}
// ✅ Centralized
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(OrderNotFoundException e) {
return new ErrorResponse("NOT_FOUND", e.getMessage());
}
}
// Controller clean, chỉ happy path
@GetMapping("/orders/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
return orderMapper.toDTO(orderService.getOrderById(id));
}
N+1 trong batch processing:
// ❌
@Scheduled(cron = "0 0 9 * * ?")
public void sendDailyDigest() {
List<User> users = userRepository.findAllActiveUsers(); // 100,000 users
for (User user : users) {
List<Order> recentOrders = orderRepository.findByUser(user); // N queries!
emailService.sendDigest(user, recentOrders);
}
}
// ✅ Batch processing với pagination
@Scheduled(cron = "0 0 9 * * ?")
public void sendDailyDigest() {
int page = 0;
Page<UserWithOrders> usersPage;
do {
usersPage = userRepository.findActiveUsersWithRecentOrders(PageRequest.of(page, 500));
List<CompletableFuture<Void>> futures = usersPage.getContent().stream()
.map(user -> CompletableFuture.runAsync(
() -> emailService.sendDigest(user), emailExecutor))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
page++;
} while (usersPage.hasNext());
}
FAQ
Q: Nên enable spring.jpa.show-sql=true ở production không?
Không. Overhead của việc log mỗi SQL query đáng kể khi load cao. Trong production, dùng database-level slow query logging (log_min_duration_statement với PostgreSQL). Chỉ enable show-sql trong dev.
Q: @Transactional(readOnly = true) có thật sự nhanh hơn không?
Có. Improvement chính: Hibernate skip dirty checking (O(n) với số entities loaded), skip flush before commit, và một số DB driver/replication setup route read-only transactions sang read replica. Với queries load nhiều entities (>100), improvement từ 20-40%.
Q: FetchType.EAGER hay LAZY cho @ManyToOne?
LAZY cho tất cả, kể cả @ManyToOne. Khi biết trước cần related entity, dùng JOIN FETCH — explicit và predictable. Khi không cần, không tốn query. Default EAGER của @ManyToOne trong JPA spec là quyết định mà nhiều người cho là sai lầm lịch sử; Hibernate khuyến nghị LAZY cho mọi relationship trong tài liệu chính thức.
Q: HikariCP pool size bao nhiêu là đủ?
Không có con số universal. Starting point: (CPU cores × 2) + 1, nhưng cần profiling dưới load thực tế. Quan trọng hơn: đặt connection-timeout = 3000 và monitor hikaricp.connections.pending. Nếu metric này > 0 thường xuyên → tăng pool size. Nếu active luôn thấp hơn maximum-pool-size nhiều → giảm xuống.
Q: Có nên migrate sang Spring WebFlux để cải thiện performance không?
WebFlux không tự động nhanh hơn MVC. Phù hợp khi có nhiều concurrent connections với I/O-bound workload. Với typical CRUD backend mà database là bottleneck, Spring MVC với CompletableFuture và @Async thường đủ. Migration sang WebFlux là nỗ lực lớn — toàn bộ stack phải reactive.
Q: Khi nào dùng second-level cache của Hibernate?
Cho reference data — ít thay đổi và được read nhiều: danh sách quốc gia, category, config hệ thống. Không phù hợp cho transactional data vì invalidation phức tạp.
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Country { ... }
Bonus: Quy trình tối ưu Performance đúng cách
Quy trình
1. MEASURE trước
↓
2. Xác định bottleneck với data
↓
3. Fix bottleneck LỚN NHẤT
↓
4. MEASURE lại để verify improvement
↓
5. Repeat
Không bao giờ "mình đoán là chỗ này chậm" mà không có profiling data.
Checklist
Database & ORM
- [ ] Enable Hibernate SQL logging trong dev — kiểm tra N+1
- [ ] Tất cả relationship default LAZY, EAGER chỉ khi có dữ liệu justify
- [ ] JOIN FETCH hoặc EntityGraph cho known-needed relationships
- [ ]
@Transactional(readOnly = true)cho tất cả read operations - [ ] Transaction scope không bao gồm external calls
- [ ] Batch size configured (
hibernate.jdbc.batch_size=50) - [ ] HikariCP pool size tuned (không phải default 10)
- [ ]
connection-timeout≤ 5000ms - [ ] Slow query logging bật trên database
Async & Concurrency
- [ ] Independent external calls chạy parallel với CompletableFuture
- [ ] Fire-and-forget operations dùng
@Async - [ ] Dedicated thread pool cho mỗi loại async operation
- [ ] Timeout set cho tất cả external calls
- [ ] Circuit breaker implement cho external services
Caching
- [ ] Tất cả cache entries có TTL
- [ ] Cache invalidation strategy cho mỗi cache
- [ ] Cache hit rate ≥ 80% cho hot caches
- [ ] Không cache null values (hoặc có null TTL ngắn)
- [ ] Cache warm-up khi startup cho critical caches
Observability
- [ ] Spring Boot Actuator enabled với Prometheus endpoint
- [ ] Custom metrics cho business operations
- [ ] Structured logging với requestId, userId trong MDC
- [ ] Distributed tracing configured
- [ ] Alert rules: latency P95 > 1s, error rate > 1%, pool exhaustion
JVM & Infrastructure
- [ ] JVM heap size phù hợp
- [ ] GC algorithm phù hợp (ZGC hoặc G1GC cho low-latency)
- [ ]
spring.jpa.open-in-view=false - [ ] HTTP/2 enabled nếu phù hợp
JVM flags production
java \
-Xms512m \
-Xmx2g \
-XX:+UseZGC \
-XX:MaxGCPauseMillis=100 \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:+TieredCompilation \
-XX:+FlightRecorder \
-XX:StartFlightRecording=maxage=10m,maxsize=100m,dumponexit=true \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/app/dumps/ \
-jar app.jar
Kết luận
Bốn điều quan trọng hơn bất kỳ kỹ thuật cụ thể nào trong bài:
Hiểu tools bạn dùng. Spring Boot làm nhiều thứ tự động, nhưng khi nó làm sai, bạn cần biết tại sao để fix đúng chỗ. Hibernate, HikariCP, Spring AOP — hiểu cơ chế bên dưới giúp bạn tự debug thay vì mò mẫm.
Measure trước khi fix. Đừng optimize thứ "trông có vẻ chậm". Profile trước. Số liệu không nói dối.
Observability không phải luxury. Developer là người hiểu application nhất — nên define metrics gì cần đo, log gì cần capture, threshold nào cần alert. Đừng để đó cho devops xử lý sau.
Context > Best practice. Mọi kỹ thuật trong bài đều có trade-off. readOnly = true tốt nhưng không apply cho write. JOIN FETCH giải quyết N+1 nhưng có thể gây Cartesian explosion nếu fetch nhiều collection. Cache tốt nhưng thêm complexity. Hiểu context để chọn đúng công cụ.
80% performance issue trong Spring Boot đến từ 20% vấn đề này. Fix chúng trước, rồi tính tiếp.
Tags: #SpringBoot #Performance #Java #Backend #Hibernate #JPA #Cache #Async #Observability #VirtualThreads
Phụ lục: Công cụ và Resources
Profiling & Monitoring
| Tool | Use Case | Chi phí |
|---|---|---|
| async-profiler | CPU & memory profiling | Free |
| JDK Mission Control | JFR analysis | Free (JDK bundled) |
| VisualVM | JVM monitoring | Free |
| Arthas | Runtime diagnosis production | Free |
| Hypersistence Optimizer | Hibernate N+1 detection | Commercial |
Load Testing
| Tool | Use Case | Chi phí |
|---|---|---|
| k6 | Modern load testing với JS | Free |
| Gatling | Scala-based, good reports | Free/Commercial |
| JMeter | Legacy nhưng mạnh | Free |
| wrk | Simple HTTP benchmarking | Free |
APM
| Tool | Use Case | Chi phí |
|---|---|---|
| Grafana + Prometheus | Metrics visualization | Free |
| Jaeger / Zipkin | Distributed tracing | Free |
| OpenTelemetry | Vendor-neutral instrumentation | Free |
| Datadog | Full-stack APM | Commercial |
| New Relic | Full-stack APM | Commercial |
Arthas — production debugging không cần restart
Arthas (Alibaba) cho phép attach vào JVM đang chạy và inspect, trace, profile mà không cần restart hay redeploy.
# Attach vào JVM
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
# Trace method execution time
trace com.example.OrderService getActiveOrders
# Output:
# ---[2ms] com.example.OrderService:getActiveOrders()
# +---[1.2ms] com.example.OrderRepository:findByStatus()
# +---[0.3ms] mapping
# `---[0.5ms] com.example.ProductService:getProducts()
# Watch arguments và return values
watch com.example.OrderService createOrder '{args, returnObj, throwExp}' -x 3
# Monitor call frequency và avg time (mỗi 10 giây)
monitor com.example.OrderService -c 10
# Flame graph
profiler start
profiler stop --format html --file /tmp/flamegraph.html
# Top 10 busiest threads
thread -n 10
trace command là thứ hữu ích nhất — hiển thị call tree với timing của từng bước. Khi API chậm mà không biết chỗ nào, trace chỉ mặt ngay mà không cần thêm log hay restart.
Benchmark đúng cách với JMH
Đừng dùng System.currentTimeMillis() để benchmark. Dùng JMH (Java Microbenchmark Harness) — nó handle JVM warmup, JIT compilation, và statistical analysis.
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(1)
public class OrderServiceBenchmark {
@Benchmark
public List<OrderDTO> getOrdersNPlusOne(BenchmarkState state) {
return state.orderService.getActiveOrdersNaive();
}
@Benchmark
public List<OrderDTO> getOrdersJoinFetch(BenchmarkState state) {
return state.orderService.getActiveOrdersOptimized();
}
}
// mvn clean install
// java -jar target/benchmarks.jar
All rights reserved