Product Management
Đăng nhập
ESC

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

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

Test data generation cho performance

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

Hãy tưởng tượng bạn vừa hoàn thành một kịch bản test JMeter hoặc k6 rất công phu: cấu hình đủ số lượng virtual user, ramp-up chỉn chu, threshold rõ ràng. Bạn nhấn Run, và kết quả trả về đẹp không tì vết — p95 latency chỉ 80ms, throughput 5.000 request/giây, error rate 0%. Bạn tự tin báo cáo với sếp: "Hệ thống chịu tải tốt." Rồi tuần sau, đúng đợt khuyến mãi thật, hệ thống sập trong 3 phút.

Chuyện gì đã xảy ra? Rất có thể vấn đề không nằm ở kịch bản test của bạn, mà nằm ở dữ liệu test (test data). Kết quả test đẹp một cách phi thực tế thường là dấu hiệu của test data nghèo nàn: cùng một product_id được gọi đi gọi lại hàng nghìn lần, database cache lại kết quả và trả về trong tích tắc, còn bạn thì tưởng hệ thống của mình nhanh.

Đây chính là một trong những cạm bẫy chết người nhất của performance testing: garbage in, garbage out. Dù công cụ có mạnh đến đâu, dù bạn chạy trên hạ tầng cloud đắt tiền cỡ nào, nếu dữ liệu đầu vào không phản ánh đúng thực tế thì con số bạn thu được chỉ là ảo giác. Bài học này sẽ trang bị cho bạn tư duy và kỹ thuật để tạo ra dữ liệu test đủ lớn, đủ đa dạng, và đủ giống thật — để mỗi con số bạn báo cáo đều đáng tin.

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

Vì sao "data thật phản ánh thật"

Nguyên lý nền tảng của bài này rất ngắn gọn: kết quả performance test chỉ chính xác khi dữ liệu test tái tạo được hành vi truy cập của người dùng thật trên dữ liệu production thật. Một hệ thống không xử lý "một request" chung chung — nó xử lý một request cụ thể trên một tập dữ liệu cụ thể, với kích thước, phân bố, và tính đa dạng cụ thể. Thay đổi bất kỳ yếu tố nào trong đó, bạn thay đổi hoàn toàn đường đi của code và tải lên database.

Cạm bẫy dữ liệu nhỏ

Đây là lỗi phổ biến nhất và cũng nguy hiểm nhất. Giả sử bảng products trên môi trường test chỉ có 100 sản phẩm, trong khi production có 2 triệu. Điều gì xảy ra khi bạn bắn 5.000 request/giây?

  • Cache che giấu sự thật. Với 100 sản phẩm, toàn bộ dataset lọt vào RAM, vào query cache của MySQL/PostgreSQL, vào Redis. Query nào cũng là cache hit, trả về trong 1–2ms. Nhưng production với 2 triệu sản phẩm có cache hit ratio thấp hơn nhiều, buộc database phải đọc từ đĩa, quét index lớn hơn, và chậm đi hàng chục lần.
  • Index behavior khác hẳn. Một B-tree index trên 100 dòng chỉ cao 1–2 tầng; trên 2 triệu dòng nó cao 4–5 tầng, mỗi lần tra cứu tốn nhiều I/O hơn. Query planner cũng chọn kế hoạch thực thi khác nhau tùy kích thước bảng.
  • Không lộ ra lock contention. Với dữ liệu nhỏ, nhiều request cùng đụng vào một vài dòng, nhưng do query quá nhanh nên lock được giải phóng ngay. Ở quy mô thật, cùng số request đó phân bổ trên tập dữ liệu lớn, thời gian giữ lock lâu hơn, và các vấn đề tranh chấp mới lộ diện.
Kết luận: Kích thước dataset test nên tiệm cận production, hoặc ít nhất đủ lớn để vượt qua ngưỡng cache của hệ thống.

Cạm bẫy dữ liệu trùng lặp (data uniqueness)

Ngay cả khi database đủ lớn, nếu tất cả virtual user cùng gọi một product_id, một user_id, một từ khóa tìm kiếm, thì bạn lại rơi vào bẫy cache ở tầng application. Dữ liệu test cần phân tán (distributed): mỗi virtual user nên thao tác trên các bản ghi khác nhau, giống như hàng nghìn người dùng thật đang xem những sản phẩm khác nhau, đăng nhập bằng những tài khoản khác nhau.

Cạm bẫy phân bố không thực tế (data distribution)

