Spark & Lakehouse

Spark, PySpark, ClickHouse, Hadoop / HDFS, lakehouse, Flink, troubleshooting, and English read-aloud practice.

KAFKA → FLINK → CLICKHOUSE

📨 Kafka & ClickHouse

One continuous learning path: Kafka fundamentals → Flink handoff → ClickHouse internals → production design → visual reinforcement → high-value Q&A.

现在只有一条连续学习路径:原 Tab 3 完整内容保留,旧 ClickHouse 的图与 Q&A 已融入同一个课程内部,不再单独堆在下方。

🗄️ Hadoop / HDFS + YARN — One-Day Essentials

Keep only the architecture, data path, resource flow, and troubleshooting logic that are most useful for a platform role.

HDFS RolesRead / WriteYARN FlowJob StuckDistributed Systems
🔊 Click an English explanation or answer to hear it. Click the same line again to stop.
🧠

HDFS Core Mental Model

NameNode, DataNode, blocks, replication — enough to explain the architecture clearly.

›01
NameNodeMetadata: filenames · block locations · permissions
↓ metadata tells client where blocks live
DataNode ABlock 1 · Block 4
DataNode BBlock 1 copy · Block 2
DataNode CBlock 2 copy · Block 3
🔊The NameNode manages metadata, while DataNodes store the actual HDFS blocks. Replication keeps multiple copies of blocks for fault tolerance.
中文:NameNode 管元数据,DataNode 存实际 block;Replication 为容错保留多份 block 副本。
Q1
What does the NameNode store?
🔊The NameNode stores filesystem metadata such as file names, permissions, and block locations. It does not store the actual file blocks.
中文:NameNode 保存文件系统元数据,不保存实际数据块。
Q2
What happens if one DataNode fails?
🔊The NameNode detects missing replicas and schedules replication from healthy copies to restore the desired replication factor.
中文:DataNode 挂掉后,NameNode 会检测副本不足并从健康副本重新复制。
Q3
Why are many small files bad for HDFS?
🔊Each file and block needs metadata in the NameNode, so millions of tiny files create metadata and scheduling overhead.
中文:大量小文件会增加 NameNode 元数据和调度开销。
Q4
What is replication for?
🔊Replication provides fault tolerance by keeping multiple copies of each block on different DataNodes or failure domains.
中文:Replication 通过在不同 DataNode/故障域保存多个副本提供容错。
🔄

HDFS Read, Write & Failure Path

Two flows are enough: how a client reads and how a client writes.

›02

Read Path

1 · Client asks NameNode for block locations↓2 · NameNode returns nearby DataNodes↓3 · Client reads blocks directly from DataNodes↓4 · If one replica fails, try another

Write Path

1 · Client asks NameNode to create file↓2 · NameNode chooses a DataNode pipeline↓3 · Client writes DN1 → DN2 → DN3↓4 · ACKs return through the pipeline
🔊The NameNode is in the metadata path, but clients transfer the actual file blocks directly to and from DataNodes.
中文:NameNode 参与元数据路径,但实际 block 数据由客户端直接与 DataNode 传输。
Failure idea: DataNode failure is expected. Replication + re-replication keeps data available. NameNode availability is handled separately with HA architecture.
🧭

YARN + Spark Resource Flow

Condensed from the SRE study material: understand how resources are actually allocated.

›03
1 · ClientSubmit application to ResourceManager
↓
2 · ResourceManagerAllocates one container for ApplicationMaster
↓
3 · ApplicationMasterRegisters with RM and requests more containers
↓
4 · ResourceManagerAllocates containers based on queue / capacity / resources
↓
5 · NodeManagersLaunch the actual work in those containers
🔊The ResourceManager allocates a container for the ApplicationMaster. The ApplicationMaster then requests more containers, and NodeManagers launch the actual work.
中文:ResourceManager 先分配 container 启动 ApplicationMaster;AM 再申请更多 container,由 NodeManager 启动实际计算。
Memory: Client → ResourceManager → ApplicationMaster → more Containers → NodeManagers launch work.
🛠️

Job Stuck — Troubleshooting Flow

This is worth carrying over from the SRE material because it matches the platform role directly.

›04
Job appears stuckFirst question: has it received resources and started Tasks?

No — waiting for resources

YARN queue capacity↓Pending containers↓ResourceManager / scheduler↓Cluster capacity

Yes — Tasks are running

Find slow Stage↓Find long-tail Task↓Skew / GC / spill↓Disk / network / bad worker
CPUtop
Memoryfree -h
Diskiostat -x
Networkss
Service / Logssystemctl · journalctl
🔊For a stuck job, I first separate resource waiting from runtime slowness. If it is waiting, I check YARN queues and containers. If it is already running, I drill into Stage, Task, skew, garbage collection, spill, disk, and network.
中文:Job 卡住时先区分“没拿到资源”还是“已经运行但很慢”。前者查 YARN queue/container;后者继续查 Stage、Task、skew、GC、spill、磁盘和网络。
🌐

Distributed Systems Essentials

Only the concepts that repeatedly show up in platform discussions.

›05
ShardingSplit data / load across nodes
ReplicationCopy data for availability
RetryTransient failure recovery
IdempotencySafe repeated execution
ConsistencyWhat clients can observe
Failure DomainAvoid putting all copies in one place
Events→Ingestion→HDFS / Object Storage→Spark→ClickHouse→Tea
🔊The platform combines durable storage, distributed compute, low-latency serving, replication, and failure recovery. The important skill is knowing which layer owns which responsibility.
中文:整个平台把持久化存储、分布式计算、低延迟查询、副本和故障恢复组合在一起;关键是知道每一层负责什么。
One-day boundary: know these concepts well enough to reason through a failure. Do not spend today memorizing deep HDFS or YARN internals.

PySpark All Slides

High-contrast study-note version · 16 slides · scroll from top to bottom.

16 slides
01Create DataFrames — SparkSession + Input Data
PySpark slide 1: Create DataFrames — SparkSession + Input Data
02PySpark Study Notes — SQL First, PySpark Second
PySpark slide 2: PySpark Study Notes — SQL First, PySpark Second
03Latest Row / Dedup — ROW_NUMBER
PySpark slide 3: Latest Row / Dedup — ROW_NUMBER
04Conditional Aggregation — CASE WHEN → F.when
PySpark slide 4: Conditional Aggregation — CASE WHEN → F.when
05Join Patterns — inner / left / semi / anti
PySpark slide 5: Join Patterns — inner / left / semi / anti
06Null + Date + String — Small APIs That Often Block a Task
PySpark slide 6: Null + Date + String — Small APIs That Often Block a Task
07Window Advanced — lag / lead / rank / dense_rank
PySpark slide 7: Window Advanced — lag / lead / rank / dense_rank
08Array + Union — explode / unionByName
PySpark slide 8: Array + Union — explode / unionByName
09Built-in Expression First — CASE WHEN → F.when
PySpark slide 9: Built-in Expression First — CASE WHEN → F.when
10Python UDF — Use Only When Built-ins Are Not Enough
PySpark slide 10: Python UDF — Use Only When Built-ins Are Not Enough
11Partitioning + Cache / Persist
PySpark slide 11: Partitioning + Cache / Persist
12Collect + Read / Write I/O
PySpark slide 12: Collect + Read / Write I/O
13Complete Program A1 — Filter → Broadcast Join
PySpark slide 13: Complete Program A1 — Filter → Broadcast Join
14Complete Program A2 — Window → Latest Row
PySpark slide 14: Complete Program A2 — Window → Latest Row
15Complete Program B — Derive → Aggregate → Final Grain
PySpark slide 15: Complete Program B — Derive → Aggregate → Final Grain
16Practice Drill — Translate the Requirement Before You Code
PySpark slide 16: Practice Drill — Translate the Requirement Before You Code
S — Situation

Example data (illustrative)

Simplified sample rows to illustrate the shape of the skew — in reality each hot group had 3-4 million rows, not 3.

Reference
partner_remittance_providersender_countrycurrencysender_idtransaction_idamountfdate
WiseUSUSDU10023TXN00001482.5020240115
WiseUSUSDU10024TXN000021,205.0020240115
WiseUSUSDU10023TXN00003310.0020240116
... 4,000,000 more rows, same (Wise, US, USD) key
PandaRemitKRKRWU55010TXN04000001150,00020240201
... 3,000,000 more rows, same (PandaRemit, KR, KRW) key
GrabPaySGSGDU88221TXN07000001600.0020240301
AirwallexAUAUDU99110TXN070000022,100.0020240301
Detail
currencyfdatefx_to_cnhfx_to_usd
USD202401157.181.00
KRW202402010.00540.00075
SGD202403015.320.74
⚠️ Two GROUP BY keys — (Wise, US, USD) and (PandaRemit, KR, KRW) — each held millions of rows, while every other key held a normal, evenly-sized handful.

