Spark & Lakehouse Study Guide

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

🍵 Tea / Finder — One-Day Fast Track

Goal: understand the platform role first, then learn the troubleshooting path. Do not try to become a ClickHouse or Hadoop expert in one day.

P0 · platform scopeP0 · ClickHouse diagnosisP0 · Spark stability storyP1 · HDFS / YARNP1 · Linux / OS
🧭

What Tea / Finder Needs

Start from the product and map it to platform responsibilities.

›01
Tea / Finder
User-event analytics · funnel · retention · segmentation
↓
Analytics Query Layer
Fast filters · aggregation · drill-down over massive event data
↓
ClickHouse / ByteHouse
Low-latency OLAP serving
↓
Durable Storage
HDFS / object storage · historical data
Compute
Spark and batch processing
↓
Platform Engineering
Deploy · monitor · tune · upgrade · recover
Memory: the role is about keeping a distributed analytics platform fast, stable, observable, and recoverable.
🗺️

End-to-End Platform Mental Model

One flow to connect event ingestion, storage, compute, and fast analytics.

›02
EventsApp / web user behavior
IngestionEvent collector / Kafka-like log
Storage + ComputeHDFS / object storage + Spark
OLAP ServingClickHouse / ByteHouse
Tea QueryFunnel · retention · segmentation
Use this as a mental model, not as a claim about every internal Tea component. It helps you explain transport vs storage vs compute vs serving clearly.

Storage question

Where is historical data kept? How is durability and replication handled?

Compute question

Where do heavy ETL / aggregation jobs run? How do you diagnose long-tail tasks?

Serving question

How do users get low-latency aggregation over a huge event table?

🛠️

Production Troubleshooting — Narrow the Scope

The strongest answer is a layer-by-layer diagnosis, not a random list of configuration changes.

›03
ObserveLatency · failures · saturation
ScopeOne query? shard? node? cluster?
Locate LayerQuery · storage · network · host · recent change
MitigateRollback · isolate · scale · fail over
PreventRCA · alert · test · runbook
SymptomFirst QuestionsLikely Layers
Latency spikeOne query or whole cluster? recent deploy? traffic change?query plan · CPU · disk I/O · shard imbalance · merge pressure
Node unhealthyProcess alive? disk full? port open? replica healthy?service · OS · disk · network · replication
Job stuckwaiting for resources or already running?YARN queue · Stage · Task · skew · GC · I/O
Upgrade regressionwhat changed? canary vs baseline?compatibility · config · query plan · rollback
🐧

Linux / OS Quick Triage

The JD explicitly values OS fundamentals; know how to separate CPU, memory, disk, network, and service failures.

›04
Process / CPUps · top · top -HWhich process / thread is hot?
Memoryfree -h · vmstatHost pressure, swap, OOM?
Diskdf -h · iostat -xFull disk, high await, high %util?
Networkss -tlnp · ss -sPort listening? connection pressure?
Service / Logssystemctl · journalctlProcess failed? recent errors?
# fast host triage free -h vmstat 2 top df -h iostat -x 2 ss -tlnp systemctl status <service> journalctl -u <service> --since "10 min ago"
Connection to your Spark story: one slow Task is not always skew. If one worker has very high disk await / %util, the root cause can be host I/O instead.
🌍

Global Platform Operations

Think about multi-region rollout, capacity, change safety, and failure recovery—not only local query performance.

›05
P0

Platform Health

  • latency and error rate
  • CPU / memory / disk / network
  • replica / node health
P0

Performance

  • slow query / heavy scan
  • skew and long-tail Tasks
  • merge / part pressure
P1

Change Safety

  • compatibility testing
  • canary rollout
  • rollback and validation
Designcapacity + failure domain
Deployautomation + config
Observemetrics + logs + alerts
Recoverfailover + rollback
ImproveRCA + prevention
⏱️

One-Day Preparation — What Is Enough?

Use the 80/20 rule. Fluency on P0 matters more than shallow coverage of everything.

