Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

jmeter

Tung Bui Thanh (TungDaDev)27 mins read

Jmeter.webp

Trước khi mở JMeter, nên phân biệt rõ mình đang chạy loại test nào, vì mỗi loại có mục tiêu và cách đọc kết quả khác nhau:

Loại testMục đíchVí dụ
Load TestKiểm tra với tải bình thường/dự kiến500 concurrent users trong 10 phút
Stress TestTìm giới hạn chịu tải tối đaTăng dần từ 100 → 5000 users
Spike TestKiểm tra đột biến tải0 → 2000 users trong 10 giây
Soak/Endurance TestKiểm tra memory leak, resource drain200 users liên tục 24 giờ
Scalability TestĐo khả năng mở rộngSo sánh 1 instance vs 3 instances

# cài đặt & cấu hình

# cài đặt jmeter

# Download từ https://jmeter.apache.org/download_jmeter.cgi
# Giải nén và chạy
cd apache-jmeter-5.6.3/bin
 
# Windows
jmeter.bat
 
# Linux/Mac
./jmeter.sh

# cấu hình jvm cho jmeter (test lớn)

# File: bin/jmeter.bat hoặc bin/jmeter
# Tăng heap cho JMeter khi chạy test lớn (>1000 threads)
HEAP="-Xms2g -Xmx4g"

Mặc định script khởi động của JMeter 5.6.3 đặt -Xms1g -Xmx1g -XX:MaxMetaspaceSize=256m. Trên Linux/Mac không cần sửa file: script bin/jmeter đọc biến môi trường HEAP nếu có, nên chỉ cần export HEAP="-Xms2g -Xmx4g" trước khi chạy (khi đặt HEAP thì nhớ tự thêm -XX:MaxMetaspaceSize nếu cần). Trên Windows thì sửa dòng set HEAP=... trong bin\jmeter.bat, hoặc đặt biến HEAP tương tự.

# kiến trúc jmeter: các thành phần chính

Test Plan
├── Thread Group (mô phỏng virtual users)
│   ├── Sampler (HTTP Request, JDBC, etc.)
│   ├── Config Element (Header Manager, Cookie Manager, CSV Data)
│   ├── Pre-Processor (JSR223, User Parameters)
│   ├── Post-Processor (JSON Extractor, Regex Extractor)
│   ├── Assertion (Response Assertion, Duration Assertion)
│   └── Timer (Constant, Gaussian, Uniform Random)
├── Listener (View Results Tree, Summary Report, Aggregate Report)
└── Logic Controller (If, Loop, Transaction, Random Order)

# giải thích các thành phần

ComponentChức năngKhi nào dùng
Thread GroupĐịnh nghĩa số user, ramp-up time, loop countLuôn cần: entry point
HTTP RequestGửi HTTP request đến APITest REST API
Header ManagerThêm headers (Authorization, Content-Type)API cần auth token
CSV Data Set ConfigĐọc test data từ file CSVParameterize requests
JSON ExtractorTrích xuất giá trị từ JSON responseLấy token, ID cho request tiếp
Response AssertionKiểm tra response đúng mong đợiValidate status code, body
Constant TimerDelay giữa các requestMô phỏng think time thực tế
Transaction ControllerNhóm nhiều request thành 1 transactionĐo thời gian một business flow

# spring boot api mẫu để test

# controller

@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
 
   private final ProductService productService;
 
   @GetMapping
   public ResponseEntity<APIResponse<Page<ProductDTO>>> list(
           @RequestParam(defaultValue = "0") int page,
           @RequestParam(defaultValue = "20") int size,
           @RequestParam(required = false) String keyword) {
       Page<ProductDTO> result = productService.search(keyword, PageRequest.of(page, size));
       return ResponseEntity.ok(APIResponse.success(result));
   }
 
   @GetMapping("/{id}")
   public ResponseEntity<APIResponse<ProductDTO>> getById(@PathVariable UUID id) {
       return ResponseEntity.ok(APIResponse.success(productService.getById(id)));
   }
 
   @PostMapping
   public ResponseEntity<APIResponse<ProductDTO>> create(
           @Valid @RequestBody CreateProductRequest request) {
       ProductDTO created = productService.create(request);
       return ResponseEntity.status(HttpStatus.CREATED).body(APIResponse.success(created));
   }
 
   @PutMapping("/{id}")
   public ResponseEntity<APIResponse<ProductDTO>> update(
           @PathVariable UUID id,
           @Valid @RequestBody UpdateProductRequest request) {
       return ResponseEntity.ok(APIResponse.success(productService.update(id, request)));
   }
 
   @DeleteMapping("/{id}")
   @ResponseStatus(HttpStatus.NO_CONTENT)
   public void delete(@PathVariable UUID id) {
       productService.delete(id);
   }
}

# security config (endpoint cần bearer token)