Người dùng thật không truy cập đồng đều. Theo nguyên lý Pareto, khoảng 20% sản phẩm hot chiếm 80% lượt xem. Nếu bạn phân bố request đều tăm tắp trên toàn bộ dataset, bạn sẽ bỏ sót "hot key problem" — hiện tượng một vài bản ghi cực nóng gây nghẽn cache, nghẽn lock. Dữ liệu test tốt phải mô phỏng được cả phần đầu phân bố (hot data) lẫn phần đuôi dài (long tail).

Ba loại test data bạn cần chuẩn bị

  • Seed data (dữ liệu nền): khối lượng lớn nằm sẵn trong database trước khi test — sản phẩm, người dùng, đơn hàng lịch sử. Mục tiêu: đưa kích thước bảng lên gần production.
  • Input data (dữ liệu đầu vào): giá trị mà mỗi virtual user dùng để tham số hóa request — danh sách product_id, username/password, từ khóa tìm kiếm. Thường nạp qua CSV Data Set (JMeter) hoặc SharedArray (k6).
  • Runtime-generated data (dữ liệu sinh lúc chạy): dữ liệu độc nhất được tạo ngay trong lúc test, ví dụ email đăng ký mới user_${__UUID}@test.vn, để tránh trùng khóa unique khi test luồng "tạo mới".

Tình huống thực tế

Ví dụ 1 — Tiki và cái bẫy "100 sản phẩm"

Một đội QA của sàn thương mại điện tử (gọi là bối cảnh giống Tiki) chuẩn bị test tải cho trang chi tiết sản phẩm trước mùa sale. Môi trường staging của họ được seed bằng script demo cũ, chỉ có đúng 500 sản phẩm. Họ chạy k6 với 2.000 VU, mỗi VU random một product_id trong khoảng 1–500.

Kết quả: p95 = 45ms, throughput 8.000 rps, không lỗi. Đội mừng rỡ. Nhưng một kỹ sư nghi ngờ con số quá đẹp, bèn kiểm tra và phát hiện toàn bộ 500 sản phẩm nằm gọn trong Redis cache và query cache MySQL — không một truy vấn nào chạm đĩa. Anh ta nạp thêm dữ liệu để staging có 1,5 triệu sản phẩm (bằng cách nhân bản có xáo trộn), rồi rút danh sách 50.000 product_id thật từ log truy cập production làm input data theo đúng phân bố truy cập.

Chạy lại: p95 nhảy lên 380ms, throughput tụt còn 2.100 rps, và ở mức 2.500 VU database bắt đầu timeout. Hóa ra một query lấy "sản phẩm liên quan" thiếu index, chỉ lộ ra khi bảng đủ lớn. Bài học: dữ liệu nhỏ đã suýt cho họ một báo cáo sai lệch gần 8 lần, đủ để sập hệ thống trong ngày sale thật.

Ví dụ 2 — Ngân hàng số và luồng đăng nhập

Một ngân hàng số ở Việt Nam test luồng đăng nhập của app mobile. Ban đầu, để cho nhanh, cả 1.000 VU đều đăng nhập bằng một tài khoản test_user_01. Kết quả cực đẹp: hệ thống trả token trong 30ms.

Vấn đề là gì? Ở production, mỗi lần đăng nhập hệ thống phải: tra user trong bảng, verify mật khẩu bằng bcrypt (tốn CPU có chủ đích), ghi session mới, cập nhật last_login, và kiểm tra rate-limit theo tài khoản. Khi 1.000 VU cùng đăng nhập một tài khoản:

  • Kết quả bcrypt của tài khoản đó nằm sẵn trong cache tầng ứng dụng.
  • Rate-limit theo tài khoản đáng lẽ phải chặn ngay từ vài request đầu — nhưng đội test lại tắt nó đi cho "tiện".
  • Row-level lock trên dòng last_login biến thành điểm nghẽn nhân tạo mà production thật không có.
Khi họ đổi sang dùng CSV chứa 200.000 tài khoản thật (đã ẩn danh dữ liệu), mỗi VU đăng nhập một tài khoản khác nhau, chi phí bcrypt bung ra đúng thực tế: CPU của auth-service tăng vọt, p95 lên 600ms ở 800 VU. Đây mới là con số dùng được để lên kế hoạch năng lực. Bài học: với luồng tính toán nặng như xác thực, tính đa dạng của input data quyết định hoàn toàn tính đúng đắn của kết quả.