The problem

What the on-call engineer actually saw before diagnosis.

Detail
#Symptom
1Runtime fluctuated 30–75 minutes
2Some reducers stalled for over 30 minutes
3Sometimes, Executor OOM errors
4Frequent GC pauses (heap pressure)
5Spark UI showed severe task skew
⚠️ SLA compliance target was 99%+, but the job was only hitting ~95% — meaning it missed its deadline on roughly 1 in 20 days.

BEFORE — original query with a measured performance bottleneck

This single-stage COUNT(DISTINCT) is what caused the entire problem. This exact query was later REPLACED — see the final production query at the end of the Action section below, which is a different query, not a patch on this one.

SELECT t.partner_remittance_provider, t.sender_country, t.currency, COUNT(t.transaction_id) AS tx_count, COUNT(DISTINCT t.sender_id) AS sender_count, SUM(t.amount) AS total_amount, SUM(t.amount * fx.fx_to_cnh) AS total_cnh, SUM(t.amount * fx.fx_to_usd) AS total_usd FROM transactions t JOIN exchange_rates fx ON t.currency = fx.currency AND t.fdate = fx.fdate WHERE t.fdate BETWEEN '20240101' AND '20241231' GROUP BY t.partner_remittance_provider, t.sender_country, t.currency;
High distinct cardinality and skew can concentrate aggregation and shuffle work. Inspect the physical plan and task metrics rather than assuming one unspillable HashSet per business key.
A — Action
Four levels, cheapest first: try config before code, and code before the last-resort surgical fix. Framework changes are separate — they apply to the whole pipeline, not just this one query.
🔍
Level 0 — Diagnosis
Understand what's actually broken before touching anything.

1.Diagnose: find the root cause

Three compounding factors, not just one.

Detail
ProblemTechnical Root Cause
OOMHashAggregate retained large intermediate states
GC PressureExcessive heap usage from large in-memory hash maps
Data SkewUneven key distribution in GROUP BY
Shuffle OverloadToo few partitions (200) → oversized reducer tasks
⚠️ Skewed keys 'Wise+US+USD' (~4M txns) and 'PandaRemit+KR+KRW' (~3M txns) created 5GB+ shuffle partitions each.

2.Why HashAggregate specifically fails under this pattern

High-cardinality distinct aggregation increases per-task memory usage; spilling can help, but does not prevent every OOM.

Aggregation and shuffle: each task holds grouping keys and aggregation buffers. Many distinct key-sender pairs increase memory pressure, which may cause executor OOM or trigger spill to disk. Spill does not prevent every OOM; HashAggregate can fall back to sorting.

Spark SLA · Corrected interview explanation

A critical Spark job for cross-border remittance analytics normally took about 30 minutes, but sometimes rose to 75 minutes and hit Executor OOM, missing the SLA.

  1. Diagnose: Spark UI showed a few unusually slow reducer tasks with much larger shuffle reads. A key-frequency check identified Wise-US-USD and PandaRemit-KR-KRW, together accounting for over 7 million transactions. Row counts alone do not establish distinct sender cardinality; inspect that too.
  2. Explain the pressure: COUNT(DISTINCT sender_id) processes unique business-key/sender combinations through aggregation and shuffle stages. High cardinality and skew caused heavy memory pressure and GC in this case; avoid claiming a single unspillable HashSet per group.
  3. Configuration first: test more shuffle partitions and executor memory. A sort-based aggregation plan, if actually tested and verified, may reduce hash-state pressure at the cost of sorting and disk I/O. AQE is reference material here, not an applied fix or an automatic hot-aggregation-key splitter.
  4. Two-stage rewrite: deduplicate business key + sender_id, then count non-null sender IDs at the original grain. The improvement can be limited because Spark commonly already uses a similar multi-stage plan for one COUNT(DISTINCT).
  5. Targeted salting: for the two measured hot keys, hash sender_id into 8 deterministic buckets, count distinct non-null senders per bucket, then sum the partial counts. The same sender must always stay in one bucket within each business key; random per-row salting would overcount. Eight was the case's tested choice, not a universal optimum.
  6. Validate the result: compare exact outputs and all other metrics, then test peak volume. Reported outcome: runtime dropped from 75 to 13 minutes and Executor OOM stopped. Deterministic sender salting does not split all repetitions of one single sender; reassess the bottleneck if that is the actual skew.
Likely follow-up questions

Why OOM? Large distinct-key cardinality and skew concentrated aggregation/shuffle work and memory pressure; confirm the failing operator in the executed plan and logs.

Why not AQE? AQE's skew-join optimization targets supported joins, not automatic bucketed aggregation of a GROUP BY hot key. Spark version and actual settings determine its availability.

Why two stages? Make the exact deduplication grain explicit while preserving null semantics. Do not assume faster execution than the native single-distinct plan.

Why 8 deterministic buckets? A sender maps to exactly one bucket per business key, so partial distinct counts can be summed exactly. Bucket count and performance require measurements; partial bucket groups do not guarantee eight equally sized tasks.

Memory line: Spark UI → verify skew and distinct cardinality → memory/GC pressure → configuration tests → verify sort-based plan → compare two-stage rewrite → 8-way deterministic targeted salting → validate exact results → 75 to 13 min. AQE: optional reference only.

Technical references: Spark performance tuning · Aggregation planner. Version-specific behavior must match the deployed environment.

💡 HashAggregate can fall back to sorting and spill to disk. Spilling reduces memory pressure, but it does not guarantee that executor OOM cannot occur.
⚙️
Level 1 — Config (no code change)
Cheapest fixes. Pure Spark settings — the query stays untouched.

3. Fix 1: Tune partitioning and executor memory first

Increase initial shuffle parallelism and test executor memory against measured task size and GC. More partitions can reduce collisions between different groups, but do not by themselves split one final aggregation key. The values below are examples from this case, not universal defaults.

-- Submit-time example; compare with the actual cluster limits:
-- spark-submit --executor-memory 6G --executor-cores 4 ...
SET spark.sql.shuffle.partitions = 2000;

-- Optional reference only: NOT an applied fix in this account.
-- AQE requires Spark 3.x+; enabled by default in upstream Spark 3.2+.
-- SET spark.sql.adaptive.enabled = true;
-- SET spark.sql.adaptive.coalescePartitions.enabled = true;
-- SET spark.sql.adaptive.skewJoin.enabled = true;
-- SET spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes = 256MB;
-- SET spark.sql.adaptive.skewJoin.skewedPartitionFactor = 5;
AQE can coalesce shuffle partitions and optimize supported skewed joins. Its skew-join feature is not automatic salting of a plain GROUP BY hot key. Check version and actual settings; do not say enabling AQE solved this aggregation bottleneck.

4. Fix 2: Test sort-based aggregation and inspect the physical plan

Compare the executed plan and measured GC, spill and runtime. Sort-based aggregation may lower hash-state pressure while adding sorting and disk I/O; it is not guaranteed to run faster or eliminate every OOM.

-- Do not use an undocumented switch as a universal production fix.
-- spark.sql.execution.useHashAggregateExec is not a standard upstream setting.
-- Disabling useObjectHashAggregateExec does not disable ordinary HashAggregate.
-- Use the controls supported by the deployed distribution and Spark version.
EXPLAIN FORMATTED
SELECT partner_remittance_provider, sender_country, currency,
       COUNT(DISTINCT sender_id) AS sender_count
FROM transactions
GROUP BY partner_remittance_provider, sender_country, currency;
In the interview, say: “I tested a sort-based plan and verified SortAggregate in the executed physical plan.” Name the exact mechanism only when it matches the real environment. If it was not actually tested, describe it as an option rather than a completed action.
💻
Level 2 — Code (query rewrite required)
Only reached because Level 1 alone wasn't sufficient.

5.Fix 3: Two-stage aggregation — config alone wasn't enough, so I rewrote the query

This is the first step that required an actual code change — only necessary because pure configuration wasn't sufficient on its own.