@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
   @Bean
   public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       return http
           .csrf(AbstractHttpConfigurer::disable)
           .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
           .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
           .authorizeHttpRequests(auth -> auth
               .requestMatchers("/actuator/**").permitAll()
               .anyRequest().authenticated())
           .build();
   }
}

# thiết kế test plan trong jmeter

# test plan cơ bản: get api (không auth)

Test Plan: Product API Load Test
└── Thread Group
   ├── Number of Threads: 100
   ├── Ramp-Up Period: 30 seconds
   ├── Loop Count: 10
   │
   ├── HTTP Request Defaults
   │   ├── Server: localhost
   │   ├── Port: 8093
   │   └── Protocol: http
   │
   ├── HTTP Request: GET /api/v1/products
   │   ├── Method: GET
   │   ├── Path: /api/v1/products
   │   └── Parameters: page=0, size=20
   │
   ├── Response Assertion
   │   └── Response Code: 200
   │
   ├── Constant Timer
   │   └── Thread Delay: 1000ms (think time)
   │
   └── Listeners
       ├── Summary Report
       ├── Aggregate Report
       └── Response Time Graph

# test plan với authentication (oauth2/jwt)

Khi Spring Boot dùng OAuth2 Resource Server thì mỗi virtual user phải lấy token trước, sau đó gửi kèm header Authorization: Bearer ... ở mọi request.

Test Plan: Authenticated API Load Test
└── Thread Group (100 users, 60s ramp-up, loop=20)
   │
   ├── [Once Only Controller] Lay token 1 lan moi thread
   │   ├── HTTP Request: POST /realms/csp/protocol/openid-connect/token
   │   │   ├── Server: keycloak.local
   │   │   ├── Port: 8080
   │   │   ├── Method: POST
   │   │   ├── Content-Type: application/x-www-form-urlencoded
   │   │   └── Body Data:
   │   │       grant_type=client_credentials
   │   │       &client_id=csp-service
   │   │       &client_secret=${CLIENT_SECRET}
   │   │
   │   └── JSON Extractor
   │       ├── Variable Name: access_token
   │       └── JSON Path: $.access_token
   │
   ├── HTTP Header Manager
   │   ├── Authorization: Bearer ${access_token}
   │   └── Content-Type: application/json
   │
   ├── [Transaction Controller: Create Product]
   │   └── HTTP Request: POST /api/v1/products
   │       ├── Method: POST
   │       ├── Body Data:
   │       │   {
   │       │     "name": "Product-${__UUID()}",
   │       │     "code": "PRD-${__counter(,)}",
   │       │     "price": ${__Random(100,9999,)}
   │       │   }
   │       │
   │       └── JSON Extractor (extract product ID, dat CON cua request POST)
   │           ├── Variable Name: product_id
   │           └── JSON Path: $.data.id
   │
   ├── [Transaction Controller: Get Product by ID]
   │   └── HTTP Request: GET /api/v1/products/${product_id}
   │
   ├── [Transaction Controller: Update Product]
   │   └── HTTP Request: PUT /api/v1/products/${product_id}
   │       └── Body Data:
   │           {
   │             "name": "Updated-${__UUID()}",
   │             "price": ${__Random(200,19999,)}
   │           }
   │
   ├── [Transaction Controller: Delete Product]
   │   └── HTTP Request: DELETE /api/v1/products/${product_id}
   │
   ├── Constant Timer: 500ms
   │
   └── Listeners
       ├── Aggregate Report
       └── View Results Tree (debug only, disable in real test)

Vài chỗ trong cây trên dễ làm sai:

  • Scope của Post-Processor. Trong JMeter, Post-Processor (JSON Extractor...) chạy sau mọi sampler nằm cùng cấp hoặc bên dưới nó. Nếu đặt JSON Extractor lấy product_id ở cấp Thread Group, nó sẽ chạy cả sau GET, PUT, DELETE. Request DELETE trả 204 không có body, extractor không tìm thấy $.data.id và ghi đè product_id bằng giá trị mặc định. Vì vậy extractor phải là con trực tiếp của request POST.
  • Đường dẫn token của Keycloak. Từ Keycloak 17 (bản Quarkus), prefix /auth bị bỏ, endpoint là /realms/<realm>/protocol/openid-connect/token. Bản cũ (WildFly) hoặc bản cấu hình http-relative-path=/auth mới cần /auth/realms/....
  • Token hết hạn. Once Only Controller chỉ lấy token một lần cho mỗi thread. Test chạy lâu hơn thời gian sống của access token (Keycloak mặc định 5 phút) thì nửa sau toàn 401. Với soak test, cần thêm logic refresh token (If Controller kiểm tra thời điểm hết hạn) hoặc tăng lifetime của client dùng để test.

# test plan với csv data (nhiều users khác nhau)

File users.csv:

username,password
user001,Pass@123
user002,Pass@123
user003,Pass@123
...
user100,Pass@123
Test Plan
└── Thread Group
   ├── CSV Data Set Config
   │   ├── Filename: users.csv
   │   ├── Variable Names: username,password
   │   ├── Delimiter: ,
   │   ├── Recycle on EOF: True
   │   └── Sharing mode: All threads
   │
   ├── HTTP Request: Login
   │   └── Body: grant_type=password&username=${username}&password=${password}
   │
   └── ... (rest of test)

Với Sharing mode: All threads, mỗi thread lấy dòng kế tiếp trong file, nên 100 thread sẽ dùng 100 user khác nhau. Lưu ý grant_type=password (Resource Owner Password Credentials) đã bị loại khỏi OAuth 2.1 và bị khuyến cáo không dùng trong OAuth 2.0 Security BCP; ở đây nó chỉ tiện cho môi trường test. Client dùng grant này nên là client riêng cho performance test, không bật trên production.

# chạy jmeter: gui vs cli

# gui mode (chỉ dùng để thiết kế & debug)

# Mở GUI
./bin/jmeter        # Windows: bin\jmeter.bat
# Tạo test plan → Save as .jmx file
# KHÔNG chạy test thực sự ở GUI — tốn resource, kết quả không chính xác

# cli mode (non-gui, dùng cho test thực tế)

# Chạy test từ command line
jmeter -n -t test-plan.jmx -l results.jtl -e -o report-output/
 
# Giải thích:
# -n : non-GUI mode
# -t : test plan file (.jmx)
# -l : log results file (.jtl)
# -e : generate HTML report sau khi test xong
# -o : output folder cho HTML report (phải rỗng hoặc chưa tồn tại)
 
# Với custom properties
jmeter -n -t test-plan.jmx -l results.jtl \
 -Jthreads=200 \
 -Jrampup=60 \
 -Jduration=300 \
 -Jhost=api.staging.tungdadev.com \
 -Jport=443

# sử dụng properties trong test plan

Trong Thread Group:

  • Number of Threads: ${__P(threads,100)}, mặc định 100, override bằng -Jthreads=200
  • Ramp-Up: ${__P(rampup,30)}
  • Duration: ${__P(duration,180)}

Duration chỉ có tác dụng khi tick ô Specify Thread lifetime và đặt Loop Count là Infinite. Nếu vẫn để loop count cố định, thread nào chạy hết số vòng sẽ dừng sớm, còn thread nào chưa hết sẽ bị cắt khi hết duration.

__P đọc JMeter property, nên giá trị truyền qua -J trên command line. Biến môi trường của shell thì không đọc được trực tiếp, phải dùng -Jthreads=$THREADS như ví dụ CI ở cuối bài.

# jmeter functions hữu ích

FunctionMô tảVí dụ
${__UUID()}Generate UUID ngẫu nhiênTạo unique name
${__Random(1,1000,)}Số ngẫu nhiên trong rangeRandom price
${__counter(,)}Bộ đếm tăng dầnSequential ID
${__time(yyyy-MM-dd,)}Timestamp hiện tạiCreated date
${__threadNum}Số thứ tự thread hiện tạiDebugging
${__P(prop,default)}Đọc propertyParameterize từ CLI
${__groovy(code)}Chạy Groovy inlineComplex logic
${__RandomString(10,abcdef,)}Chuỗi ngẫu nhiênRandom code
${__CSVRead(file,column)}Đọc CSVTest data

# đọc kết quả: các metrics quan trọng

# aggregate report

MetricÝ nghĩaNgưỡng chấp nhận (ví dụ)
SamplesTổng số request đã gửi:
Average (ms)Thời gian phản hồi trung bình< 500ms cho API thường
Median (ms)P50: 50% request nhanh hơn giá trị này< 300ms
90% Line (ms)P90: 90% request nhanh hơn< 1000ms
95% Line (ms)P95: quan trọng cho SLA< 1500ms
99% Line (ms)P99: worst case trừ outliers< 3000ms
Min/Max (ms)Response time nhỏ nhất/lớn nhất:
Error %Tỷ lệ lỗi< 1% (load test), < 5% (stress)
Throughput (req/s)Số request server xử lý mỗi giâyPhụ thuộc yêu cầu
KB/secBandwidth sử dụng:

# phân tích kết quả

Ví dụ kết quả Aggregate Report (số liệu minh hoạ):

Label          | Samples | Avg  | P90  | P95  | P99  | Error% | Throughput
---------------|---------|------|------|------|------|--------|----------
GET /products  | 10000   | 125  | 250  | 380  | 890  | 0.02%  | 312.5/s
POST /products | 5000    | 340  | 680  | 920  | 1850 | 0.12%  | 156.2/s
GET /products/{id} | 5000 | 85  | 150  | 210  | 450  | 0.0%   | 156.2/s

Đánh giá:
✅ GET /products: P95 < 500ms → đạt
✅ GET /products/{id}: rất nhanh, có thể đã cache
⚠️  POST /products: P99 = 1850ms → cần investigate (DB write? validation heavy?)
✅ Error rate: < 1% tổng thể → acceptable

