Báo Động Hệ Thống: Xử Lý Lỗi Full GC Do Xung Đột Thời Gian Sống Với Connection Pool

Mô Tả Sự Cố

Dịch vụ sản xuất bắt đầu ghi nhận các chỉ số độ trễ tăng vọt vào một thời điểm cụ thể trong ngày. Quan sát log cho thấy nguyên nhân chính đến từ lỗi thời gian khi lấy kết nối cơ sở dữ liệu (database connection timeout). Các sự cố này diễn ra theo từng đợt, mỗi lần kéo dài khoảng từ 3 đến 4 giây rồi tự động ổn định lại. Chu kỳ này lặp đi lặp lại sau vài phút ngắn ngủi.

Vị trí là dịch vụ cấp nền tảng (底层服务) phụ thuộc bởi nhiều lớp ứng dụng khác, nên những dao động này gây ảnh hưởng nghiêm trọng trải nghiệm người dùng. Cần tiến hành tìm hiểu nguyên nhân ngay lập tức.

Hành Trình Điều Tra

Khi tín hiệu cảnh báo đầu tiên xuất hiện, suy nghĩ đầu tiên hướng về phía lượng truy cập quá tải hoặc truy vấn phức tạp làm cạn kiệt tài nguyên liên kết DB. Tuy nhiên, sau khi rà soát metric API, không có sự tăng đột biến lưu lượng nào được phát hiện trước hoặc trong lúc xảy ra sự cố.

Trạng thái của Database Backend cũng được kiểm tra kỹ lưỡng: CPU负载 ở mức thấp, không tồn tại các truy vấn chậm (slow query), loại trừ khả năng hệ thống CSDL là nguồn gốc gây nghẽn cổ chai.

Lệnh chuyển hướng sang kiểm tra hạ tầng chạy container và thông số JVM. Dữ liệu cho thấy hầu hết các yêu cầu lỗi đều xuất phát từ cùng một địa chỉ IP của container. Tỷ lệ cao gợi ý vấn đề nằm ở vòng đời xử lý bộ nhớ (Garbage Collection).

Kiểm tra CPU container thấy tải ổn định, nhưng biểu đồ sử dụng bộ nhớ Heap lại có hình thái đáng ngờ:

  • Vùng Young Gen hoạt động bình thường nhưng không giải phóng được rác.
  • Vùng Old Gen tăng trưởng liên tục trong thời gian dài.
  • Chỉ khi Old Gen đầy mới kích hoạt Full GC để thu hồi vật chứa.
(Biểu đồ minh họa tình trạng Old Gen bị chiếm dụng tài nguyên)

Giải pháp tạm thời: Do hiện tượng này đã bắt đầu xuất hiện trên vài instance và có nguy cơ lan rộng, việc duy trì đồng loạt các instance sẽ dẫn đến rủi ro sập hệ thống (service avalanche). Chúng tôi quyết định dừng khởi động lại tuần tự (rolling restart) các instance còn lại trong khi vẫn giữ lại một instance đang gặp sự cố để chụp lại dữ liệu phân tích.

Tìm Kiếm Nguyên Nhân Sâu

Vấn đề cốt lõi nằm ở các đối tượng bộ nhớ không được Young GC thu hồi mà buộc phải chờ đợi chu trình Full GC. Công cụ `jmap` được sử dụng để danh sách hóa các đối tượng sống sót:

jmap -histo:live ${PROCESS_ID} | head -n 20

Các đối tượng xếp hạng cao ban đầu trông vô hại, nhưng khi phân tích sâu hơn bằng Memory Analyzer Tool (MAT) từ file dump bộ nhớ, lớp đối tượng `class com.mysql.cj.jdbc.AbandonedConnectionCleanupThread` hiện diện với tỷ lệ chiếm dụng bộ nhớ vượt trội (trên 80%).

Đây là một luồng đơn thể (singleton) trong thư viện MySQL Driver, nhiệm vụ quét và đóng các kết nối chưa được đóng bởi ứng dụng một cách rõ ràng. Bên trong nó quản lý một tập hợp chứa các phantom reference của kết nối:

// Internal logic representation
private static final Set<ConnectionFinalizerPhantomReference> refs = ConcurrentHashMap.newKeySet();

