未记录事实核验 · 状态:维护中
记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。
warning 适用边界与维护说明
本文适合有 dbt 项目经验的读者用作电商数仓分层建模骨架,不是可直接复制运行并通过全部测试的完整工程。运行前须准备与示例 SQL、增量策略兼容的仓库和适配器、符合字段契约的原始表,并补齐必需的客户分层 seed 等未展示材料。首单及客户累计指标只覆盖加载窗口;订单与明细过滤范围尚未对齐,增量模型不能可靠处理独立的明细更新、历史首单变化、七天以前的收入变更或有效订单全部退出后的旧汇总。收入字段须依据实际折扣、税费、退款及支付契约解释,不能直接视为财务净收入;支付方式分布、产品分类分析等需求尚未实现。本文仅经结构审阅,未作外部事实核验或运行验证。
dbt 项目实战 - 电商数据仓库完整构建
abstract 实战项目说明
本文通过一个电商数据仓库项目骨架,把前面学的所有知识串联起来;完整运行仍需补齐缺失材料并处理维护说明中的适用边界。 场景:某电商平台的数据分析团队,需要构建一个可靠的数据仓库,支撑 BI 报表和用户画像分析。 覆盖:source 定义 → staging 清洗 → intermediate 关联 → marts 聚合 → 测试 → 文档
项目背景
某电商平台有以下原始数据:
| 数据库 | 表 | 描述 |
|---|---|---|
| shopify | orders | 订单主表 |
| shopify | order_items | 订单商品明细 |
| shopify | customers | 客户信息 |
| shopify | products | 产品信息 |
| stripe | payments | 支付记录 |
业务分析需求:
- 每日/每月 GMV 和订单量
- 客户分层(新客、回客、高价值客户)
- 产品品类销售分析
- 支付方式分布
第一步:项目初始化
# 创建项目
dbt init ecommerce_analytics
cd ecommerce_analytics
# 创建目录结构
mkdir -p models/staging/shopify
mkdir -p models/staging/stripe
mkdir -p models/intermediate
mkdir -p models/marts/core
mkdir -p models/marts/finance
mkdir -p seeds
mkdir -p snapshots
mkdir -p macros
dbt_project.yml 配置
# dbt_project.yml
name: 'ecommerce_analytics'
version: '1.0.0'
config-version: 2
profile: 'ecommerce_analytics'
model-paths: ["models"]
seed-paths: ["seeds"]
test-paths: ["tests"]
macro-paths: ["macros"]
snapshot-paths: ["snapshots"]
target-path: "target"
clean-targets: ["target", "dbt_packages"]
vars:
# 数据起始日期(控制历史数据加载范围)
data_start_date: '2022-01-01'
# 测试环境只处理最近 30 天
is_test_run: false
models:
ecommerce_analytics:
staging:
+materialized: view
+schema: staging
intermediate:
+materialized: view
+schema: intermediate
marts:
+materialized: table
core:
+schema: core
finance:
+schema: finance
packages.yml
# packages.yml
packages:
- package: dbt-labs/dbt_utils
version: [">=1.2.0", "<2.0.0"]
- package: calogica/dbt_expectations
version: [">=0.10.0", "<0.11.0"]
dbt deps
第二步:定义数据源(Source)
# models/staging/shopify/_shopify_sources.yml
version: 2
sources:
- name: shopify
description: "Shopify 电商平台原始数据,通过 Fivetran 同步"
database: raw_db
schema: shopify_raw
loaded_at_field: _fivetran_synced
freshness:
warn_after: {count: 6, period: hour}
error_after: {count: 24, period: hour}
tables:
- name: orders
description: "Shopify 订单表,一行代表一个订单"
freshness:
warn_after: {count: 2, period: hour} # 订单数据要求更高的新鲜度
columns:
- name: id
tests: [unique, not_null]
- name: created_at
tests: [not_null]
- name: order_items
description: "订单商品明细,一行代表一个订单的一个商品"
columns:
- name: id
tests: [unique, not_null]
- name: customers
description: "客户基础信息"
columns:
- name: id
tests: [unique, not_null]
- name: products
description: "产品信息"
columns:
- name: id
tests: [unique, not_null]
# models/staging/stripe/_stripe_sources.yml
version: 2
sources:
- name: stripe
description: "Stripe 支付平台数据"
database: raw_db
schema: stripe_raw
loaded_at_field: _fivetran_synced
freshness:
warn_after: {count: 6, period: hour}
tables:
- name: payments
description: "Stripe 支付记录"
columns:
- name: id
tests: [unique, not_null]
第三步:Staging 层(数据清洗)
订单 Staging
-- models/staging/shopify/stg_shopify__orders.sql
WITH source AS (
SELECT * FROM {{ source('shopify', 'orders') }}
),
renamed AS (
SELECT
-- 主键
id AS order_id,
-- 外键
customer_id,
-- 日期
CAST(created_at AS TIMESTAMP) AS created_at,
CAST(updated_at AS TIMESTAMP) AS updated_at,
CAST(processed_at AS DATE) AS processed_date,
CAST(cancelled_at AS TIMESTAMP) AS cancelled_at,
-- 金额(统一为分)
ROUND(total_price::NUMERIC * 100)::BIGINT AS total_price_cents,
ROUND(subtotal_price::NUMERIC * 100)::BIGINT AS subtotal_price_cents,
ROUND(total_discounts::NUMERIC * 100)::BIGINT AS total_discounts_cents,
ROUND(total_tax::NUMERIC * 100)::BIGINT AS total_tax_cents,
ROUND(total_shipping::NUMERIC * 100)::BIGINT AS total_shipping_cents,
-- 状态(标准化)
LOWER(TRIM(financial_status)) AS financial_status,
LOWER(TRIM(fulfillment_status)) AS fulfillment_status,
LOWER(TRIM(cancel_reason)) AS cancel_reason,
-- 地址
LOWER(TRIM(shipping_address_country_code)) AS shipping_country_code,
LOWER(TRIM(billing_address_country_code)) AS billing_country_code,
-- 来源渠道
LOWER(TRIM(source_name)) AS source_name,
-- 元数据
_fivetran_synced AS synced_at
FROM source
),
filtered AS (
SELECT *
FROM renamed
WHERE order_id IS NOT NULL
AND financial_status != 'test'
{% if var('is_test_run', false) %}
-- 开发模式:只取最近 30 天
AND created_at >= CURRENT_DATE - INTERVAL '30 days'
{% else %}
AND created_at >= '{{ var("data_start_date") }}'
{% endif %}
)
SELECT * FROM filtered
订单明细 Staging
-- models/staging/shopify/stg_shopify__order_items.sql
WITH source AS (
SELECT * FROM {{ source('shopify', 'order_items') }}
),
renamed AS (
SELECT
id AS order_item_id,
order_id,
product_id,
variant_id,
-- 字符串标准化
LOWER(TRIM(name)) AS product_name,
LOWER(TRIM(sku)) AS sku,
-- 数量和价格
quantity::INTEGER AS quantity,
ROUND(price::NUMERIC * 100)::BIGINT AS unit_price_cents,
ROUND(total_discount::NUMERIC * 100)::BIGINT AS item_discount_cents,
-- 计算行小计(折后价格)
ROUND(
(price::NUMERIC * quantity - total_discount::NUMERIC) * 100
)::BIGINT AS line_total_cents,
-- 是否赠品
(gift_card = 'true')::BOOLEAN AS is_gift_card,
(requires_shipping = 'true')::BOOLEAN AS requires_shipping
FROM source
)
SELECT * FROM renamed
客户 Staging
-- models/staging/shopify/stg_shopify__customers.sql
WITH source AS (
SELECT * FROM {{ source('shopify', 'customers') }}
),
renamed AS (
SELECT
id AS customer_id,
-- 基础信息
LOWER(TRIM(email)) AS email,
TRIM(first_name) AS first_name,
TRIM(last_name) AS last_name,
first_name || ' ' || last_name AS full_name,
phone,
-- 状态
(accepts_marketing = 'true')::BOOLEAN AS accepts_marketing,
(tax_exempt = 'true')::BOOLEAN AS is_tax_exempt,
LOWER(TRIM(state)) AS account_state,
-- 时间
CAST(created_at AS TIMESTAMP) AS created_at,
CAST(updated_at AS TIMESTAMP) AS updated_at
FROM source
)
SELECT * FROM renamed
Staging 层测试配置
# models/staging/shopify/_stg_shopify_models.yml
version: 2
models:
- name: stg_shopify__orders
description: "Shopify 订单清洗层"
columns:
- name: order_id
tests: [unique, not_null]
- name: financial_status
tests:
- accepted_values:
values: ['pending', 'authorized', 'paid', 'partially_paid',
'refunded', 'partially_refunded', 'voided']
- name: total_price_cents
tests:
- not_null
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
max_value: 100000000
- name: stg_shopify__order_items
description: "Shopify 订单明细清洗层"
columns:
- name: order_item_id
tests: [unique, not_null]
- name: order_id
tests:
- not_null
- relationships:
to: ref('stg_shopify__orders')
field: order_id
- name: stg_shopify__customers
description: "Shopify 客户清洗层"
columns:
- name: customer_id
tests: [unique, not_null]
- name: email
tests: [not_null]
第四步:Intermediate 层(业务逻辑关联)
-- models/intermediate/int_order_items_enriched.sql
-- 把订单明细和产品、订单信息关联,形成富化后的行级数据
WITH order_items AS (
SELECT * FROM {{ ref('stg_shopify__order_items') }}
),
orders AS (
SELECT
order_id,
customer_id,
created_at AS order_created_at,
financial_status,
shipping_country_code
FROM {{ ref('stg_shopify__orders') }}
),
final AS (
SELECT
oi.order_item_id,
oi.order_id,
oi.product_id,
oi.sku,
oi.product_name,
-- 来自 orders 的信息
o.customer_id,
o.order_created_at,
o.financial_status,
o.shipping_country_code,
-- 明细度量
oi.quantity,
oi.unit_price_cents,
oi.item_discount_cents,
oi.line_total_cents,
oi.is_gift_card,
oi.requires_shipping,
-- 派生字段
(oi.item_discount_cents > 0) AS has_discount,
CASE
WHEN oi.item_discount_cents > 0
THEN oi.item_discount_cents * 1.0 / NULLIF(oi.unit_price_cents * oi.quantity, 0)
ELSE 0
END AS discount_rate
FROM order_items oi
LEFT JOIN orders o ON oi.order_id = o.order_id
)
SELECT * FROM final
第五步:Marts 层(最终数据产品)
订单事实表
-- models/marts/core/fct_orders.sql
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge'
) }}
WITH orders AS (
SELECT * FROM {{ ref('stg_shopify__orders') }}
),
order_item_aggregates AS (
-- 把订单明细聚合到订单粒度
SELECT
order_id,
COUNT(DISTINCT product_id) AS unique_products,
SUM(quantity) AS total_items,
SUM(line_total_cents) AS items_subtotal_cents,
SUM(item_discount_cents) AS total_item_discounts_cents,
BOOL_OR(is_gift_card) AS has_gift_card,
BOOL_OR(requires_shipping) AS requires_shipping
FROM {{ ref('stg_shopify__order_items') }}
GROUP BY order_id
),
-- 识别首单
customer_first_order AS (
SELECT
customer_id,
created_at AS first_order_at,
order_id AS first_order_id
FROM (
SELECT
customer_id,
created_at,
order_id,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at, order_id
) AS order_rank
FROM {{ ref('stg_shopify__orders') }}
WHERE financial_status IN ('paid', 'partially_paid')
) ranked_orders
WHERE order_rank = 1
),
final AS (
SELECT
-- 主键
o.order_id,
-- 外键
o.customer_id,
-- 时间维度
o.created_at,
o.processed_date,
DATE_TRUNC('week', o.created_at)::DATE AS order_week,
DATE_TRUNC('month', o.created_at)::DATE AS order_month,
DATE_TRUNC('quarter', o.created_at)::DATE AS order_quarter,
-- 状态
o.financial_status,
o.fulfillment_status,
o.shipping_country_code,
o.source_name,
-- 度量值
o.total_price_cents,
o.subtotal_price_cents,
o.total_discounts_cents,
o.total_tax_cents,
o.total_shipping_cents,
COALESCE(oia.unique_products, 0) AS unique_products,
COALESCE(oia.total_items, 0) AS total_items,
-- 布尔标志
(o.order_id = cfo.first_order_id) AS is_first_order,
COALESCE(oia.has_gift_card, FALSE) AS has_gift_card,
(o.cancelled_at IS NOT NULL) AS is_cancelled
FROM orders o
LEFT JOIN order_item_aggregates oia ON o.order_id = oia.order_id
LEFT JOIN customer_first_order cfo ON o.customer_id = cfo.customer_id
{% if is_incremental() %}
WHERE o.updated_at > (SELECT MAX(created_at) - INTERVAL '3 days' FROM {{ this }})
{% endif %}
)
SELECT * FROM final
客户维度表
-- models/marts/core/dim_customers.sql
{{ config(materialized='table') }}
WITH customers AS (
SELECT * FROM {{ ref('stg_shopify__customers') }}
),
-- 聚合每个客户的订单指标
customer_orders AS (
SELECT
customer_id,
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE financial_status = 'paid') AS paid_orders,
SUM(total_price_cents) FILTER (WHERE financial_status = 'paid') AS lifetime_value_cents,
MIN(created_at) AS first_order_at,
MAX(created_at) AS most_recent_order_at,
BOOL_OR(is_first_order) AS has_made_first_order
FROM {{ ref('fct_orders') }}
GROUP BY customer_id
),
final AS (
SELECT
c.customer_id,
c.email,
c.first_name,
c.last_name,
c.full_name,
c.phone,
c.accepts_marketing,
c.account_state,
c.created_at AS customer_since,
-- 订单统计
COALESCE(co.total_orders, 0) AS total_orders,
COALESCE(co.paid_orders, 0) AS paid_orders,
COALESCE(co.lifetime_value_cents, 0) AS lifetime_value_cents,
co.first_order_at,
co.most_recent_order_at,
-- 客户分层(引用 seed 规则表)
COALESCE(t.tier_name, 'new') AS customer_tier,
COALESCE(t.discount_percentage, 0) AS tier_discount_pct,
-- 活跃度标志
CASE
WHEN co.most_recent_order_at >= CURRENT_DATE - INTERVAL '90 days' THEN 'active'
WHEN co.most_recent_order_at >= CURRENT_DATE - INTERVAL '365 days' THEN 'at_risk'
WHEN co.most_recent_order_at IS NOT NULL THEN 'churned'
ELSE 'never_ordered'
END AS activity_status
FROM customers c
LEFT JOIN customer_orders co ON c.customer_id = co.customer_id
LEFT JOIN {{ ref('customer_tier_rules') }} t -- 引用 seed
ON co.lifetime_value_cents BETWEEN t.min_ltv_cents AND t.max_ltv_cents
)
SELECT * FROM final
每日收入聚合表
-- models/marts/finance/fct_daily_revenue.sql
{{ config(
materialized='incremental',
unique_key='revenue_date',
incremental_strategy='delete+insert'
) }}
WITH orders AS (
SELECT * FROM {{ ref('fct_orders') }}
WHERE financial_status IN ('paid', 'partially_paid')
AND NOT is_cancelled
),
daily_aggregated AS (
SELECT
processed_date AS revenue_date,
-- 订单量
COUNT(DISTINCT order_id) AS order_count,
COUNT(DISTINCT customer_id) AS unique_customers,
COUNT(DISTINCT order_id) FILTER (WHERE is_first_order) AS new_customer_orders,
-- 金额
SUM(total_price_cents) AS gross_revenue_cents,
SUM(total_discounts_cents) AS total_discounts_cents,
SUM(total_price_cents - total_discounts_cents) AS net_revenue_cents,
SUM(total_shipping_cents) AS shipping_revenue_cents,
SUM(total_tax_cents) AS tax_collected_cents,
-- 平均值
AVG(total_price_cents) AS avg_order_value_cents,
AVG(total_items) AS avg_items_per_order,
-- 渠道分布
SUM(total_price_cents) FILTER (WHERE source_name = 'web') AS web_revenue_cents,
SUM(total_price_cents) FILTER (WHERE source_name = 'mobile') AS mobile_revenue_cents
FROM orders
GROUP BY processed_date
)
SELECT * FROM daily_aggregated
{% if is_incremental() %}
WHERE revenue_date >= (SELECT MAX(revenue_date) - INTERVAL '7 days' FROM {{ this }})
{% endif %}
第六步:Marts 层测试和文档
# models/marts/core/_core_models.yml
version: 2
models:
- name: fct_orders
description: |
**订单事实表**
每行代表一个订单,包含订单金额、状态、来源和关联的客户信息。
**粒度**:订单(order_id 唯一)
**更新频率**:增量,每小时
tests:
# 表级:确保数据足够新鲜
- dbt_expectations.expect_row_values_to_have_recent_data:
column_name: created_at
datepart: hour
interval: 6
row_condition: "financial_status = 'paid'"
columns:
- name: order_id
description: "订单唯一标识"
tests: [unique, not_null]
- name: customer_id
description: "客户 ID,外键"
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: financial_status
tests:
- accepted_values:
values: ['pending', 'authorized', 'paid', 'partially_paid',
'refunded', 'partially_refunded', 'voided']
- name: total_price_cents
tests:
- not_null
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
- name: dim_customers
description: "客户维度表,包含客户属性和历史聚合指标"
columns:
- name: customer_id
tests: [unique, not_null]
- name: email
tests: [not_null]
- name: customer_tier
tests:
- accepted_values:
values: ['new', 'bronze', 'silver', 'gold', 'platinum']
第七步:Snapshot(历史追踪)
-- snapshots/snap_customers.sql
-- 追踪客户等级的历史变化
{% snapshot snap_customers %}
{{
config(
target_schema='snapshots',
strategy='check',
unique_key='customer_id',
check_cols=['email', 'account_state', 'customer_tier', 'lifetime_value_cents', 'activity_status'],
)
}}
SELECT
customer_id,
email,
account_state,
customer_tier,
lifetime_value_cents,
activity_status
FROM {{ ref('dim_customers') }}
{% endsnapshot %}
完整的运行序列
# 1. 安装包
dbt deps
# 2. 检查连接
dbt debug
# 3. 加载 seed
dbt seed
# 4. 检查数据源新鲜度
dbt source freshness
# 5. 全量构建(首次或 --full-refresh)
dbt build --full-refresh
# 6. 查看结果
dbt docs generate
dbt docs serve
日常增量运行:
# 每日例行运行
dbt snapshot && dbt build --fail-fast
项目最终结构
ecommerce_analytics/
├── dbt_project.yml
├── packages.yml
│
├── models/
│ ├── staging/
│ │ ├── shopify/
│ │ │ ├── _shopify_sources.yml
│ │ │ ├── _stg_shopify_models.yml
│ │ │ ├── stg_shopify__orders.sql
│ │ │ ├── stg_shopify__order_items.sql
│ │ │ └── stg_shopify__customers.sql
│ │ └── stripe/
│ │ ├── _stripe_sources.yml
│ │ └── stg_stripe__payments.sql
│ │
│ ├── intermediate/
│ │ ├── _int_models.yml
│ │ └── int_order_items_enriched.sql
│ │
│ └── marts/
│ ├── core/
│ │ ├── _core_models.yml
│ │ ├── fct_orders.sql
│ │ └── dim_customers.sql
│ └── finance/
│ ├── _finance_models.yml
│ └── fct_daily_revenue.sql
│
├── seeds/
│ └── customer_tier_rules.csv
│
├── snapshots/
│ └── snap_customers.sql
│
└── macros/
├── cents_to_dollars.sql
└── date_dimensions.sql
下一步
12-dbt最佳实践 - 团队协作规范、性能优化、dbt Mesh 多项目架构介绍
最后更新:2026-03-18
#dbt #项目实战 #电商数仓 #数据建模 #端到端
info 前置知识
- 完成 dbt 系列 01-10 的核心内容(尤其是三层分层、增量模型、测试、Jinja 宏)
- 理解电商业务的基本数据模型(订单、客户、商品之间的关系)
- 有在本地或云端运行完整 dbt 项目的实际经验
掌握检查
- [ ] 能按照 staging → intermediate → marts 的顺序,独立搭建本项目的模型文件结构,不遗漏核心模型
- [ ] 能为
fct_orders配置增量模型(is_incremental()过滤 +unique_key),并在dim_customers使用 snapshot 追踪等级变化历史 - [ ] 能为本项目所有 marts 层模型配置主键测试(unique + not_null),并为关键外键配置 relationships 测试
- [ ] 知道如何用
dbt docs generate生成项目文档,并在血缘图中看到完整的 staging → intermediate → marts 数据流 - [ ] 能按照本文的运行步骤(deps → debug → seed → snapshot → build → docs)完整跑通一次项目
实践练习
综合实战:按照本文的完整步骤,在你的本地环境中还原这个电商数仓项目:从 dbt init 开始,到所有 marts 层模型正常运行、所有测试通过、文档网站可访问为止。记录你遇到的错误和解决过程。
扩展挑战:在本项目基础上,增加一个新的业务需求:计算每个商品分类(category)的月度销售额排名(用窗口函数 RANK())。从原始数据到最终输出,确定这个需求需要新增或修改哪些 staging/intermediate/marts 层的模型,并实现它。