# distributed testing: chạy jmeter phân tán

Muốn mô phỏng vài nghìn user trở lên thì một máy JMeter thường không đủ CPU, RAM hoặc băng thông mạng. Lúc đó dùng distributed mode: một máy master điều khiển, nhiều máy slave (JMeter gọi là server) sinh tải thật.

# kiến trúc

distributed-testing

# cấu hình

# Trên mỗi Slave machine — chạy JMeter server
jmeter-server -Djava.rmi.server.hostname=192.168.1.101
 
# Trên Master — file jmeter.properties
remote_hosts=192.168.1.101,192.168.1.102,192.168.1.103
 
# Chạy distributed test từ Master
jmeter -n -t test-plan.jmx -l results.jtl -R 192.168.1.101,192.168.1.102,192.168.1.103

# lưu ý quan trọng

  • Tất cả machines phải cùng version JMeter
  • CSV data files phải có trên mỗi slave (hoặc dùng absolute path chung)
  • Firewall phải mở port RMI (default 1099) và port range cho data transfer
  • Master chỉ điều khiển, không chạy test (trừ khi thêm localhost vào remote_hosts)
  • Từ JMeter 4.0, kênh RMI mặc định dùng SSL. Phải tạo keystore bằng bin/create-rmi-keystore.sh rồi copy file rmi_keystore.jks sang mọi máy, hoặc tắt SSL bằng server.rmi.ssl.disable=true (chỉ nên làm trong mạng nội bộ tin cậy). Không làm bước này thì jmeter-server không khởi động được.
  • Mỗi slave chạy toàn bộ test plan. 3 slave với Thread Group 100 threads nghĩa là tổng 300 user, không phải 100 chia ba.
  • Port data mặc định là ngẫu nhiên, khó mở firewall. Nên cố định bằng server.rmi.localport (trên slave) và client.rmi.localport (trên master).

# tối ưu spring boot để chịu tải tốt hơn

Khi JMeter đã chỉ ra chỗ nghẽn thì mới tuning. Đừng chỉnh mò khi chưa có số đo. Dưới đây là mấy chỗ hay phải đụng tới trong Spring Boot.

# connection pool (HikariCP)

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 30 # Tăng theo số concurrent request
      minimum-idle: 10
      connection-timeout: 20000 # 20s
      idle-timeout: 300000 # 5 phút
      max-lifetime: 1200000 # 20 phút
      leak-detection-threshold: 60000 # Log warning nếu connection giữ > 60s

Comment "tăng theo số concurrent request" dễ gây hiểu lầm. Pool lớn hơn không có nghĩa là nhanh hơn: database chỉ thực sự chạy song song được cỡ số core (cộng thêm phần chờ disk). Tài liệu "About Pool Sizing" của HikariCP gợi ý điểm xuất phát connections = (core_count * 2) + effective_spindle_count rồi đo để chỉnh. Nếu 200 request đồng thời mà pool chỉ có 30 thì 170 request còn lại xếp hàng chờ connection, chuyện đó bình thường. Chỉ khi thấy hikaricp_connections_pending cao kéo dài trong lúc CPU database vẫn rảnh thì mới nên tăng.

# tomcat thread pool

server:
  tomcat:
    threads:
      max: 200 # Max worker threads (default: 200)
      min-spare: 20 # Min idle threads
    max-connections: 8192
    accept-count: 100 # Queue size khi tất cả threads bận
    connection-timeout: 20000

Các giá trị mặc định của Spring Boot là threads.max=200, min-spare=10, max-connections=8192, accept-count=100. Khi cả 200 thread đều bận, connection mới vẫn được Tomcat nhận (tới 8192) và chờ thread rảnh. Vượt quá đó thì vào backlog của OS (accept-count), đầy nốt thì client bị từ chối hoặc timeout ở tầng TCP. Từ Spring Boot 3.2 trên Java 21, có thể bật spring.threads.virtual.enabled=true để Tomcat dùng virtual thread cho mỗi request, lúc đó threads.max gần như không còn là giới hạn, nút thắt chuyển sang connection pool của database.

# redis cache (giảm db hits)

@Service
@RequiredArgsConstructor
public class ProductService {
 
   private final ProductRepository repo;
   private final RedisTemplate<String, ProductDTO> redisTemplate;
 
   @Cacheable(value = "products", key = "#id", unless = "#result == null")
   public ProductDTO getById(UUID id) {
       return repo.findById(id)
           .map(this::toDTO)
           .orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
   }
 
   @CacheEvict(value = "products", key = "#id")
   public ProductDTO update(UUID id, UpdateProductRequest request) {
       // ... update logic
   }
}

Ở đây update dùng @CacheEvict nên lần GET tiếp theo sẽ đọc lại từ DB. Muốn ghi luôn giá trị mới vào cache thì dùng @CachePut(value = "products", key = "#id"). Annotation cache chỉ có tác dụng khi đã bật @EnableCaching và có cache provider (ví dụ spring-boot-starter-data-redis); field redisTemplate trong ví dụ chỉ cần khi bạn thao tác Redis thủ công.