›06
P0 · Must explain without notesClickHouse: columnar OLAP, MergeTree, PARTITION BY vs ORDER BY, shard vs replica, small parts / merge pressure, slow-query troubleshooting.
P0 · Must lead with one production storyYour Spark SLA case: Spark UI → hot keys → config first → two-stage aggregation / salting → validation → stable SLA.
P1 · Must know the architectureHDFS NameNode / DataNode / blocks / replication; YARN ResourceManager / NodeManager / ApplicationMaster.
P1 · OS + distributed systemsCPU / memory / disk / network triage; sharding, replication, retries, idempotency, consistency.
P1 · Coding safety checkSpend at least 30–45 minutes on Python data structures, HashMap / Top-K / sliding window, and SQL Window basics.
P2 · Do not over-invest todayDeep Kubernetes, deep Flink internals, deep Hive internals. Learn only the high-level mapping if time remains.
One-day answer: if you can explain all P0 items smoothly and reason through the troubleshooting flows, you have covered the highest-value part of this role. It does not guarantee the exact question set, so keep a short coding / OS review at the end.
🔥

Project Story to Lead With

Your existing Spark SLA case is your strongest evidence of platform troubleshooting and stability ownership.

›07
ContextCritical Spark aggregation · unstable runtime · production SLA
EvidenceSpark UI · outlier Tasks · Shuffle Read · GC / OOM
Fixconfig first · then two-stage aggregation + targeted salting
Resultruntime stabilized · OOM resolved · SLA > 99%
Lead with this story. It directly demonstrates the same mindset the role needs: observe → diagnose → mitigate → validate → prevent recurrence.
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
⭐ Full Story
Two ready-to-say versions, broken into short segments so you can practice and recall them piece by piece. Each segment has its own Listen button; the card title also plays the whole thing.

⚡ 30-Second Version

1. "I stabilized a critical Spark job that was hitting OOM errors and missing its SLA deadline."

2. "The root cause was two hot keys forcing a massive in-memory HashSet during a COUNT DISTINCT."

3. "I fixed it with two-stage aggregation, targeted salting on those hot keys, and AQE tuning."

4. "Runtime dropped from 75 to 13 minutes, and the job's on-time SLA compliance rate improved from 95% to over 99%."

📖 Full STAR Version

Situation"Our cross-border remittance platform ran a daily Spark job powering remittance analytics — about 12 million transactions and 50 billion RMB a year."

Situation (problem)"Runtime fluctuated between 30 and 75 minutes, sometimes hitting executor out-of-memory errors, and the job's on-time SLA compliance rate had dropped to around 95%."

Task"I was responsible for stabilizing this critical-path job — getting it reliably completed within the SLA window, without sacrificing accuracy, since the metrics fed compliance and business reporting."

Action (diagnosis)"I traced the root cause to two extremely hot keys — together about 7 million transactions — forcing Spark's COUNT DISTINCT into a single giant in-memory HashSet per group."

Action (fix 1)"First, I tried the cheapest fix — pure configuration: tuning shuffle partitions from 200 up to 2000 and enabling Adaptive Query Execution, no code change needed."

Action (fix 2)"Second, still without touching the query, I switched from HashAggregate to SortAggregate to remove most of the OOM risk."

Action (fix 3)"Since config alone wasn't fully sufficient, I then rewrote the query using two-stage aggregation, converting that unspillable HashSet into many small spillable groups."

Action (fix 4)"Finally, for the two specific extreme hot keys that remained the bottleneck, I applied targeted salting — splitting them into 8 buckets each using a deterministic hash, keeping results exact rather than approximate."

Action (framework)"I also made framework-level changes — trigger-based scheduling and failure alerting — to catch future issues earlier."

Result"Runtime dropped from 75 minutes to 13 minutes, about a 6x improvement."

Result (stability)"OOM errors were fully resolved, and GC pauses dropped from over 500 to under 100 per run."

Result (SLA)"The job's on-time SLA compliance rate improved from about 95% to over 99% — meaning it now meets its deadline on more than 99% of days, instead of missing it roughly 1 in 20 days."

🎯🔧 Situation & Action
S — Situation

The business context

Dimensions: sender country, remittance partner, currency. Metrics: transaction count, unique senders, remittance amount, converted amount, and average transaction amount.

Business metrics used throughout this SLA case