Reference
AspectNative single COUNT(DISTINCT)Explicit two-stage rewrite
PlanNormally already uses multi-stage aggregation over business key + sender_idMakes sender-level reduction explicit
PerformanceDepends on plan, data and shuffleMay compile to a similar plan; not automatically faster
CorrectnessIgnores null sender IDsCount non-null sender IDs after deduplication
-- Stage 1: dedup rows to one row per (partner_remittance_provider, sender_country, currency, sender_id) WITH dedup AS ( SELECT t.partner_remittance_provider, t.sender_country, t.currency, t.sender_id FROM transactions t WHERE t.fdate BETWEEN '20240101' AND '20241231' GROUP BY t.partner_remittance_provider, t.sender_country, t.currency, t.sender_id ) -- Stage 2: count deduplicated sender_id rows per dimension (exact distinct count) SELECT d.partner_remittance_provider, d.sender_country, d.currency, COUNT(d.sender_id) AS sender_count FROM dedup d GROUP BY d.partner_remittance_provider, d.sender_country, d.currency;
💡 The rewrite makes the deduplication grain explicit. Spark often already plans a single COUNT(DISTINCT) similarly, so this step alone may give little improvement. Compare the plans and timings; the two extreme hot keys can remain bottlenecks.

Expanded analysis — what the two stages actually do

Stage 1 deduplicates at business key + sender_id. This is a logical rewrite, not proof of a new physical strategy: Spark normally already uses this kind of grouping for a single distinct expression.

Stage 2 removes sender_id and rolls those sender-level rows back to the original business dimensions. Because Stage 1 produces at most one row per sender inside each business key, SUM(sender_flag) is the exact equivalent of COUNT(DISTINCT sender_id) for non-null senders.

tx_countStage 1 counts transactions per sender. Stage 2 SUMs those counts. Exact.
sender_countStage 1 leaves one row per sender. Stage 2 SUMs sender_flag. Exact.
total_amount / converted amountsStage 1 SUMs raw amount and converted amounts per sender. Stage 2 SUMs them again. Exact because these metrics are additive.
averageDo not average averages. Recompute avg_txn_amt_cnh = total_cnh / tx_count only after the final merge.
What to verify: compare the native and rewritten physical plans. If the exchanges and aggregation stages are equivalent, do not attribute a speedup to the SQL rewrite alone.
But two-stage does not magically remove every extreme-key problem. The final Stage 2 still groups by the original business key. If one hot key has an extremely large number of distinct senders, that one final reducer can still receive far more sender-level rows than its peers. The rewrite is not a general OOM guarantee; a long-tail task can remain.
Complete two-stage SQL — all original metrics preservedcode change · exact result
-- Expanded two-stage version: preserve ALL original metrics exactly -- Assumption: exchange_rates has one row per (currency, fdate). WITH sender_stage AS ( -- Stage 1: one row per original dimension + sender_id. -- This distributes the DISTINCT work by sender_id instead of keeping -- an implicit logical distinct operation; compare the native physical plan. SELECT t.partner_remittance_provider, t.sender_country, t.currency, t.sender_id, COUNT(t.transaction_id) AS sender_tx_count, SUM(t.amount) AS sender_total_amount, SUM(t.amount * fx.fx_to_cnh) AS sender_total_cnh, SUM(t.amount * fx.fx_to_usd) AS sender_total_usd, -- COUNT(DISTINCT sender_id) ignores NULL. Preserve that behavior. CASE WHEN t.sender_id IS NULL THEN 0 ELSE 1 END AS sender_flag FROM transactions t JOIN exchange_rates fx ON t.currency = fx.currency AND t.fdate = fx.fdate WHERE t.fdate BETWEEN '20240101' AND '20241231' GROUP BY t.partner_remittance_provider, t.sender_country, t.currency, t.sender_id ), final_stage AS ( -- Stage 2: roll the sender-level rows back to the original dimensions. SELECT partner_remittance_provider, sender_country, currency, SUM(sender_tx_count) AS tx_count, SUM(sender_flag) AS sender_count, SUM(sender_total_amount) AS total_amount, SUM(sender_total_cnh) AS total_cnh, SUM(sender_total_usd) AS total_usd FROM sender_stage GROUP BY partner_remittance_provider, sender_country, currency ) SELECT partner_remittance_provider, sender_country, currency, tx_count, sender_count, total_amount, total_cnh, total_usd, ROUND(total_cnh / NULLIF(tx_count, 0), 2) AS avg_txn_amt_cnh FROM final_stage;
CheckWhat to validate
CorrectnessCompare tx_count, sender_count, total_amount, total_cnh, total_usd, and avg_txn_amt_cnh with the original query for the same date range.
DistributionIn Spark UI, compare max Task vs median Task in the sender-level aggregation and final aggregation.
MemoryConfirm spill / GC / peak execution memory no longer show the previous DISTINCT-state pressure.
DecisionIf the two hot keys are still the long-tail Tasks, escalate only those keys to deterministic salting.

6.✅ AFTER — Fix 4: Targeted salting — the FINAL production query

For this SLA story, treat targeted salting as an extension of the two-stage exact-distinct rewrite, not as an unrelated replacement. The imported bucketed-DISTINCT version is preserved below as an alternative implementation.

Recommended framing for THIS case

Two-stage aggregation is the base method. It expresses exact deduplication at the grain of (business key, sender_id), then rolls the sender-level rows back to the original dimensions.

Targeted salting is the escalation layer on top of two-stage. If the final business-key aggregation is still skewed for the two extreme keys, add a deterministic salt derived from sender_id, aggregate inside each salt bucket, then merge the buckets.

