Mô tả sự cố
Hệ thống đơn khối (monolith) được triển khai trên Tomcat hoạt động ổn định trong giai đoạn đầu sau khi上线. Tuy nhiên, sau một thời gian vận hành, dịch vụ bắt đầu xuất hiện tình trạng phản hồi chậm dần và cuối cùng là超时 (timeout). Việc khởi động lại service chỉ giải quyết vấn đề tạm thời, lỗi vẫn tái diễn sau một khoảng thời gian nhất định.
Quá trình chẩn đoán
Kiểm tra tài nguyên máy chủ cho thấy CPU hoạt động ở mức bình thường, log hệ thống không ghi nhận lỗi tràn bộ nhớ (OOM). Khi sử dụng JConsole để giám sát, nhận thấy số lượng thread tăng liên tục và không có dấu hiệu giảm xuống:
Điều này gợi ý rằng các thread xử lý request đang bị kẹt ở trạng thái chờ đợi somewhere trong hệ thống.
Tiến hành xuất thread dump bằng jvisualvm và phân tích trên công cụ online, kết quả cho thấy hơn 80% thread đang ở trạng thái WAITING. Đáng chú ý là tất cả các thread này đều bị阻塞 tại cùng một điểm:
Điểm阻塞 cụ thể nằm trong phương thức DruidDataSource.takeLast:
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at com.alibaba.druid.pool.DruidDataSource.takeLast(DruidDataSource.java:2002)
at com.alibaba.druid.pool.DruidDataSource.getConnectionInternal(DruidDataSource.java:1539)
...
Dấu hiệu này cho thấy khả năng cao là connection pool của Druid đã bị cạn kiệt.
Nguyên nhân và giải pháp
Giả thuyết ban đầu là có sự rò rỉ kết nối (connection leak) khiến các connection không được trả về pool. Đã thử cấu hình removeAbandoned và removeAbandonedTimeout để强制 thu hồi, nhưng giải pháp này không hiệu quả mà còn làm tăng CPU đột biến do vô tình thu hồi các kết nối đang được sử dụng bởi các thành phần cốt lõi, gây ra vòng lặp vô tận.
Xem xét lại bối cảnh, vấn đề không xuất hiện ngay từ đầu mà xảy ra khi dữ liệu tăng và lượng truy cập cao hơn. Các báo cáo thống kê phức tạp (slow SQL) giữ kết nối lâu hơn, kết hợp với tần suất request tăng dẫn đến việc pool bị đầy. Khi đó, các thread mới không thể lấy được kết nối và bị treo.
Kiểm tra cấu hình Druid mặc định phát hiện hai thông số quan trọng chưa được thiết lập:
max-active: Số lượng kết nối tối đa đang hoạt động (mặc định là 8).max-wait: Thời gian tối đa chờ lấy kết nối (mặc định là -1, tức chờ vô hạn).
Phân tích logic nguồn của Druid tại vị trí lấy kết nối cho thấy:
// Mô phỏng logic lấy kết nối
long thoiGianCho = cauHinh.getMaxWait();
if (thoiGianCho > 0) {
// Có giới hạn thời gian, sẽ trả về null nếu hết hạn
ketNoi = pool.pollLast(thoiGianCho);
} else {
// Không giới hạn, thread sẽ chờ mãi đến khi có kết nối rảnh
ketNoi = pool.takeLast();
}
Do không cấu hình max-wait, hệ thống đi vào nhánh takeLast(), khiến thread chờ vô định. Giải pháp là thiết lập thời gian chờ và tăng dung lượng pool:
@Configuration
public class DatabaseConfig {
@Bean
public DataSource configDataSource() {
DruidDataSource source = new DruidDataSource();
// Tăng số lượng kết nối đồng thời
source.setMaxActive(150);
// Thiết lập timeout khi lấy kết nối (60 giây)
source.setMaxWait(60000);
return source;
}
}
Việc đặt max-wait giúp các request quá hạn sẽ bị hủy sau 60 giây, giải phóng thread để phục vụ các yêu cầu khác. Tăng max-active giúp giảm nguy cơ pool bị đầy ngay khi có một vài truy vấn nặng.
Sau khi áp dụng, số lượng thread đã ổn định trở lại sau khi timeout. Tuy nhiên, các truy vấn chậm vẫn sẽ báo lỗi khi vượt quá thời gian chờ:
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
The last packet successfully received from the server was 60,021 milliseconds ago.
Điều này chấp nhận được vì nó ngăn chặn việc treo toàn bộ dịch vụ. Giải pháp tối ưu lâu dài vẫn là cần phân tích và cải thiện hiệu suất các câu lệnh SQL chậm để giảm thời gian giữ kết nối.