tx_countCOUNT(transaction_id) = total remittance transactions / orders.
sender_countCOUNT(DISTINCT sender_id) = unique remittance senders.
total_amountSUM(amount) = total remittance amount in the original currency. Safe here because currency is part of the GROUP BY key.
total_cnh / total_usdConverted remittance amount after joining the daily FX rate.
avg_txn_amt_cnhtotal_cnh / tx_count = average remittance amount per transaction in CNH.
Rule for every rewrite: Config tuning, Two-stage, and Two-stage + Targeted Salting must preserve all of these business metrics exactly.

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 — the original (broken) query

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;
⚠️ COUNT(DISTINCT ...) forces a single giant in-memory HashSet per group — the direct cause of OOM under skew.
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

The precise mechanical reason COUNT(DISTINCT) plus skew equals OOM.

Shuffle by key HashMap per key Holds group + agg buffer DISTINCT set grows Per group, unbounded Memory pressure rises OOM crash Fallback not early enough Spill to disk SortAgg fallback triggers
💡 This is exactly why Fix 3 (SortAggregate) helps: it spills to disk safely instead of crashing when memory runs out.
⚙️
Level 1 — Config (no code change)
Cheapest fixes. Pure Spark settings — the query stays untouched.

3.Fix 1: Tune Spark configuration & enable AQE — try this FIRST, no code change

Always try config first — if this alone solves it, you never have to touch the query logic at all.

--num-executors 100 --executor-cores 4 --executor-memory 6G --driver-memory 4G SET spark.sql.shuffle.partitions = 2000; SET spark.dynamicAllocation.enabled=true; SET spark.dynamicAllocation.maxExecutors=120; SET spark.dynamicAllocation.minExecutors=5; SET spark.sql.adaptive.enabled = true; SET spark.sql.adaptive.coalescePartitions.enabled = true; SET spark.sql.adaptive.skewJoin.enabled = true; SET spark.sql.adaptive.skewedPartitionThresholdInBytes = 64MB; SET spark.sql.adaptive.skewedPartitionFactor = 5; SET spark.sql.autoBroadcastJoinThreshold = 10MB;
Detail
AQE mechanismWhat it does
Coalesce partitionsMerges small shuffle partitions at runtime
Skew join splitSplits large skewed partitions into multiple tasks
Switch join strategySortMergeJoin → BroadcastHashJoin at runtime based on real stats
💡 2000 shuffle partitions (up from default 200) gave finer-grained skew detection, and AQE cleaned up the MILD skew automatically — but the two EXTREME hot keys were still too large for AQE to split alone.

4.Fix 2: Switch HashAggregate → SortAggregate — still just a config flag

Still a pure config change — no query rewrite yet. This alone eliminates most of the OOM risk.

SET spark.sql.execution.useObjectHashAggregateExec = false; SET spark.sql.execution.useHashAggregateExec = false;
Detail
FeatureHashAggregateSortAggregate
Memory UsageHigh (hash map)Lower (disk spillable)
OOM RiskHighMuch lower
Skewed DataPoorStable
💡 After this step, OOM crashes stopped — but runtime was STILL inconsistent, because the two hot keys were still funneling millions of rows through one aggregation path.
💻
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
Single-stageTwo-stage
Memory modelOne giant HashSetMany small groups
SpillableNoYes
OOM riskHighLow
-- 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(*) AS sender_count FROM dedup d GROUP BY d.partner_remittance_provider, d.sender_country, d.currency;
💡 This converts one unspillable giant HashSet into many small spillable groups — runtime became stable, but the 2 extreme hot keys were STILL the slowest part of every run.

Expanded analysis — what the two stages actually do

