Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Mobile app & WebSocket performance testing

Performance Testing with JMeter and k6 Bài 30/60

Mở đầu — vì sao bài này quan trọng

Cho tới giờ, phần lớn các bài trước trong khóa học tập trung vào một mô hình rất "sạch": client gửi HTTP request tới server qua đường mạng cáp quang ổn định trong datacenter, server trả response, ta đo latency và throughput. Đó là bối cảnh lý tưởng của một web app chạy trên trình duyệt desktop.

Nhưng thực tế người dùng Việt Nam ngày nay không ngồi trước máy tính bàn. Họ mở app Shopee, MoMo, VieON, Grab trên điện thoại, đang di chuyển trên xe máy giữa trưa nắng, sóng 4G chập chờn, pin còn 15%, và họ mong app phản hồi tức thì. Một app chat, một màn hình theo dõi tài xế đang tới, một bảng giá chứng khoán nhảy real-time — tất cả đều dựa trên WebSocket chứ không phải HTTP request-response truyền thống.

Hai thế giới này — mobileWebSocket — phá vỡ nhiều giả định mà công cụ load test mặc định. Nếu bạn chỉ chạy JMeter/k6 với 5.000 HTTP request từ một con máy đặt cạnh server, bạn sẽ có những con số đẹp nhưng vô nghĩa, vì chúng không phản ánh điều kiện mạng di động thật, cũng không mô phỏng được một kết nối WebSocket giữ mở suốt 30 phút với hàng nghìn message qua lại. Bài này giúp bạn hiểu sự khác biệt cốt lõi và cách test đúng cho hai bối cảnh đó.

Khái niệm cốt lõi

Mobile app khác web ở đâu về mặt performance?

Điểm mấu chốt cần nhớ: backend của app mobile thường vẫn là HTTP/REST hoặc gRPC, nên về mặt tạo tải bạn vẫn dùng JMeter/k6 bắn vào API. Cái khác biệt nằm ở điều kiện phía client — và điều kiện đó ảnh hưởng ngược lại số liệu bạn đo.

Network — không phải cáp quang. Người dùng mobile chạy trên 3G/4G/5G hoặc Wi-Fi công cộng. Điều này kéo theo ba đặc điểm:

  • Latency cao và biến thiên (jitter): từ điện thoại tới datacenter thường 50–300ms, thậm chí cao hơn khi server đặt ở Singapore/US mà người dùng ở Việt Nam. Với cáp quang trong cùng thành phố chỉ 5–20ms.
  • Bandwidth thấp và không ổn định: 4G lý thuyết vài chục Mbps nhưng thực tế lúc đông người (giờ tan tầm, sự kiện) có thể tụt xuống vài trăm Kbps.
  • Packet loss và reconnect: khi di chuyển qua vùng phủ sóng yếu, gói tin rớt, kết nối bị reset. App phải retry — và retry tạo thêm tải lên server.
Hệ quả quan trọng cho tester: một API trả về trong 200ms trong lab có thể "cảm giác" như 800ms–2s trên tay người dùng thật, vì cộng thêm latency mạng di động và thời gian TLS handshake trên kết nối chậm. Nếu payload lớn (ảnh, JSON cồng kềnh), bandwidth thấp làm thời gian tải trội hẳn lên.

Battery & CPU. Đây là chiều mà load test server không thấy được, nhưng bạn phải ý thức. App poll API mỗi 2 giây để cập nhật trạng thái sẽ ngốn pin và giữ radio mạng luôn active (radio là bộ phận tốn pin bậc nhất trên điện thoại). Một thiết kế backend buộc client phải request liên tục không chỉ hại pin người dùng mà còn nhân tải server lên. Vì thế khi test, ta cần mô phỏng đúng tần suất và pattern gọi API của app thật, không phải bắn liên tục 100% công suất.