# async processing

@Configuration
@EnableAsync
public class AsyncConfig {
 
   @Bean("taskExecutor")
   public Executor taskExecutor() {
       ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
       executor.setCorePoolSize(10);
       executor.setMaxPoolSize(30);
       executor.setQueueCapacity(500);
       executor.setThreadNamePrefix("async-");
       executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
       executor.initialize();
       return executor;
   }
}
 
// Non-critical work (audit log, notification) chạy async
@Async("taskExecutor")
public void logActivity(ActivityEvent event) {
   activityRepository.save(event);
}

@Async chạy qua proxy, nên gọi logActivity() từ một method khác trong cùng class sẽ không async. CallerRunsPolicy nghĩa là khi queue 500 đã đầy, thread của request phải tự chạy task, nên latency của request tăng lên. Đây là cơ chế tự hãm (backpressure) có chủ đích chứ không phải lỗi, nhưng nhìn kết quả JMeter cần nhớ điều này.

# jvm tuning

# Cho production với Spring Boot
JAVA_OPTS="-Xms2g -Xmx2g \
 -XX:+UseG1GC \
 -XX:MaxGCPauseMillis=200 \
 -XX:+UseStringDeduplication \
 -XX:+OptimizeStringConcat \
 -Djava.security.egd=file:/dev/./urandom"

Xem kỹ thì phần lớn các flag trên đã là mặc định trên JDK hiện đại. G1 là GC mặc định từ JDK 9 (trên máy đủ 2 CPU và 2GB RAM), MaxGCPauseMillis mặc định đã là 200, OptimizeStringConcat bật sẵn. Mẹo java.security.egd=file:/dev/./urandom là để chữa chuyện khởi động chậm vì chờ entropy trên JDK 8 cũ, JDK 11+ gần như không cần. Flag đáng giữ là -Xms bằng -Xmx (tránh heap co giãn trong lúc test) và UseStringDeduplication nếu app giữ nhiều String trùng. Chạy trong container thì nên dùng -XX:MaxRAMPercentage=75 thay vì số cứng.

# monitoring spring boot trong khi load test

# spring boot actuator + prometheus + grafana

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,prometheus,metrics
  prometheus:
    metrics:
      export:
        enabled: true
  metrics:
    tags:
      application: ${spring.application.name}

Cần thêm dependency micrometer-registry-prometheus. Từ Spring Boot 3.0, property management.metrics.export.prometheus.enabled đã đổi thành management.prometheus.metrics.export.enabled như trên (mặc định đã là true khi có dependency).

# metrics cần theo dõi song song với jmeter

MetricPrometheus QueryAlert khi
HTTP request durationhttp_server_requests_seconds{quantile="0.95"}> 2s
Active threadstomcat_threads_busy_threads> 80% max
DB connection poolhikaricp_connections_active> 80% max pool
JVM Heapjvm_memory_used_bytes{area="heap"}> 80% max
GC pausejvm_gc_pause_seconds_max> 500ms
Error ratehttp_server_requests_seconds_count{status=~"5.."}> 1%
Queue depth (RabbitMQ)rabbitmq_queue_messagesTăng liên tục

Bảng trên cần đọc kèm vài lưu ý. Series http_server_requests_seconds{quantile="0.95"} chỉ có khi bạn bật management.metrics.distribution.percentiles.http.server.requests=0.95, và percentile kiểu này tính riêng trên từng instance, không cộng gộp được. Chạy nhiều instance thì nên bật percentiles-histogram rồi dùng histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket[1m]))). Dòng error rate thì _count là counter tăng dần, muốn so với 1% phải lấy rate của request 5xx chia cho rate của tổng request. Metric tomcat_threads_busy_threads chỉ xuất hiện khi bật server.tomcat.mbeanregistry.enabled=true.

# kết hợp jmeter + grafana

JMeter có sẵn Backend Listener để đẩy metric real-time lên InfluxDB, rồi Grafana đọc từ đó để vẽ:

# Thêm Backend Listener trong JMeter:
# Type: org.apache.jmeter.visualizers.backend.influxdb.InfluxdbBackendListenerClient
# influxdbUrl: http://influxdb:8086/write?db=jmeter
# application: csp-product-api
# measurement: jmeter

URL trên là kiểu InfluxDB 1.x. Với InfluxDB 2.x, dùng endpoint /api/v2/write?org=...&bucket=... và thêm tham số influxdbToken. Dashboard Grafana sẽ hiển thị:

  • Response time (avg, P90, P95, P99) theo thời gian
  • Throughput (req/s) theo thời gian
  • Error rate theo thời gian
  • Active threads theo thời gian

# kịch bản test thực tế: csp api

# scenario 1: load test cho product search api