Điều này khớp với quan sát trên HEAP dump nơi các nút HashMap chiếm dung lượng lớn. Việc xác nhận sự mất mát bộ nhớ (memory leak) tại đây trở nên chắc chắn.

Cơ Chế Hoạt Động Của Phantom Reference

Phantom Reference đặc trưng bởi việc đối tượng chỉ bị xóa khi VM thực hiện thu gom rác, đồng thời đẩy tham chiếu đó vào hàng đợi (Queue) để xử lý tiếp theo.

Kiểm tra code nguồn xác nhận rằng:

  1. Mỗi khi một kết nối MySQL mới được tạo, nó được bao bọc thành Phantom Reference và thêm vào tập hợp `connectionFinalizerPhantomRefs`.
  2. Nhiệm vụ dọn dẹp chạy vòng lặp vô tận để lấy các kết nối từ Queue và thực hiện đóng (close).
  3. Chỉ khi kết nối hoàn toàn không còn ai trỏ tới (ngoài Phantom Reference), nó mới đủ điều kiện để GC và đưa vào Queue.

Giai Đoạn Phân Tích Logic

Ứng dụng sử dụng Apache Druid cho kết nối. Cấu hình hiện tại cho phép `keepAlive` nhưng giới hạn thời gian tối thiểu cho kết nối bị hủy bỏ trống (`minEvictableIdleTimeMillis`) được đặt ở giá trị 5 phút.

spring.datasource.druid.min-evictable-idle-time-millis: 300000

Lý thuyết thì Druid sẽ bảo trì tối thiểu 30 kết nối và thực hiện giữ kết nối sống (ping) nếu chúng nhàn rỗi quá lâu. Tuy nhiên, khi kiểm tra biến cấu hình trên MySQL Server, thông số `wait_timeout` lại được thiết lập cứng ở mức 5 phút.

# Truy vấn kiểm tra thời gian chờ trên Server
mysql> show variables like 'wait_timeout%';

Sự mâu thuẫn rõ rệt đã lộ diện:

Đã có nhiều thay đổi trên lưu lượng giao dịch (giao dịch định kỳ lớn sau 30 phút hoặc 1 tiếng). Khi số lượng kết nối hoạt động giảm xuống dưới ngưỡng 30, Druid chuẩn bị loại bỏ các kết nối nhàn rỗi. Tuy nhiên, vì `minEvictableIdleTimeMillis` (5 phút) gần bằng với `wait_timeout` (5 phút) của Server,

khi Druid thực hiện lệnh kiểm tra sức khỏe (keep-alive) thì Server đã đóng cuộc hẹn trước đó do hết hạn timeout. Kết quả là kết nối bị coi là chết, Druid xóa nó và tạo kết nối mới.

Mỗi lần tạo kết nối mới, một Phantom Reference mới lại được gắn vào bộ đếm dọn dẹp. Vì các kết nối mới được dùng ngay lập tức, chúng không thể thoát ra khỏi Young Gen nhanh chóng, dần dà trượt sang Old Gen. Theo chu kỳ này, SET quản lý Phantom Reference phình to ra và không bao giờ co nhỏ lại đúng cách, dẫn đến tràn Old Gen.

Chiến Lược Khôi Phục

Để chấm dứt vòng lặp này, cần đảm bảo thời gian giữ alive của Client luôn nhỏ hơn thời gian chết của Server.

Cải tiến chính:

  1. Giảm thời gian `minEvictableIdleTimeMillis` trong cấu hình Druid xuống 3 phút (hoặc ít hơn 5 phút đáng kể).
  2. Đảm bảo luồng heartbeat của Druid được kích hoạt sớm hơn trước khi Server kịp đóng kết nối.
  3. Ngăn chặn việc liên tục tạo mới kết nối do nhầm lẫn trạng thái.

Thay đổi này giúp các kết nối được kiểm tra và sửa chữa (refresh) thành công trước khi bị Server reject, đảm bảo tính nhất quán giữa vòng đời kết nối ở hai đầu client-server và ngăn chặn sự tích lũy không cần thiết của các đối tượng bộ nhớ.

Thẻ: JVM Memory Tuning MySQL Performance Apache Druid Java Debugging Full GC Resolution

Đăng vào ngày 21 tháng 10 lúc 11:34