DataOps nâng cao 31 — Debug & profiling pipeline
Kỹ năng ít được dạy nhất
Trường lớp và tài liệu dạy bạn dựng pipeline: dbt model, DAG, contract, test. Nhưng thời gian thật của một data engineer đổ vào việc mà không giáo trình nào dạy bài bản: gỡ lỗi khi pipeline chậm hoặc sai. Lúc 8h sáng ngày đóng sổ, một chỉ tiêu trên báo cáo NHNN lệch 3 tỷ so với sổ cái, và bạn có 30 phút để tìm ra bước nào trong 40 model đã làm hỏng con số — đó mới là công việc thực.
Bài này hệ thống hoá kỹ năng đó thành quy trình thay vì mò mẫm. Debug giỏi không phải "thông minh hơn" mà là khoanh vùng có phương pháp: chia đôi không gian tìm kiếm, kiểm chứng từng giả định, dùng đúng công cụ để nhìn thấy cái đang ẩn.
Phân loại vấn đề — ba loại rất khác nhau
Việc đầu tiên khi có sự cố là phân loại, vì ba loại đòi hỏi kỹ thuật khác hẳn nhau:
| Loại | Biểu hiện | Độ khó | Công cụ chính |
|---|---|---|---|
| Pipeline FAIL | Task đỏ, exception, DAG dừng | Dễ nhất — có stack trace chỉ đường | Log, task instance, error message |
| Pipeline CHẬM | Chạy xong nhưng quá lâu, SLA vỡ | Trung bình — cần đọc query plan | EXPLAIN, query profile, metrics |
| Dữ liệu SAI | Chạy xanh, số sai thầm lặng | Khó nhất — không có báo lỗi | Lineage, bisect, reconciliation |
Loại nguy hiểm nhất là dữ liệu sai: pipeline "up", dashboard trả về số, nhưng số sai. Không có exception, không alert — chỉ có một cán bộ nghiệp vụ tinh mắt phát hiện tổng dư nợ lệch. Xem thêm khung phát hiện ở Observability & DRE. Phần lớn bài này tập trung vào loại này và loại "chậm".
Debug FAIL — nhanh nhưng đừng ẩu
FAIL là dễ nhất vì có stack trace chỉ đường, nhưng nhiều người vẫn debug ẩu. Nguyên tắc:
- Đọc dòng lỗi CUỐI trước, rồi dòng ĐẦU. Dòng cuối là exception thật; dòng đầu (nếu lồng) thường là nguyên nhân gốc bị nuốt.
- Xem task instance, không chỉ log tổng. Trong Airflow/Dagster, mỗi task instance có log riêng và
execution_date— DAG "đỏ" thường chỉ một task hỏng. - Tái tạo cục bộ. Copy câu SQL/đoạn code của bước hỏng, chạy tay với đúng tham số ngày — 80% lỗi lộ ngay khi chạy tách rời.
- Đọc SQL thực sự được sinh ra, không phải template.
dbt compilebiến{{ ref() }}, macro, Jinja thành SQL cuối — lỗi "column does not exist" thường do macro sinh sai tên cột.
Lệnh cần thuộc: dbt run --select model+ --debug (in SQL và profile kết nối), dbt compile --select model (xem SQL sinh ra trong target/compiled/).
Debug DỮ LIỆU SAI — quy trình 5 bước
Đây là phần cốt lõi. Khi một con số sai mà pipeline chạy xanh, đừng đọc code ngẫu nhiên. Đi theo quy trình:
Bước 1 — Khoanh vùng bằng lineage
Bắt đầu từ cột sai trên báo cáo cuối, đi ngược đồ thị phụ thuộc (dbt docs, DAG lineage) về các model thượng nguồn cho tới nguồn thô. Mục tiêu: liệt kê danh sách hữu hạn các bước mà con số đi qua, thay vì nghi ngờ cả kho. Nếu chỉ tiêu "tổng dư nợ nhóm 5" đi qua 6 model, không gian tìm kiếm là 6 chứ không phải 200.
Bước 2 — So sánh giữa các tầng
Chạy cùng một phép tổng ở từng tầng kiến trúc medallion: bronze (thô), silver (làm sạch), gold (tổng hợp). Tầng nào con số bắt đầu lệch chính là nơi lỗi được sinh ra. Ví dụ: SUM ở bronze = 100 tỷ, ở silver = 103 tỷ → bước bronze→silver nhân bản dòng.
Bước 3 — Bisect (chia đôi để tìm bước hỏng)
Đây là kỹ thuật mạnh nhất, mượn từ git bisect. Chuỗi phụ thuộc có N bước; thay vì kiểm tuần tự, kiểm bước ở giữa trước. Nếu số ở giữa đã sai → lỗi ở nửa trên; nếu đúng → lỗi ở nửa dưới. Mỗi lần kiểm loại bỏ một nửa: 40 model chỉ cần ~6 lần kiểm (log₂40) thay vì 40 lần.
Bước 4 — Tái tạo trên dữ liệu nhỏ
Đừng debug trên 200 triệu dòng. Chọn một khoá cụ thể đã biết sai (một khách hàng, một tài khoản, một ngày) rồi lọc WHERE customer_id = ... xuyên suốt chuỗi. Bug lộ rõ trên 3 dòng dữ liệu, không lộ trên 200 triệu.
Bước 5 — Kiểm giả định
Đến bước hỏng rồi thì kiểm từng giả định ngầm — đây là nơi 90% bug số liệu ẩn:
| Giả định sai | Triệu chứng | Cách kiểm |
|---|---|---|
| JOIN fan-out | Số bị nhân lên sau join | Đếm dòng trước/sau join; COUNT(*) vs COUNT(DISTINCT khoá) |
| NULL trong tính toán | Tổng bị hụt, NULL + 5 = NULL | Đếm null; dùng COALESCE chủ động |
| Timezone | Giao dịch nhảy sang ngày sai ở biên ngày | So created_at UTC vs local, filter theo ngày |
| Kiểu dữ liệu | Ép ngầm làm tròn/cắt; text so number sai | SUM(amount::numeric), kiểm pg_typeof |
| Trùng khoá / late data | Bản ghi lặp; version cũ ghi đè mới | COUNT vs COUNT DISTINCT trên khoá tự nhiên |
Ba check SQL debug (chạy được trên sandbox)
(a) Phát hiện JOIN fan-out — nhân bản dòng
Nguyên nhân số một khiến tổng bị "phồng": join với bảng bên phải không unique theo khoá join, mỗi dòng bên trái bị nhân lên. Cách phát hiện nhanh: đếm dòng của accounts gốc so với dòng sau khi join transactions, và so COUNT(*) với COUNT(DISTINCT):
-- ▶ Chạy được
SELECT
COUNT(*) AS rows_after_join, -- phồng lên
COUNT(DISTINCT a.id) AS distinct_accounts, -- số account thật
ROUND(COUNT(*)::numeric
/ NULLIF(COUNT(DISTINCT a.id), 0), 2) AS fanout_ratio -- >1 là bị nhân
FROM accounts a
JOIN transactions t ON t.account_id = a.id;
fanout_ratio > 1 nghĩa là mỗi account bị nhân trung bình bấy nhiêu lần vì có nhiều transaction. Nếu bạn định SUM(a.balance) sau join này, balance sẽ bị đếm lặp — bug fan-out kinh điển. Cách sửa: hoặc tổng hợp transactions trước khi join (một dòng/account), hoặc dùng SUM(DISTINCT ...) cẩn thận.
(b) EXPLAIN một truy vấn tổng hợp — đọc kế hoạch
Khi query chậm, đừng đoán. EXPLAIN cho bạn xem PostgreSQL định chạy thế nào — có full scan không, join kiểu gì, ước lượng bao nhiêu dòng:
-- ▶ Chạy được
EXPLAIN
SELECT c.city, COUNT(*) AS n_txn, SUM(t.amount::numeric) AS total_amount
FROM transactions t
JOIN accounts a ON t.account_id = a.id
JOIN customers c ON a.customer_id = c.id
GROUP BY c.city;
Đọc plan từ trong ra ngoài: Seq Scan (full table scan — tốn), Hash Join/Nested Loop (kiểu join), HashAggregate (gom nhóm), cột rows= (ước lượng). Cảnh báo cần tìm: Seq Scan trên bảng lẽ ra phải dùng index, rows= lệch xa thực tế (thống kê cũ — cần ANALYZE), hoặc một nhánh join sinh số dòng khổng lồ (exploding join). Trên warehouse thật (Snowflake/BigQuery), đọc query profile để thấy spill to disk, shuffle và phân bổ thời gian theo operator.
(c) So sánh tổng giữa hai cách tính — tìm lệch
Reconciliation là vũ khí chính với "số sai". Tính cùng một đại lượng theo hai đường độc lập rồi so; lệch ≠ 0 là có bug. Ở đây so tổng số dư tính từ accounts với tổng dòng tiền ròng suy từ transactions:
-- ▶ Chạy được
WITH from_accounts AS (
SELECT SUM(balance::numeric) AS total_balance
FROM accounts
),
from_txn AS (
SELECT SUM(amount::numeric) AS total_txn_flow
FROM transactions
)
SELECT
fa.total_balance,
ft.total_txn_flow,
ROUND(fa.total_balance - ft.total_txn_flow, 2) AS diff -- ≠ 0 → điều tra
FROM from_accounts fa CROSS JOIN from_txn ft;
Đây là mẫu control total: khi hai đường ra khác nhau bất ngờ, diff chỉ thẳng vào chỗ giả định bị vi phạm (fan-out, filter thiếu, đơn vị/tiền tệ khác nhau). Trong thực tế bạn sẽ so tổng theo từng partition/ngày để khoanh vùng dòng nào lệch.
Profiling & tối ưu HIỆU NĂNG
Khi pipeline chậm, quy trình cũng là khoanh vùng: tìm bước chậm nhất (từ Airflow/Dagster task duration hoặc dbt run_results.json), rồi mổ query đó bằng EXPLAIN/profile.
Đọc query plan — tìm các "mùi" xấu
| Dấu hiệu trong plan | Nghĩa | Hướng sửa |
|---|---|---|
| Full scan bảng lớn | Quét toàn bộ khi lẽ ra lọc được | Thêm partition filter, index/cluster key, đẩy WHERE sớm |
| Spill to disk | Bộ nhớ không đủ, ghi tạm ra đĩa | Giảm dữ liệu quét, tăng warehouse, giảm cardinality group by |
| Shuffle lớn | Dữ liệu di chuyển giữa node (distributed) | Chọn cluster/distribution key theo khoá join |
| Exploding join | Số dòng phình sau join | Sửa fan-out (tổng hợp trước join), kiểm khoá unique |
Nút thắt hiệu năng thường gặp
- Data skew — dữ liệu lệch: một khoá chiếm phần lớn dòng, một task/partition làm mãi không xong trong khi các task khác đã nghỉ. Kinh điển ở ngân hàng: một "tài khoản treo" nội bộ ôm hàng triệu bút toán.
- Small files — quá nhiều file nhỏ: hàng nghìn file vài KB làm overhead mở file lấn át thời gian đọc. Cần compaction/
OPTIMIZE. - Thiếu partition/index/cluster: quét full thay vì lọc theo ngày. Partition theo cột lọc thường xuyên (thường là ngày).
- Quét thừa dữ liệu:
SELECT *, không đẩy filter xuống, không dùng incremental.
Phát hiện data skew — check chạy được minh hoạ
Skew = một số khoá "nặng" bất thường. Xem phân bố số transaction theo account: nếu một account chiếm tỷ lệ áp đảo, mọi phép join/group theo account đều bị nghẽn ở khoá đó.
-- ▶ Chạy được
SELECT
account_id,
COUNT(*) AS n_txn,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS pct_of_total -- khoá nào áp đảo?
FROM transactions
GROUP BY account_id
ORDER BY n_txn DESC
LIMIT 10;
Nếu top 1-2 account chiếm hàng chục phần trăm tổng, đó là nguồn skew — cần salting khoá, tách xử lý riêng khoá nặng, hoặc tổng hợp trước.
Hướng tối ưu
- Giảm dữ liệu quét — đẩy
WHERE/partition filter sớm nhất; chỉ chọn cột cần. - Sửa join — bỏ fan-out, join trên khoá có index, tổng hợp bảng "một-nhiều" trước khi join.
- Incremental — chỉ xử lý dữ liệu mới thay vì full-refresh mỗi lần; xem Cost & Performance để cân đối chi phí/tốc độ.
- Vật liệu hoá hợp lý — table vs view vs incremental theo tần suất đọc/ghi.
Debug các lỗi phổ biến
| Lỗi | Nguyên nhân | Cách bắt / phòng |
|---|---|---|
| Nguồn đổi schema | Upstream đổi tên/xoá cột, đổi kiểu | Schema test, contract, advanced testing |
| Dữ liệu trễ (late data) | Bản ghi tới sau khi partition đã đóng | Watermark, xử lý cửa sổ trễ, backfill |
| Race condition | Đọc trong khi bảng đang được ghi | WAP (write-audit-publish), transaction, đọc snapshot |
| Quyền (permission) | Thiếu grant sau khi tạo bảng mới | Kiểm role/grant, tự động hoá cấp quyền |
| Memory / OOM | Group by/join cardinality quá lớn | Giảm dữ liệu, spill có kiểm soát, tăng resource |
Phòng ngừa — rẻ hơn debug rất nhiều
Debug tốn nhất là lúc 2h sáng ngày đóng sổ. Đầu tư để không phải debug:
- Test bắt sớm:
unique,not_null,relationships, reconciliation test — bug lộ ở CI, không lộ ở báo cáo NHNN. - Observability: freshness/volume/schema monitor để phát hiện trước khi consumer phát hiện (xem SLA & Downtime).
- Log tốt: mỗi bước log số dòng vào/ra, tham số ngày, thời gian — bisect nhanh gấp bội khi có log dòng-vào/dòng-ra.
- Dữ liệu mẫu tái tạo được: bộ dữ liệu nhỏ cố định để chạy lại bug bất kỳ lúc nào.
Checklist gỡ lỗi nhanh
Khi có sự cố, chạy qua checklist thay vì hoảng:
- Phân loại: FAIL / CHẬM / SAI? (quyết định công cụ)
- FAIL → đọc dòng lỗi cuối trước, xem task instance,
dbt compile, tái tạo cục bộ. - SAI → lineage khoanh vùng → so tầng bronze/silver/gold → bisect → dữ liệu nhỏ → kiểm giả định (fan-out/NULL/timezone/kiểu/trùng khoá).
- CHẬM → tìm bước chậm nhất → EXPLAIN/profile → tìm full scan/spill/shuffle/skew → giảm quét, sửa join, incremental.
- Sau khi sửa: thêm test bắt lại bug, backfill dữ liệu lỗi, ghi lại vào runbook.
Use case thực tế
Bối cảnh: 8h05 ngày đóng sổ tháng, phòng QLRR báo chỉ tiêu tổng dư nợ nhóm 5 trên báo cáo phân loại nợ lệch +2,8 tỷ so với số sổ cái. Pipeline chạy xanh toàn bộ, không alert.
Điều tra theo quy trình:
- Lineage: chỉ tiêu đi qua 5 model:
stg_loans→int_loan_group→int_loan_daily→fct_loan_balance→rpt_npl. Không gian tìm kiếm = 5 bước. - So tầng:
SUMởstg_loans= 412 tỷ (khớp sổ cái), ởrpt_npl= 414,8 tỷ. Lỗi nằm trong chuỗi giữa. - Bisect: kiểm bước giữa
int_loan_daily= 412 tỷ (đúng) → lỗi ở nửa dưới. Kiểmfct_loan_balance= 414,8 tỷ (sai) → bướcint_loan_daily → fct_loan_balancelà thủ phạm. - Dữ liệu nhỏ: lọc một khách hàng đã biết lệch, thấy dư nợ bị nhân đôi.
- Kiểm giả định:
fct_loan_balancejoin thêm bảngloan_collateral(một khoản vay có 2 tài sản bảo đảm) → fan-out.COUNT(*)vsCOUNT(DISTINCT loan_id)cho ratio 1,4 → xác nhận. Khoản vay nhiều TSBĐ bị đếm dư nợ nhiều lần.
Kết quả: tổng hợp loan_collateral trước khi join (một dòng/khoản vay), thêm test COUNT(*) = COUNT(DISTINCT loan_id) sau bước đó, backfill partition tháng. Điều tra ~35 phút thay vì đọc mò cả kho. Bài học: bug fan-out ẩn sau join tưởng vô hại, và bisect biến 5 bước thành 2 lần kiểm.
Case tối ưu song song: cùng pipeline đóng sổ chạy 90 phút, vỡ SLA 8h. run_results.json chỉ ra fct_loan_balance chiếm 70 phút. EXPLAIN cho thấy Seq Scan toàn bộ bảng giao dịch 2 năm dù chỉ cần tháng hiện tại — thiếu partition filter. Thêm filter theo ngày + chuyển sang incremental → còn 12 phút.
Ghi nhớ
- Phân loại trước tiên: FAIL (dễ, có trace) / CHẬM (đọc plan) / SAI (khó nhất, im lặng) — mỗi loại dùng công cụ khác nhau.
- Debug số liệu sai theo quy trình: lineage khoanh vùng → so tầng bronze/silver/gold → bisect (chia đôi, log₂ lần kiểm) → tái tạo trên dữ liệu nhỏ → kiểm giả định.
- Kiểm giả định là nơi 90% bug ẩn: JOIN fan-out (nhân dòng), NULL, timezone, kiểu dữ liệu, trùng khoá.
- Vũ khí phát hiện fan-out:
COUNT(*)vsCOUNT(DISTINCT khoá); ratio > 1 là bị nhân. - Reconciliation / control total: tính một đại lượng theo hai đường độc lập,
diff ≠ 0là có bug. - Profiling: đọc
EXPLAIN/query profile tìm full scan, spill, shuffle, exploding join; nút thắt hay gặp là data skew, small files, thiếu partition/index. - Tối ưu: giảm dữ liệu quét, sửa join, dùng incremental;
EXPLAINtrước khi đoán. - Phòng ngừa rẻ hơn debug: test bắt sớm, observability, log dòng-vào/dòng-ra, dữ liệu mẫu tái tạo được.
- Sau khi sửa: luôn thêm test bắt lại đúng bug đó và backfill dữ liệu đã lỗi.
Bài viết liên quan
CSV/JSON/Parquet/Avro, lưu trữ theo hàng vs cột, nén, và OLTP vs OLAP.
Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.
Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.
Đảm bảo chất lượng & minh bạch dữ liệu bằng dbt: data tests dựng sẵn (unique, not_null, relationships, accepted_values), singular test, unit test, và tài liệu tự sinh kèm lineage graph.