Stage 1 changes the aggregation grain from one hot business key to (business key, sender_id). Instead of one Task maintaining a very large distinct-state structure for the whole hot group, sender-level groups can be processed across many partitions.

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.
Why this is safer: it removes the large per-group DISTINCT state from the final aggregation. The second stage only combines already-reduced sender-level rows.
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. OOM risk is much lower, but 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 -- one giant per-group distinct set. 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 removes the expensive exact-DISTINCT state by changing the grain to (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 onlyUse first after config. Best when distinct-state pressure is the main problem and the final reducers are reasonably balanced.
→
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: two-stage reduces the expensive distinct state, but 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.
Originalone hot key → one huge DISTINCT state
→
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 balanceThe two hot keys spread across roughly 8 sender buckets instead of one final hot partition.
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 — the hot task's in-memory state (HashSet) outgrows its heap 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 multi-COUNT(DISTINCT) makes it worse specifically

With two distinct dimensions (c and d) in the same GROUP BY, the hot key's single task must hold TWO separate deduplication structures in memory at once — for the SAME oversized partition.

💡 This is the same underlying failure as the HashAggregate OOM in the SLA case study — just doubled, because c and d each need their own HashSet on the same hot task.

What does a task's memory actually look like, precisely?

A task doesn't process just ONE key — it processes a whole batch of keys. Internally it holds one HashMap, where EACH key gets its own aggregation buffer, and each COUNT(DISTINCT) column gets its own HashSet inside that buffer.

Task 3 — HashMap<key, AggregationBuffer> a = 'x' (hot key, millions of rows) sum_b accumulator: 1 number (cheap) HashSet_c: {c1, c2, c3, ... } — grows toward millions of entries HashSet_d: {d1, d2, d3, ... } — grows toward millions of entries a = 'p' (normal key, a few hundred rows) sum_b accumulator + small HashSet_c + small HashSet_d a = 'q' (normal key, a few hundred rows) sum_b accumulator + small HashSet_c + small HashSet_d Total HashSets in this task = (number of keys) × (number of DISTINCT columns) = 3 × 2 = 6 But only the hot key's 2 HashSets are actually large enough to cause OOM
💡 The formula is: total HashSets per task = (distinct GROUP BY keys landing on that task) × (number of COUNT(DISTINCT) expressions). With 3 keys and 2 distinct columns, that's 6 — but memory pressure comes almost entirely from the hot key's 2 oversized ones, not the other small ones.
✅
Level 1 — Baseline (try this first)
The plain, obvious query. Fine for small-to-medium data — don't optimize prematurely.

Just use plain GROUP BY — this is the right default

Simple, readable, and correct. For this dataset (or anything small-to-medium), this is the right choice — don't reach for anything fancier without evidence of a real problem.

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
💡 Premature optimization trades away readability for a performance problem that may not exist. Write this first, then measure.

When does it actually become a problem?

Only optimize once one of these is genuinely true — don't guess.

Detail
SignalWhy it matters
Table has 10M+ rowsMore raw data funneling into each group's HashSets
c or d has high cardinalityBigger HashSets per group
Data is skewed on a (like key 'x' here)One task does disproportionately more work than others
Spark UI shows one long-tail task or an OOMConcrete evidence, not a guess
🔀
Level 2 — Split into single-dimension subqueries
Only reached because Level 1 showed real slowness or OOM on large data.

Split into single-dimension subqueries, then JOIN

Each subquery handles exactly ONE aggregation dimension, so no single task ever needs to hold two HashSets at once.

SELECT t1.a, t1.sum_b, t2.cnt_c, t3.cnt_d FROM (SELECT a, SUM(b) AS sum_b FROM test GROUP BY a) t1 JOIN (SELECT a, COUNT(DISTINCT c) AS cnt_c FROM test GROUP BY a) t2 ON t1.a = t2.a JOIN (SELECT a, COUNT(DISTINCT d) AS cnt_d FROM test GROUP BY a) t3 ON t1.a = t3.a;
Detail
asum_bcnt_ccnt_d
x139044
y13021
z7012
💡 Matches the ground truth exactly. Trade-off: 3 full table scans instead of 1, plus 2 joins.

Alternative: UNION ALL with a tag column (single scan)

Tags each row with which dimension it belongs to, scans the table only once, then filters per-dimension inside the aggregate functions.

SELECT a, SUM(CASE WHEN tag=0 THEN b END) AS sum_b, COUNT(DISTINCT CASE WHEN tag=1 THEN c END) AS cnt_c, COUNT(DISTINCT CASE WHEN tag=2 THEN d END) AS cnt_d FROM ( SELECT a, b, NULL AS c, NULL AS d, 0 AS tag FROM test UNION ALL SELECT a, NULL, c, NULL, 1 FROM test UNION ALL SELECT a, NULL, NULL, d, 2 FROM test ) unioned GROUP BY a;
💡 One scan instead of three, but the intermediate UNION ALL result is 3x the row count. Usually split+JOIN is easier for the optimizer to parallelize well.
🧂
Level 3 — Data skew: salting the hot key
Only needed when a specific key is disproportionately large.

When GROUP BY a, c alone is enough — and when it isn't

Grouping by the composite key (a, c) shuffles by hash(a, c), not just hash(a) — so it often self-distributes even when 'a' is a hot key. The deciding factor is c's cardinality WITHIN that hot key.

Detail
ScenarioDistinct c under hot key 'x'Is (a,c) shuffle skewed?
This dataset4 distinct values (c1,c2,c3,c4)Mild — spreads across 4 shuffle partitions, not 1
Worse caseOnly 1 distinct value repeated 1000s of timesYes — every row still funnels into ONE partition
💡 In THIS dataset, GROUP BY a,c alone already spreads key 'x' across 4 partitions instead of 1 — a real improvement even before adding any salt.

Salting — for when (a,c) itself is still skewed

Add a deterministic hash-based salt bucket to spread the dedup work across more partitions, then sum the per-bucket counts back together.

salt = RANDOM() % N — WRONG Same c value lands in a DIFFERENT bucket each time -> double-counted salt = HASH(c) % N — CORRECT Same c value ALWAYS lands in the SAME bucket -> exact dedup, spread across N partitions The salt MUST be a deterministic function of the value being deduplicated
WITH salted AS ( SELECT a, b, c, d, (HASH(c) & 2147483647) % 8 AS salt_c, (HASH(d) & 2147483647) % 8 AS salt_d FROM test ), c_dedup AS ( SELECT DISTINCT a, salt_c, c FROM salted ), cnt_c AS ( SELECT a, SUM(cnt) AS cnt_c FROM ( SELECT a, salt_c, COUNT(*) AS cnt FROM c_dedup GROUP BY a, salt_c ) x GROUP BY a ), d_dedup AS ( SELECT DISTINCT a, salt_d, d FROM salted ), cnt_d AS ( SELECT a, SUM(cnt) AS cnt_d FROM ( SELECT a, salt_d, COUNT(*) AS cnt FROM d_dedup GROUP BY a, salt_d ) x GROUP BY a ) SELECT a, cnt_c, cnt_d FROM cnt_c JOIN cnt_d USING (a);
Detail
acnt_ccnt_d
x44
y21
z12
⚠️ A real mistake caught while building this: using RANDOM() instead of a deterministic HASH() broke correctness on a larger test (300 distinct became 2,152 after salting). Always salt using a hash of the value itself, never a random number.
⚡
Level 4 — Efficient combined version
The leanest correct version — fewest full table scans.

The leanest correct version — piggyback SUM(b) onto the dedup scan

Instead of a separate scan just for SUM(b), compute it as a byproduct of the (a,c) aggregation — saving one full table scan compared to the 3-way split.

WITH q1 AS ( SELECT a, c, SUM(b) AS sum_b_ac FROM test GROUP BY a, c ), q2 AS ( SELECT a, d FROM test GROUP BY a, d -- no need to recompute sum(b) here ), q1_agg AS ( SELECT a, SUM(sum_b_ac) AS sum_b, COUNT(*) AS cnt_c FROM q1 GROUP BY a ), q2_agg AS ( SELECT a, COUNT(*) AS cnt_d FROM q2 GROUP BY a ) SELECT q1_agg.a, q1_agg.sum_b, q1_agg.cnt_c, q2_agg.cnt_d FROM q1_agg JOIN q2_agg ON q1_agg.a = q2_agg.a;
Detail
asum_bcnt_ccnt_d
x139044
y13021
z7012
💡 SUM is associative — summing partial sums grouped by (a,c) and re-summing by a gives EXACTLY the same result as summing directly by a. This cuts table scans from 3 down to 2, and the (a,c) shuffle already partially fixes skew for free.

Full decision summary

The complete escalation path for this pattern, cheapest to most involved.

Detail
LevelApproachTable scansUse when
1Plain GROUP BY1Small/medium data, no observed problem
2Split + JOIN3Confirmed slowness, no skew
2bq1/q2 combined (piggyback sum)2Same as above, slightly leaner
3Salted two-stage2 + salt overheadConfirmed skew AND low cardinality under hot key
💡 Always diagnose with real evidence (Spark UI task times, OOM logs) before escalating — each level trades simplicity for performance, and the trade isn't free.
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. Snowflake, BigQuery, Databricks, Redshift, Postgres, or another supported platform stores data and executes SQL. A slow query still needs platform-level SQL and storage optimization.

DBT 是控制与工程化管理层,不是计算引擎。Snowflake、BigQuery、Databricks、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 Amazon settlement events / Amazon 原始结算事件"
        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 的核心。