DataOps nâng cao 31 — Debug & profiling pipeline

13 thg 7, 2026 2 lượt xem
#data-engineering
#dataops
#profiling
#troubleshooting
#debugging

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ạiBiểu hiệnĐộ khóCông cụ chính
Pipeline FAILTask đỏ, exception, DAG dừngDễ nhất — có stack trace chỉ đườngLog, task instance, error message
Pipeline CHẬMChạy xong nhưng quá lâu, SLA vỡTrung bình — cần đọc query planEXPLAIN, query profile, metrics
Dữ liệu SAIChạy xanh, số sai thầm lặngKhó nhất — không có báo lỗiLineage, 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:

  1. Đọ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.
  2. 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.
  3. 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.
  4. Đọc SQL thực sự được sinh ra, không phải template. dbt compile biế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 saiTriệu chứngCách kiểm
JOIN fan-outSố bị nhân lên sau joinĐếm dòng trước/sau join; COUNT(*) vs COUNT(DISTINCT khoá)
NULL trong tính toánTổng bị hụt, NULL + 5 = NULLĐếm null; dùng COALESCE chủ động
TimezoneGiao dịch nhảy sang ngày sai ở biên ngàySo 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 saiSUM(amount::numeric), kiểm pg_typeof
Trùng khoá / late dataBản ghi lặp; version cũ ghi đè mớiCOUNT 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 planNghĩaHướng sửa
Full scan bảng lớnQuét toàn bộ khi lẽ ra lọc đượcThêm partition filter, index/cluster key, đẩy WHERE sớm
Spill to diskBộ nhớ không đủ, ghi tạm ra đĩaGiảm dữ liệu quét, tăng warehouse, giảm cardinality group by
Shuffle lớnDữ liệu di chuyển giữa node (distributed)Chọn cluster/distribution key theo khoá join
Exploding joinSố dòng phình sau joinSử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

  1. Giảm dữ liệu quét — đẩy WHERE/partition filter sớm nhất; chỉ chọn cột cần.
  2. 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.
  3. 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 độ.
  4. 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ỗiNguyên nhânCách bắt / phòng
Nguồn đổi schemaUpstream đổi tên/xoá cột, đổi kiểuSchema test, contract, advanced testing
Dữ liệu trễ (late data)Bản ghi tới sau khi partition đã đóngWatermark, xử lý cửa sổ trễ, backfill
Race conditionĐọc trong khi bảng đang được ghiWAP (write-audit-publish), transaction, đọc snapshot
Quyền (permission)Thiếu grant sau khi tạo bảng mớiKiểm role/grant, tự động hoá cấp quyền
Memory / OOMGroup by/join cardinality quá lớnGiả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:

  1. Phân loại: FAIL / CHẬM / SAI? (quyết định công cụ)
  2. FAIL → đọc dòng lỗi cuối trước, xem task instance, dbt compile, tái tạo cục bộ.
  3. 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á).
  4. 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.
  5. 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:

  1. Lineage: chỉ tiêu đi qua 5 model: stg_loansint_loan_groupint_loan_dailyfct_loan_balancerpt_npl. Không gian tìm kiếm = 5 bước.
  2. So tầng: SUMstg_loans = 412 tỷ (khớp sổ cái), ở rpt_npl = 414,8 tỷ. Lỗi nằm trong chuỗi giữa.
  3. Bisect: kiểm bước giữa int_loan_daily = 412 tỷ (đúng) → lỗi ở nửa dưới. Kiểm fct_loan_balance = 414,8 tỷ (sai) → bước int_loan_daily → fct_loan_balance là thủ phạm.
  4. Dữ liệu nhỏ: lọc một khách hàng đã biết lệch, thấy dư nợ bị nhân đôi.
  5. Kiểm giả định: fct_loan_balance join thêm bảng loan_collateral (một khoản vay có 2 tài sản bảo đảm) → fan-out. COUNT(*) vs COUNT(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(*) vs COUNT(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 ≠ 0 là 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; EXPLAIN trướ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.

13 thg 7, 2026 8

Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.

13 thg 7, 2026 5

Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.

13 thg 7, 2026 5

Đả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.

13 thg 7, 2026 5