Payload và protocol. App tốt thường dùng nén (gzip/brotli), gộp request (batch), dùng HTTP/2 để multiplex nhiều request trên một kết nối. Khi viết kịch bản test, hãy bật đúng các header và protocol này để số đo khớp thực tế.

WebSocket — mô hình kết nối hoàn toàn khác

HTTP là request-response, kết nối ngắn: mở, gửi, nhận, đóng. WebSocket là kết nối hai chiều, giữ mở lâu dài (persistent, full-duplex). Sau một "handshake" nâng cấp từ HTTP, kết nối chuyển sang giao thức ws:// hoặc wss:// và cả server lẫn client có thể chủ động đẩy message bất cứ lúc nào.

Điều này thay đổi hoàn toàn cách đo tải:

  • Metric quan trọng không còn là "request/giây" mà là số kết nối đồng thời (concurrent connections) mà server chịu được. Mỗi kết nối mở tiêu tốn một chút RAM và một file descriptor trên server. 50.000 kết nối treo cùng lúc có thể làm cạn file descriptor hoặc RAM dù lượng message rất ít.
  • Message rate và message latency: bao nhiêu message đẩy qua mỗi giây, và độ trễ từ lúc gửi tới lúc nhận (round-trip trong kênh mở) là bao nhiêu.
  • Connection lifecycle: thời gian thiết lập kết nối, tỷ lệ kết nối thất bại, hành vi khi mạng rớt và client reconnect hàng loạt (reconnect storm).
Về công cụ: k6 hỗ trợ WebSocket rất tốt qua module k6/ws (hoặc k6/experimental/websockets mới hơn), trong khi JMeter cần plugin bên thứ ba (thường là plugin của Peter Doornbosch — "JMeter WebSocket Samplers") vì core JMeter không có sampler WebSocket. Đây là lý do nhiều team làm real-time chọn k6 cho phần này.

Tình huống thực tế

Ví dụ 1 — Grab-like: màn hình theo dõi tài xế và bài toán mobile latency

Giả sử bạn là QA cho một app gọi xe kiểu Grab/Be tại TP.HCM. Màn hình "tài xế đang tới" gọi API GET /trip/{id}/location mỗi 3 giây để cập nhật vị trí. Team backend chạy load test JMeter từ một server đặt cùng datacenter với API, kết quả p95 = 90ms, tuyên bố "ngon".

Nhưng khi lên production, người dùng phàn nàn màn hình lag, chấm tài xế nhảy giật. Điều tra ra: người dùng thật ở trên đường, 4G có latency 180ms + jitter cao. Cộng thêm mỗi 3 giây lại phải mở kết nối TLS mới (app không tái sử dụng connection), thời gian thực tế lên tới 700ms mỗi lần cập nhật. Chưa kể vào giờ cao điểm 17h–19h, hàng chục nghìn người cùng poll mỗi 3 giây tạo ra một "làn sóng request" đồng bộ đập vào server.

Bài học: load test trong datacenter cho con số đẹp giả tạo. Team đã làm hai việc. Thứ nhất, dùng k6 với option mô phỏng độ trễ mạng và chèn sleep() đúng nhịp 3 giây để mô phỏng pattern polling thật, đo lại p95 dưới điều kiện đó. Thứ hai — và quan trọng hơn — họ nhận ra polling là sai kiến trúc và chuyển màn hình này sang WebSocket: server chủ động đẩy vị trí, giảm số kết nối mới và giảm cả tải lẫn tiêu hao pin. Bài test tiếp theo chuyển thành test WebSocket concurrent connections.

Ví dụ 2 — App chat "Zalo-like": 50.000 kết nối WebSocket đồng thời

Một startup Đông Nam Á xây app nhắn tin. Yêu cầu: chịu được 50.000 người online đồng thời, mỗi người trung bình gửi 1 message mỗi 20 giây, và nhận message từ nhóm chat. Họ dùng k6 với k6/ws.

