Data Engineering 9 — Transformation với dbt

13 thg 7, 2026 2 lượt xem
#data-engineering
#transformation
#dbt
#elt

Vấn đề: transformation rời rạc và SQL khó bảo trì

Hãy hình dung một đội data đã đưa được dữ liệu thô vào warehouse (đúng tinh thần ELT — load trước, transform sau). Bây giờ là lúc biến đống dữ liệu thô đó thành các bảng "sạch", dễ dùng cho báo cáo và phân tích. Cách làm "tự nhiên" mà rất nhiều đội bắt đầu là viết hàng loạt câu SQL rồi chạy thủ công, hoặc nhét chúng vào các stored procedure, hoặc lưu rải rác trong vài chục file .sql trên máy mỗi người.

Mô hình này nhanh chóng trở thành cơn ác mộng:

  • Không có thứ tự phụ thuộc rõ ràng. Bảng doanh_thu_theo_thang phụ thuộc vào don_hang_da_lam_sach, bảng này lại phụ thuộc vào don_hang_tho. Nhưng thứ tự đó chỉ tồn tại trong đầu người viết. Chạy sai thứ tự là ra số sai.
  • Sao chép logic (copy-paste). Cùng một đoạn logic "tính ngày làm việc" được dán vào 15 query khác nhau. Sửa một chỗ, quên 14 chỗ còn lại.
  • Không ai biết bảng này từ đâu ra. Sáu tháng sau, một con số trong dashboard bị nghi sai, và không ai truy được nó được tính từ những bảng nguồn nào.
  • Không có kiểm thử. Dữ liệu bị trùng khóa, bị null ở cột quan trọng — không ai phát hiện cho tới khi sếp hỏi.
  • Không có version control, không CI, không môi trường dev/prod tách bạch. Sửa trực tiếp trên production và cầu nguyện.

Nói cách khác: phần code SQL transformation thường được đối xử như "script vứt đi", trong khi nó thực ra là tài sản logic nghiệp vụ quan trọng nhất của công ty. dbt sinh ra để sửa đúng vấn đề này.

dbt là gì

dbt (data build tool) là công cụ lo phần chữ T trong ELT — tức là Transform, và chỉ Transform. Nó không kéo dữ liệu, không load dữ liệu. dbt giả định dữ liệu thô đã nằm sẵn trong warehouse (Snowflake, BigQuery, Redshift, Databricks, Postgres...), rồi giúp bạn biến đổi dữ liệu ngay bên trong warehouse đó bằng chính sức mạnh tính toán của warehouse.

Triết lý cốt lõi: bạn chỉ viết các câu SELECT, dbt lo phần còn lại.

Bạn không viết CREATE TABLE, không INSERT, không DROP. Bạn viết một câu SELECT mô tả "dữ liệu kết quả trông như thế nào", và dbt tự sinh ra DDL/DML để vật chất hóa (materialize) nó thành view hay table trong warehouse.

Hai trụ cột kỹ thuật của dbt:

  1. SQL — ngôn ngữ ai làm data cũng biết.
  2. Jinja — một template engine cho phép nhúng logic lập trình (vòng lặp, điều kiện, biến, hàm) vào SQL, biến SQL "tĩnh" thành SQL "động" và DRY (Don't Repeat Yourself).

Điều quan trọng nhất về mặt tư duy: dbt mang kỹ thuật phần mềm vào thế giới data. Code transformation giờ nằm trong Git, có review, có test tự động, có CI/CD, được module hóa và tái sử dụng. Warehouse được phát triển như một sản phẩm phần mềm, không phải như một mớ script.

Model — đơn vị xây dựng cơ bản

Trong dbt, một model = một file .sql chứa đúng một câu SELECT. Tên file chính là tên đối tượng sẽ được tạo trong warehouse.

Ví dụ file stg_don_hang.sql:

-- models/staging/stg_don_hang.sql
select
    id            as don_hang_id,
    khach_hang_id,
    cast(ngay_dat as date) as ngay_dat,
    upper(trang_thai)      as trang_thai,
    so_tien / 100.0        as so_tien_vnd
from {{ source('shop', 'orders_raw') }}
where ngay_dat is not null

Khi chạy dbt run, dbt sẽ tự bọc câu SELECT này thành create view stg_don_hang as (select ...) (hoặc create table, tùy cấu hình) và thực thi trong warehouse.

ref() — keo dán tạo nên DAG phụ thuộc

Đây là tính năng quan trọng nhất của dbt. Khi một model cần đọc từ một model khác, bạn không viết tên bảng cứng. Bạn dùng hàm ref():

-- models/marts/doanh_thu_theo_thang.sql
select
    date_trunc('month', ngay_dat) as thang,
    sum(so_tien_vnd)              as tong_doanh_thu,
    count(*)                      as so_don_hang
from {{ ref('stg_don_hang') }}
where trang_thai = 'COMPLETED'
group by 1

{{ ref('stg_don_hang') }} báo cho dbt biết: "model này phụ thuộc vào model stg_don_hang". Từ tập hợp các ref() trên toàn dự án, dbt tự dựng nên một DAG (đồ thị có hướng không chu trình) thể hiện toàn bộ quan hệ phụ thuộc. Nhờ đó dbt:

  • Biết phải build model nào trước, model nào sau (sắp xếp topo tự động).
  • Có thể chạy song song những model độc lập với nhau.
  • Tự thay ref() bằng tên bảng đúng theo môi trường (dev hay prod, schema nào).

Bạn không bao giờ phải tự nhớ thứ tự chạy nữa. Đây là điểm chấm dứt cơn ác mộng "chạy sai thứ tự" đã nói ở đầu bài.

Materialization — biến model thành cái gì trong warehouse

Cùng một câu SELECT, dbt có thể vật chất hóa nó theo nhiều cách khác nhau. Đây gọi là materialization, cấu hình bằng một dòng:

{{ config(materialized='table') }}

Bốn loại chính:

view (mặc định)

dbt tạo một VIEW. Không lưu dữ liệu, chỉ lưu định nghĩa query; dữ liệu được tính lại mỗi lần truy vấn.

  • Khi nào dùng: model nhẹ, dữ liệu nhỏ, luôn cần số mới nhất, hoặc lớp staging mỏng. Build nhanh, không tốn dung lượng.

table

dbt chạy CREATE TABLE AS SELECT, lưu toàn bộ kết quả thành bảng vật lý. Mỗi lần dbt run là drop và tạo lại từ đầu.

  • Khi nào dùng: model nặng, query phức tạp, được nhiều dashboard đọc lại nhiều lần. Đọc nhanh, nhưng build lại toàn bộ mỗi lần (tốn compute nếu dữ liệu lớn).

incremental

dbt chỉ xử lý và chèn thêm những dòng mới/thay đổi kể từ lần chạy trước, thay vì build lại toàn bộ bảng.

{{ config(materialized='incremental', unique_key='don_hang_id') }}

select * from {{ ref('stg_don_hang') }}
{% if is_incremental() %}
where ngay_dat > (select max(ngay_dat) from {{ this }})
{% endif %}
  • Khi nào dùng: bảng sự kiện/log cực lớn (hàng tỷ dòng) mà mỗi ngày chỉ thêm vài triệu dòng mới. Build lại toàn bộ mỗi đêm là lãng phí tiền compute. Đây là loại quan trọng nhất để tiết kiệm chi phí ở quy mô lớn.

ephemeral

Model không tồn tại trong warehouse. Thay vào đó dbt "nhúng" SQL của nó dưới dạng CTE (WITH ...) vào các model dùng ref() tới nó.

  • Khi nào dùng: logic trung gian nhỏ, chỉ dùng làm bước trung chuyển, không cần truy vấn trực tiếp. Giúp giữ code gọn mà không làm rác warehouse bằng các view/table phụ.

Source và Seed

Source khai báo các bảng dữ liệu thô do hệ thống bên ngoài đưa vào warehouse (kết quả của bước Extract-Load). Bạn khai báo source trong YAML rồi tham chiếu bằng {{ source(...) }} thay vì viết tên bảng cứng:

# models/staging/_sources.yml
sources:
  - name: shop
    schema: raw
    tables:
      - name: orders_raw
      - name: customers_raw
        freshness:
          warn_after: {count: 12, period: hour}

Lợi ích: tập trung định nghĩa nguồn ở một chỗ, kiểm tra "độ tươi" của dữ liệu (dbt source freshness), và source cũng xuất hiện trong lineage như điểm khởi đầu của DAG.

Seed là các file CSV nhỏ nằm trong dự án dbt, được dbt seed nạp thẳng vào warehouse thành bảng. Dùng cho dữ liệu tra cứu tĩnh, ít thay đổi: bảng mã quốc gia, ánh xạ mã tỉnh, danh sách ngày lễ, mapping mã sản phẩm. Không dùng seed cho dữ liệu lớn hay dữ liệu nghiệp vụ động.

Test dữ liệu — chất lượng được tự động kiểm tra

Một trong những điều khiến dbt thực sự "kỹ thuật phần mềm" là test dữ liệu được khai báo ngay cạnh model và chạy tự động qua dbt test. dbt có bốn test dựng sẵn (generic test) bao phủ phần lớn nhu cầu:

  • unique — cột không được có giá trị trùng (ví dụ khóa chính).
  • not_null — cột không được null.
  • accepted_values — giá trị cột phải nằm trong tập cho trước.
  • relationships — mỗi giá trị phải tồn tại ở một cột của bảng khác (kiểm tra toàn vẹn khóa ngoại).

Các test này khai báo trong file schema.yml (tên thường gặp: _models.yml):

# models/staging/_stg_don_hang.yml
models:
  - name: stg_don_hang
    description: "Đơn hàng đã làm sạch từ nguồn shop.orders_raw"
    columns:
      - name: don_hang_id
        description: "Khóa chính của đơn hàng"
        tests:
          - unique
          - not_null
      - name: trang_thai
        tests:
          - accepted_values:
              values: ['COMPLETED', 'PENDING', 'CANCELLED']
      - name: khach_hang_id
        tests:
          - not_null
          - relationships:
              to: ref('stg_khach_hang')
              field: khach_hang_id

Cơ chế bên dưới rất thanh lịch: mỗi test thực chất là một câu SELECT đi tìm các dòng vi phạm. Nếu query trả về 0 dòng → test PASS; trả về > 0 dòng → test FAIL. Bạn có thể đưa dbt test vào pipeline CI để chặn dữ liệu xấu trước khi nó lên production.

Test tùy chỉnh

Khi 4 test dựng sẵn không đủ, bạn viết custom/singular test — chỉ là một file .sql trong thư mục tests/ trả về các dòng "sai":

-- tests/doanh_thu_khong_am.sql
select thang, tong_doanh_thu
from {{ ref('doanh_thu_theo_thang') }}
where tong_doanh_thu < 0

Nếu có tháng nào doanh thu âm, test này sẽ fail. Logic kiểm tra chất lượng dữ liệu giờ nằm trong Git, có review, có lịch sử — đúng như code phần mềm.

Documentation và Data Lineage tự sinh

Còn nhớ vấn đề "không ai biết bảng này từ đâu ra"? dbt giải quyết bằng tài liệu tự sinh. Từ các mô tả bạn đã viết trong schema.yml (description ở cấp model và cấp cột) cộng với DAG dựng từ ref(), lệnh dbt docs generatedbt docs serve tạo ra một website tài liệu tương tác, trong đó nổi bật nhất là biểu đồ lineage: một sơ đồ cho thấy dữ liệu chảy từ source nào, qua những model trung gian nào, đến mart cuối cùng nào.

Điều tuyệt vời: lineage và tài liệu không bao giờ lỗi thời, vì chúng được sinh trực tiếp từ chính code đang chạy, chứ không phải một file Word ai đó viết tay rồi quên cập nhật. Khi một con số bị nghi sai, bạn click vào model trên lineage và truy ngược về tận nguồn trong vài giây.

Macro và Jinja — viết SQL theo nguyên tắc DRY

Jinja cho phép nhúng logic lập trình vào SQL. Macro là "hàm" SQL có thể tái sử dụng — chính là cách dbt diệt vấn nạn copy-paste logic.

-- macros/quy_doi_vnd.sql
{% macro quy_doi_vnd(cot_tien, mau_so=100.0) %}
    cast({{ cot_tien }} as numeric) / {{ mau_so }}
{% endmacro %}

Dùng lại ở mọi nơi:

select {{ quy_doi_vnd('so_tien') }} as so_tien_vnd
from {{ ref('stg_don_hang') }}

Sửa công thức quy đổi một lần trong macro, mọi model dùng nó đều cập nhật theo. Jinja còn cho phép vòng lặp ({% for %}) để sinh hàng loạt cột tương tự, điều kiện ({% if %}), và biến — biến SQL lặp lại buồn tẻ thành code ngắn gọn, dễ bảo trì.

Môi trường dev và prod

dbt tách biệt môi trường qua khái niệm target trong file profiles.yml. Mỗi developer chạy ở một schema riêng (ví dụ dbt_kien), còn production chạy ở schema chính (analytics):

shop_project:
  target: dev
  outputs:
    dev:
      type: snowflake
      schema: dbt_kien      # sandbox của lập trình viên
      threads: 4
    prod:
      type: snowflake
      schema: analytics     # production
      threads: 8

Nhờ ref() không hardcode tên bảng, cùng một codebase chạy được ở cả hai môi trường mà không sửa một dòng SQL nào. Lập trình viên thử nghiệm thoải mái trong sandbox của mình, không đụng vào dữ liệu thật. Khi merge vào main, CI chạy dbt build lên target prod. Đây chính là quy trình phát triển phần mềm chuẩn — chỉ là áp cho data.

Layering: staging → intermediate → marts

dbt cổ vũ kiến trúc model phân lớp, ánh xạ rất tự nhiên với mô hình medallion (bronze/silver/gold) mà bạn có thể đã gặp ở các bài trước:

  • Staging (stg_)làm sạch nhẹ dữ liệu từ source: đổi tên cột, ép kiểu, chuẩn hóa định dạng. Một staging model thường ứng 1-1 với một bảng source. Tương đương lớp bạc/silver.
  • Intermediate (int_)logic nghiệp vụ trung gian: join, gộp, các bước biến đổi phức tạp được tách nhỏ để dễ đọc và tái dùng. Thường để ephemeral hoặc view.
  • Marts (fct_, dim_)bảng cuối phục vụ phân tích: bảng fact và dimension, sẵn sàng cho dashboard và BI. Tương đương lớp vàng/gold. Thường table hoặc incremental.

Quy tắc vàng: dữ liệu chảy một chiều theo lớp — marts đọc từ intermediate/staging, không bao giờ ngược lại. Cấu trúc này giúp codebase data dễ hiểu, dễ test, và dễ mở rộng cho cả đội.

Tóm tắt

dbt là công cụ lo chữ T của ELT: biến đổi dữ liệu ngay trong warehouse bằng SQL cộng Jinja, với triết lý "bạn chỉ viết SELECT, dbt lo phần còn lại". Đơn vị cơ bản là model (một file SQL = một SELECT); hàm ref() liên kết các model thành một DAG phụ thuộc để dbt tự sắp thứ tự chạy. Materialization (view / table / incremental / ephemeral) quyết định model được lưu thế nào trong warehouse, mỗi loại hợp với một tình huống về tốc độ và chi phí. dbt cho phép khai báo source và nạp seed, đồng thời test dữ liệu (unique, not_null, relationships, accepted_values và test tùy chỉnh) chạy tự động để bảo vệ chất lượng. Documentation và lineage được sinh tự động nên không bao giờ lỗi thời; macro/Jinja giúp code DRY; và việc tách dev/prod cùng kiến trúc phân lớp staging → intermediate → marts (liên hệ medallion) đưa toàn bộ kỹ thuật phần mềm — version control, CI, module hóa — vào việc phát triển warehouse.

Tự kiểm tra

  1. Vì sao nói dbt chỉ lo chữ "T" trong ELT, và điều đó đòi hỏi dữ liệu phải ở trạng thái nào trước khi dbt chạy?
  2. Hàm ref() khác gì với việc viết tên bảng cứng, và nó cho phép dbt làm được điều quan trọng gì?
  3. Khi nào bạn chọn materialization incremental thay vì table, và lợi ích chính là gì?
  4. Bốn generic test dựng sẵn của dbt là gì, và cơ chế "pass/fail" của một test hoạt động ra sao?
  5. Vì sao tài liệu và lineage do dbt sinh ra "không bao giờ lỗi thời"?
  6. Ba lớp staging / intermediate / marts tương ứng thế nào với mô hình medallion, và dữ liệu được phép chảy theo chiều nào giữa các lớp?

Đọc tiếp

Data Engineering 10 — Mô hình kho dữ liệu

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 7

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 4