Bài viết này trình bày chi tiết một vấn đề gặp phải trong quá trình kiểm thử hiệu năng (stress testing) gần đây, tập trung vào nguyên nhân giao diện bị chậm và không thể ghi nhận dữ liệu khi chịu tải cao.
Các phương thức quản lý giao dịch trong Java
Trước tiên, cần hiểu rõ về các cách thức quản lý giao dịch (transaction) trong Java:
- Cấu hình Aspect trong XML: Phương pháp này từng phổ biến nhưng hiện nay ít được sử dụng. Nó cấu hình Aspect dựa trên tên của service và phương thức để kích hoạt giao dịch.
- Annotation
@Transactional: Đây là phương pháp được sử dụng phổ biến nhất hiện nay. - Sử dụng Spring Transaction Template: Phương pháp này ít người dùng, được đề cập trong tình huống thực tế của bài viết.
Trong bài viết này, chúng ta sẽ tập trung vào việc phân tích nguyên nhân gây chậm khi sử dụng Spring Transaction Template, mà không đi sâu vào lý do lựa chọn nó ở đây. Điều quan trọng cần nắm là nó có chức năng kích hoạt giao dịch.
Dấu hiệu bất thường
Khi kiểm tra log sau một đợt kiểm thử hiệu năng, phát hiện khoảng thời gian giữa hai log (một log khi bắt đầu vào phương thức và một log sau khi kích hoạt giao dịch) lên tới gần 5 giây. Việc mở một giao dịch tốn nhiều thời gian như vậy là dấu hiệu bất thường và cần được điều tra.
Phương pháp tiếp cận giải quyết vấn đề
Khi gặp vấn đề về hiệu năng cao trên môi trường production, việc tái hiện lỗi thường khó khăn. Một phương pháp phổ biến là phân tích mã nguồn tĩnh. Tuy nhiên, bài viết này sẽ tập trung vào các kỹ thuật phân tích phổ biến hơn, giúp người mới bắt đầu có thể tiếp cận vấn đề một cách bình tĩnh.
May mắn là vấn đề trong trường hợp này không quá phức tạp và rất phù hợp cho người mới bắt đầu. Chúng ta có thể mô phỏng lại kịch bản trên môi trường local để thu hẹp phạm vi tìm kiếm nguyên nhân.
Mô phỏng trên môi trường local
Để mô phỏng tải cao, chúng ta có thể sử dụng một lớp tiện ích hỗ trợ kiểm thử đồng thời (concurrency testing). Mã nguồn dưới đây được cung cấp để bạn có thể sao chép và chạy trực tiếp trong dự án của mình.
Nguyên lý hoạt động của lớp tiện ích này dựa trên lớp CountDownLatch của JDK. Bạn có thể tìm hiểu chi tiết hơn bằng cách tìm kiếm từ khóa này trên các công cụ tìm kiếm.
CountDownLatchUtil.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CountDownLatchUtil {
private CountDownLatch startSignal;
private CountDownLatch doneSignal;
private int poolSize = 10;
public CountDownLatchUtil() {
this(10);
}
public CountDownLatchUtil(int poolSize) {
this.poolSize = poolSize;
startSignal = new CountDownLatch(1);
doneSignal = new CountDownLatch(poolSize);
}
public void executeWithConcurrency(Runnable task) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(poolSize);
for (int i = 0; i < poolSize; i++) {
Runnable worker = new Runnable() {
@Override
public void run() {
try {
startSignal.await();
task.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Task interrupted: " + e.getMessage());
} finally {
doneSignal.countDown();
}
}
};
executorService.submit(worker);
}
startSignal.countDown(); // Cho phép tất cả các worker bắt đầu chạy
doneSignal.await(); // Chờ tất cả các worker hoàn thành
executorService.shutdown();
}
// Giao diện chức năng tùy chỉnh (tương tự Runnable nhưng có thể cần cho mục đích khác)
// Trong ví dụ này, Runnable đã đủ dùng.
}
BusinessService.java
public interface BusinessService {
void performOperation(long startTimeMillis);
}
BusinessServiceImpl.java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BusinessServiceImpl implements BusinessService {
private final Logger logger = LoggerFactory.getLogger(BusinessServiceImpl.class);
@Transactional
@Override
public void performOperation(long startTimeMillis) {
long elapsedTime = System.currentTimeMillis() - startTimeMillis;
if (elapsedTime > 5000) {
logger.warn("Operation took too long: {} ms", elapsedTime);
}
try {
// Mô phỏng thời gian xử lý nghiệp vụ là 1 giây
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Operation interrupted", e);
}
}
}
BusinessServiceTest.java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension; // Sử dụng Jupiter Extension
import org.junit.jupiter.api.extension.ExtendWith; // Sử dụng ExtendWith cho Jupiter
@SpringBootTest
@ExtendWith(SpringExtension.class) // Cấu hình Spring Extension cho JUnit 5
public class BusinessServiceTest {
@Autowired
private BusinessService businessService;
@Test
public void testPerformOperationConcurrency() throws Exception {
long startTime = System.currentTimeMillis();
int numberOfThreads = 1000;
CountDownLatchUtil latchUtil = new CountDownLatchUtil(numberOfThreads);
latchUtil.executeWithConcurrency(() -> {
businessService.performOperation(startTime);
});
}
}
Sau khi chạy kiểm thử trên môi trường local, log cho thấy nhiều trường hợp thực thi vượt quá 5 giây. Có một quy luật nhất định trong các khoảng thời gian này:
(Hình ảnh minh họa log với các khoảng thời gian được khoanh tròn theo nhóm 5, cách nhau khoảng 1 giây)
Nguyên nhân gốc rễ
Phần cốt lõi của annotation @Transactional (sẽ được phân tích chi tiết trong các bài viết sau) có liên quan đến việc lấy kết nối cơ sở dữ liệu.
Để minh họa rõ hơn vấn đề, chúng ta cấu hình tham số của bộ kết nối cơ sở dữ liệu (sử dụng Druid trong ví dụ này) như sau:
spring.datasource.initial-size=1
spring.datasource.max-active=5
Với maxActive là 5, khi 1000 luồng thực thi đồng thời, chỉ có 5 luồng đầu tiên lấy được kết nối và bắt đầu xử lý nghiệp vụ (mất 1 giây). 995 luồng còn lại phải chờ đợi. Khi 5 luồng đầu tiên hoàn thành và giải phóng kết nối, 5 luồng tiếp theo trong hàng đợi sẽ nhận được kết nối và tiếp tục xử lý. Phép tính đơn giản cho thấy để hoàn thành cho tất cả các luồng cần một khoảng thời gian đáng kể. Điều này giải thích tại sao log hiển thị theo nhóm 5 luồng, với khoảng cách 1 giây giữa mỗi nhóm.
Giải pháp
Có nhiều cách để giải quyết vấn đề, không có giải pháp nào là tối ưu tuyệt đối, chỉ có giải pháp phù hợp hơn.
Một số người có thể đề xuất tăng maxActive. Tuy nhiên, cần lưu ý rằng giá trị 5 trong ví dụ này chỉ để minh họa. Trong môi trường production, giá trị này cần được xác định dựa trên đặc điểm nghiệp vụ và kết quả kiểm thử hiệu năng.
Vấn đề thực tế xảy ra ngay cả khi maxActive được đặt là 200 và tải không quá lớn. Nguyên nhân nằm ở một đoạn mã khác:
(Đoạn mã kiểm tra xác thực, gọi đến một giao diện RPC)
Gọi đến một giao diện RPC (Remote Procedure Call) này không được tối ưu hóa về độ tin cậy và hiệu năng, dẫn đến thời gian xử lý kéo dài. Kết hợp với việc các luồng khác đang chờ lấy kết nối cơ sở dữ liệu, thời gian chờ đợi trở nên rất dài, gây ra hiện tượng giao diện bị chậm.
Bài học kinh nghiệm
Bài học quan trọng rút ra là cần suy nghĩ sâu sắc về vấn đề. Ví dụ này mở ra nhiều suy ngẫm:
(Một đoạn hội thoại phỏng vấn về việc sử dụng @Transactional trong các tác vụ tốn thời gian)
Vấn đề trong cuộc phỏng vấn này tương tự như vấn đề kiểm thử hiệu năng đã phân tích. Tuy nhiên, kết luận của người phỏng vấn chưa hoàn toàn chính xác.
Theo quy tắc phát triển của Alibaba:
(Hình ảnh quy tắc phát triển của Alibaba về việc không nên đặt các thao tác tốn thời gian hoặc không liên quan trong cùng một giao dịch)
Vậy "lạm dụng" ở đây là gì? Ngay cả khi một phương thức được gọi thường xuyên, nếu chỉ thực hiện các thao tác INSERT, UPDATE đơn giản trên một bảng và thời gian thực thi rất ngắn, thì nó có thể chịu được tải cao. Điều quan trọng là các phương thức bên trong giao dịch có thực sự cần thiết phải thuộc về giao dịch đó hay không.
Trong một số công ty, lập trình viên thường chỉ thực hiện các công việc CRUD và có xu hướng gắn annotation @Transactional cho mọi phương thức service. Sau đó, họ thực hiện nhiều thao tác tốn thời gian không liên quan đến giao dịch, như I/O file, hoặc các bước kiểm tra, xác thực. Ví dụ, các bước xác thực trong bài viết này hoàn toàn không cần đặt trong giao dịch.
Việc thiếu kinh nghiệm thực tế và không hiểu rõ nguyên lý hoạt động dẫn đến những sai lầm này. Khi bị hỏi về nguyên lý, nhiều người gặp khó khăn, buộc người phỏng vấn phải chuyển sang hướng khác.
Chúng ta có thể rút ra bài học gì từ đây? Vấn đề luôn tồn tại, nhưng qua suy ngẫm, chúng ta có thể khai thác được nhiều giá trị hơn.
Theo quy tắc của Alibaba:
(Hình ảnh quy tắc Alibaba về việc giảm thiểu phạm vi khóa và tránh gọi RPC trong khi giữ khóa)
Nói một cách đơn giản, hãy giảm thiểu phạm vi khóa và tránh gọi các phương thức RPC khi đang giữ khóa. Bởi vì các phương thức RPC liên quan đến mạng, thời gian thực thi của chúng không thể kiểm soát, dễ dẫn đến việc chiếm giữ khóa quá lâu.
Điều này tương tự như vấn đề kiểm thử hiệu năng của chúng ta: gọi RPC trong một giao dịch không những không đảm bảo tính nhất quán phân tán (cần Distributed Transaction) mà còn có thể chiếm giữ kết nối cơ sở dữ liệu quá lâu do các yếu tố không thể kiểm soát của RPC, dẫn đến lỗi timeout.
Chúng ta cũng có thể sử dụng các công cụ APM (Application Performance Management) để phân tích biểu đồ tương tác của các giao diện, giúp phát hiện sớm các vấn đề như thế này trước khi tiến hành kiểm thử hiệu năng.