Ví dụ 3 — Grab và bài toán tạo dữ liệu độc nhất

Một đội test luồng "đặt đơn mới" của nền tảng gọi xe/giao đồ ăn. Mỗi đơn tạo ra một bản ghi với order_code là unique key. Lần chạy đầu, script hard-code order_code = "TEST123", nên chỉ request đầu tiên thành công, còn lại đều lỗi trùng khóa (duplicate key) — error rate 99%, và họ tưởng hệ thống hỏng.

Giải pháp là sinh dữ liệu độc nhất lúc chạy. Trong k6 họ dùng kết hợp timestamp và số ngẫu nhiên; trong JMeter họ dùng hàm ${__UUID}${__threadNum}. Nhờ đó mỗi request mang một order_code khác nhau, luồng ghi vào database diễn ra đúng như thật. Nhưng họ học thêm một điều: test tạo đơn hàng làm database phình lên rất nhanh (mỗi phút thêm hàng trăm nghìn dòng), nên cần chuẩn bị sẵn kịch bản dọn dẹp (teardown/cleanup) để trả môi trường về trạng thái sạch sau mỗi lần chạy. Bài học: dữ liệu ghi (write) cần tính độc nhất, và cần chiến lược dọn dẹp — nếu không mỗi lần test lại làm bẩn dần môi trường.

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

Bước 1 — Xác định "hình dạng" dữ liệu production. Trước khi tạo bất cứ thứ gì, hãy trả lời: bảng nào đang lớn nhất và số dòng bao nhiêu? Phân bố truy cập ra sao (20% nào là hot)? Trường nào có ràng buộc unique? Nguồn tốt nhất để trả lời là log truy cập production và số liệu thống kê bảng trong database.

Bước 2 — Seed database đến quy mô thật. Nâng kích thước các bảng chính lên gần production. Bạn có ba lựa chọn:

  • Sao chép có ẩn danh (masked production copy): chính xác nhất về phân bố, nhưng phải che dữ liệu nhạy cảm (tên, số điện thoại, CCCD) để tuân thủ quy định bảo mật.
  • Sinh tổng hợp (synthetic): dùng công cụ như Faker (Python), faker-js, hoặc script SQL sinh dữ liệu ngẫu nhiên có kiểm soát. Chủ động về khối lượng, an toàn về pháp lý, nhưng phải cố ý tái tạo phân bố thực (đừng để mọi giá trị đều đều nhau).
  • Nhân bản có xáo trộn: lấy một tập nhỏ dữ liệu thật rồi nhân lên nhiều lần với các giá trị khóa và text được xáo trộn, để tránh mọi bản ghi giống hệt nhau.
Bước 3 — Chuẩn bị input data cho tham số hóa. Rút danh sách các giá trị thật (product_id, username, keyword) ra file CSV, tốt nhất là theo đúng tần suất xuất hiện trong log để giữ phân bố hot/cold. Trong JMeter dùng CSV Data Set Config (đặt Sharing mode "All threads", Recycle on EOF tùy nhu cầu). Trong k6 dùng SharedArray để nạp file một lần và chia sẻ giữa các VU mà không nhân đôi bộ nhớ.

Bước 4 — Đảm bảo mỗi VU dùng dữ liệu khác nhau. Với JMeter, để CSV Data Set phân phối lần lượt từng dòng cho các thread. Với k6, dùng __VU__ITER hoặc chỉ số ngẫu nhiên để mỗi vòng lặp chọn một phần tử khác nhau trong SharedArray. Mục tiêu: xóa bỏ mọi trùng lặp không mong muốn.

Bước 5 — Sinh dữ liệu độc nhất cho luồng ghi. Với các request tạo mới (đăng ký, đặt đơn), tạo giá trị unique tại runtime: ${__UUID}, ${__time}, ${__Random} trong JMeter; uuidv4() hoặc Date.now() kết hợp __VU trong k6. Tránh mọi hằng số hard-code cho trường unique.

Bước 6 — Lên kế hoạch dọn dẹp. Nếu test ghi dữ liệu, hãy có script dọn dẹp sau test (xóa bản ghi tạo trong khung giờ test theo prefix/tag riêng), hoặc test trên môi trường có thể khôi phục snapshot. Đừng để mỗi lần test làm phình database và làm sai lệch lần test kế tiếp.