Kịch bản: mỗi VU (virtual user) mở một kết nối wss://, gửi một message định kỳ, lắng nghe message đến, và giữ kết nối mở 10 phút. Họ tăng dần lên 50.000 kết nối. Kết quả bất ngờ: ở mốc ~28.000 kết nối, server bắt đầu từ chối kết nối mới với lỗi "too many open files". Nguyên nhân là giới hạn ulimit -n (file descriptor) trên server chỉ đặt 32.768, và mỗi WebSocket chiếm một fd. Sau khi nâng ulimit và tinh chỉnh kernel (net.core.somaxconn), họ đạt 50.000 nhưng RAM server tăng vọt — mỗi kết nối idle vẫn giữ buffer.

Bài học: với WebSocket, giới hạn không nằm ở CPU hay request/giây mà ở tài nguyên giữ kết nối: file descriptor, RAM per connection, và giới hạn của load balancer. Một bài học phụ: chính máy chạy k6 cũng gặp giới hạn fd khi tạo 50.000 kết nối, nên họ phải nâng ulimit cả trên máy sinh tải và phân tán qua nhiều máy.

Ví dụ 3 — Reconnect storm sau sự cố mạng của một sàn giao dịch

Một sàn theo dõi giá coin/chứng khoán hiển thị bảng giá real-time qua WebSocket cho ~15.000 người dùng mobile. Một hôm nhà mạng gặp sự cố 30 giây, toàn bộ 15.000 kết nối rớt gần như cùng lúc. Khi mạng phục hồi, tất cả app đồng loạt reconnect trong vài giây — một reconnect storm. Server nhận 15.000 yêu cầu thiết lập kết nối + subscribe + gửi snapshot dữ liệu ban đầu trong khoảnh khắc ngắn, CPU tăng đột biến và sập.

QA của sàn sau đó thiết kế một bài test riêng: dùng k6 mở 15.000 kết nối, rồi đóng đột ngột toàn bộ và cho reconnect trong cửa sổ 5 giây, đo xem server có chịu nổi đợt "dồn toa" này không. Kết quả giúp team thêm cơ chế jitter/backoff phía client (reconnect sau một khoảng ngẫu nhiên 0–10 giây thay vì tức thì) và rate-limit phía server.

Bài học: với hệ thống real-time, kịch bản nguy hiểm nhất thường không phải "tải đều đặn" mà là sự kiện đồng bộ hóa — mọi client làm cùng một việc cùng lúc. Test bình thường sẽ không bao giờ lộ ra điểm gãy này.

Hướng dẫn từng bước

Phần A — Test API cho mobile (bằng k6) dưới điều kiện mạng thực tế

Bước 1. Xác định pattern gọi API thật của app: tần suất poll, kích thước payload, có dùng HTTP/2 không, header nào (User-Agent mobile, Accept-Encoding). Lấy từ việc bắt gói (Charles Proxy, mitmproxy) hoặc từ tài liệu API.

Bước 2. Viết kịch bản k6 mô phỏng đúng nhịp, dùng sleep() để tái hiện khoảng nghỉ giữa các request thay vì bắn liên tục.

import http from 'k6/http';
import { sleep, check } from 'k6';

export const options = { scenarios: { mobile_polling: { executor: 'ramping-vus', stages: [ { duration: '1m', target: 500 }, { duration: '3m', target: 5000 }, { duration: '2m', target: 5000 }, ], }, }, thresholds: { http_req_duration: ['p(95)<1000'] }, };