Method A · Two-stage onlyTest after configuration tuning; compare with the native DISTINCT plan before expecting any gain.
→
Method B · Two-stage + targeted saltingUse only if proven extreme business keys remain long-tail after Method A. This is the final version for this SLA story.
Why salting comes AFTER two-stage in this case: an explicit deduplication rewrite may match the native plan, and the original hot business key can still collapse to one final aggregation partition. Targeted salting adds a controlled extra distribution key only for those extreme groups, so the remaining long-tail work is split before the final merge.
Originalskew + high distinct cardinality → concentrated work
→
Two-stagesender-level reduction → much smaller final input
→
Targeted saltingextreme hot key → 8 deterministic buckets → merge
-- Recommended final pattern for THIS case: -- Two-stage exact distinct + one targeted salting layer for the proven hot keys. WITH base AS ( SELECT t.partner_remittance_provider, t.sender_country, t.currency, t.sender_id, t.transaction_id, t.amount, t.fdate, fx.fx_to_cnh, fx.fx_to_usd, CASE WHEN (t.partner_remittance_provider = 'Wise' AND t.sender_country = 'US' AND t.currency = 'USD') OR (t.partner_remittance_provider = 'PandaRemit' AND t.sender_country = 'KR' AND t.currency = 'KRW') THEN (HASH(t.sender_id) & 2147483647) % 8 ELSE 0 END AS salt_bucket FROM transactions t JOIN exchange_rates fx ON t.currency = fx.currency AND t.fdate = fx.fdate WHERE t.fdate BETWEEN '20240101' AND '20241231' ), sender_stage AS ( -- Stage 1: exact sender-level reduction INSIDE each deterministic salt bucket. -- Same sender always lands in the same bucket for the same business key. SELECT partner_remittance_provider, sender_country, currency, salt_bucket, sender_id, COUNT(transaction_id) AS sender_tx_count, SUM(amount) AS sender_total_amount, SUM(amount * fx_to_cnh) AS sender_total_cnh, SUM(amount * fx_to_usd) AS sender_total_usd, CASE WHEN sender_id IS NULL THEN 0 ELSE 1 END AS sender_flag FROM base GROUP BY partner_remittance_provider, sender_country, currency, salt_bucket, sender_id ), bucket_stage AS ( -- Salting layer: reduce each hot business key into up to 8 parallel partial buckets. SELECT partner_remittance_provider, sender_country, currency, salt_bucket, SUM(sender_tx_count) AS tx_count_bucket, SUM(sender_flag) AS sender_count_bucket, SUM(sender_total_amount) AS total_amount_bucket, SUM(sender_total_cnh) AS total_cnh_bucket, SUM(sender_total_usd) AS total_usd_bucket FROM sender_stage GROUP BY partner_remittance_provider, sender_country, currency, salt_bucket ), final_stage AS ( -- Final merge: remove salt and restore the original business grain. SELECT partner_remittance_provider, sender_country, currency, SUM(tx_count_bucket) AS tx_count, SUM(sender_count_bucket) AS sender_count, SUM(total_amount_bucket) AS total_amount, SUM(total_cnh_bucket) AS total_cnh, SUM(total_usd_bucket) AS total_usd FROM bucket_stage GROUP BY partner_remittance_provider, sender_country, currency ) SELECT partner_remittance_provider, sender_country, currency, tx_count, sender_count, total_amount, total_cnh, total_usd, ROUND(total_cnh / NULLIF(tx_count, 0), 2) AS avg_txn_amt_cnh FROM final_stage;
Exactness rule: the salt is deterministic on sender_id. One sender cannot be split across multiple buckets within the same business key, so bucket-level sender counts can be summed exactly. tx_count, total_amount, total_cnh, and total_usd are additive and are also reconstructed exactly by SUMming the bucket totals.
Alternative bucketed-DISTINCT implementation from the imported SLA materialkept below so no source information is lost
-- A. Dedup exchange rates first (avoid duplicate join fan-out) WITH fx_dedup AS ( SELECT currency, fdate, fx_to_cnh, fx_to_usd FROM ( SELECT currency, fdate, fx_to_cnh, fx_to_usd, ROW_NUMBER() OVER (PARTITION BY currency, fdate ORDER BY fdate DESC) AS rn FROM exchange_rates WHERE fdate BETWEEN '20240101' AND '20241231' ) d WHERE rn = 1 ), -- B. Salt only the 2 known hot keys; everyone else goes to fixed bucket 8 salted_txn AS ( SELECT t.partner_remittance_provider, t.sender_country, t.currency, t.sender_id, t.transaction_id, t.amount, t.fdate, CASE WHEN (t.sender_country = 'US' AND t.partner_remittance_provider = 'Wise') OR (t.sender_country = 'KR' AND t.partner_remittance_provider = 'PandaRemit') THEN (HASH(t.sender_id) & 2147483647) % 8 -- hot keys → 8 buckets ELSE 8 -- everyone else → fixed bucket 8 END AS salt_bucket FROM transactions t WHERE t.fdate BETWEEN '20240101' AND '20241231' ), -- C. Stage 1: aggregate per dimension + salt bucket (exact dedup within bucket) agg_stage1 AS ( SELECT s.partner_remittance_provider, s.sender_country, s.currency, s.salt_bucket, COUNT(s.transaction_id) AS tx_cnt_bucket, COUNT(DISTINCT s.sender_id) AS sender_cnt_bucket, SUM(s.amount) AS total_amount_bucket, SUM(s.amount * fx.fx_to_cnh) AS total_cnh_bucket, SUM(s.amount * fx.fx_to_usd) AS total_usd_bucket FROM salted_txn s JOIN fx_dedup fx ON s.currency = fx.currency AND s.fdate = fx.fdate GROUP BY s.partner_remittance_provider, s.sender_country, s.currency, s.salt_bucket ) -- D. Stage 2: sum buckets back to original dimensions (still exact) SELECT partner_remittance_provider, sender_country, currency, SUM(tx_cnt_bucket) AS tx_count, SUM(sender_cnt_bucket) AS sender_count, SUM(total_amount_bucket) AS total_amount, SUM(total_cnh_bucket) AS total_cnh, SUM(total_usd_bucket) AS total_usd, ROUND(SUM(total_cnh_bucket) / NULLIF(SUM(tx_cnt_bucket), 0), 2) AS avg_txn_amt_cnh FROM agg_stage1 GROUP BY partner_remittance_provider, sender_country, currency;
💡 Because the salt only depends on sender_id, the same sender ALWAYS lands in the same bucket — summing bucket counts gives an EXACT distinct count, not an approximation.

Expanded analysis — deterministic salting on top of two-stage aggregation

Salt only the known extreme groups. Normal groups stay in bucket 0. For the two extreme groups, use PMOD(HASH(sender_id), 8) so the same sender always lands in the same bucket.

Do not use RANDOM(). If the same sender can land in different buckets, adding per-bucket sender counts can double-count that sender. Deterministic salting preserves exact distinct semantics.

Stage 1AGroup by business key + salt_bucket + sender_id. One row per sender in one deterministic bucket.
Stage 1BAggregate each bucket. An extreme business key now becomes up to 8 smaller bucket groups.
Stage 2SUM the 8 bucket results back to the original business key. Final result stays exact.
Exactness proof: every transaction belongs to exactly one bucket; every non-null sender belongs to exactly one deterministic bucket; additive metrics are SUMmed once; therefore merging bucket totals reconstructs the original exact business result.
What salting costs: extra grouping keys, an additional merge stage, more shuffle metadata, and more complicated SQL. That is why it is a surgical fix for proven extreme keys, not the first thing to apply to all data.
Complete targeted-salting SQL — built on the two-stage design8 buckets · deterministic · exact
-- Expanded targeted salting ON TOP OF the two-stage design -- Hot groups are split into 8 deterministic sender buckets. -- Non-hot groups stay in bucket 0, so normal data is not unnecessarily expanded. WITH salted_txn AS ( SELECT t.partner_remittance_provider, t.sender_country, t.currency, t.sender_id, t.transaction_id, t.amount, t.fdate, CASE WHEN ( t.sender_country = 'US' AND t.partner_remittance_provider = 'Wise' ) OR ( t.sender_country = 'KR' AND t.partner_remittance_provider = 'PandaRemit' ) THEN CASE WHEN t.sender_id IS NULL THEN 0 ELSE PMOD(HASH(t.sender_id), 8) END ELSE 0 END AS salt_bucket FROM transactions t WHERE t.fdate BETWEEN '20240101' AND '20241231' ), sender_stage AS ( -- Stage 1A: one row per sender inside each deterministic bucket. -- A sender cannot appear in two buckets because salt = HASH(sender_id). SELECT s.partner_remittance_provider, s.sender_country, s.currency, s.salt_bucket, s.sender_id, COUNT(s.transaction_id) AS sender_tx_count, SUM(s.amount) AS sender_total_amount, SUM(s.amount * fx.fx_to_cnh) AS sender_total_cnh, SUM(s.amount * fx.fx_to_usd) AS sender_total_usd, CASE WHEN s.sender_id IS NULL THEN 0 ELSE 1 END AS sender_flag FROM salted_txn s JOIN exchange_rates fx ON s.currency = fx.currency AND s.fdate = fx.fdate GROUP BY s.partner_remittance_provider, s.sender_country, s.currency, s.salt_bucket, s.sender_id ), bucket_stage AS ( -- Stage 1B: reduce each hot key into 8 smaller partial aggregates. SELECT partner_remittance_provider, sender_country, currency, salt_bucket, SUM(sender_tx_count) AS tx_count_bucket, SUM(sender_flag) AS sender_count_bucket, SUM(sender_total_amount) AS total_amount_bucket, SUM(sender_total_cnh) AS total_cnh_bucket, SUM(sender_total_usd) AS total_usd_bucket FROM sender_stage GROUP BY partner_remittance_provider, sender_country, currency, salt_bucket ), final_stage AS ( -- Stage 2: merge the 8 buckets back to the original business key. SELECT partner_remittance_provider, sender_country, currency, SUM(tx_count_bucket) AS tx_count, SUM(sender_count_bucket) AS sender_count, SUM(total_amount_bucket) AS total_amount, SUM(total_cnh_bucket) AS total_cnh, SUM(total_usd_bucket) AS total_usd FROM bucket_stage GROUP BY partner_remittance_provider, sender_country, currency ) SELECT partner_remittance_provider, sender_country, currency, tx_count, sender_count, total_amount, total_cnh, total_usd, ROUND(total_cnh / NULLIF(tx_count, 0), 2) AS avg_txn_amt_cnh FROM final_stage;
ValidationExpected evidence
Business equalityAll business outputs — tx_count, sender_count, total_amount, total_cnh, total_usd, and avg_txn_amt_cnh — match the unsalted / two-stage query exactly on the same input.
Bucket balanceVerify sender cardinality, per-bucket sizes and task distribution. Eight logical buckets do not guarantee eight distinct tasks or equal load.
Task tailmax Task duration and max Shuffle Read move much closer to the median.
StabilityNo recurring Executor OOM; GC pressure and spill remain bounded across peak-volume runs.
SLAValidate runtime over repeated production days, not a single fast run.
📚
Reference — documented, not applied here
General techniques written down for future incidents, not used in THIS fix.

7.Related technique: general JOIN salting (for future unknown hot keys)

A reusable pattern for future incidents where the hot key isn't known ahead of time — not used in this specific fix, but documented for the team.

