zalo-icon
facebook-icon
phone-icon
Snapshot và lịch sử thay đổi dữ liệu trong dbt: Khách hàng và SCD Type 2 | Bài 8/10

Series dbt · Bài 8/10. Khách An chuyển từ Ho Chi Minh sang Ha Noi. Nếu dim_customers chỉ giữ bản hiện tại, báo cáo giao dịch tháng trước cũng có thể đổi thành phố theo thông tin mới. Snapshot giúp giữ các phiên bản đã quan sát để bạn chọn góc nhìn hiện tại hoặc lịch sử một cách có chủ đích.

Dùng tiếp project dbt-retail sau bài 7, đã hoàn nguyên dữ liệu bán hàng về 400.000 VND. Bài dùng dbt Core 1.9.8 với snapshot SQL dạng legacy còn được hỗ trợ ở dòng này. Không trộn cấu hình YAML snapshot mới của tài liệu phiên bản khác vào cùng bài lab khi chưa đối chiếu. Các mốc thời gian cố định là dữ liệu mô phỏng có kiểm soát.

1. Snapshot khác bảng trạng thái hiện tại

Incremental delete+insert của bài 7 thay bản cũ bằng bản mới theo cùng order_id. Snapshot theo kiểu SCD Type 2 giữ phiên bản cũ và đóng khoảng hiệu lực, đồng thời thêm phiên bản mới. Mỗi customer_id có thể xuất hiện nhiều dòng, vì grain đã đổi thành một phiên bản của khách hàng.

Góc nhìnCâu hỏi nó trả lờiMô hình phù hợp
Trạng thái hiện tạiKhách đang ở thành phố nào?Một dòng mỗi customer_id
Phiên bản lịch sửỞ khoảng thời gian này, thuộc tính được ghi nhận thế nào?Nhiều phiên bản có valid_from/valid_to
Lịch sử sự kiện đầy đủMọi lần đổi trạng thái đã xảy ra là gì?Event log/CDC hoặc nguồn lịch sử đủ chi tiết

Snapshot không thể khôi phục thay đổi chưa từng quan sát. Nếu nguồn đổi A thành B rồi thành C giữa hai lần chụp và snapshot chỉ đọc được C, B có thể không được lưu. Timestamp strategy còn dựa vào độ tin cậy của updated_at. Lịch sử được lưu không tự động là sự thật nghiệp vụ đầy đủ ở mọi thời điểm.

Trong lab ta biết chính xác các lần thay đổi và chủ động chạy snapshot sau từng lần, nên có thể kiểm chứng khoảng thời gian. Khi dùng thực tế, cần làm rõ updated_at là thời gian thay đổi thuộc tính, thời gian cập nhật hệ thống hay chỉ là thời gian ingestion. Ba đồng hồ đó dẫn tới cách diễn giải lịch sử khác nhau.

Snapshot giữ các phiên bản đã quan sát thay vì chỉ ghi đè trạng thái hiện tại.
Snapshot giữ các phiên bản đã quan sát thay vì chỉ ghi đè trạng thái hiện tại.

2. Bổ sung updated_at cho bảng khách hàng

Tạo init/03_customer_history_columns.sql. Cột thêm vào raw.customers độc lập với updated_at của raw.orders ở bài trước. Không cần sửa staging khách hàng hiện tại vì snapshot sẽ đọc trực tiếp source đã khai báo.

alter table raw.customers
    add column if not exists updated_at timestamptz
    not null default '2026-01-01 00:00:00+00';
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 \
  -U dbt_lab -d retail < init/03_customer_history_columns.sql
mkdir -p snapshots

Giữ file migration trong project để CI dựng được cùng schema trên database mới. Default ngày 1 chỉ là điểm xuất phát mô phỏng; không được lấy timestamp giả như vậy để khẳng định khách thật có địa chỉ này từ ngày đó. Nếu đã làm bài nhiều lần và bảng snapshot còn lịch sử, kết quả đếm phiên bản không còn giống lần đầu, đó là dữ liệu tồn tại chứ không phải lỗi snapshot.

3. Tạo snapshot bằng timestamp strategy

Có thể thêm snapshot-paths: [snapshots] ở cấp gốc dbt_project.yml để khai báo rõ thư mục. Nếu không thêm, dùng thư mục mặc định snapshots của Core trong lab. File dưới là snapshots/snap_customers.sql.

{% snapshot snap_customers %}

{{ config(
    target_schema=target.schema ~ '_snapshots',
    unique_key='customer_id',
    strategy='timestamp',
    updated_at='updated_at'
) }}

select
    customer_id,
    trim(customer_name) as customer_name,
    trim(city) as city,
    updated_at
from {{ source('shop', 'customers') }}

{% endsnapshot %}

unique_key xác định thực thể; updated_at cho biết khi nào bản nguồn mới hơn. target.schema của target dev là dev, nên snapshot SQL này ghi vào dev_snapshots theo target_schema đã khai báo trực tiếp. Khi đổi target qa, nó dùng qa_snapshots. Không hard-code một schema snapshot chung cho mọi môi trường nếu không chủ ý chia sẻ lịch sử.

dbt snapshot --profiles-dir profiles --select snap_customers

docker compose exec -T postgres psql -U dbt_lab -d retail \
  -c "select customer_id, city, dbt_valid_from, dbt_valid_to from dev_snapshots.snap_customers order by customer_id, dbt_valid_from;"

Lần đầu trên database mới, snapshot có bốn dòng, một dòng mỗi khách. Với timestamp strategy và metadata nguồn của lab, valid_from theo updated_at ngày 1. dbt_valid_to null là phiên bản hiện hành vì bài không đặt dbt_valid_to_current. Chạy lại mà nguồn không đổi không được tự sinh thêm một bản giống hệt chỉ vì đồng hồ hệ thống đã trôi qua.

Cột do dbt thêmVai tròĐiểm cần nhớ
dbt_scd_idĐịnh danh phiên bản snapshotKhác customer_id của thực thể
dbt_valid_fromBắt đầu khoảng phiên bản theo strategyKhông mặc định là ngày bạn chạy lệnh
dbt_valid_toKết thúc khoảng phiên bảnNull đại diện bản hiện hành trong cấu hình này
dbt_updated_atMetadata thời gian phục vụ snapshotDiễn giải theo strategy đang dùng
Thời gian nguồn thay đổi và thời gian hệ thống quan sát có thể khác nhau.
Thời gian nguồn thay đổi và thời gian hệ thống quan sát có thể khác nhau.

4. Đổi thành phố và xem phiên bản mới

docker compose exec -T postgres psql -U dbt_lab -d retail <<'SQL'
update raw.customers
set city = 'Ha Noi', updated_at = '2026-01-10 00:00:00+00'
where customer_id = 1;
SQL

dbt snapshot --profiles-dir profiles --select snap_customers

docker compose exec -T postgres psql -U dbt_lab -d retail <<'SQL'
select customer_id, customer_name, city, dbt_valid_from, dbt_valid_to
from dev_snapshots.snap_customers
where customer_id = 1
order by dbt_valid_from;
SQL
Khách 1Thành phốKhoảng mô phỏng
Phiên bản cũHo Chi MinhTừ 2026-01-01 đến trước 2026-01-10
Phiên bản mớiHa NoiTừ 2026-01-10, chưa có valid_to

Snapshot lúc này có tổng năm dòng. Chạy snapshot lần nữa không đổi nguồn vẫn năm. Nếu chỉ sửa city mà giữ updated_at như cũ, timestamp strategy có thể không coi đó là bản mới; vì vậy kiểm tra chất lượng updated_at là một phần của thiết kế, không phải chi tiết có thể bỏ qua.

Một cách khác là check strategy so các cột được chọn, nhưng nó cũng chỉ phát hiện trạng thái nhìn thấy ở lần chạy. Không có strategy nào biến việc chụp thưa thành nhật ký đầy đủ của mọi thay đổi trung gian. Chọn theo đặc tính nguồn và yêu cầu lịch sử.

5. Xây dimension có grain phiên bản

models/marts/dim_customers_history.sql

select
    dbt_scd_id as customer_version_key,
    customer_id,
    customer_name,
    city,
    dbt_valid_from as valid_from,
    dbt_valid_to as valid_to
from {{ ref('snap_customers') }}
dbt run --profiles-dir profiles --select dim_customers dim_customers_history

docker compose exec -T postgres psql -U dbt_lab -d retail \
  -c "select customer_id, city from dev_marts.dim_customers where customer_id = 1;"

dim_customers hiện tại của bài 5 sẽ có Ha Noi sau khi chạy lại. dim_customers_history giữ cả Ho Chi Minh và Ha Noi. Không đặt unique cho customer_id trong dimension lịch sử: lặp thực thể qua nhiều phiên bản là đúng thiết kế. Khóa cần unique là customer_version_key; đồng thời phải kiểm tra khoảng thời gian không chồng lấn.

ref snapshot tạo dependency trong graph. dbt run không thay cho việc chụp snapshot mới. Khi điều phối, phải thực hiện snapshot đúng thời điểm trước những model đọc lịch sử cần cập nhật, hoặc dùng build với lựa chọn đầy đủ và hiểu các tài nguyên được chọn.

Phân tích lịch sử cần chọn phiên bản phù hợp với thời điểm giao dịch.
Phân tích lịch sử cần chọn phiên bản phù hợp với thời điểm giao dịch.

6. Gắn giao dịch với phiên bản theo khoảng thời gian

Tạo fct_sales_asof riêng, không thay fct_sales hiện tại. Grain vẫn là item. Lab chỉ có order_date, nên quy ước một ngày đơn hàng tương ứng 00:00 UTC để làm phép join thời gian. Đây là quy ước mô phỏng làm mất chi tiết trong ngày; production cần timestamp sự kiện thật và quy tắc timezone/ngày nghiệp vụ rõ ràng.

models/marts/fct_sales_asof.sql

select
    i.order_item_id,
    i.order_id,
    o.customer_id,
    h.customer_version_key,
    o.order_date,
    o.order_status,
    i.quantity,
    i.line_amount_vnd
from {{ ref('stg_order_items') }} as i
left join {{ ref('stg_orders') }} as o
    on i.order_id = o.order_id
left join {{ ref('dim_customers_history') }} as h
    on o.customer_id = h.customer_id
   and (o.order_date::timestamp at time zone 'UTC') >= h.valid_from
   and (
       h.valid_to is null
       or (o.order_date::timestamp at time zone 'UTC') < h.valid_to
   )

Dùng khoảng nửa kín: lớn hơn hoặc bằng đầu, nhỏ hơn cuối. Một giao dịch đúng tại thời điểm đổi phiên bản chỉ ghép với bản mới, tránh việc <= ở cả hai phía làm trùng. Không chỉ join customer_id: khách 1 có hai phiên bản sẽ nhân mỗi item nếu thiếu điều kiện thời gian.

dbt run --profiles-dir profiles --select dim_customers_history fct_sales_asof

Doanh thu theo ngữ cảnh lịch sử

select
    h.city,
    sum(f.line_amount_vnd) as revenue_vnd
from dev_marts.fct_sales_asof as f
join dev_marts.dim_customers_history as h
    on f.customer_version_key = h.customer_version_key
where f.order_status = 'paid'
group by h.city
order by h.city;

Các đơn ngày 1–3 thuộc thời kỳ trước ngày 10: Ho Chi Minh có 350.000 VND của An, Ha Noi có 50.000 VND của Binh. Báo cáo dùng dimension hiện tại sau khi đổi địa chỉ có thể gán cả 400.000 VND về Ha Noi. Hai báo cáo trả lời hai câu hỏi khác nhau; cần đặt tên và mô tả rõ để người dùng không coi đó là lỗi số liệu.

7. Kiểm tra không trùng phiên bản và không chồng khoảng

models/marts/_history.yml

version: 2
models:
  - name: dim_customers_history
    columns:
      - name: customer_version_key
        tests: [unique, not_null]
      - name: customer_id
        tests: [not_null]
      - name: valid_from
        tests: [not_null]
  - name: fct_sales_asof
    columns:
      - name: order_item_id
        tests: [unique, not_null]
      - name: customer_version_key
        tests:
          - not_null
          - relationships:
              to: ref('dim_customers_history')
              field: customer_version_key

tests/assert_customer_history_intervals.sql

with versions as (
    select
        *,
        lead(valid_from) over (
            partition by customer_id order by valid_from, customer_version_key
        ) as next_valid_from
    from {{ ref('dim_customers_history') }}
)
select *
from versions
where (valid_to is not null and valid_to <= valid_from)
   or (
       next_valid_from is not null
       and (valid_to is null or valid_to > next_valid_from)
   )

tests/assert_one_current_customer_version.sql

select customer_id, count(*) as current_versions
from {{ ref('dim_customers_history') }}
where valid_to is null
group by customer_id
having count(*) > 1

Test khoảng phát hiện phiên bản có độ dài sai hoặc chồng sang phiên bản sau. Test current kiểm tra tối đa một bản hiện hành, không khẳng định mọi khách luôn phải có một current row trong mọi chiến lược xử lý xóa. Test not_null của khóa lịch sử trong fact phát hiện đơn không nằm trong khoảng lịch sử đã ghi nhận.

dbt test --profiles-dir profiles --select dim_customers_history fct_sales_asof
dbt test --profiles-dir profiles --select assert_customer_history_intervals assert_one_current_customer_version

Nếu giao dịch có trước valid_from sớm nhất của khách, join có thể trả null. Không tự lấy bản đầu tiên rồi áp ngược về quá khứ nếu chưa có cơ sở nghiệp vụ. Phải quyết định backfill từ nguồn lịch sử, dùng Unknown có giải thích hoặc đánh dấu dữ liệu chưa đủ ngữ cảnh.

8. Hoàn nguyên thuộc tính nhưng giữ lịch sử thật của lab

Đưa An về Ho Chi Minh bằng một cập nhật mới ngày 20, không quay ngược updated_at về ngày 1 và không xóa snapshot. Cách này thể hiện một lần đổi địa chỉ tiếp theo.

docker compose exec -T postgres psql -U dbt_lab -d retail <<'SQL'
update raw.customers
set city = 'Ho Chi Minh', updated_at = '2026-01-20 00:00:00+00'
where customer_id = 1;
SQL

dbt snapshot --profiles-dir profiles --select snap_customers
dbt build --profiles-dir profiles

Sau lần thực hành đầu tiên đầy đủ, customer 1 có ba phiên bản; toàn snapshot có sáu dòng. Dimension hiện tại trở về Ho Chi Minh, còn lịch sử ngày 10–20 vẫn lưu Ha Noi. Tổng paid giữ 400.000 VND. Nếu chạy lại từ trạng thái này, không đặt lại mốc ngày 10 rồi kỳ vọng snapshot tạo cùng lịch sử; muốn tái diễn nguyên bài nên dùng database lab mới tách biệt.

9. Giới hạn cần hiểu trước khi vận hành

Tình huốngRủi roCách thiết kế
Timestamp không tăng khi thuộc tính đổiKhông tạo phiên bản mới theo timestamp strategyKiểm metadata nguồn hoặc chọn strategy thích hợp
Nhiều đổi giữa hai lần chụpMất phiên bản trung gian chưa quan sátChạy đủ thường xuyên hoặc dùng CDC/event history
Nguồn xóa vật lýCấu hình mặc định không tự phản ánh mọi xóaThiết kế hard-delete handling đúng phiên bản hoặc dùng soft delete
Hai job snapshot cùng ghiCạnh tranh và kết quả khó dự đoánMột writer/quy trình khóa và lịch rõ ràng
Đổi unique_key hoặc thay logic nguồnLịch sử có thể đổi nghĩa hoặc bị phân mảnhReview migration, backup và diễn tập trước

Snapshot là dữ liệu lịch sử cần quản lý như một tài sản riêng: quyền ghi hạn chế, backup, retention phù hợp và quy trình phục hồi. Git giữ code snapshot chứ không giữ các phiên bản dữ liệu đã chụp. Tạo lại database từ raw hiện tại không thể tái dựng tự động mọi trạng thái cũ đã mất.

Nghiệm thu: chụp lần đầu không trùng; đổi ngày 10 tạo phiên bản mới; chạy lại không đổi không tăng dòng; join as-of giữ đúng bảy item và không null khóa lịch sử; hoàn nguyên ngày 20 vẫn giữ ba phiên bản của An. Bài 9 sẽ giảm SQL lặp bằng macro/Jinja mà vẫn kiểm tra được SQL cuối cùng gửi tới PostgreSQL.

Tài liệu tham khảo

Scroll to Top