Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn là BA tại một sàn thương mại điện tử. Đội product vừa thiết kế lại nút "Mua ngay" — màu cam thay vì xanh, đặt cao hơn trên màn hình. Câu hỏi của CEO rất đơn giản: "Nút mới có làm tăng đơn hàng không?" Nếu bạn trả lời "Em thấy tuần này đơn nhiều hơn tuần trước" thì bạn đang đặt cả triệu đồng ngân sách vào một phỏng đoán. Tuần này có thể đông hơn vì vừa qua đợt sale, vì lương về, vì thời tiết. So sánh "trước và sau" gần như luôn sai.
A/B test (thử nghiệm có đối chứng) sinh ra để cắt đứt những phỏng đoán đó. Bạn chia ngẫu nhiên người dùng thành hai nhóm: nhóm A (control) thấy nút cũ, nhóm B (treatment) thấy nút mới, trong cùng một khoảng thời gian. Vì việc chia là ngẫu nhiên, mọi yếu tố nhiễu — mùa vụ, loại khách, thiết bị — phân bố đều cho cả hai nhóm. Khác biệt còn lại giữa A và B chính là tác động của thay đổi.
Vấn đề là: kết quả A/B test nằm trong database, không nằm trong dashboard có sẵn. Là BA, bạn phải tự viết SQL để kéo dữ liệu, tính tỷ lệ chuyển đổi từng nhóm, và quan trọng nhất — đánh giá khác biệt đó có thật hay chỉ là may rủi. Bài này dạy bạn đúng phần SQL đó: từ cấu trúc bảng, cách tính metric đúng, đến cách tự kiểm tra ý nghĩa thống kê ngay trong query. Đây là một trong những kỹ năng phân biệt BA "kéo số" với BA "ra quyết định".
Khái niệm cốt lõi
Cấu trúc dữ liệu của một A/B test
Một thử nghiệm tử tế cần ít nhất hai bảng. Bảng đầu tiên ghi lại ai được gán vào nhóm nào và khi nào:
CREATE TABLE experiment_assignments (
user_id BIGINT,
experiment_id VARCHAR(64), -- ví dụ 'buy_button_color_2026'
variant VARCHAR(16), -- 'control' hoặc 'treatment'
assigned_at TIMESTAMP
);
Bảng thứ hai là sự kiện bạn muốn đo — đơn hàng, click, đăng ký:
CREATE TABLE orders (
order_id BIGINT,
user_id BIGINT,
amount DECIMAL(12,2),
created_at TIMESTAMP
);
Nguyên tắc vàng: một user chỉ thuộc đúng một variant trong một experiment. Nếu cùng một người vừa nằm trong control vừa nằm trong treatment, mọi con số sau đó đều vô nghĩa. Việc đầu tiên khi nhận dữ liệu A/B test luôn là kiểm tra điều này (ta sẽ làm ở phần lỗi thường gặp).
Hai metric bạn cần phân biệt
Có hai loại metric, và nhầm lẫn giữa chúng là sai lầm kinh điển:
- Conversion rate (tỷ lệ chuyển đổi) — tỷ lệ user có làm hành động:
số user có đơn / tổng số user trong nhóm. Đây là metric nhị phân (mỗi user hoặc converted hoặc không). - Per-user metric (giá trị trung bình trên mỗi user) — ví dụ doanh thu trung bình mỗi user (ARPU):
tổng doanh thu / tổng số user.
Statistical significance — khác biệt có thật không?
Giả sử control chuyển đổi 10.0%, treatment 10.4%. Treatment thắng? Chưa chắc. Nếu mỗi nhóm chỉ có 200 người, con số đó dao động ngẫu nhiên rất mạnh — lặp lại thử nghiệm có khi control lại thắng. Ta cần biết khác biệt 0.4 điểm phần trăm này có vượt ngoài "nhiễu ngẫu nhiên" hay không.
Công cụ cho việc đó là z-test cho hai tỷ lệ. Ý tưởng: tính độ lệch chuẩn của khác biệt (standard error), rồi xem khác biệt quan sát được lớn gấp bao nhiêu lần độ lệch chuẩn đó. Con số "gấp bao nhiêu lần" gọi là z-score. Quy ước phổ biến: |z| > 1.96 tương ứng với độ tin cậy 95% (p-value < 0.05) — khi đó ta nói khác biệt "có ý nghĩa thống kê". Công thức:
SE = sqrt( p_pool (1 - p_pool) (1/n_A + 1/n_B) )
z = (p_B - p_A) / SE
trong đó p_pool là tỷ lệ chuyển đổi gộp chung cả hai nhóm. Tin tốt cho BA: toàn bộ công thức này tính được bằng SQL thuần, không cần Python.
Tình huống thực tế
Ví dụ 1 — Sàn TMĐT "ShopViet": nút Mua ngay màu cam
ShopViet (giả định) chạy thử nghiệm buy_button_2026 trong 14 ngày. Control giữ nút xanh, treatment đổi sang cam. Metric chính: tỷ lệ user đặt ít nhất một đơn. BA viết query sau:
WITH base AS (
SELECT
a.variant,
a.user_id,
MAX(CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END) AS converted
FROM experiment_assignments a
LEFT JOIN orders o
ON o.user_id = a.user_id
AND o.created_at >= a.assigned_at -- chỉ tính đơn SAU khi được gán
WHERE a.experiment_id = 'buy_button_2026'
GROUP BY a.variant, a.user_id
)
SELECT
variant,
COUNT(*) AS users,
SUM(converted) AS converters,
ROUND(AVG(converted) * 100, 2) AS conv_rate_pct
FROM base
GROUP BY variant;
Kết quả: control 50,000 user — 5,000 đơn — 10.00%; treatment 50,000 user — 5,350 đơn — 10.70%. Chênh 0.70 điểm phần trăm, tức tăng tương đối 7%. Với cỡ mẫu 50,000 mỗi nhóm, z-score (tính ở phần dưới) khoảng 3.8 — vượt xa ngưỡng 1.96. Bài học: khác biệt nhỏ về con số tuyệt đối (0.7%) nhưng có ý nghĩa thống kê rất mạnh nhờ cỡ mẫu lớn. ShopViet tự tin roll-out nút cam cho toàn bộ user.
Lưu ý điều kiện o.created_at >= a.assigned_at: đây không phải chi tiết vụn vặt. Nếu bỏ nó đi, bạn sẽ tính cả những đơn user đặt trước khi được gán vào thử nghiệm — những đơn mà nút mới không thể nào ảnh hưởng. Đó là một dạng rò rỉ làm sai lệch kết quả.
Ví dụ 2 — Ứng dụng gọi xe "GoTrip": coupon làm tăng chuyến nhưng giảm doanh thu
GoTrip (giả định, kiểu Be/Grab) test một banner coupon giảm 20k cho chuyến đầu trong tuần. Treatment đúng là tăng tỷ lệ user đặt chuyến: từ 22% lên 26%. Đội marketing ăn mừng. Nhưng BA cẩn thận kéo thêm metric doanh thu trên mỗi user:
WITH rev AS (
SELECT
a.variant,
a.user_id,
COALESCE(SUM(r.fare), 0) AS user_revenue
FROM experiment_assignments a
LEFT JOIN rides r
ON r.user_id = a.user_id
AND r.created_at >= a.assigned_at
WHERE a.experiment_id = 'coupon_banner_2026'
GROUP BY a.variant, a.user_id
)
SELECT
variant,
COUNT(*) AS users,
ROUND(AVG(user_revenue), 0) AS arpu
FROM rev
GROUP BY variant;
Kết quả: control ARPU 48,000đ, treatment ARPU 44,500đ. Tỷ lệ đặt chuyến tăng, nhưng doanh thu trung bình mỗi user lại giảm vì khoản giảm giá 20k ăn mòn nhiều hơn phần chuyến tăng thêm. Bài học: đừng bao giờ chỉ nhìn metric chính. Một thử nghiệm có thể "thắng" ở metric bề mặt nhưng "thua" ở metric kinh doanh thật sự. BA giỏi luôn kéo cả guardrail metric (doanh thu, lợi nhuận, tỷ lệ huỷ) bên cạnh metric mục tiêu.
Ví dụ 3 — Ví điện tử "PayNhanh": dừng test sớm và bẫy peeking
PayNhanh (giả định, kiểu MoMo/ZaloPay) test luồng đăng ký rút gọn. Ngày thứ 3, treatment dẫn 12.0% vs control 11.2%, z-score đạt 2.1. Một bạn vội đề xuất dừng test, tuyên bố thắng. BA phản đối, và đúng: tiếp tục chạy đến đủ 2 tuần thì khác biệt thu hẹp còn 11.6% vs 11.4%, z-score rớt xuống 0.7 — không còn ý nghĩa. Bài học: "peeking" — liên tục nhìn kết quả và dừng ngay khi thấy đẹp — làm tỷ lệ báo động giả tăng vọt. Hãy cố định trước cỡ mẫu và thời lượng (thường tính theo tuần trọn để cân bằng hiệu ứng ngày trong tuần), rồi mới đọc kết quả.
Hướng dẫn từng bước
Đây là quy trình chuẩn cho một lần phân tích A/B test bằng SQL.
Bước 1 — Kiểm tra tính toàn vẹn của assignment. Trước khi tin bất kỳ con số nào, xác nhận không user nào nằm ở hai variant:
SELECT user_id, COUNT(DISTINCT variant) AS n_variants
FROM experiment_assignments
WHERE experiment_id = 'buy_button_2026'
GROUP BY user_id
HAVING COUNT(DISTINCT variant) > 1;
Nếu query trả về dòng nào, hãy dừng lại và báo đội engineering — dữ liệu bị nhiễm bẩn.
Bước 2 — Kiểm tra cân bằng cỡ mẫu (Sample Ratio Mismatch). Nếu bạn chia 50/50 nhưng thực tế ra 50,000 vs 47,000, hệ thống chia có thể đang lỗi. Lệch quá vài phần trăm là dấu hiệu đáng ngờ:
SELECT variant, COUNT(*) AS users
FROM experiment_assignments
WHERE experiment_id = 'buy_button_2026'
GROUP BY variant;
Bước 3 — Tính metric ở cấp độ user. Gom mỗi user thành một dòng với cờ converted (hoặc giá trị doanh thu), như các query ở phần ví dụ. Luôn dùng LEFT JOIN để user không có sự kiện vẫn được tính vào mẫu số với giá trị 0.
Bước 4 — Tính z-score ngay trong SQL. Đây là phần BA hay né, nhưng nó hoàn toàn làm được:
WITH base AS (
SELECT a.variant, a.user_id,
MAX(CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END) AS converted
FROM experiment_assignments a
LEFT JOIN orders o
ON o.user_id = a.user_id AND o.created_at >= a.assigned_at
WHERE a.experiment_id = 'buy_button_2026'
GROUP BY a.variant, a.user_id
),
agg AS (
SELECT
SUM(CASE WHEN variant='control' THEN 1 ELSE 0 END) AS n_a,
SUM(CASE WHEN variant='control' THEN converted ELSE 0 END) AS x_a,
SUM(CASE WHEN variant='treatment' THEN 1 ELSE 0 END) AS n_b,
SUM(CASE WHEN variant='treatment' THEN converted ELSE 0 END) AS x_b
FROM base
)
SELECT
x_a*1.0/n_a AS p_a,
x_b*1.0/n_b AS p_b,
( (x_b1.0/n_b) - (x_a1.0/n_a) ) /
SQRT(
((x_a + x_b)1.0/(n_a + n_b)) (1 - (x_a + x_b)*1.0/(n_a + n_b))
* (1.0/n_a + 1.0/n_b)
) AS z_score
FROM agg;
Đọc kết quả: z_score dương nghĩa treatment cao hơn; |z_score| > 1.96 nghĩa khác biệt có ý nghĩa ở mức 95%; > 2.58 là mức 99%. Lưu ý ép kiểu bằng *1.0 (hoặc CAST) để tránh integer division — chia số nguyên trong SQL sẽ cho ra 0 thay vì 0.107.
Bước 5 — Diễn giải bằng ngôn ngữ kinh doanh. Đừng nộp z-score cho sếp. Hãy nói: "Nút cam tăng tỷ lệ đặt đơn từ 10.0% lên 10.7% (tăng tương đối 7%), có ý nghĩa thống kê ở mức 95%. Nếu áp cho 1 triệu user/tháng, ước tính thêm khoảng 7,000 đơn." Đó mới là output của một BA.
Lỗi thường gặp & mẹo
Integer division. 5350 / 50000 trong nhiều hệ SQL cho ra 0. Luôn nhân 1.0, dùng CAST(... AS DECIMAL), hoặc AVG() trên cột số nguyên 0/1.
Đếm sự kiện thay vì đếm user. COUNT(order_id) đếm đơn hàng; một user mua 3 lần bị tính 3. Conversion rate cần đếm user có đơn: dùng COUNT(DISTINCT user_id) hoặc cờ MAX(CASE WHEN ...) ở cấp user. Nhầm chỗ này làm phồng tỷ lệ vượt 100%.
Quên user không chuyển đổi. Nếu JOIN thay vì LEFT JOIN, những user không mua biến mất khỏi mẫu số, đẩy conversion rate lên cao giả tạo cho cả hai nhóm. Mẫu số phải là toàn bộ user được gán.
Tính cả sự kiện trước assignment. Luôn lọc event_time >= assigned_at. Đơn đặt trước khi vào test không thể do test gây ra.
Peeking và dừng sớm. Như ví dụ PayNhanh: cố định thời lượng trước, đọc kết quả sau. Mẹo thực tế: chạy trọn tuần (bội số 7 ngày) để hành vi cuối tuần và ngày thường không làm lệch.
Bỏ qua guardrail metric. Một thay đổi tăng click nhưng tăng tỷ lệ huỷ đơn, hay tăng đăng ký nhưng giảm doanh thu (ví dụ GoTrip), là thắng giả. Luôn kéo thêm 1–2 metric bảo vệ.
Mẹo so sánh nhiều biến thể. Khi test A/B/C (nhiều treatment), bạn so từng treatment với control bằng CASE WHEN trong cùng query; nhưng nhớ rằng càng nhiều phép so sánh, khả năng báo động giả càng cao — cân nhắc nâng ngưỡng z lên.
Mẹo trình bày. Bọc toàn bộ logic trong CTE (WITH) để query đọc như một câu chuyện: base → agg → kết quả. Đồng nghiệp review sẽ cảm ơn bạn, và chính bạn sau 3 tháng cũng vậy.
Bài tập thực hành
Dùng hai bảng experiment_assignments và orders ở trên cho experiment checkout_redesign_2026.
- Viết query kiểm tra Sample Ratio Mismatch: đếm số user mỗi variant và tính tỷ lệ phần trăm mỗi nhóm trên tổng. Nhận xét nếu lệch quá 2%.
- Tính conversion rate (tỷ lệ user có ít nhất một đơn sau
assigned_at) cho từng variant, dùngLEFT JOINvà cờ ở cấp user. Đảm bảo tránh integer division. - Mở rộng query (2) để tính ARPU — doanh thu trung bình mỗi user — cho từng variant. So sánh: nếu conversion tăng nhưng ARPU giảm, bạn sẽ khuyến nghị gì?
- Viết query một-lần-chạy tính
z_scorecho conversion rate giữa treatment và control. Nếuz = 2.3, bạn kết luận gì ở mức tin cậy 95%? - (Nâng cao) Thêm cột tính conversion rate theo ngày trong suốt thử nghiệm (
GROUP BY variant, DATE(assigned_at)), để kiểm tra khác biệt có ổn định qua thời gian hay chỉ do một ngày bất thường.
base → agg trong phần hướng dẫn, chỉ đổi tên experiment.Tóm tắt
A/B test là cách duy nhất đáng tin để biết một thay đổi gây ra tác động gì, vì việc chia nhóm ngẫu nhiên triệt tiêu các yếu tố nhiễu mà so sánh "trước–sau" không xử lý được. Là BA, công việc của bạn nằm gọn trong vài bước SQL: kiểm tra tính toàn vẹn assignment, xác nhận cỡ mẫu cân bằng, tính metric ở cấp user với mẫu số là toàn bộ user được gán, rồi tính z-score ngay trong query để biết khác biệt có thật hay chỉ là nhiễu. Nhớ ba cạm bẫy lớn nhất: integer division, đếm sự kiện thay vì đếm user, và peeking/dừng sớm. Và luôn kéo guardrail metric — như câu chuyện GoTrip nhắc ta, một thử nghiệm "thắng" trên bề mặt có thể đang âm thầm bào mòn doanh thu. Khi bạn trình bày được "tăng từ 10.0% lên 10.7%, có ý nghĩa ở mức 95%, ước tính thêm 7,000 đơn/tháng", bạn không còn là người kéo số — bạn là người giúp công ty ra quyết định.