-- Large table: deterministic salt for every row, based on currency+fdate WITH salted_txn AS ( SELECT t.*, (HASH(t.currency, t.fdate) & 2147483647) % 8 AS salt FROM transactions t WHERE t.fdate BETWEEN '20240101' AND '20241231' ), -- Small table: replicate exchange_rates 8 times, salt = 0..7 salted_fx AS ( SELECT fx.*, CAST(r.id AS INT) AS salt FROM exchange_rates fx CROSS JOIN (SELECT CAST(id AS INT) AS id FROM range(8)) r ) -- Join on (currency, fdate, salt) instead of just (currency, fdate) SELECT s.partner_remittance_provider, s.sender_country, s.currency, COUNT(s.transaction_id) AS tx_count, SUM(s.amount * f.fx_to_cnh) AS total_cnh FROM salted_txn s JOIN salted_fx f ON s.currency = f.currency AND s.fdate = f.fdate AND s.salt = f.salt GROUP BY s.partner_remittance_provider, s.sender_country, s.currency;
💡 Trade-off vs targeted salting: simpler, no need to know hot keys in advance, but replicates the WHOLE small table N times.

8.Related technique: targeted JOIN salting (when the hot key IS known)

More efficient than the general pattern above — use this variant once you've identified the SPECIFIC hot key, without replicating the ENTIRE small table.

-- Large table: only the known hot key gets a randomized salt CASE WHEN f.merchant_id = 123 THEN CAST(FLOOR(RAND() * 8) AS INT) + 1 -- 1..8 ELSE 0 END AS salt -- Small table: hot key replicated 1-8, everyone else stays at 0 SELECT d.*, 0 AS salt FROM dim_merchant d WHERE d.merchant_id <> 123 UNION ALL SELECT d.*, s.salt FROM dim_merchant d JOIN (SELECT 1 AS salt UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8) s ON d.merchant_id = 123
💡 Trade-off vs the general pattern: only the ONE hot row gets replicated 8 times, not the whole table — more efficient once you know exactly which key is hot.
🏗️
Level 3 — Framework (beyond this one job)
Orchestration-layer changes affecting the whole pipeline, not just this Spark job.

9.Framework-level changes (beyond this one job)

Structural changes applied at the orchestration layer, not just inside this one Spark job.

Detail
AreaDescription
🔗 Workflow DependencyRemoved non-critical and redundant dependencies to streamline DAG execution
⏱️ Trigger-Based SchedulingReplaced fixed-time triggers — tasks auto-execute on upstream success
🚨 Monitoring & AlertingAdded alerting for job failures and delays for early detection
🧂 SQL: Multi-Distinct & Skew
A full deep-dive: a concrete example dataset, a complete analysis of what data skew is and why it happens, the specific problems it causes, and finally a step-by-step SQL rewrite from simplest to most optimized — all verified against the same dataset throughout.
📊
Example Dataset
A small, fully-traceable table used throughout this whole analysis.

The dataset used throughout this analysis

12 rows total. Key 'x' has 8 rows (67% of the table) — a deliberately hot key. 'y' and 'z' have 2 rows each, representing normal, evenly-sized keys.

Detail
abcd
x100c1d1
x200c1d2
x150c2d1
x300c1d3
x250c3d2
x120c2d1
x180c4d4
x90c1d2
y50c5d5
y80c6d5
z30c7d6
z40c7d7
💡 In production, 'x' wouldn't be 8 rows — it would be millions, while 'y' and 'z' stay small. The 67% concentration ratio is what matters, not the absolute row count.

The query we're optimizing, and its exact correct answer

This is the ground truth every optimized rewrite in this tab must match exactly.

SELECT a, sum(b), count(distinct c), count(distinct d) FROM test GROUP BY a;
Detail
asum(b)count(distinct c)count(distinct d)
x139044
y13021
z7012
💡 Verify by hand: x has c-values {c1,c2,c3,c4} → 4 distinct. d-values {d1,d2,d3,d4} → 4 distinct. sum(b) = 100+200+150+300+250+120+180+90 = 1390.
🔍
Data Skew — Causes & Problems
Understand WHY skew happens and WHAT it breaks, before touching any SQL.

What is data skew?

Data skew means rows are NOT evenly distributed across the GROUP BY / shuffle key — a small number of key values (like 'x' above) hold a disproportionate share of the total rows.

Row count per key — balanced vs skewed Balanced: every reducer does similar work Skewed (this dataset): key 'x' — 8 rows (67%) 'y','z' — 2 rows each One task handles 'x' alone — it does 4x the work of any other task In production this ratio is often 1000:1 or worse, not 4:1

Common causes of data skew — 5 typical patterns

Skew almost always traces back to a real, non-random property of the business data — not bad luck.

Detail
CauseExample
Power-law user/customer behaviorA handful of VIP accounts or bot accounts generate most transactions
Default / placeholder valuesNULL, 0, or 'UNKNOWN' used as a fallback dominates the join or group key
Popular product / hot categoryOne best-selling SKU or trending category dwarfs all others
Time-based clusteringMost rows are from 'today' in an incrementally-loaded table
Upstream join fan-outA join earlier in the pipeline duplicated rows for one specific key
💡 Diagnosing the CAUSE matters — a NULL-dominated join key is fixed differently (filter or isolate NULLs) than a genuinely hot VIP customer (salting).

What problems does skew actually cause?

Four concrete failure modes, from mild to catastrophic.

Task duration per reducer — one straggler holds up the whole job task for key 'x' — 10x+ longer than the others done 1. Long-tail task — job waits on the slowest reducer to finish 2. Executor OOM — aggregation / shuffle memory pressure exceeds the available executor memory 3. Wasted cluster capacity — 99% of executors sit idle waiting on the 1 straggler 4. Retry storms — a killed/OOM task retries, often failing the same way again
💡 The business impact is usually #1 and #3 combined: the job takes far longer than the DATA VOLUME alone would suggest, because parallelism collapses down to whatever the hot key's single task is doing.

Why multiple distinct expressions can add overhead

Spark can rewrite multiple distinct argument sets using Expand, a group identifier, deduplication aggregates, and shuffles. Row expansion and high cardinality can increase intermediate work and memory pressure. It is not generally implemented as one literal HashSet per expression inside each business-key buffer.

Check EXPLAIN FORMATTED for the actual plan and use task-level spill, GC and peak execution memory to identify the costly stage. Splitting queries or salting is a measured alternative, not a guaranteed improvement.

What memory does an aggregation task use?

A task processes a partition containing potentially many grouping keys. Hash aggregation stores grouping keys and aggregate buffers; exact-distinct rewrites can make the distinct values part of those grouping keys. Sorters, shuffle buffers and other operators also need memory.

The operator may spill or fall back to sorting, but spill does not prevent all executor OOMs. Inspect the deployed Spark version and physical plan instead of multiplying the number of business keys by the number of distinct expressions to infer a count of HashSets.

BEGINNER · ONE DAY · FROM ZERO TO PROJECT

DBT from Zero to a Working Project

Learn what DBT is, why teams use it, how a project runs, and how to build, test, document, deploy, and troubleshoot models.

从 DBT 是什么开始,逐步学习项目运行原理、模型构建、测试、文档、部署与排错。

🧠 Understand 🏗️ Build 🔍 Debug 🎤 Explain
🔄 01 · Your Warehouse vs DBTDefine DBT first, map it to your real warehouse, then decide whether it fits.先说明 DBT 的定义和功能,再对应你的真实数仓,最后判断是否适合引入。

What DBT is, what it does, and why teams use it

🔊 click English

DBT is a transformation engineering framework, not a database, storage system, ingestion tool, or compute engine. It organizes SQL transformations as version-controlled models, resolves ref-based dependencies, compiles Jinja and SQL, calls the connected platform to execute them, and attaches tests, documentation, lineage, environments, and CI workflows to the models.

DBT 是数据转换工程框架,不是数据库、存储系统、采集工具或计算引擎。它把 SQL 转换组织成可版本管理的 Model,用 ref 解析依赖,编译 Jinja 与 SQL,调用连接的数据平台执行,并把测试、文档、血缘、环境和 CI 流程绑定到模型上。

FunctionWhat DBT providesWhat still executes it
Model managementSQL files, ref DAG, naming conventionsGit + team workflow
Quality + knowledgeTests, docs, lineage, ownershipDBT metadata
ExecutionCompile and submit SQLWarehouse, Spark or supported engine
SchedulingInternal model orderDBT Cloud job or Airflow
Model SQLDBTAdapterData platform

Your current warehouse architecture

🔊 click English

