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.
One query or whole cluster? recent deploy? traffic change?
query plan · CPU · disk I/O · shard imbalance · merge pressure
Node unhealthy
Process alive? disk full? port open? replica healthy?
service · OS · disk · network · replication
Job stuck
waiting for resources or already running?
YARN queue · Stage · Task · skew · GC · I/O
Upgrade regression
what 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
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.
🔊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.
🔊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
02PySpark Study Notes — SQL First, PySpark Second
03Latest Row / Dedup — ROW_NUMBER
04Conditional Aggregation — CASE WHEN → F.when
05Join Patterns — inner / left / semi / anti
06Null + Date + String — Small APIs That Often Block a Task
07Window Advanced — lag / lead / rank / dense_rank
08Array + Union — explode / unionByName
09Built-in Expression First — CASE WHEN → F.when
10Python UDF — Use Only When Built-ins Are Not Enough
11Partitioning + Cache / Persist
12Collect + Read / Write I/O
13Complete Program A1 — Filter → Broadcast Join
14Complete Program A2 — Window → Latest Row
15Complete Program B — Derive → Aggregate → Final Grain
16Practice 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.
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_provider
sender_country
currency
sender_id
transaction_id
amount
fdate
Wise
US
USD
U10023
TXN00001
482.50
20240115
Wise
US
USD
U10024
TXN00002
1,205.00
20240115
Wise
US
USD
U10023
TXN00003
310.00
20240116
... 4,000,000 more rows, same (Wise, US, USD) key
PandaRemit
KR
KRW
U55010
TXN04000001
150,000
20240201
... 3,000,000 more rows, same (PandaRemit, KR, KRW) key
GrabPay
SG
SGD
U88221
TXN07000001
600.00
20240301
Airwallex
AU
AUD
U99110
TXN07000002
2,100.00
20240301
Detail
currency
fdate
fx_to_cnh
fx_to_usd
USD
20240115
7.18
1.00
KRW
20240201
0.0054
0.00075
SGD
20240301
5.32
0.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
1
Runtime fluctuated 30–75 minutes
2
Some reducers stalled for over 30 minutes
3
Sometimes, Executor OOM errors
4
Frequent GC pauses (heap pressure)
5
Spark 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'GROUPBY 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
Problem
Technical Root Cause
OOM
HashAggregate retained large intermediate states
GC Pressure
Excessive heap usage from large in-memory hash maps
Data Skew
Uneven key distribution in GROUP BY
Shuffle Overload
Too 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.
💡 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 4GSET 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 mechanism
What it does
Coalesce partitions
Merges small shuffle partitions at runtime
Skew join split
Splits large skewed partitions into multiple tasks
Switch join strategy
SortMergeJoin → 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
Feature
HashAggregate
SortAggregate
Memory Usage
High (hash map)
Lower (disk spillable)
OOM Risk
High
Much lower
Skewed Data
Poor
Stable
💡 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-stage
Two-stage
Memory model
One giant HashSet
Many small groups
Spillable
No
Yes
OOM risk
High
Low
-- 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'GROUPBY 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
GROUPBY 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.CASEWHEN t.sender_id ISNULLTHEN0ELSE1ENDAS 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'GROUPBY
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
GROUPBY
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;
Check
What to validate
Correctness
Compare tx_count, sender_count, total_amount, total_cnh, total_usd, and avg_txn_amt_cnh with the original query for the same date range.
Distribution
In Spark UI, compare max Task vs median Task in the sender-level aggregation and final aggregation.
Memory
Confirm spill / GC / peak execution memory no longer show the previous DISTINCT-state pressure.
Decision
If 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 SQL — Two-stage + targeted saltingsame two-stage logic, plus a salt bucket before the final 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,
CASEWHEN (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) % 8ELSE0ENDAS 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,
CASEWHEN sender_id ISNULLTHEN0ELSE1ENDAS sender_flag
FROM base
GROUPBY
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
GROUPBY
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
GROUPBY
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 (PARTITIONBY currency, fdate ORDERBY 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,
CASEWHEN (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 bucketsELSE8-- everyone else → fixed bucket 8ENDAS 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
GROUPBY 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
GROUPBY 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,
CASEWHEN (
t.sender_country = 'US'AND t.partner_remittance_provider = 'Wise'
)
OR (
t.sender_country = 'KR'AND t.partner_remittance_provider = 'PandaRemit'
)
THENCASEWHEN t.sender_id ISNULLTHEN0ELSEPMOD(HASH(t.sender_id), 8)
ENDELSE0ENDAS 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,
CASEWHEN s.sender_id ISNULLTHEN0ELSE1ENDAS sender_flag
FROM salted_txn s
JOIN exchange_rates fx
ON s.currency = fx.currency
AND s.fdate = fx.fdate
GROUPBY
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
GROUPBY
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
GROUPBY
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;
Validation
Expected evidence
Business equality
All 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 balance
The two hot keys spread across roughly 8 sender buckets instead of one final hot partition.
Task tail
max Task duration and max Shuffle Read move much closer to the median.
Stability
No recurring Executor OOM; GC pressure and spill remain bounded across peak-volume runs.
SLA
Validate 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+fdateWITH salted_txn AS (
SELECT
t.*,
(HASH(t.currency, t.fdate) & 2147483647) % 8AS 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
CROSSJOIN (SELECTCAST(id AS INT) AS id FROMrange(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
GROUPBY 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 saltCASEWHEN f.merchant_id = 123THENCAST(FLOOR(RAND() * 8) AS INT) + 1-- 1..8ELSE0ENDAS salt
-- Small table: hot key replicated 1-8, everyone else stays at 0SELECT d.*, 0AS salt FROM dim_merchant d WHERE d.merchant_id <> 123UNIONALLSELECT d.*, s.salt FROM dim_merchant d
JOIN (SELECT1AS salt UNIONALLSELECT2UNIONALLSELECT3UNIONALLSELECT4UNIONALLSELECT5UNIONALLSELECT6UNIONALLSELECT7UNIONALLSELECT8) 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
Area
Description
🔗 Workflow Dependency
Removed non-critical and redundant dependencies to streamline DAG execution
⏱️ Trigger-Based Scheduling
Replaced fixed-time triggers — tasks auto-execute on upstream success
🚨 Monitoring & Alerting
Added 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
a
b
c
d
x
100
c1
d1
x
200
c1
d2
x
150
c2
d1
x
300
c1
d3
x
250
c3
d2
x
120
c2
d1
x
180
c4
d4
x
90
c1
d2
y
50
c5
d5
y
80
c6
d5
z
30
c7
d6
z
40
c7
d7
💡 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
GROUPBY a;
Detail
a
sum(b)
count(distinct c)
count(distinct d)
x
1390
4
4
y
130
2
1
z
70
1
2
💡 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.
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
Cause
Example
Power-law user/customer behavior
A handful of VIP accounts or bot accounts generate most transactions
Default / placeholder values
NULL, 0, or 'UNKNOWN' used as a fallback dominates the join or group key
Popular product / hot category
One best-selling SKU or trending category dwarfs all others
Time-based clustering
Most rows are from 'today' in an incrementally-loaded table
Upstream join fan-out
A 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.
💡 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.
💡 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
GROUPBY a;
Detail
a
sum(b)
count(distinct c)
count(distinct d)
x
1390
4
4
y
130
2
1
z
70
1
2
💡 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
Signal
Why it matters
Table has 10M+ rows
More raw data funneling into each group's HashSets
c or d has high cardinality
Bigger 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 OOM
Concrete 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 GROUPBY a) t1
JOIN (SELECT a, COUNT(DISTINCT c) AS cnt_c FROM test GROUPBY a) t2 ON t1.a = t2.a
JOIN (SELECT a, COUNT(DISTINCT d) AS cnt_d FROM test GROUPBY a) t3 ON t1.a = t3.a;
Detail
a
sum_b
cnt_c
cnt_d
x
1390
4
4
y
130
2
1
z
70
1
2
💡 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(CASEWHEN tag=0THEN b END) AS sum_b,
COUNT(DISTINCTCASEWHEN tag=1THEN c END) AS cnt_c,
COUNT(DISTINCTCASEWHEN tag=2THEN d END) AS cnt_d
FROM (
SELECT a, b, NULLAS c, NULLAS d, 0AS tag FROM test
UNIONALLSELECT a, NULL, c, NULL, 1FROM test
UNIONALLSELECT a, NULL, NULL, d, 2FROM test
) unioned
GROUPBY 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
Scenario
Distinct c under hot key 'x'
Is (a,c) shuffle skewed?
This dataset
4 distinct values (c1,c2,c3,c4)
Mild — spreads across 4 shuffle partitions, not 1
Worse case
Only 1 distinct value repeated 1000s of times
Yes — 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.
WITH salted AS (
SELECT a, b, c, d,
(HASH(c) & 2147483647) % 8AS salt_c,
(HASH(d) & 2147483647) % 8AS salt_d
FROM test
),
c_dedup AS (
SELECTDISTINCT 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 GROUPBY a, salt_c
) x GROUPBY a
),
d_dedup AS (
SELECTDISTINCT 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 GROUPBY a, salt_d
) x GROUPBY a
)
SELECT a, cnt_c, cnt_d FROM cnt_c JOIN cnt_d USING (a);
Detail
a
cnt_c
cnt_d
x
4
4
y
2
1
z
1
2
⚠️ 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
GROUPBY a, c
),
q2 AS (
SELECT a, d
FROM test
GROUPBY 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
GROUPBY a
),
q2_agg AS (
SELECT a, COUNT(*) AS cnt_d
FROM q2
GROUPBY 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
a
sum_b
cnt_c
cnt_d
x
1390
4
4
y
130
2
1
z
70
1
2
💡 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
Level
Approach
Table scans
Use when
1
Plain GROUP BY
1
Small/medium data, no observed problem
2
Split + JOIN
3
Confirmed slowness, no skew
2b
q1/q2 combined (piggyback sum)
2
Same as above, slightly leaner
3
Salted two-stage
2 + salt overhead
Confirmed 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.
dbtBEGINNER · 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.
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 Binlog→InLong + MQ→ODS→DWD + DIM→DM + ADS
Current layer
Main responsibility
Closest DBT concept
InLong + MQ
CDC, transport, replay
Outside DBT
ODS
Preserve source truth
source() declaration
DWD / DIM
Clean, dedupe, conform
staging / intermediate
DM: Merchant / Store / Order商户、店铺、订单主题
Reusable business models
marts
ADS / BI
Reports and consumers
exposures / consumers
Airflow
Cross-system DAG and SLA
Works 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.
TDW / internal Hive support is uncertainTDW 或内部 Hive 支持不确定
Major blocker
Mostly SQL transforms
Spark jobs and platform-specific logic are importantSpark Job 与平台逻辑占重要部分
Partial fit
Missing engineering workflow
Airflow, governance and layered standards already exist已有 Airflow、治理和分层规范
Benefit is smaller
Migration risk
Payment 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.
Confirm pain→Check adapter→Small DM / ADS pilot→Measure results→Adopt 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 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.
Scattered SQL→Implicit DAG→DBT project→Review + 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 / CDC→MQ→ODS tables→DBT 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 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 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.
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.
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.
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.
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.
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.
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 incrementalmodel that processes only new and changed rows.
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.
🗂️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.
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.
Keep staging focused: rename fields, cast types, normalize codes, and perform source-specific cleanup. Do not mix final KPI aggregation into this layer.
-- 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.
-- 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.
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
Testsource 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.
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。
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。
Resource
Solves
Simple example
seed
Small static reference data
country_codes.csv国家代码表
snapshot
Track row changes over time
merchant status history
exposure
Ownership and downstream lineage
settlement dashboard
Source row changes→Snapshot history→Mart→Exposure
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.
A safe incrementalmodel 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 rows→Lookback→Merge / insert→Reconcile
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.
▶️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.
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.
# 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.
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.
Error message→YAML / Jinja→Compiled SQL→Fix 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.
Compiled SQL→Platform error→Plan + logs→Correct 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.
Failing rows→Check grain→Data 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.
🗓️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.
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.
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.
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.