Mục tiêu: Xác nhận API search product chịu được 500 concurrent users

Thread Group:
- Threads: 500
- Ramp-up: 120s (mỗi giây thêm ~4 users)
- Duration: 600s (10 phút)

Kịch bản mỗi user:
1. Login lấy token (Once Only)
2. Loop:
  a. GET /api/v1/products?keyword=${random_keyword}&page=0&size=20
  b. Think time: 2-5s (Gaussian Random Timer)
  c. GET /api/v1/products/${random_id}
  d. Think time: 1-3s

Assertions:
- Response code = 200
- Response time < 2000ms
- Body contains "data"

Pass criteria:
- P95 response time < 1000ms
- Error rate < 1%
- Throughput > 150 req/s

Con số throughput phải tính từ think time chứ không đặt bừa. Theo định luật Little, throughput ≈ số user / (thời gian một vòng). Mỗi vòng ở đây có 2 request và trung bình khoảng 3.5s + 2s = 5.5s think time, cộng thời gian phản hồi. Vậy 500 user sinh tối đa khoảng 500 × 2 / 5.5 ≈ 180 req/s, có đặt tiêu chí "> 200 req/s" thì test không bao giờ đạt dù server khoẻ cỡ nào. Hiểu ngược lại, nếu throughput đo được thấp hơn nhiều so với con số này thì server đang làm chậm vòng lặp, response time đang tăng.

# scenario 2: stress test: tìm breaking point

Mục tiêu: Tìm số users tối đa trước khi error rate > 5%

Thread Group (Stepping Thread Group plugin):
- Start threads: 50
- Add 50 threads every 60 seconds (moi bac giu tai ~60s)
- Max threads: 2000
- Hold load for 300s khi da dat max

Quan sát:
- Tại bước nào error rate bắt đầu > 1%?
- Tại bước nào response time P95 > 3s?
- Tại bước nào throughput bắt đầu giảm (saturation point)?

Ví dụ kết quả:
- 50-400 users: ổn định, P95 < 500ms
- 400-600 users: P95 tăng lên 800ms-1200ms
- 600-800 users: error rate bắt đầu 2-3%, P95 = 2000ms
- > 800 users: error rate > 5% → BREAKING POINT ≈ 700-800 users

# scenario 3: soak test: phát hiện memory leak

Mục tiêu: Chạy liên tục 4 giờ, kiểm tra memory leak

Thread Group:
- Threads: 100 (moderate load)
- Ramp-up: 60s
- Duration: 14400s (4 giờ)

Quan sát theo thời gian:
- JVM Heap usage: tăng dần không giải phóng? → Memory leak
- Response time: tăng dần? → Resource exhaustion
- GC frequency: tăng? → Heap pressure
- DB connection: tăng dần? → Connection leak
- Thread count: tăng dần? → Thread leak

Red flags:
⚠️ Heap tăng liên tục qua mỗi giờ (sawtooth pattern bình thường, upward trend = leak)
⚠️ Full GC ngày càng thường xuyên
⚠️ Response time tăng 50%+ sau 2 giờ so với ban đầu

# jmeter plugins hữu ích

# cài đặt plugin manager

# Download từ https://jmeter-plugins.org/install/Install/
# Copy jmeter-plugins-manager.jar vào lib/ext/
# Restart JMeter → Menu Options → Plugins Manager

# plugins nên cài

PluginChức năng
Custom Thread GroupsStepping Thread Group, Ultimate Thread Group (ramp pattern phức tạp)
3 Basic GraphsResponse Time Over Time, Active Threads, Transactions Per Second
Throughput Shaping TimerKiểm soát chính xác throughput theo thời gian
JSON/YAML PluginsHỗ trợ JSON Path Assertion, YAML processing
Parallel ControllerChạy nhiều request song song trong 1 thread
Inter-Thread CommunicationChia sẻ data giữa thread groups
InfluxDB WriterPush metrics real-time lên InfluxDB/Grafana

# ultimate thread group: load pattern phức tạp

Ví dụ: Mô phỏng traffic thực tế trong ngày

Row 1: Threads=100, InitialDelay=0,   Startup=60, Hold=300, Shutdown=30
Row 2: Threads=200, InitialDelay=120, Startup=60, Hold=240, Shutdown=30  (peak)
Row 3: Threads=50,  InitialDelay=360, Startup=30, Hold=120, Shutdown=60  (off-peak)

Timeline:
0-60s:    Ramp 0→100
60-120s:  Hold 100
120-180s: Ramp 100→300 (thêm 200)
180-360s: Hold 300
360-390s: Row 1 tắt dần, Row 3 lên dần → ~250
390-420s: Hold ~250
420-450s: Row 2 tắt dần → 50
450-510s: Hold 50
510-570s: 50→0