Your warehouse is already a complete payment-data platform. Apache InLong captures MySQL changes, MQ buffers events, ODS preserves source truth, DWD and DIM standardize detailed facts and dimensions, DM builds reusable Merchant, Store, and Order subjects, ADS and BI publish outputs, Spark or Hive and TDW perform computation, and Airflow manages the end-to-end DAG, SLA, retries, and alerts.

你现在的方案已经是一套完整的支付数据平台:Apache InLong 捕获 MySQL 变化,MQ 缓冲事件,ODS 保留源端事实,DWD 与 DIM 统一明细和维度,DM 构建 Merchant、Store、Order 可复用主题,ADS 与 BI 输出结果;Spark 或 Hive/TDW 负责计算,Airflow 管理端到端 DAG、SLA、重试和告警。

MySQL BinlogInLong + MQODSDWD + DIMDM + ADS
Current layerMain responsibilityClosest DBT concept
InLong + MQCDC, transport, replayOutside DBT
ODSPreserve source truthsource() declaration
DWD / DIMClean, dedupe, conformstaging / intermediate
DM: Merchant / Store / Order商户、店铺、订单主题Reusable business modelsmarts
ADS / BIReports and consumersexposures / consumers
AirflowCross-system DAG and SLAWorks with DBT

Why DBT is not the best fit for your warehouse right now

🔊 click English

DBT is not automatically a good fit just because this is a warehouse. Your internal Hive or TDW environment may not have a mature supported adapter; many transformations are Spark jobs rather than portable warehouse SQL; Airflow already owns orchestration; and your layered modeling, governance, SLA, reconciliation, security, and cost controls already exist. Migrating mature payment pipelines would add adapter, rewrite, validation, and dual-operation cost without replacing ingestion, Spark tuning, security, or platform operations.

不能因为这是数仓就默认应该使用 DBT。你的内部 Hive/TDW 环境可能没有成熟、可靠的 Adapter;很多转换是 Spark Job,而不是可直接迁移的标准数仓 SQL;Airflow 已经承担调度;分层建模、治理、SLA、对账、安全和成本控制也已经存在。迁移成熟的支付链路会增加适配、重写、验证和双轨运行成本,却无法替代采集、Spark 调优、安全或平台运维。

Fit checkYour situationDecision
Reliable adapterTDW / internal Hive support is uncertainTDW 或内部 Hive 支持不确定Major blocker
Mostly SQL transformsSpark jobs and platform-specific logic are importantSpark Job 与平台逻辑占重要部分Partial fit
Missing engineering workflowAirflow, governance and layered standards already exist已有 Airflow、治理和分层规范Benefit is smaller
Migration riskPayment SLA, reconciliation and compliance are strict支付 SLA、对账和合规要求严格Do not rebuild for DBT
⚖️
Conclusion: learn DBT, but do not force it into the current platform.结论:DBT 值得学习,但不应强行加入当前平台。

When a small DBT pilot would make sense

🔊 click English

A pilot makes sense only when the target platform has a reliable adapter and a real pain exists: scattered SQL, unclear lineage, detached tests and documentation, weak code review, or difficult environment isolation. Start with one non-critical DM or ADS domain on a supported engine, keep InLong, MQ, Spark, Airflow, and governance unchanged, then measure maintainability, failure rate, delivery speed, and operating cost before expanding.

只有当目标平台具备可靠 Adapter,并且确实存在 SQL 分散、血缘不清、测试文档脱节、代码评审薄弱或环境隔离困难等痛点时,才值得试点。可以在受支持引擎上选择一个非关键 DM 或 ADS 主题域;InLong、MQ、Spark、Airflow 和治理体系保持不变,再通过维护性、失败率、交付速度和运维成本判断是否扩大。

Confirm painCheck adapterSmall DM / ADS pilotMeasure resultsAdopt only if better
🧱 02 · DBT FoundationsWhat DBT is, how a project runs, and the core concepts used everywhere.一次理解 DBT 是什么、项目如何运行,以及贯穿全程的核心概念。

DBT in one sentence

🔊 click English

DBT is a transformation framework that turns SELECT statements into managed warehouse models. It resolves dependencies, compiles SQL, asks the data platform to execute it, and records tests, documentation, and lineage.

DBT 是管理数据转换工程的框架:把 SELECT SQL 组织成模型,解析依赖,编译 SQL,调用数据平台执行,并记录测试、文档和血缘。

SELECT SQLDBTWarehouseTrusted model
DBT manages the work; the warehouse performs the computation.DBT 管理转换工程,真正计算仍由数仓或 Spark 完成。

Why teams introduce DBT

🔊 click English

Without a framework, SQL often lives in scattered scripts, dependencies are implicit, data tests run separately, and documentation becomes stale. DBT puts model code, dependency declarations, tests, and documentation in one version-controlled project.

没有统一框架时,SQL 往往散落在脚本中,依赖靠人记,测试单独维护,文档很快过期。DBT 把模型代码、依赖声明、测试和文档放进同一个可版本管理的项目。

Scattered SQLImplicit DAGDBT projectReview + CI

What DBT does not replace

🔊 click English

DBT does not ingest MySQL binlogs, replace MQ, store data, or provide distributed compute by itself. It starts after source data is already available in a supported data platform. Airflow can still schedule the whole pipeline.

DBT 不负责读取 MySQL Binlog,不替代 MQ,不自己存数据,也不独立提供分布式计算。它通常从原始数据已经进入受支持的数据平台后开始工作;Airflow 仍可负责整条链路调度。

Apache InLong / CDCMQODS tablesDBT starts here

DBT Core and DBT Cloud

🔊 click English

DBT Core is the open-source command-line engine you install and run yourself. DBT Cloud is a managed service built around the same project concepts, adding a browser IDE, hosted job scheduling, managed credentials, collaboration, and platform features. Learn the shared project language first; choose the operating model later.

DBT Core 是需要自行安装和运行的开源命令行引擎;DBT Cloud 是围绕同一套项目概念提供的托管服务,增加浏览器 IDE、托管任务调度、凭据管理、协作与平台能力。先掌握两者共用的项目语言,再决定采用哪种运行方式。

OptionYou manageBest for
DBT CoreRuntime, scheduling, credentialsLocal learning and custom platforms
DBT CloudProject logic and job settingsManaged teamwork and deployment

The four parts of a project

🔊 click English

Models contain transformation SQL. YAML files describe sources, tests, and documentation. Macros package reusable Jinja logic. Project configuration controls folders, materializations, schemas, and environments.

Models 保存转换 SQL;YAML 描述 Source、测试和文档;Macros 封装可复用的 Jinja 逻辑;项目配置负责目录、物化方式、Schema 和环境。