export default function () { const res = http.get('https://api.example.vn/trip/123/location', { headers: { 'Accept-Encoding': 'gzip', 'User-Agent': 'MyApp/2.1 (Android 13)' }, }); check(res, { 'status 200': (r) => r.status === 200 }); sleep(3); // mô phỏng nhịp poll 3 giây của app thật }

Bước 3. Mô phỏng độ trễ mạng di động. k6 không giả lập mạng trực tiếp, nên có hai cách: (a) đặt máy sinh tải ở vùng địa lý gần người dùng thật (dùng k6 Cloud hoặc VPS ở khu vực đó), hoặc (b) dùng công cụ network shaping như tc netem trên Linux để thêm 150ms delay + jitter + packet loss vào máy chạy test. Cách (b) cho phép tái hiện điều kiện 4G ngay trong lab.

Bước 4. Đặt threshold theo trải nghiệm mobile thật, ví dụ p(95)<1000ms chứ không phải <100ms như lab, vì đó mới là con số người dùng cảm nhận.

Phần B — Test WebSocket concurrent connections (bằng k6)

Bước 1. Viết kịch bản mở kết nối, gửi/nhận message định kỳ, và giữ mở một khoảng thời gian.

import ws from 'k6/ws';
import { check, sleep } from 'k6';

export const options = { vus: 10000, duration: '10m', };

export default function () { const url = 'wss://chat.example.vn/socket'; const res = ws.connect(url, {}, function (socket) { socket.on('open', () => { socket.setInterval(() => { socket.send(JSON.stringify({ type: 'msg', text: 'hello' })); }, 20000); // gửi mỗi 20 giây, giống hành vi chat thật }); socket.on('message', (data) => { check(data, { 'nhận được message': (d) => d && d.length > 0 }); }); socket.setTimeout(() => socket.close(), 600000); // giữ kết nối 10 phút }); check(res, { 'handshake 101': (r) => r && r.status === 101 }); }

Bước 2. Nâng giới hạn hệ thống trên cả máy chạy k6 lẫn server: ulimit -n 200000, kiểm tra net.core.somaxconn, net.ipv4.tcp_tw_reuse. Đây là bước hay bị quên và là nguyên nhân số một khiến test WebSocket "gãy sớm".

Bước 3. Tăng dần số VU (concurrent connections) theo bậc thang, quan sát: số kết nối thành công, message latency, và tài nguyên server (RAM, fd, CPU) qua công cụ giám sát. Tìm ngưỡng mà kết nối bắt đầu bị từ chối hoặc message latency tăng vọt.

Bước 4. Test kịch bản đặc thù real-time: reconnect storm (đóng hàng loạt rồi reconnect trong cửa sổ ngắn) và broadcast (một message tới nhiều kết nối cùng lúc) để kiểm tra khả năng fan-out của server.

Phần C — Nếu buộc phải dùng JMeter cho WebSocket

Cài plugin "JMeter WebSocket Samplers" của Peter Doornbosch, dùng các sampler "WebSocket Open Connection", "WebSocket Request-Response", "WebSocket Ping/Pong". Lưu ý JMeter dùng mô hình một thread một kết nối, nên tốn nhiều RAM hơn k6 khi mô phỏng số kết nối lớn — với WebSocket quy mô lớn, k6 thường là lựa chọn hiệu quả hơn.

Lỗi thường gặp & mẹo

Test API mobile trong datacenter rồi tin vào con số. Đây là lỗi phổ biến nhất. p95 = 90ms trong lab không nói lên gì về trải nghiệm người dùng 4G. Luôn thêm network shaping hoặc test từ vùng địa lý thật, và đặt threshold theo cảm nhận thật.

Bắn API liên tục 100% thay vì mô phỏng nhịp poll. App thật gọi API mỗi vài giây, không phải liên tục. Bỏ sleep() sẽ tạo tải sai lệch, khiến bạn tối ưu nhầm chỗ hoặc báo động giả.

Quên ulimit và giới hạn kernel khi test WebSocket. Bạn sẽ thấy test "sập ở 30.000 kết nối" và tưởng server yếu, trong khi thực ra là giới hạn file descriptor trên chính máy sinh tải. Luôn nâng ulimit hai đầu trước khi kết luận.

Đánh giá WebSocket bằng request/giây. Sai metric. Với WebSocket, hãy nhìn vào concurrent connections, RAM per connection, và message latency. Một server có thể chịu triệu request HTTP/giây nhưng gục ở 50.000 kết nối treo.

Bỏ qua reconnect storm và các sự kiện đồng bộ. Tải đều đặn hiếm khi làm sập hệ thống real-time; chính đợt reconnect hàng loạt sau sự cố mạng mới nguy hiểm. Hãy chủ động test kịch bản này.

Không gửi ping/pong (heartbeat). Nhiều load balancer và proxy tự đóng kết nối WebSocket idle sau 30–60 giây. Nếu kịch bản không có heartbeat, bạn sẽ đo nhầm "kết nối rớt" trong khi đó là do proxy chứ không phải server.

Mẹo — đo ở phía client thật khi có thể. Để hiểu ảnh hưởng battery/CPU của app mobile, không có công cụ load test server nào thay được: dùng Android Profiler / Xcode Instruments trên vài thiết bị thật để xem mức tiêu hao pin theo pattern gọi API. Load test cho ta phía server; profiler cho ta phía thiết bị — cả hai bổ sung nhau.

Bài tập thực hành

  • Mô phỏng mạng 4G: Lấy một API công khai bất kỳ (hoặc API của app bạn đang làm). Viết kịch bản k6 gọi API với nhịp poll 3 giây, chạy hai lần: một lần bình thường, một lần với tc netem thêm 200ms delay + 30ms jitter + 1% packet loss. So sánh p95 hai lần và giải thích chênh lệch.
  • Tìm ngưỡng WebSocket: Dựng một echo server WebSocket đơn giản (Node.js ws chỉ vài dòng). Viết kịch bản k6 tăng dần concurrent connections: 1.000 → 5.000 → 10.000, mỗi bậc giữ kết nối 3 phút và gửi message mỗi 15 giây. Ghi lại số kết nối thành công, message latency, và RAM/fd của server ở mỗi bậc. Xác định ngưỡng gãy và nguyên nhân (fd? RAM? CPU?).
  • Reconnect storm: Mở rộng bài 2: sau khi ổn định ở 10.000 kết nối, cho đóng toàn bộ trong 1 giây rồi reconnect ngay lập tức. Quan sát CPU spike của server. Sau đó thêm jitter 0–5 giây vào lúc reconnect và so sánh — bạn sẽ thấy trực quan vì sao backoff quan trọng.
  • Đối chiếu công cụ: Viết cùng một kịch bản WebSocket đơn giản bằng cả k6 (k6/ws) và JMeter (plugin WebSocket Samplers) ở mức 2.000 kết nối. So sánh RAM tiêu thụ của máy sinh tải giữa hai công cụ và rút ra kết luận cho riêng dự án của bạn.

Tóm tắt

Mobile và WebSocket phá vỡ các giả định của load test truyền thống theo hai hướng khác nhau. Với mobile, backend vẫn là HTTP nên công cụ không đổi, nhưng bạn phải test dưới điều kiện mạng di động thật (latency 50–300ms, jitter, packet loss, bandwidth thấp) và mô phỏng đúng pattern gọi API của app — nếu không, con số lab đẹp đẽ sẽ đánh lừa bạn; đồng thời ý thức rằng thiết kế API ảnh hưởng tới pin và CPU của thiết bị.

Với WebSocket, mô hình kết nối persistent, hai chiều làm metric quan trọng chuyển từ "request/giây" sang số kết nối đồng thời, RAM per connection, và message latency. Điểm gãy thường nằm ở tài nguyên giữ kết nối (file descriptor, RAM, giới hạn load balancer) và ở các sự kiện đồng bộ như reconnect storm. k6 với module k6/ws là lựa chọn mạnh và tiết kiệm tài nguyên cho phần này, trong khi JMeter cần plugin bên thứ ba. Nhớ nâng ulimit cả hai đầu, thêm heartbeat, và luôn test kịch bản dồn toa — vì đó mới là nơi hệ thống real-time thật sự đổ vỡ.