Mỗi dòng của Ultimate Thread Group có 5 cột: Start Threads Count, Initial Delay, Startup Time, Hold Load For, Shutdown Time. Các dòng chạy độc lập và chồng lên nhau theo thời gian, nên tổng số thread tại một thời điểm là tổng các dòng đang hoạt động.

# scripting trong jmeter: jsr223 (groovy)

# pre-processor: generate complex request body

// JSR223 PreProcessor (Groovy)
import groovy.json.JsonOutput
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
 
def now = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
def requestBody = [
   name: "Product-" + UUID.randomUUID().toString().take(8),
   code: "PRD-" + ctx.getThreadNum() + "-" + System.currentTimeMillis(),
   price: new Random().nextInt(9000) + 1000,
   category: ["ELECTRONICS", "CLOTHING", "FOOD", "BOOKS"][new Random().nextInt(4)],
   metadata: [
       createdAt: now,
       source: "jmeter-load-test",
       batchId: vars.get("BATCH_ID") ?: "batch-001"
   ]
]
 
vars.put("requestBody", JsonOutput.toJson(requestBody))

Hai chỗ cần để ý khi viết script JSR223. Thứ nhất, __threadNum là JMeter function chứ không phải biến, nên vars.get('__threadNum') luôn trả null; trong Groovy lấy số thứ tự thread bằng ctx.getThreadNum(). Thứ hai, JMeter thay thế cú pháp ${...} trong nội dung script trước khi Groovy chạy. Viết ${myVar} trong script vừa dễ nhầm với GString của Groovy, vừa làm hỏng cache compile của script. Cách an toàn là đọc biến bằng vars.get("myVar") và ghép chuỗi thường. Sampler HTTP sau đó dùng ${requestBody} làm Body Data.

# post-processor: extract và validate response

// JSR223 PostProcessor (Groovy)
import groovy.json.JsonSlurper
 
def response = new JsonSlurper().parseText(prev.getResponseDataAsString())
 
if (response.data?.id) {
   vars.put("created_product_id", response.data.id.toString())
   log.info("Created product: ${response.data.id}")
} else {
   log.error("Failed to create product. Response: ${prev.getResponseDataAsString()}")
   prev.setSuccessful(false)
   prev.setResponseMessage("Product creation failed - no ID returned")
}
 
// Lưu metrics custom
def responseTime = prev.getTime()
if (responseTime > 2000) {
   log.warn("SLOW REQUEST: ${prev.getSampleLabel()} took ${responseTime}ms")
}

# assertion: custom business logic validation

// JSR223 Assertion (Groovy)
import groovy.json.JsonSlurper
 
def response = new JsonSlurper().parseText(prev.getResponseDataAsString())
 
// Validate business rules
if (response.data?.price != null && response.data.price <= 0) {
   AssertionResult.setFailure(true)
   AssertionResult.setFailureMessage("Product price must be positive, got: ${response.data.price}")
}
 
if (response.data?.name?.length() > 255) {
   AssertionResult.setFailure(true)
   AssertionResult.setFailureMessage("Product name exceeds 255 chars")
}

# ci/cd integration: tự động hóa load test

# gitlab ci pipeline

# .gitlab-ci.yml
load-test:
  stage: performance
  image:
    name: alpine/jmeter:5.6.3
    entrypoint: ['']
  variables:
    TARGET_HOST: 'api.staging.internal'
    TARGET_PORT: '443'
    THREADS: '200'
    DURATION: '300'
  script:
    - apk add --no-cache python3
    - mkdir -p results
    - jmeter -n
      -t tests/load-test-plan.jmx
      -l results/results.jtl
      -e -o results/report/
      -Jhost=${TARGET_HOST}
      -Jport=${TARGET_PORT}
      -Jthreads=${THREADS}
      -Jduration=${DURATION}
    # Fail pipeline nếu error rate > 2% hoặc P95 > 2000ms
    - python3 scripts/check-results.py results/results.jtl
      --max-error-rate 2
      --max-p95 2000
  artifacts:
    paths:
      - results/report/
    when: always
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule" # Chạy theo schedule
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

# script kiểm tra kết quả tự động

# scripts/check-results.py
import csv
import math
import sys
import argparse
 
def analyze_results(jtl_file, max_error_rate, max_p95):
   errors = 0
   total = 0
   response_times = []
 
   with open(jtl_file, 'r') as f:
       reader = csv.DictReader(f)
       for row in reader:
           total += 1
           if row['success'] == 'false':
               errors += 1
           response_times.append(int(row['elapsed']))
 
   error_rate = (errors / total) * 100 if total > 0 else 0
   response_times.sort()
   # Nearest-rank: phần tử thứ ceil(0.95 * n), index bắt đầu từ 0
   p95_index = max(0, math.ceil(len(response_times) * 0.95) - 1)
   p95 = response_times[p95_index] if response_times else 0
 
   print(f"Total requests: {total}")
   print(f"Error rate: {error_rate:.2f}%")
   print(f"P95 response time: {p95}ms")
 
   if error_rate > max_error_rate:
       print(f"FAIL: Error rate {error_rate:.2f}% exceeds threshold {max_error_rate}%")
       sys.exit(1)
   if p95 > max_p95:
       print(f"FAIL: P95 {p95}ms exceeds threshold {max_p95}ms")
       sys.exit(1)
 
   print("PASS: All thresholds met")
 
if __name__ == "__main__":
   parser = argparse.ArgumentParser()
   parser.add_argument("jtl_file")
   parser.add_argument("--max-error-rate", type=float, default=2.0)
   parser.add_argument("--max-p95", type=int, default=2000)
   args = parser.parse_args()
   analyze_results(args.jtl_file, args.max_error_rate, args.max_p95)

Script này giả định file .jtl ở dạng CSV có dòng header, đúng với cấu hình mặc định của JMeter (jmeter.save.saveservice.output_format=csv). Nếu team đổi sang XML thì phải sửa phần đọc file. Image alpine/jmeter không có sẵn Python nên job cài thêm bằng apk; entrypoint: [''] cần thiết vì entrypoint mặc định của image là lệnh jmeter, GitLab Runner sẽ không chạy được shell script.

# best practices

# thiết kế test plan

  1. Dùng Transaction Controller nhóm related requests, đo đúng business operation time
  2. Thêm Think Time giữa requests (1-5s), mô phỏng user thực, không phải DDoS
  3. Parameterize mọi thứ: host, port, threads, duration từ properties → dùng lại được
  4. Tách test data vào CSV, không hardcode trong test plan
  5. Disable View Results Tree khi chạy thực, tốn memory, chỉ dùng debug

# chạy test

  1. Luôn chạy CLI mode cho test thực tế, GUI chỉ để design
  2. Warm-up server trước khi đo, chạy 1-2 phút tải nhẹ trước
  3. Ramp-up đủ chậm: tránh thundering herd effect
  4. Chạy đủ lâu: tối thiểu 5-10 phút cho load test, 1-4 giờ cho soak test
  5. Chạy nhiều lần: 1 lần chạy không đủ tin cậy, chạy 3 lần lấy trung bình

# phân tích kết quả

  1. Xem P95/P99 thay vì average, average che giấu long tail latency
  2. Correlate với server metrics: JMeter chỉ cho client view, cần server-side monitoring
  3. So sánh với baseline: lưu kết quả mỗi lần, so sánh regression
  4. Tìm saturation point: throughput không tăng dù thêm threads = server saturated
  5. Kiểm tra resource: CPU, memory, disk I/O, network trên server khi test

# sai lầm thường gặp

Sai lầmHậu quảCách đúng
Chạy JMeter trên cùng máy với serverKết quả sai (cạnh tranh resource)Tách riêng machines
Không có think timeTải không thực tế, server overwhelmThêm 1-5s random delay
Test trên localhostBỏ qua network latencyTest trên staging environment
Quá nhiều ListenersJMeter OOM, kết quả saiChỉ giữ Summary/Aggregate, log ra .jtl
Không monitor serverChỉ biết "chậm" không biết "tại sao"Kết hợp Prometheus + Grafana
Ramp-up = 0Tất cả threads bắn cùng lúc = spike testRamp-up = threads/5 ~ threads/10

# checklist trước khi load test

□ Test plan đã được review và debug ở GUI mode (5-10 requests)
□ Assertions đúng (status code, response body)
□ Think time hợp lý (1-5s)
□ CSV data đủ dùng cho số threads × loops
□ Token/auth mechanism hoạt động (Once Only Controller)
□ Target server là staging/performance environment (KHÔNG phải production)
□ Server monitoring đã bật (Prometheus, Grafana, APM)
□ Database đã seed đủ data thực tế
□ JMeter machine có đủ resource (RAM, CPU, network)
□ Firewall/proxy không throttle
□ Team đã thông báo (tránh ai đó nghĩ server bị attack)
□ Có baseline metrics để so sánh
□ Kết quả sẽ được lưu lại (artifacts, reports)

# lời kết

Giai đoạnCông cụ/Kỹ thuật
Thiết kế testJMeter GUI + Plugins (Ultimate Thread Group)
Chạy testJMeter CLI + properties parameterization
Monitor serverSpring Actuator + Prometheus + Grafana
Monitor JMeterInfluxDB Backend Listener + Grafana
Phân tíchHTML Report + Aggregate Report + custom scripts
CI/CDGitLab CI + Docker image + threshold check script
Tối ưuHikariCP tuning, Redis cache, Async, JVM tuning

JMeter chỉ cho bạn góc nhìn từ phía client. Phải đặt nó cạnh metric phía server (Actuator, Prometheus, Grafana) thì mới biết chậm ở đâu và vì sao. Bắt đầu nhỏ thôi: một load test cho API quan trọng nhất, lưu kết quả làm baseline, rồi đưa vào pipeline chạy định kỳ để bắt regression trước khi lên production.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.