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ìn | Câu hỏi nó trả lời | Mô hình phù hợp |
|---|---|---|
| Trạng thái hiện tại | Khá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.

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êm | Vai trò | Điểm cần nhớ |
|---|---|---|
| dbt_scd_id | Định danh phiên bản snapshot | Khác customer_id của thực thể |
| dbt_valid_from | Bắt đầu khoảng phiên bản theo strategy | Không mặc định là ngày bạn chạy lệnh |
| dbt_valid_to | Kết thúc khoảng phiên bản | Null đại diện bản hiện hành trong cấu hình này |
| dbt_updated_at | Metadata thời gian phục vụ snapshot | Diễn giải theo strategy đang dùng |

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 1 | Thành phố | Khoảng mô phỏng |
|---|---|---|
| Phiên bản cũ | Ho Chi Minh | Từ 2026-01-01 đến trước 2026-01-10 |
| Phiên bản mới | Ha Noi | Từ 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.

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ống | Rủi ro | Cách thiết kế |
|---|---|---|
| Timestamp không tăng khi thuộc tính đổi | Không tạo phiên bản mới theo timestamp strategy | Kiểm metadata nguồn hoặc chọn strategy thích hợp |
| Nhiều đổi giữa hai lần chụp | Mất phiên bản trung gian chưa quan sát | Chạ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óa | Thiết kế hard-delete handling đúng phiên bản hoặc dùng soft delete |
| Hai job snapshot cùng ghi | Cạnh tranh và kết quả khó dự đoán | Một writer/quy trình khóa và lịch rõ ràng |
| Đổi unique_key hoặc thay logic nguồn | Lịch sử có thể đổi nghĩa hoặc bị phân mảnh | Review 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.