models/*.sql*.ymlmacros/dbt_project.yml

From model code to a warehouse object

🔊 click English

DBT reads the project, resolves ref and source dependencies, renders Jinja into SQL, orders models as a DAG, and submits the compiled statements to the connected data platform. The adapter translates DBT operations for that platform.

DBT 读取项目,解析 ref 与 source 依赖,把 Jinja 渲染成 SQL,按 DAG 排序模型,再把编译后的语句提交给连接的数据平台;Adapter 负责适配具体平台。

ParseCompileDAG orderAdapterExecute

Where computation really happens

🔊 click English

DBT is the control and engineering layer, not the compute engine. S, BigQuery, D, Redshift, Postgres, or another supported platform stores data and executes SQL. A slow query still needs platform-level SQL and storage optimization.

DBT 是控制与工程化管理层,不是计算引擎。S、BigQuery、D、Redshift、Postgres 或其他受支持的平台负责存储和执行 SQL;慢查询仍要在平台层优化 SQL、存储和资源。

DBT projectAdapterData platformTables / views

Development, CI, and production

🔊 click English

Developers build selected models in a personal schema, CI validates only the changed dependency graph, and production runs approved code on a schedule. Separate targets and credentials prevent practice work from overwriting production data.

开发者在个人 Schema 中构建选定模型;CI 验证发生变化的依赖图;生产环境按计划运行已审批代码。不同 Target 和凭据可防止练习或开发数据覆盖生产数据。

Dev schemaPull requestCI buildProd job

profiles.yml, targets, and adapters

🔊 click English

dbt_project.yml describes the project, while profiles.yml usually supplies the connection profile outside the project repository. A target selects an environment such as dev or prod, and the adapter translates DBT operations into the dialect and connection behavior of the chosen data platform. Keep passwords out of Git and inject secrets through environment variables or a managed credential store.

dbt_project.yml 描述项目本身;profiles.yml 通常在项目仓库之外提供连接配置。Target 用来选择 dev、prod 等环境;Adapter 把 DBT 操作翻译成目标数据平台的 SQL 方言与连接行为。密码不要提交到 Git,应通过环境变量或托管凭据系统注入。

dbt_project.ymlprofiles.ymltarget: dev/prodAdapterWarehouse
profiles.yml (shape only)
collection_dbt:
  target: dev
  outputs:
    dev:
      type: <adapter>
      schema: dev_alice
      password: "{{ env_var('DBT_PASSWORD') }}

1. Model

🔊 click English

A DBT model is usually one SQL file containing a SELECT statement. The file name becomes the model name. DBT turns that SELECT into a view, table, incremental table, or another supported object.

一个 DBT Model 通常就是一个包含 SELECT 的 SQL 文件;文件名就是模型名。DBT 会把这条 SELECT 创建成 View、Table、Incremental Table 或其他受支持对象。

stg_settlement.sqlSELECTRelation

2. source() and ref()

🔊 click English

source() points to an external table that DBT does not build, such as your ODS settlement table. ref() points to another DBT-managed resource. ref() also creates a dependency edge, so DBT knows the build order and resolves the correct environment schema.

source() 指向 DBT 不负责构建的外部表,例如你的 ODS 结算表;ref() 指向另一个由 DBT 管理的资源。ref() 还会创建依赖边,因此 DBT 知道构建顺序,并能解析开发或生产环境的正确 Schema。

source('ods','settlement')stg_settlementref('stg_settlement')mart_merchant_daily

3. DAG and build lifecycle

🔊 click English

DBT parses project files, compiles Jinja into executable SQL, orders models through the dependency graph, executes them on the data platform, and then evaluates tests. A failure should be classified as parse, compile, run, or test before troubleshooting.

DBT 先解析项目文件,再把 Jinja 编译成可执行 SQL,根据依赖图排序模型,交给数据平台执行,最后运行测试。排错时应先判断失败属于 Parse、Compile、Run 还是 Test。

ParseCompileRunTest

4. Materialization

🔊 click English

Materialization defines how a model is persisted. The same SELECT can become a view for light logic, a table for expensive reusable results, or an incremental model that processes only new and changed rows.

Materialization 决定模型怎样落地。同一条 SELECT 可以生成轻量 View、昂贵但可复用的 Table,或只处理新增和变化记录的 Incremental Model。

One SELECTviewtableincremental

5. Tests and documentation

🔊 click English

Tests express assumptions such as primary-key uniqueness, required fields, accepted status values, and valid relationships. Documentation lives beside the model, so the generated catalog and lineage are tied to the code version.

测试把数据假设写成代码,例如主键唯一、字段非空、状态值合法、外键关系有效。文档与模型放在一起,因此生成的数据目录和血缘能对应具体代码版本。

Model SQLYAMLdbt buildDocs + lineage
🗂️ 03 · Build Your First ProjectBuild a small end-to-end project with a familiar payment example.用熟悉的支付示例完成一个小型端到端 DBT 项目。

Project structure

🔊 click English

Organize by transformation stage and business domain. Staging stays close to source systems; intermediate models hold reusable joins or reshaping; marts expose stable business entities and metrics.

目录同时体现转换阶段和业务域。Staging 贴近源系统;Intermediate 放可复用 Join 或重塑逻辑;Marts 输出稳定的业务实体和指标。

Minimal project tree
collection_dbt/
├── dbt_project.yml
├── models/
│   ├── staging/collection/
│   │   ├── _collection__sources.yml
│   │   └── stg_collection__settlements.sql
│   ├── intermediate/
│   │   └── int_collection__settlement_enriched.sql
│   └── marts/
│       ├── mart_merchant_daily.sql
│       └── marts.yml
├── macros/
├── tests/
└── seeds/

Step 1: declare the ODS source

🔊 click English

Declare the existing ODS table in YAML. This does not copy the data. It gives the raw table a stable project name and a place for freshness, descriptions, and source-level tests.

在 YAML 中声明已有 ODS 表,这一步不会复制数据;它只是为原始表建立稳定的项目名称,并承载新鲜度、字段说明和源表测试。

_collection__sources.yml
version: 2

sources:
  - name: ods_collection
    schema: ods
    tables:
      - name: settlement_event
        description: "Raw A settlement events / A 原始结算事件"
        columns:
          - name: settlement_id
            tests: [not_null, unique]

Step 2: build a staging model

🔊 click English

Keep staging focused: rename fields, cast types, normalize codes, and perform source-specific cleanup. Do not mix final KPI aggregation into this layer.

Staging 应保持专注:重命名字段、统一类型、规范编码并处理源系统特有的清洗;不要在这一层混入最终 KPI 聚合。

stg_collection__settlements.sql
-- models/staging/collection/stg_collection__settlements.sql
with source as (
    select *
    from {{ source('ods_collection', 'settlement_event') }}
),

renamed as (
    select
        cast(settlement_id as string)  as settlement_id,
        cast(merchant_id as string)    as merchant_id,
        cast(store_id as string)       as store_id,
        upper(currency)                as currency_code,
        cast(amount as decimal(18, 2)) as settlement_amount,
        cast(event_time as timestamp)  as settlement_at
    from source
)

select * from renamed

Step 3: build the reusable Merchant mart

🔊 click English

Join reusable business context in intermediate models, then aggregate at an explicitly declared mart grain. Here the grain is one merchant per day, matching the daily Merchant subject in your existing DM design.

先在 Intermediate 中连接可复用业务信息,再按明确粒度聚合到 Mart。这里的粒度是“每天每个 Merchant 一行”,对应你原 DM 中的日级 Merchant 主题。

ODS settlementstg_settlementint_enrichedmart_merchant_daily
mart_merchant_daily.sql
-- models/marts/mart_merchant_daily.sql
{{ config(materialized='incremental', unique_key=['dt','merchant_id']) }}

select
    cast(settlement_at as date) as dt,
    merchant_id,
    count(*)                    as settlement_count,
    sum(settlement_amount)      as settlement_amount
from {{ ref('int_collection__settlement_enriched') }}

{% if is_incremental() %}
where settlement_at >= dateadd(day, -2, current_date)
{% endif %}

group by 1, 2
🛡️ 04 · Quality, Docs & MaterializationsTest and document models, then choose how each result should be persisted.为模型添加测试与文档,再决定每个结果应如何落地。

Data quality as code

🔊 click English

Your existing checks—zero records, primary-key uniqueness, valid status values, and SLA alerts—already express data contracts. DBT lets you attach many of those assertions directly to sources and models, then execute them with the build.

你原来的零记录检查、主键唯一性、状态值合法性和 SLA 告警,本质上已经是数据契约。DBT 可以把其中很多断言直接绑定到 Source 和 Model,并随构建一起执行。

marts.yml
version: 2

models:
  - name: mart_merchant_daily
    description: "One row per merchant per day / 每天每个商户一行"
    columns:
      - name: merchant_id
        tests: [not_null]
      - name: settlement_amount
        tests:
          - dbt_utils.expression_is_true:
              expression: ">= 0"

      # Example for a status column:
      # - name: settlement_status
      #   tests:
      #     - accepted_values:
      #         values: ['PENDING', 'PAID', 'FAILED']

    # Composite-grain uniqueness can be implemented with a generic test
    # or a surrogate key such as dt + merchant_id.

Where to test

🔊 click English

Test source assumptions at the source or staging boundary, reusable business rules in intermediate models, and final grain and KPI guarantees in marts. Do not repeat every test on every layer; test the contract each layer promises.

源系统假设在 Source 或 Staging 边界测试;可复用业务规则在 Intermediate 测试;最终粒度和 KPI 保证在 Marts 测试。不要每层机械重复所有测试,而要检查该层承诺的契约。

SourceStagingIntermediateMart

Docs and lineage

🔊 click English

Descriptions in YAML, ref-based dependencies, and generated artifacts make model ownership and impact analysis easier. A change to settlement_amount can be traced through intermediate models to Merchant marts and dashboards.

YAML 描述、ref 依赖和生成的元数据让模型责任和影响分析更清楚。settlement_amount 的变化可以沿 Intermediate、Merchant Mart 一直追踪到 Dashboard。

Column changeLineageAffected martsDashboard owner

Other resources: seed, snapshot, and exposure

🔊 click English

A seed loads a small version-controlled CSV such as a country-code mapping. A snapshot records how mutable source rows change over time, often as slowly changing history. An exposure declares a downstream dashboard, report, application, or ML use case so ownership and lineage extend beyond the final mart. These resources solve different problems and are not substitutes for models.

Seed 把国家代码映射等小型 CSV 作为版本化数据加载;Snapshot 记录可变源记录随时间的变化,常用于缓慢变化历史;Exposure 声明下游 Dashboard、报表、应用或机器学习用途,让责任人和血缘延伸到最终 Mart 之外。三者解决不同问题,都不能替代 Model。

ResourceSolvesSimple example
seedSmall static reference datacountry_codes.csv国家代码表
snapshotTrack row changes over timemerchant status history
exposureOwnership and downstream lineagesettlement dashboard
Source row changesSnapshot historyMartExposure

Five built-in choices

🔊 click English

DBT provides view, table, incremental, ephemeral, and materialized_view materializations. Adapter support varies, so the data platform determines which behaviors are available.

DBT 提供 View、Table、Incremental、Ephemeral 和 Materialized View 五种内置物化方式;不同 Adapter 的支持情况不同,最终能力由数据平台决定。

TypeBest fitMain trade-off
viewLight staging logicRecomputes on query
tableExpensive reusable martsFull rebuild cost
incrementalLarge factsMore correctness logic
ephemeralTiny helper logicNot independently queryable
materialized_viewPlatform-managed refreshAdapter/platform limits

Incremental is not just a date filter

🔊 click English

A safe incremental model needs a stable unique key, a strategy for inserts and updates, a lookback window for late records, deduplication of source versions, and reconciliation against trusted totals. A full refresh is required when historical logic changes beyond the incremental window.

安全的增量模型需要稳定唯一键、插入与更新策略、迟到数据回看窗口、源端版本去重以及可信总量对账。如果历史逻辑发生变化并超出增量窗口,就需要 Full Refresh。

New + changed rowsLookbackMerge / insertReconcile

How to choose by model layer

🔊 click English

A practical starting point is staging views, intermediate views or ephemeral helpers when they are small, tables for expensive reusable dimensions and marts, and incremental models for large settlement or transaction facts. Measure before optimizing.

实用起点是:Staging 用 View;较小的 Intermediate 用 View 或 Ephemeral;昂贵且高复用的维度和 Mart 用 Table;大型结算或交易事实表用 Incremental。先测量,再优化。

StagingIntermediateDimensions + martsLarge facts
▶️ 05 · Development, Deployment & TroubleshootingUse a repeatable daily workflow and diagnose failures by execution stage.建立可重复的日常流程,并按执行阶段定位故障。

The daily development loop

🔊 click English

Work in a personal development schema, compile the selected model, build only the affected graph, inspect outputs and failing rows, open a pull request, run CI in an isolated environment, and deploy approved code to production.

在个人开发 Schema 中工作;编译目标模型;只构建受影响依赖图;检查输出与失败行;提交 Pull Request;在隔离环境运行 CI;审批后部署到生产。

EditCompileBuildPR + CIDeploy

Commands you need on day one

🔊 click English

Start with debug, compile, build, show, and selectors. Prefer dbt build for a selected graph because it runs the selected resources in dependency order and includes tests; remember that source freshness is normally a separate check.

第一天先掌握 debug、compile、build、show 和选择器。对选定依赖图优先使用 dbt build,因为它会按依赖顺序构建资源并执行测试;Source Freshness 通常需要单独检查。

CLI practice
# Validate connection and project configuration
dbt debug

# See compiled SQL without creating models
dbt compile --select mart_merchant_daily

# Create selected models only (does not run tests)
dbt run --select +mart_merchant_daily

# Run tests only
dbt test --select mart_merchant_daily

# Build a mart plus all upstream dependencies
dbt build --select +mart_merchant_daily

# Build everything downstream from staging
dbt build --select staging+

# Preview rows during development
dbt show --select mart_merchant_daily

# Generate and open the documentation site
dbt docs generate
dbt docs serve

# Check declared source freshness separately
dbt source freshness

# Rebuild complete history only when required
dbt build --full-refresh --select mart_merchant_daily

DBT with Airflow

🔊 click English

Airflow can continue coordinating CDC completion, upstream readiness, DBT builds, source freshness, exports, and alerts. DBT owns transformation models and their internal dependency graph; Airflow owns the cross-system workflow and operational schedule.

Airflow 仍可协调 CDC 完成、上游就绪、DBT Build、Source Freshness、导出和告警。DBT 管转换模型及内部依赖图;Airflow 管跨系统工作流和运行计划。

Ingestion sensordbt buildFreshness / auditPublish ADSAlert

Parse or compile failure

🔊 click English

Check YAML indentation, project configuration, model names, source and ref spelling, Jinja braces, and the compiled SQL under target. dbt debug verifies connectivity and configuration; it does not prove every model is correct.

检查 YAML 缩进、项目配置、模型名、source/ref 拼写、Jinja 括号,以及 target 目录中的编译 SQL。dbt debug 验证连接和配置,但不能证明每个模型都正确。

Error messageYAML / JinjaCompiled SQLFix smallest cause

Run failure

🔊 click English

Open the compiled SQL and run it against the target platform. Check permissions, missing relations, unsupported SQL functions, schema changes, query plans, data volume, and warehouse or Spark runtime logs.

打开编译后的 SQL,并在目标平台验证。检查权限、缺失表、平台不支持的函数、Schema 变化、执行计划、数据量,以及数仓或 Spark 运行日志。

Compiled SQLPlatform errorPlan + logsCorrect model

Test failure

🔊 click English

Inspect the failing rows. Confirm the promised grain, duplicate keys, nulls, accepted values, join cardinality, and source quality. Do not disable a test simply because it blocks deployment; decide whether the data or the contract is wrong.

查看具体失败行,确认承诺的粒度、重复键、空值、合法值、Join 基数和源数据质量。不要因为测试阻塞部署就直接关闭它;应判断是数据错误,还是契约定义错误。

Failing rowsCheck grainData or rule?Fix + rebuild

Freshness and SLA

🔊 click English

A DBT job can finish successfully while the business SLA still fails because ingestion was late, an upstream source was stale, or publication was delayed. Monitor the end-to-end path, not only the DBT command status.

DBT Job 即使成功,业务 SLA 仍可能失败,因为采集迟到、上游 Source 过期或发布延迟。监控必须覆盖端到端链路,不能只看 DBT 命令是否成功。

Source arrivalDBT buildTestsADS publishBusiness SLA
🗓️ 06 · One-Day Learning PlanFinish with a focused study route and an interview-ready explanation.用聚焦的学习路线和面试表达完成收尾。

Morning: understand the system

🔊 click English

Learn what DBT is, where it starts in a data pipeline, how source and ref create a graph, and how staging, intermediate, marts, tests, and documentation fit together.

理解 DBT 是什么、在数据链路哪里开始、source/ref 如何形成依赖图,以及 staging、intermediate、marts、测试和文档怎样组合成完整项目。

09:0010:0011:0012:00

Afternoon: build the collection example

🔊 click English

Create the project tree, declare the ODS settlement source, build staging and intermediate models, create the daily Merchant mart, add tests, and run dbt build on the selected dependency graph.

创建项目目录,声明 ODS 结算 Source,构建 Staging 和 Intermediate,创建日级 Merchant Mart,添加测试,再对选定依赖图执行 dbt build。

13:3015:0016:3017:30

Evening: explain it aloud

🔊 click English

Explain the difference between DBT and a compute engine, source and ref, run and build, and table and incremental. Then explain one model from source declaration through testing and production deployment.

脱稿说明 DBT 与计算引擎、source 与 ref、run 与 build、table 与 incremental 的区别,再讲清一个模型如何从 Source 声明走到测试和生产部署。

ExplainCompareDemoReview

A 30-second interview answer

🔊 click English

I use DBT to manage warehouse transformations as version-controlled models. Sources declare upstream tables, staging models standardize raw fields, intermediate models package reusable logic, and marts expose business-ready datasets. ref builds the dependency graph, tests enforce data contracts, and an external orchestrator can still manage the end-to-end SLA.

我使用 DBT 把数仓转换逻辑管理成可版本控制的模型。Sources 声明上游表;Staging 标准化原始字段;Intermediate 封装可复用逻辑;Marts 输出业务可用数据集。ref 构建依赖图,Tests 保障数据契约,外部调度器仍可管理端到端 SLA。

🎯
If you can explain this without reading, you understand the core.如果你能脱稿讲清这段话,就已经掌握 DBT 的核心。