Bước 7 — Kiểm chứng lại (sanity check). Sau khi có dữ liệu, chạy thử một lần nhỏ và tự hỏi: cache hit ratio có phản ánh thực tế không? Có query nào chạm đĩa như production không? Nếu kết quả vẫn "đẹp một cách đáng ngờ", nhiều khả năng dữ liệu của bạn vẫn chưa đủ thật.

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

  • Seed dữ liệu bằng giá trị lặp lại y hệt. Sinh 2 triệu dòng nhưng tên sản phẩm nào cũng là "Product 1", giá nào cũng bằng nhau — database sẽ nén và cache cực tốt, che mất chi phí thật. Luôn thêm entropy (ngẫu nhiên hóa) vào các trường quan trọng.
  • Quên phân bố hot/cold. Random đều trên toàn bộ dataset bỏ sót hot key problem. Hãy làm lệch phân bố: cho ~20% bản ghi xuất hiện với tần suất cao trong input data.
  • CSV cạn giữa chừng. File CSV ít dòng hơn số lần lặp cần thiết, đến khi hết dòng thì hoặc test dừng, hoặc quay vòng khiến dữ liệu bị dùng lại. Ước lượng: số dòng ≥ số VU × số vòng lặp mong muốn, hoặc bật recycle một cách có chủ đích.
  • Dùng dữ liệu production thô chưa ẩn danh. Vi phạm quyền riêng tư và quy định. Luôn mask các trường nhạy cảm trước khi đưa vào môi trường test.
  • Nạp CSV lớn sai cách trong k6. Đọc file trong hàm default sẽ nhân bản bộ nhớ theo số VU và làm sập máy gen tải. Luôn dùng SharedArray cho dataset lớn.
  • Mẹo — dùng chính log production làm nguồn vàng. Access log (nginx, application log) chứa sẵn phân bố truy cập thật: URL nào nóng, tham số nào phổ biến. Trích xuất từ đó, bạn có input data sát thực tế nhất mà không phải đoán.
  • Mẹo — tách biệt dữ liệu đọc và ghi. Test đọc cần dataset lớn và phân tán; test ghi cần dữ liệu độc nhất và kế hoạch dọn dẹp. Đừng gộp lẫn hai tư duy này.

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

  • Đo tác động của kích thước dataset. Tạo một bảng products với 100 dòng, viết script k6/JMeter random product_id gọi API chi tiết sản phẩm, ghi lại p95. Sau đó seed lên 1 triệu dòng (dùng Faker hoặc SQL sinh ngẫu nhiên có entropy), chạy lại cùng kịch bản. So sánh p95 và cache hit ratio giữa hai lần, viết vài dòng giải thích khác biệt.
  • Xây input data theo phân bố thực. Từ một file access log (thật hoặc mô phỏng), trích xuất danh sách product_id kèm tần suất, tạo file CSV giữ nguyên phân bố hot/cold. Cấu hình JMeter CSV Data Set hoặc k6 SharedArray để mỗi VU dùng một giá trị theo phân bố này. Xác minh không VU nào dùng trùng dữ liệu ngoài ý muốn.
  • Luồng tạo mới không trùng khóa. Viết một kịch bản test luồng "đăng ký người dùng" với email/username unique sinh tại runtime (${__UUID} hoặc uuidv4()). Chạy 500 VU và xác nhận error rate do trùng khóa bằng 0. Bổ sung một bước dọn dẹp xóa các bản ghi test sau khi chạy.

Tóm tắt

  • Kết quả performance test chỉ đáng tin khi dữ liệu test phản ánh đúng thực tế production: garbage in, garbage out.
  • Ba cạm bẫy cần tránh: dataset quá nhỏ (bị cache che giấu), dữ liệu trùng lặp (mọi VU gọi cùng một bản ghi), và phân bố không thực tế (bỏ sót hot key problem).
  • Chuẩn bị ba loại dữ liệu: seed data để nâng bảng lên quy mô thật, input data để tham số hóa mỗi VU khác nhau, và runtime-generated data cho luồng ghi cần tính độc nhất.
  • Nguồn tốt nhất để tái tạo phân bố thật là access log production; công cụ tạo dữ liệu tổng hợp là Faker và script SQL có entropy; luôn ẩn danh dữ liệu nhạy cảm.
  • Với luồng ghi, đừng quên kế hoạch dọn dẹp để không làm bẩn môi trường qua từng lần test.
  • Quy tắc vàng cuối cùng: nếu kết quả test đẹp một cách đáng ngờ, hãy nghi ngờ dữ liệu của bạn trước khi tin vào hệ thống.
Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi