← Kỹ sư dữ liệu← Data Engineer
Kỹ sư dữ liệuData Engineer19 Th7, 2026Jul 19, 202624 phút đọc19 min read

Data Modeling & Data WarehousingData Modeling & Warehousing

Thuộc bộ kiến thức Data Engineer Roadmap.

Tổng quan

Data warehouse là kho lưu trữ tập trung, có cấu trúc, được thiết kế cho các truy vấn phân tích — kiểu câu hỏi quét qua nhiều tháng hoặc nhiều năm dữ liệu lịch sử và tổng hợp theo nhiều chiều khác nhau (“tổng doanh thu theo khu vực và danh mục sản phẩm trong 8 quý gần nhất”). Nó đối lập với operational database (một hệ thống OLTP như hệ thống đứng sau trang thanh toán), vốn được tối ưu cho việc đọc/ghi nhanh, phạm vi hẹp trên trạng thái hiện tại của một ứng dụng duy nhất: “giỏ hàng của khách này bây giờ có gì,” “giao dịch thanh toán này có thành công không.”

Sự đối lập này quan trọng vì hai loại workload kéo thiết kế database theo hai hướng ngược nhau:

Khía cạnhOperational database (OLTP)Data warehouse (OLAP)
Mục đíchVận hành nghiệp vụ (một ứng dụng)Hiểu nghiệp vụ (toàn tổ chức)
Kiểu truy vấnNgắn, có index, tra cứu điểm và ghi nhỏChạy lâu, quét toàn bảng, tổng hợp nặng
Phạm vi dữ liệuTrạng thái hiện tạiToàn bộ lịch sử, thường nhiều năm
SchemaChuẩn hóa cao (3NF), tối ưu tránh update anomalyPhi chuẩn hóa (denormalized), tối ưu tốc độ đọc/tổng hợp
Người dùngCode ứng dụng, một service duy nhấtAnalyst, công cụ BI, data scientist, nhiều team
Kiểu cập nhậtThường xuyên, nhỏ, transactional (ACID)Load hàng loạt (batch), theo giờ/ngày, chủ yếu append
Engine ví dụPostgreSQL, MySQL, OracleSnowflake, BigQuery, Redshift, Databricks SQL

Warehouse được nạp dữ liệu bởi các pipeline lấy dữ liệu từ nhiều hệ thống operational (và các nguồn khác — xem ./03-data-sources-and-ingestion.md), biến đổi và đưa vào một schema được xây dựng cho phân tích thay vì cho giao dịch của một ứng dụng cụ thể. Note này trình bày cách thiết kế schema đó: dimensional modeling, star schema so với snowflake schema, slowly changing dimensions, data mart, và vì sao các engine warehouse hiện đại lưu dữ liệu theo cột thay vì theo dòng.

Kiến thức nền tảng

Kiến trúc warehouse: Kimball vs. Inmon

Có hai trường phái định hình cách xây dựng data warehouse, và đáng để biết cả hai dù một trường phái đã thắng thế rõ ràng trong thực tế.

Cách tiếp cận của Bill Inmon (top-down / Corporate Information Factory). Xây dựng một enterprise data warehouse chuẩn hóa cao (3NF) duy nhất trước tiên, mô hình hóa toàn bộ dữ liệu tổ chức như một khối tích hợp. Các data mart theo từng phòng ban sau đó được tạo ra từ warehouse trung tâm này. Cách này mang lại tính nhất quán mạnh và một nguồn sự thật duy nhất, nhưng xây dựng chậm và tốn kém — về cơ bản bạn phải mô hình hóa toàn bộ doanh nghiệp trước khi ai đó nhận được giá trị — và schema chuẩn hóa lại không thuận tiện cho công cụ truy vấn của người dùng cuối và phân tích ad-hoc.

Cách tiếp cận của Ralph Kimball (bottom-up / bus architecture). Xây dựng dimensional model dần dần, từng business process một (đơn hàng, giao hàng, ticket hỗ trợ), mỗi cái được biểu diễn dưới dạng một fact table join với các dimension table dùng chung, có thể tái sử dụng — gọi là conformed dimensions. Bus architecture là kỷ luật giúp việc này mở rộng được: mọi fact table tham chiếu đến “customer” hay “date” đều dùng cùng một dimension customer và date (cùng key, cùng thuộc tính, cùng grain), để một báo cáo có thể join qua nhiều fact table — ví dụ so sánh chi phí marketing với doanh thu bán hàng — và cho ra kết quả nhất quán. Bạn tạo ra giá trị nhanh (từng dimensional model một) thay vì phải chờ một mô hình enterprise hoàn chỉnh, đổi lại phải kỷ luật thực sự về conformance giữa các dimension để warehouse không bị phân mảnh thành các silo không nhất quán.

Trong thực tế, dimensional modeling của Kimball là mô hình chiếm ưu thế hiện nay, kể cả trong các cloud warehouse hiện đại. Ngay cả những tổ chức có xu hướng triết lý theo Inmon (xây một lớp “raw” hoặc “integration” chuẩn hóa trước) hầu như luôn expose một lớp dimensional kiểu Kimball ở trên để phục vụ BI, vì star schema là thứ giúp phân tích self-service ad-hoc và các công cụ BI (Looker, Tableau, Power BI) chạy nhanh và dễ hiểu đối với người không phải kỹ sư. Phần còn lại của note này tập trung vào dimensional modeling theo Kimball, vì đó là thứ bạn sẽ xây dựng hàng ngày.

Fact và dimension

Dimensional modeling tổ chức dữ liệu thành hai loại bảng:

Ví dụ thực tế: fact table bán hàng

Xét một data warehouse bán lẻ. Business process là “một khách hàng mua một sản phẩm tại một cửa hàng vào một ngày.” Grain tự nhiên là một dòng cho mỗi line item trong mỗi giao dịch.

Star schema: fact_sales
fact_salescustomer_key (FK), product_key (FK), store_key (FK), date_key (FK), order_id, quantity, unit_price, discount_amount, net_revenue
  • date_key
    dim_datedate_key (PK), full_date, day_of_week, month / quarter / year, is_holiday
  • customer_key
    dim_customercustomer_key (PK), customer_id, full_name, email, segment, city / region, valid_from / valid_to, is_current
  • product_key
    dim_productproduct_key (PK), product_id, product_name, category, subcategory, brand, unit_cost
  • store_key
    dim_storestore_key (PK), store_id, store_name, city, region

Ở dạng SQL DDL:

CREATE TABLE dim_date (
    date_key      INT PRIMARY KEY,        -- surrogate key, ví dụ 20260719
    full_date     DATE NOT NULL,
    day_of_week   SMALLINT NOT NULL,
    day_name      VARCHAR(10) NOT NULL,
    month         SMALLINT NOT NULL,
    month_name    VARCHAR(10) NOT NULL,
    quarter       SMALLINT NOT NULL,
    year          SMALLINT NOT NULL,
    is_weekend    BOOLEAN NOT NULL,
    is_holiday    BOOLEAN NOT NULL
);

CREATE TABLE dim_customer (
    customer_key  BIGINT PRIMARY KEY,     -- surrogate key
    customer_id   VARCHAR(50) NOT NULL,   -- natural/business key từ hệ thống nguồn
    full_name     VARCHAR(200),
    email         VARCHAR(200),
    segment       VARCHAR(50),
    city          VARCHAR(100),
    region        VARCHAR(100),
    valid_from    DATE NOT NULL,
    valid_to      DATE,
    is_current    BOOLEAN NOT NULL
);

CREATE TABLE dim_product (
    product_key   BIGINT PRIMARY KEY,
    product_id    VARCHAR(50) NOT NULL,
    product_name  VARCHAR(200),
    category      VARCHAR(100),
    subcategory   VARCHAR(100),
    brand         VARCHAR(100),
    unit_cost     NUMERIC(10,2)
);

CREATE TABLE dim_store (
    store_key     BIGINT PRIMARY KEY,
    store_id      VARCHAR(50) NOT NULL,
    store_name    VARCHAR(200),
    city          VARCHAR(100),
    region        VARCHAR(100)
);

CREATE TABLE fact_sales (
    sale_key        BIGINT PRIMARY KEY,       -- surrogate key cho chính dòng fact (tùy chọn)
    customer_key    BIGINT NOT NULL REFERENCES dim_customer(customer_key),
    product_key     BIGINT NOT NULL REFERENCES dim_product(product_key),
    store_key       BIGINT NOT NULL REFERENCES dim_store(store_key),
    date_key        INT    NOT NULL REFERENCES dim_date(date_key),
    order_id        VARCHAR(50) NOT NULL,     -- degenerate dimension: không cần dim table riêng
    quantity        INT NOT NULL,
    unit_price      NUMERIC(10,2) NOT NULL,
    discount_amount NUMERIC(10,2) DEFAULT 0,
    net_revenue     NUMERIC(12,2) NOT NULL     -- measure có tính additive
);

Chú ý hai điều thường gặp trong các mô hình thực tế. Thứ nhất, order_id nằm trực tiếp trên fact table mà không có dimension riêng — đây là một degenerate dimension: một identifier hữu ích để group hoặc drill-through (“cho tôi xem mọi line item của đơn hàng này”) nhưng không mang thuộc tính mô tả nào đáng để tách thành bảng riêng. Thứ hai, mọi join key đều là surrogate key (customer_key, product_key — số nguyên vô nghĩa do warehouse sinh ra), không phải natural/business key từ hệ thống nguồn (customer_id). Surrogate key tách rời warehouse khỏi các thay đổi key ở hệ thống nguồn và, quan trọng hơn, chính là thứ giúp Slowly Changing Dimensions khả thi, vì cùng một khách hàng có thể có nhiều surrogate key đại diện cho các phiên bản khác nhau của khách hàng đó theo thời gian.

Một truy vấn phân tích tiêu biểu trên schema này:

SELECT
    d.year,
    d.quarter,
    p.category,
    r.region,
    SUM(f.net_revenue)      AS total_revenue,
    SUM(f.quantity)         AS total_units
FROM fact_sales f
JOIN dim_date     d ON f.date_key = d.date_key
JOIN dim_product  p ON f.product_key = p.product_key
JOIN dim_store    r ON f.store_key = r.store_key
WHERE d.year = 2026
GROUP BY d.year, d.quarter, p.category, r.region
ORDER BY total_revenue DESC;

Một fact table, vài lượt join, một GROUP BY — đây chính xác là dạng truy vấn mà dimensional modeling được thiết kế để làm cho nhanh và dễ đọc.

Khái niệm chính

Star schema vs. snowflake schema

Ví dụ ở trên là một star schema: fact table nằm ở giữa, và mỗi dimension là một bảng đơn, phi chuẩn hóa (denormalized), join trực tiếp với fact table — vẽ ra thì trông giống một ngôi sao với fact table là trung tâm. Denormalized nghĩa là dim_product lặp lại text categorysubcategory trên mọi dòng sản phẩm thay vì chuẩn hóa chúng thành các bảng dim_category / dim_subcategory riêng.

Snowflake schema lấy cùng mô hình đó và chuẩn hóa dimension sâu hơn: dim_product sẽ tham chiếu đến một bảng dim_category, bảng này có thể tham chiếu đến dim_department, và cứ thế — mỗi dimension “phân nhánh” (snowflake) ra thành các sub-dimension, giảm dư thừa dữ liệu nhưng đổi lại phải join thêm.

-- Phiên bản snowflake của dim_product
CREATE TABLE dim_category (
    category_key   INT PRIMARY KEY,
    category_name  VARCHAR(100),
    department_key INT REFERENCES dim_department(department_key)
);

CREATE TABLE dim_product_snowflake (
    product_key   BIGINT PRIMARY KEY,
    product_id    VARCHAR(50) NOT NULL,
    product_name  VARCHAR(200),
    category_key  INT REFERENCES dim_category(category_key),  -- đã chuẩn hóa ra
    brand_key     INT REFERENCES dim_brand(brand_key),        -- đã chuẩn hóa ra
    unit_cost     NUMERIC(10,2)
);

-- Truy vấn giờ cần thêm một bước join để lấy tên category
SELECT c.category_name, SUM(f.net_revenue)
FROM fact_sales f
JOIN dim_product_snowflake p ON f.product_key = p.product_key
JOIN dim_category c ON p.category_key = c.category_key
GROUP BY c.category_name;
Khía cạnhStar schemaSnowflake schema
Cấu trúc dimensionPhi chuẩn hóa (phẳng, rộng)Chuẩn hóa thành sub-dimension
Số lượt join từ fact đến thuộc tínhMột bướcNhiều bước
StorageDư thừa nhiều hơn (lặp lại giá trị text)Ít dư thừa hơn
Độ phức tạp truy vấnSQL đơn giản hơn, ít join hơnNhiều join hơn, SQL phức tạp hơn
Tốc độ truy vấn (engine row-oriented)Thường nhanh hơnCó thể chậm hơn (overhead do join thêm)
Tốc độ truy vấn (engine columnar)Nhanh; chi phí storage của dư thừa được giảm thiểu nhờ compressionCũng nhanh, nhưng join thêm hiếm khi đáng giá
Độ phức tạp ETL/loadLoad và cập nhật đơn giản hơnNhiều bảng hơn cần duy trì và đồng bộ
Phù hợp nhất choBI ad-hoc, self-service analytics, hầu hết cloud warehouse hiện đạiDimension rất lớn, có tính phân cấp thực sự, hiếm thay đổi; hệ thống bị giới hạn storage

Trong các cloud warehouse columnar hiện đại (Snowflake, BigQuery, Redshift), star schema thường được ưu tiên hơn. Storage rẻ và nén rất tốt với text dimension lặp lại (xem phần dưới), nên chi phí dư thừa của denormalization là nhỏ, trong khi lợi ích về đơn giản khi join và tốc độ truy vấn lại lớn và rõ ràng ngay lập tức — cả với người viết SQL lẫn với công cụ BI tự sinh SQL. Snowflaking vẫn xuất hiện với các dimension thực sự khổng lồ, phân cấp (ví dụ nhà bán lẻ với taxonomy sản phẩm sâu), hoặc trong các hệ thống on-premise row-oriented nơi storage từng là nút thắt cổ chai — nhưng đó là ngoại lệ chứ không còn là lựa chọn mặc định ngày nay.

Slowly Changing Dimensions (SCD)

Các thuộc tính dimension được gọi là “slowly changing” vì, khác với fact, chúng vẫn thay đổi — chỉ là không thường xuyên và không đều đặn. Vấn đề kinh điển: một khách hàng chuyển từ Hà Nội vào TP.HCM. Bạn ghi đè giá trị city, làm mất đi thông tin rằng mọi giao dịch trong quá khứ thực ra được thực hiện khi khách hàng đó còn sống ở Hà Nội? Hay bạn cần giữ lại lịch sử đó để báo cáo lịch sử chính xác (“cho xem doanh thu theo thành phố của khách hàng tại thời điểm mỗi giao dịch”)? Câu trả lời phụ thuộc vào câu hỏi nghiệp vụ, và các thuộc tính khác nhau trên cùng một dimension thậm chí có thể dùng chiến lược khác nhau. Các loại SCD của Kimball là bộ từ vựng chuẩn cho quyết định này.

Type 0 — Giữ nguyên gốc. Thuộc tính không bao giờ thay đổi sau khi được load, theo thiết kế (ví dụ: ngày đăng ký ban đầu hoặc kênh thu hút khách hàng ban đầu). Mọi cập nhật từ nguồn đều bị bỏ qua.

Type 1 — Ghi đè (overwrite). Giá trị mới thay thế giá trị cũ tại chỗ; không giữ lịch sử. Đơn giản nhất để triển khai, phù hợp khi giá trị cũ không có giá trị phân tích (sửa lỗi chính tả trong tên) hoặc nghiệp vụ chỉ thực sự quan tâm đến trạng thái hiện tại.

-- SCD Type 1: ghi đè tại chỗ
UPDATE dim_customer
SET city = 'Ho Chi Minh City', region = 'South'
WHERE customer_id = 'CUST-00123';

Mọi dòng fact từng join với khách hàng này giờ sẽ báo cáo “Ho Chi Minh City,” kể cả các giao dịch thực hiện khi khách hàng còn sống ở Hà Nội. Lịch sử bị mất.

Type 2 — Thêm dòng mới (phổ biến nhất trong thực tế). Thay vì ghi đè, chèn một dòng dimension mới đại diện cho phiên bản mới của khách hàng, và làm hết hiệu lực (expire) dòng cũ. Mỗi phiên bản có surrogate key riêng, cộng với valid_from / valid_to (hoặc effective_date / expiration_date) và cờ is_current. Các dòng fact được load về sau sẽ join với surrogate key mới; các dòng fact đã load trong quá khứ vẫn trỏ đến surrogate key cũ, nên báo cáo lịch sử tự nhiên phản ánh đúng “thành phố của khách hàng tại thời điểm giao dịch.”

-- SCD Type 2: expire dòng cũ, insert dòng mới
BEGIN;

UPDATE dim_customer
SET valid_to   = CURRENT_DATE - INTERVAL '1 day',
    is_current = FALSE
WHERE customer_id = 'CUST-00123'
  AND is_current = TRUE;

INSERT INTO dim_customer
    (customer_key, customer_id, full_name, email, segment, city, region, valid_from, valid_to, is_current)
VALUES
    (nextval('customer_key_seq'), 'CUST-00123', 'Nguyen Van A', 'a@example.com', 'Retail',
     'Ho Chi Minh City', 'South', CURRENT_DATE, NULL, TRUE);

COMMIT;
-- Truy vấn trạng thái "hiện tại" chỉ là một filter
SELECT * FROM dim_customer WHERE is_current = TRUE;

-- Truy vấn "tại một thời điểm trong quá khứ" (gán đúng thuộc tính lịch sử)
SELECT * FROM dim_customer
WHERE customer_id = 'CUST-00123'
  AND '2025-03-15' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31');

Type 2 là “cỗ máy chính” của dimensional modeling chính vì nó cho bạn cả hai góc nhìn miễn phí: join fact table với dim_customer theo surrogate key được lưu tại thời điểm load, bạn tự động có được thuộc tính đúng theo thời điểm (point-in-time-correct); lọc dimension theo is_current = TRUE, bạn có góc nhìn hiện tại cho báo cáo kiểu operational.

Type 3 — Thêm cột mới. Thay vì thêm dòng, thêm một cột như previous_city bên cạnh current_city. Cách này chỉ giữ đúng một giá trị trước đó — hữu ích cho so sánh “hiện tại vs. trước đây” (ví dụ: “khu vực trước đây vs. hiện tại của nhân viên bán hàng”) nhưng không thể ghi nhận nhiều hơn một lần thay đổi, nên hiếm khi được dùng ngoài một số thuộc tính đặc thù.

ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);

UPDATE dim_customer
SET previous_city = city,
    city = 'Ho Chi Minh City'
WHERE customer_id = 'CUST-00123';
TypeCơ chếLịch sử được giữĐộ phức tạpỨng dụng điển hình
Type 0Không bao giờ cập nhậtToàn bộ (đóng băng tại thời điểm load)Rất đơn giảnNgày đăng ký gốc, ngày sinh
Type 1Ghi đèKhông cóThấpSửa lỗi chính tả, thuộc tính không có giá trị lịch sử về phân tích
Type 2Thêm dòng + valid_from/valid_to/is_currentToàn bộTrung bìnhĐịa chỉ, segment, khu vực bán hàng — lựa chọn mặc định
Type 3Thêm cột “giá trị trước đó”Chỉ một giá trị trước đóThấp-trung bìnhHiếm gặp — so sánh giới hạn kiểu “manager trước đây”

Các chiến lược kết hợp (Type 6 = kết hợp 1+2+3) tồn tại cho các trường hợp nâng cao nhưng nằm ngoài phạm vi hầu hết team cần hàng ngày; hiểu khi nào dùng Type 1 vs. Type 2 đã bao phủ phần lớn các thiết kế thực tế.

Data mart

Data mart là một tập con của warehouse, được giới hạn phạm vi cho một chức năng nghiệp vụ hoặc phòng ban — mart marketing với fact về chiến dịch và attribution, mart tài chính với fact về sổ cái tổng hợp (GL) và ngân sách, mart hỗ trợ khách hàng với fact về ticket. Mart tồn tại vì ba lý do thực tiễn: hiệu năng (một mô hình nhỏ hơn, xây có mục đích, chỉ chứa fact và dimension liên quan sẽ truy vấn nhanh và rẻ hơn so với quét toàn bộ enterprise warehouse), kiểm soát truy cập (dữ liệu tài chính có thể bị giới hạn chỉ trong mart tài chính mà không cần giới hạn toàn bộ warehouse), và tính tự chủ của team (team phân tích marketing có thể tự phát triển schema của mart riêng mà không cần phối hợp với mọi team khác).

Đánh đổi ở đây là rủi ro về tính nhất quán. Nếu mỗi mart được xây dựng độc lập, với dimension “customer” hay “date” tự chế riêng, bạn sẽ gặp phải lỗi kinh điển trong BI khi con số doanh thu của tài chính và của marketing không khớp nhau, và việc đối soát chúng tốn hàng tuần công sức của analyst. Câu trả lời của Kimball chính là bus architecture đã đề cập ở trên: mart nên được xây dựng từ các conformed dimension dùng chung trên toàn warehouse, chứ không phải được phát minh độc lập cho từng mart. Một mart, nếu làm đúng, đơn giản chỉ là một view được chọn lọc, phân quyền (hoặc một tập con được vật lý hóa) trên cùng dim_customer, dim_date, dim_product conformed được dùng ở mọi nơi khác — không phải một vũ trụ song song các dimension.

Columnar storage trong các warehouse hiện đại

Database OLTP truyền thống lưu dữ liệu theo row-oriented: mọi cột của một dòng được lưu liền kề nhau trên đĩa, vì workload OLTP đọc và ghi cả dòng cùng lúc (chèn một đơn hàng, lấy một khách hàng). Các warehouse phân tích (Snowflake, BigQuery, Redshift, Databricks/Delta Lake, ClickHouse) lưu dữ liệu theo column-oriented: mọi giá trị của một cột, trên tất cả các dòng, được lưu liền kề nhau thay vào đó.

Điều này quan trọng vì hai lý do gắn trực tiếp với dạng truy vấn đã nêu ở trên:

  1. Chỉ đọc những gì cần. Truy vấn SELECT SUM(net_revenue) FROM fact_sales WHERE date_key BETWEEN ... GROUP BY category chỉ chạm đến ba hoặc bốn trong số hơn chục cột của fact_sales. Một row store vẫn phải đọc mọi cột của mọi dòng mà nó quét từ đĩa (hoặc ít nhất mọi cột trong khối liền kề của dòng đó) trước khi loại bỏ những cột không cần. Một column store chỉ đọc các cột net_revenue, date_key, product_key (và bất cứ thứ gì category join phân giải ra) từ đĩa — thường giảm 5-10 lần hoặc hơn số byte cần quét đối với các fact table rộng điển hình, điều này chuyển trực tiếp thành chi phí và độ trễ thấp hơn ở các warehouse tính phí theo byte quét (BigQuery) hoặc theo giây compute (Snowflake, Redshift).
  2. Nén tốt hơn. Một cột chứa một kiểu dữ liệu lặp lại và, thường, một số lượng giá trị khác biệt nhỏ — region có thể có 5 chuỗi khác biệt trên hàng tỷ dòng, is_weekend là boolean, category lặp lại vài trăm giá trị liên tục. Lưu các giá trị này cùng nhau cho phép engine áp dụng các kỹ thuật như dictionary encoding, run-length encoding, và delta encoding hiệu quả hơn nhiều so với việc nén một dòng xen kẽ giữa string, integer, timestamp, và decimal. Đây cũng là lý do vì sao chi phí dư thừa storage của các cột text denormalized trong star schema ít gây đau đầu hơn nhiều trong warehouse columnar so với trong row store — chuỗi category lặp lại được nén xuống gần như không đáng kể.

Columnar storage là một lý do lớn khiến star schema, chứ không phải snowflake schema, là khuyến nghị mặc định trong các warehouse hiện đại: lập luận truyền thống ủng hộ snowflaking (tiết kiệm storage bằng cách chuẩn hóa loại bỏ text lặp lại) không còn quan trọng nhiều khi engine đã nén sự lặp lại đó đi rồi, trong khi các join thêm mà snowflake schema đòi hỏi lại là bất lợi thuần túy.

Sự trỗi dậy của ELT so với ETL

Trước đây, quá trình biến đổi diễn ra trước khi load (ETL): extract từ nguồn, transform trên một lớp compute riêng, sau đó load dữ liệu đã sạch, đã được mô hình hóa vào warehouse. Khi compute của warehouse trở nên rẻ, co giãn, và có thể scale tách biệt với storage (đặc điểm định hình của Snowflake, BigQuery, và các engine tương tự), ngành công nghiệp chuyển sang ELT: extract từ nguồn, load dữ liệu thô/gần thô vào warehouse trước, rồi transform bên trong warehouse bằng chính compute của warehouse (thường dùng công cụ như dbt). Cách này giữ lại lịch sử thô để có thể xử lý lại, đưa logic transform vào SQL được version-control chạy ngay nơi dữ liệu đã tồn tại, và tránh phải duy trì một cluster transform riêng. Sự chuyển đổi này được trình bày sâu hơn trong ./08-etl-elt-and-data-pipelines.md; các mẫu mô hình hóa trong note này (fact, dimension, SCD) chính xác là những gì lớp transform ELT xây dựng bên trong warehouse.

Best Practices

Tài liệu tham khảo

Bài viết liên quan: ./04-relational-databases.md · ./07-data-lakes-and-modern-architectures.md · ./08-etl-elt-and-data-pipelines.md · ./13-cloud-data-platforms.md

Part of the Data Engineer Roadmap knowledge base.

Overview

A data warehouse is a centralized, structured store designed for analytical queries — the kind of question that scans months or years of history and aggregates it across many dimensions (“total revenue by region and product category for the last eight quarters”). It is the opposite of an operational database (an OLTP system like the one behind a checkout page), which is optimized for fast, narrow reads and writes on the current state of a single application: “what is this customer’s cart right now,” “did this payment succeed.”

The contrast matters because the two workloads pull database design in opposite directions:

DimensionOperational database (OLTP)Data warehouse (OLAP)
PurposeRun the business (one application)Understand the business (across the org)
Query patternShort, indexed, point lookups and small writesLong-running, full-table scans, heavy aggregation
Data scopeCurrent stateFull history, often years
SchemaHighly normalized (3NF), optimized to avoid update anomaliesDenormalized, optimized for read/aggregate speed
UsersApplication code, a single serviceAnalysts, BI tools, data scientists, many teams
Update patternFrequent, small, transactional (ACID)Bulk, batch loads (hourly/daily), append-heavy
Example enginesPostgreSQL, MySQL, OracleSnowflake, BigQuery, Redshift, Databricks SQL

A warehouse is fed by pipelines that pull data out of many operational systems (and other sources — see ./03-data-sources-and-ingestion.md), reshape it, and land it in a schema built for analysis rather than for any one application’s transactions. This note covers how that schema is designed: dimensional modeling, star vs. snowflake schemas, slowly changing dimensions, data marts, and why modern warehouse engines store data column-by-column instead of row-by-row.

Fundamentals

Warehouse architecture: Kimball vs. Inmon

Two schools of thought shaped how data warehouses are built, and it’s worth knowing both even though one clearly won in practice.

Bill Inmon’s approach (top-down / Corporate Information Factory). Build a single, highly normalized (3NF) enterprise data warehouse first, modeling the entire organization’s data as one integrated whole. Department-specific data marts are then derived from this central warehouse. This gives strong consistency and a single source of truth, but it is slow and expensive to build — you effectively have to model the whole enterprise before anyone gets value — and normalized schemas are awkward for end-user query tools and ad-hoc analysis.

Ralph Kimball’s approach (bottom-up / bus architecture). Build dimensional models incrementally, one business process at a time (orders, shipments, support tickets), each exposed as a fact table joined to shared, reusable dimension tables — the “conformed dimensions.” The bus architecture is the discipline that makes this scale: every fact table that references “customer” or “date” uses the same customer and date dimension (same keys, same attributes, same grain), so that a report can join across fact tables — say, comparing marketing spend to sales revenue — and get consistent answers. You deliver value fast (one dimensional model at a time) instead of waiting for a full enterprise model, at the cost of needing real discipline around dimension conformance so the warehouse doesn’t fragment into inconsistent silos.

In practice, Kimball’s dimensional modeling is the dominant pattern used today, including inside modern cloud warehouses. Even organizations that philosophically lean Inmon (build a normalized “raw” or “integration” layer first) almost always expose a Kimball-style dimensional layer on top for BI consumption, because star schemas are what makes ad-hoc self-service analytics and BI tools (Looker, Tableau, Power BI) fast and understandable to non-engineers. The rest of this note focuses on Kimball’s dimensional modeling, since that’s what you will build day to day.

Facts and dimensions

Dimensional modeling organizes data into two kinds of tables:

Worked example: a sales fact table

Consider a retail sales warehouse. The business process is “a customer buys a product at a store on a date.” The natural grain is one row per line item per transaction.

Star schema: fact_sales
fact_salescustomer_key (FK), product_key (FK), store_key (FK), date_key (FK), order_id, quantity, unit_price, discount_amount, net_revenue
  • date_key
    dim_datedate_key (PK), full_date, day_of_week, month / quarter / year, is_holiday
  • customer_key
    dim_customercustomer_key (PK), customer_id, full_name, email, segment, city / region, valid_from / valid_to, is_current
  • product_key
    dim_productproduct_key (PK), product_id, product_name, category, subcategory, brand, unit_cost
  • store_key
    dim_storestore_key (PK), store_id, store_name, city, region

As SQL DDL:

CREATE TABLE dim_date (
    date_key      INT PRIMARY KEY,        -- surrogate key, e.g. 20260719
    full_date     DATE NOT NULL,
    day_of_week   SMALLINT NOT NULL,
    day_name      VARCHAR(10) NOT NULL,
    month         SMALLINT NOT NULL,
    month_name    VARCHAR(10) NOT NULL,
    quarter       SMALLINT NOT NULL,
    year          SMALLINT NOT NULL,
    is_weekend    BOOLEAN NOT NULL,
    is_holiday    BOOLEAN NOT NULL
);

CREATE TABLE dim_customer (
    customer_key  BIGINT PRIMARY KEY,     -- surrogate key
    customer_id   VARCHAR(50) NOT NULL,   -- natural/business key from source system
    full_name     VARCHAR(200),
    email         VARCHAR(200),
    segment       VARCHAR(50),
    city          VARCHAR(100),
    region        VARCHAR(100),
    valid_from    DATE NOT NULL,
    valid_to      DATE,
    is_current    BOOLEAN NOT NULL
);

CREATE TABLE dim_product (
    product_key   BIGINT PRIMARY KEY,
    product_id    VARCHAR(50) NOT NULL,
    product_name  VARCHAR(200),
    category      VARCHAR(100),
    subcategory   VARCHAR(100),
    brand         VARCHAR(100),
    unit_cost     NUMERIC(10,2)
);

CREATE TABLE dim_store (
    store_key     BIGINT PRIMARY KEY,
    store_id      VARCHAR(50) NOT NULL,
    store_name    VARCHAR(200),
    city          VARCHAR(100),
    region        VARCHAR(100)
);

CREATE TABLE fact_sales (
    sale_key        BIGINT PRIMARY KEY,       -- surrogate key for the fact row itself (optional)
    customer_key    BIGINT NOT NULL REFERENCES dim_customer(customer_key),
    product_key     BIGINT NOT NULL REFERENCES dim_product(product_key),
    store_key       BIGINT NOT NULL REFERENCES dim_store(store_key),
    date_key        INT    NOT NULL REFERENCES dim_date(date_key),
    order_id        VARCHAR(50) NOT NULL,     -- degenerate dimension: no dim table needed
    quantity        INT NOT NULL,
    unit_price      NUMERIC(10,2) NOT NULL,
    discount_amount NUMERIC(10,2) DEFAULT 0,
    net_revenue     NUMERIC(12,2) NOT NULL     -- additive measure
);

Notice two things that come up constantly in real models. First, order_id sits directly on the fact table with no dimension of its own — this is a degenerate dimension: an identifier that’s useful for grouping or drill-through (“show me every line item on this order”) but carries no descriptive attributes worth a separate table. Second, every join key is a surrogate key (customer_key, product_key — meaningless integers generated by the warehouse), not the natural/business key from the source system (customer_id). Surrogate keys decouple the warehouse from source-system key changes and, critically, are what make Slowly Changing Dimensions possible, since the same customer can have multiple surrogate keys representing different versions of that customer over time.

A typical analytical query against this schema:

SELECT
    d.year,
    d.quarter,
    p.category,
    r.region,
    SUM(f.net_revenue)      AS total_revenue,
    SUM(f.quantity)         AS total_units
FROM fact_sales f
JOIN dim_date     d ON f.date_key = d.date_key
JOIN dim_product  p ON f.product_key = p.product_key
JOIN dim_store    r ON f.store_key = r.store_key
WHERE d.year = 2026
GROUP BY d.year, d.quarter, p.category, r.region
ORDER BY total_revenue DESC;

One fact table, a handful of joins, one GROUP BY — this is exactly the query shape dimensional modeling is built to make fast and readable.

Key Concepts

Star schema vs. snowflake schema

The example above is a star schema: the fact table sits in the middle, and each dimension is a single, denormalized table directly joined to it — draw it out and it looks like a star with the fact table as the hub. Denormalized means dim_product repeats category and subcategory text on every product row rather than normalizing them into separate dim_category / dim_subcategory tables.

A snowflake schema takes the same model and normalizes the dimensions further: dim_product would reference a dim_category table, which might reference a dim_department table, and so on — each dimension “snowflakes” out into sub-dimensions, reducing redundancy at the cost of extra joins.

-- Snowflaked version of dim_product
CREATE TABLE dim_category (
    category_key   INT PRIMARY KEY,
    category_name  VARCHAR(100),
    department_key INT REFERENCES dim_department(department_key)
);

CREATE TABLE dim_product_snowflake (
    product_key   BIGINT PRIMARY KEY,
    product_id    VARCHAR(50) NOT NULL,
    product_name  VARCHAR(200),
    category_key  INT REFERENCES dim_category(category_key),  -- normalized out
    brand_key     INT REFERENCES dim_brand(brand_key),        -- normalized out
    unit_cost     NUMERIC(10,2)
);

-- Query now needs an extra hop to get category name
SELECT c.category_name, SUM(f.net_revenue)
FROM fact_sales f
JOIN dim_product_snowflake p ON f.product_key = p.product_key
JOIN dim_category c ON p.category_key = c.category_key
GROUP BY c.category_name;
AspectStar schemaSnowflake schema
Dimension structureDenormalized (flat, wide)Normalized into sub-dimensions
Joins from fact to attributeOne hopMultiple hops
StorageMore redundancy (repeated text values)Less redundancy
Query complexitySimpler SQL, fewer joinsMore joins, more complex SQL
Query speed (row-oriented engines)Generally fasterCan be slower (extra join overhead)
Query speed (columnar engines)Fast; storage overhead of redundancy is minimized by compressionAlso fast, but the extra joins rarely pay off
ETL/load complexitySimpler to load and updateMore tables to maintain and keep in sync
Best forAd-hoc BI, self-service analytics, most modern cloud warehousesVery large dimensions with genuinely hierarchical, rarely-changing attributes; storage-constrained systems

In modern columnar cloud warehouses (Snowflake, BigQuery, Redshift), the star schema is generally preferred. Storage is cheap and compresses very well for repeated dimension text (see below), so the redundancy cost of denormalization is small, while the join-simplicity and query-speed benefits are large and immediate — both for humans writing SQL and for BI tools generating SQL automatically. Snowflaking still shows up for genuinely huge, hierarchical dimensions (e.g., a retailer with a deep product taxonomy) or in on-premise row-oriented systems where storage was historically the bottleneck, but it is the exception rather than the default today.

Slowly Changing Dimensions (SCD)

Dimension attributes are called “slowly changing” because, unlike facts, they do change — just infrequently and irregularly. The canonical problem: a customer moves from Hanoi to Ho Chi Minh City. Do you overwrite their city value, losing the fact that every past sale was actually made while they lived in Hanoi? Or do you need to preserve that history for accurate historical reporting (“show revenue by customer city at the time of each sale”)? The answer depends on the business question, and different attributes on the same dimension can even use different strategies. Kimball’s SCD types are the standard vocabulary for this decision.

Type 0 — Retain original. The attribute never changes once loaded, by design (e.g., a customer’s original signup date or original acquisition channel). Any update from the source is ignored.

Type 1 — Overwrite. The new value replaces the old value in place; no history is kept. Simplest to implement, appropriate when the old value has no analytical value (correcting a typo in a name) or the business genuinely only cares about the current state.

-- SCD Type 1: overwrite in place
UPDATE dim_customer
SET city = 'Ho Chi Minh City', region = 'South'
WHERE customer_id = 'CUST-00123';

Every fact row ever joined to this customer will now report “Ho Chi Minh City,” even sales made while the customer lived in Hanoi. History is lost.

Type 2 — Add a new row (the most common in practice). Instead of overwriting, insert a new dimension row representing the new version of the customer, and expire the old row. Each version gets its own surrogate key, plus valid_from / valid_to (or effective_date / expiration_date) and an is_current flag. Fact rows loaded going forward join to the new surrogate key; fact rows loaded in the past keep pointing at the old surrogate key, so historical reports naturally reflect “the customer’s city at the time of the sale.”

-- SCD Type 2: expire the old row, insert a new one
BEGIN;

UPDATE dim_customer
SET valid_to   = CURRENT_DATE - INTERVAL '1 day',
    is_current = FALSE
WHERE customer_id = 'CUST-00123'
  AND is_current = TRUE;

INSERT INTO dim_customer
    (customer_key, customer_id, full_name, email, segment, city, region, valid_from, valid_to, is_current)
VALUES
    (nextval('customer_key_seq'), 'CUST-00123', 'Nguyen Van A', 'a@example.com', 'Retail',
     'Ho Chi Minh City', 'South', CURRENT_DATE, NULL, TRUE);

COMMIT;
-- Querying "current" state is just a filter
SELECT * FROM dim_customer WHERE is_current = TRUE;

-- Querying "as of a point in time" (correct historical attribution)
SELECT * FROM dim_customer
WHERE customer_id = 'CUST-00123'
  AND '2025-03-15' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31');

Type 2 is the workhorse of dimensional modeling precisely because it gives you both views for free: join a fact table to dim_customer on the surrogate key stored at load time, and you automatically get point-in-time-correct attributes; filter dimension by is_current = TRUE, and you get today’s view for operational-style reporting.

Type 3 — Add a new column. Instead of a new row, add a column like previous_city alongside current_city. This keeps exactly one prior value — useful for “current vs. previous” comparisons (e.g., “sales rep’s previous vs. current territory”) but cannot capture more than one change, so it’s rarely used beyond a handful of niche attributes.

ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);

UPDATE dim_customer
SET previous_city = city,
    city = 'Ho Chi Minh City'
WHERE customer_id = 'CUST-00123';
TypeMechanismHistory preservedComplexityTypical use
Type 0Never updateFull (frozen at load)TrivialOriginal signup date, birth date
Type 1OverwriteNoneLowTypo fixes, attributes with no analytical history value
Type 2New row + valid_from/valid_to/is_currentFullMediumAddress, segment, sales territory — the default choice
Type 3New “previous value” columnOne prior value onlyLow-mediumRare — limited comparisons like “previous manager”

Mixed strategies (Type 6 = combining 1+2+3) exist for advanced cases but are outside what most teams need day to day; understanding when Type 1 vs. Type 2 applies covers the overwhelming majority of real designs.

Data marts

A data mart is a subset of the warehouse scoped to one business function or department — a marketing mart with campaign and attribution facts, a finance mart with GL and budget facts, a support mart with ticket facts. Marts exist for three practical reasons: performance (a smaller, purpose-built model with only the relevant facts and dimensions is faster and cheaper to query than scanning the entire enterprise warehouse), access control (finance data can be restricted to the finance mart without restricting the whole warehouse), and team autonomy (a marketing analytics team can iterate on its own mart’s schema without coordinating with every other team).

The tradeoff is consistency risk. If each mart is built independently, with its own bespoke “customer” or “date” dimension, you get the classic BI failure mode where finance’s revenue number and marketing’s revenue number don’t match, and reconciling them consumes weeks of analyst time. Kimball’s answer is the bus architecture covered earlier: marts should be built from conformed dimensions shared across the whole warehouse, not invented independently per mart. A mart, done right, is simply a curated, permissioned view (or a physically materialized subset) over the same conformed dim_customer, dim_date, dim_product used everywhere else — not a parallel universe of dimensions.

Columnar storage in modern warehouses

Traditional OLTP databases store data row-oriented: all columns of one row are stored contiguously on disk, because OLTP workloads read and write whole rows at a time (insert one order, fetch one customer). Analytical warehouses (Snowflake, BigQuery, Redshift, Databricks/Delta Lake, ClickHouse) store data column-oriented: all values of one column, across every row, are stored contiguously instead.

This matters for two reasons that map directly onto the query shape shown earlier:

  1. Reading only what you need. The query SELECT SUM(net_revenue) FROM fact_sales WHERE date_key BETWEEN ... GROUP BY category only touches three or four of fact_sales’s dozen-plus columns. A row store must still read every column of every row it scans off disk (or at least every column in the row’s contiguous block) before discarding the ones it doesn’t need. A column store reads only the net_revenue, date_key, product_key (and whatever category join resolves to) columns from disk — often a 5-10x or more reduction in bytes scanned for typical wide fact tables, which translates directly into lower cost and latency in warehouses that bill per byte scanned (BigQuery) or per compute-second (Snowflake, Redshift).
  2. Better compression. A column holds one repeated data type and, often, a small number of distinct values — region might have 5 distinct strings across a billion rows, is_weekend is boolean, category repeats a few hundred values constantly. Storing these values together lets the engine apply techniques like dictionary encoding, run-length encoding, and delta encoding far more effectively than compressing a row that interleaves a string, an integer, a timestamp, and a decimal. This is also why the storage-redundancy cost of a star schema’s denormalized text columns is much less painful in a columnar warehouse than it would be in a row store — the repeated category string compresses down to almost nothing.

Columnar storage is a major reason star schemas rather than snowflake schemas are the default recommendation in modern warehouses: the traditional argument for snowflaking (save storage by normalizing out repeated text) matters much less when the engine already compresses that repetition away, while the extra joins a snowflake schema requires are pure downside.

ELT over ETL

Historically, transformation happened before loading (ETL): extract from sources, transform on a separate compute layer, then load already-clean, already-modeled data into the warehouse. As warehouse compute became cheap, elastic, and separately scalable from storage (the defining trait of Snowflake, BigQuery, and similar engines), the industry shifted toward ELT: extract from sources, load raw/near-raw data into the warehouse first, then transform it inside the warehouse using the warehouse’s own compute (commonly with a tool like dbt). This keeps raw history available for reprocessing, pushes transformation logic into version-controlled SQL that runs where the data already lives, and avoids maintaining a separate transformation cluster. This shift is covered in depth in ./08-etl-elt-and-data-pipelines.md; the modeling patterns in this note (facts, dimensions, SCDs) are exactly what ELT transformation layers build inside the warehouse.

Best Practices

References

Related notes: ./04-relational-databases.md · ./07-data-lakes-and-modern-architectures.md · ./08-etl-elt-and-data-pipelines.md · ./13-cloud-data-platforms.md