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
S — Situation
Example data (illustrative)
Simplified sample rows to illustrate the shape of the skew — in reality each hot group had 3-4 million rows, not 3.
Reference
partner_remittance_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 — original query with a measured performance bottleneck
This single-stage COUNT(DISTINCT) is what caused the entire problem. This exact query was later REPLACED — see the final production query at the end of the Action section below, which is a different query, not a patch on this one.
SELECT
t.partner_remittance_provider, t.sender_country, t.currency,
COUNT(t.transaction_id) AS tx_count,
COUNT(DISTINCT t.sender_id) AS sender_count,
SUM(t.amount) AS total_amount,
SUM(t.amount * fx.fx_to_cnh) AS total_cnh,
SUM(t.amount * fx.fx_to_usd) AS total_usd
FROM transactions t
JOIN exchange_rates fx
ON t.currency = fx.currency AND t.fdate = fx.fdate
WHERE t.fdate BETWEEN'20240101'AND'20241231'GROUPBY t.partner_remittance_provider, t.sender_country, t.currency;
High distinct cardinality and skew can concentrate aggregation and shuffle work. Inspect the physical plan and task metrics rather than assuming one unspillable HashSet per business key.
A — Action
Four levels, cheapest first: try config before code, and code before the last-resort surgical fix. Framework changes are separate — they apply to the whole pipeline, not just this one query.
🔍
Level 0 — Diagnosis
Understand what's actually broken before touching anything.
1.Diagnose: find the root cause
Three compounding factors, not just one.
Detail
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
High-cardinality distinct aggregation increases per-task memory usage; spilling can help, but does not prevent every OOM.
Spark SLA · Corrected interview explanation
A critical Spark job for cross-border remittance analytics normally took about 30 minutes, but sometimes rose to 75 minutes and hit Executor OOM, missing the SLA.
Diagnose: Spark UI showed a few unusually slow reducer tasks with much larger shuffle reads. A key-frequency check identified Wise-US-USD and PandaRemit-KR-KRW, together accounting for over 7 million transactions. Row counts alone do not establish distinct sender cardinality; inspect that too.
Explain the pressure: COUNT(DISTINCT sender_id) processes unique business-key/sender combinations through aggregation and shuffle stages. High cardinality and skew caused heavy memory pressure and GC in this case; avoid claiming a single unspillable HashSet per group.
Configuration first: test more shuffle partitions and executor memory. A sort-based aggregation plan, if actually tested and verified, may reduce hash-state pressure at the cost of sorting and disk I/O. AQE is reference material here, not an applied fix or an automatic hot-aggregation-key splitter.
Two-stage rewrite: deduplicate business key + sender_id, then count non-null sender IDs at the original grain. The improvement can be limited because Spark commonly already uses a similar multi-stage plan for one COUNT(DISTINCT).
Targeted salting: for the two measured hot keys, hash sender_id into 8 deterministic buckets, count distinct non-null senders per bucket, then sum the partial counts. The same sender must always stay in one bucket within each business key; random per-row salting would overcount. Eight was the case's tested choice, not a universal optimum.
Validate the result: compare exact outputs and all other metrics, then test peak volume. Reported outcome: runtime dropped from 75 to 13 minutes and Executor OOM stopped. Deterministic sender salting does not split all repetitions of one single sender; reassess the bottleneck if that is the actual skew.
Likely follow-up questions
Why OOM? Large distinct-key cardinality and skew concentrated aggregation/shuffle work and memory pressure; confirm the failing operator in the executed plan and logs.
Why not AQE? AQE's skew-join optimization targets supported joins, not automatic bucketed aggregation of a GROUP BY hot key. Spark version and actual settings determine its availability.
Why two stages? Make the exact deduplication grain explicit while preserving null semantics. Do not assume faster execution than the native single-distinct plan.
Why 8 deterministic buckets? A sender maps to exactly one bucket per business key, so partial distinct counts can be summed exactly. Bucket count and performance require measurements; partial bucket groups do not guarantee eight equally sized tasks.
💡 HashAggregate can fall back to sorting and spill to disk. Spilling reduces memory pressure, but it does not guarantee that executor OOM cannot occur.
⚙️
Level 1 — Config (no code change)
Cheapest fixes. Pure Spark settings — the query stays untouched.
3. Fix 1: Tune partitioning and executor memory first
Increase initial shuffle parallelism and test executor memory against measured task size and GC. More partitions can reduce collisions between different groups, but do not by themselves split one final aggregation key. The values below are examples from this case, not universal defaults.
-- Submit-time example; compare with the actual cluster limits:
-- spark-submit --executor-memory 6G --executor-cores 4 ...
SET spark.sql.shuffle.partitions = 2000;
-- Optional reference only: NOT an applied fix in this account.
-- AQE requires Spark 3.x+; enabled by default in upstream Spark 3.2+.
-- SET spark.sql.adaptive.enabled = true;
-- SET spark.sql.adaptive.coalescePartitions.enabled = true;
-- SET spark.sql.adaptive.skewJoin.enabled = true;
-- SET spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes = 256MB;
-- SET spark.sql.adaptive.skewJoin.skewedPartitionFactor = 5;
AQE can coalesce shuffle partitions and optimize supported skewed joins. Its skew-join feature is not automatic salting of a plain GROUP BY hot key. Check version and actual settings; do not say enabling AQE solved this aggregation bottleneck.
4. Fix 2: Test sort-based aggregation and inspect the physical plan
Compare the executed plan and measured GC, spill and runtime. Sort-based aggregation may lower hash-state pressure while adding sorting and disk I/O; it is not guaranteed to run faster or eliminate every OOM.
-- Do not use an undocumented switch as a universal production fix.
-- spark.sql.execution.useHashAggregateExec is not a standard upstream setting.
-- Disabling useObjectHashAggregateExec does not disable ordinary HashAggregate.
-- Use the controls supported by the deployed distribution and Spark version.
EXPLAIN FORMATTED
SELECT partner_remittance_provider, sender_country, currency,
COUNT(DISTINCT sender_id) AS sender_count
FROM transactions
GROUP BY partner_remittance_provider, sender_country, currency;
In the interview, say: “I tested a sort-based plan and verified SortAggregate in the executed physical plan.” Name the exact mechanism only when it matches the real environment. If it was not actually tested, describe it as an option rather than a completed action.
💻
Level 2 — Code (query rewrite required)
Only reached because Level 1 alone wasn't sufficient.
5.Fix 3: Two-stage aggregation — config alone wasn't enough, so I rewrote the query
This is the first step that required an actual code change — only necessary because pure configuration wasn't sufficient on its own.
Reference
Aspect
Native single COUNT(DISTINCT)
Explicit two-stage rewrite
Plan
Normally already uses multi-stage aggregation over business key + sender_id
Makes sender-level reduction explicit
Performance
Depends on plan, data and shuffle
May compile to a similar plan; not automatically faster
Correctness
Ignores null sender IDs
Count non-null sender IDs after deduplication
-- Stage 1: dedup rows to one row per (partner_remittance_provider, sender_country, currency, sender_id)WITH dedup AS (
SELECT
t.partner_remittance_provider,
t.sender_country,
t.currency,
t.sender_id
FROM transactions t
WHERE t.fdate BETWEEN'20240101'AND'20241231'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(d.sender_id) AS sender_count
FROM dedup d
GROUPBY d.partner_remittance_provider, d.sender_country, d.currency;
💡 The rewrite makes the deduplication grain explicit. Spark often already plans a single COUNT(DISTINCT) similarly, so this step alone may give little improvement. Compare the plans and timings; the two extreme hot keys can remain bottlenecks.
Expanded analysis — what the two stages actually do
Stage 1 deduplicates at business key + sender_id. This is a logical rewrite, not proof of a new physical strategy: Spark normally already uses this kind of grouping for a single distinct expression.
Stage 2 removes sender_id and rolls those sender-level rows back to the original business dimensions. Because Stage 1 produces at most one row per sender inside each business key, SUM(sender_flag) is the exact equivalent of COUNT(DISTINCT sender_id) for non-null senders.
tx_countStage 1 counts transactions per sender. Stage 2 SUMs those counts. Exact.
sender_countStage 1 leaves one row per sender. Stage 2 SUMs sender_flag. Exact.
total_amount / converted amountsStage 1 SUMs raw amount and converted amounts per sender. Stage 2 SUMs them again. Exact because these metrics are additive.
averageDo not average averages. Recompute avg_txn_amt_cnh = total_cnh / tx_count only after the final merge.
What to verify: compare the native and rewritten physical plans. If the exchanges and aggregation stages are equivalent, do not attribute a speedup to the SQL rewrite alone.
But two-stage does not magically remove every extreme-key problem. The final Stage 2 still groups by the original business key. If one hot key has an extremely large number of distinct senders, that one final reducer can still receive far more sender-level rows than its peers. The rewrite is not a general OOM guarantee; a long-tail task can remain.
Complete two-stage SQL — all original metrics preservedcode change · exact result
-- Expanded two-stage version: preserve ALL original metrics exactly-- Assumption: exchange_rates has one row per (currency, fdate).WITH sender_stage AS (
-- Stage 1: one row per original dimension + sender_id.-- This distributes the DISTINCT work by sender_id instead of keeping-- an implicit logical distinct operation; compare the native physical plan.SELECT
t.partner_remittance_provider,
t.sender_country,
t.currency,
t.sender_id,
COUNT(t.transaction_id) AS sender_tx_count,
SUM(t.amount) AS sender_total_amount,
SUM(t.amount * fx.fx_to_cnh) AS sender_total_cnh,
SUM(t.amount * fx.fx_to_usd) AS sender_total_usd,
-- COUNT(DISTINCT sender_id) ignores NULL. Preserve that behavior.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 expresses exact deduplication at the grain of (business key, sender_id), then rolls the sender-level rows back to the original dimensions.
Targeted salting is the escalation layer on top of two-stage. If the final business-key aggregation is still skewed for the two extreme keys, add a deterministic salt derived from sender_id, aggregate inside each salt bucket, then merge the buckets.
Method A · Two-stage onlyTest after configuration tuning; compare with the native DISTINCT plan before expecting any gain.
→
Method B · Two-stage + targeted saltingUse only if proven extreme business keys remain long-tail after Method A. This is the final version for this SLA story.
Why salting comes AFTER two-stage in this case: an explicit deduplication rewrite may match the native plan, and the original hot business key can still collapse to one final aggregation partition. Targeted salting adds a controlled extra distribution key only for those extreme groups, so the remaining long-tail work is split before the final merge.
Originalskew + high distinct cardinality → concentrated work
→
Two-stagesender-level reduction → much smaller final input
→
Targeted saltingextreme hot key → 8 deterministic buckets → merge
Recommended final 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
Verify sender cardinality, per-bucket sizes and task distribution. Eight logical buckets do not guarantee eight distinct tasks or equal load.
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 multiple distinct expressions can add overhead
Spark can rewrite multiple distinct argument sets using Expand, a group identifier, deduplication aggregates, and shuffles. Row expansion and high cardinality can increase intermediate work and memory pressure. It is not generally implemented as one literal HashSet per expression inside each business-key buffer.
Check EXPLAIN FORMATTED for the actual plan and use task-level spill, GC and peak execution memory to identify the costly stage. Splitting queries or salting is a measured alternative, not a guaranteed improvement.
What memory does an aggregation task use?
A task processes a partition containing potentially many grouping keys. Hash aggregation stores grouping keys and aggregate buffers; exact-distinct rewrites can make the distinct values part of those grouping keys. Sorters, shuffle buffers and other operators also need memory.
The operator may spill or fall back to sorting, but spill does not prevent all executor OOMs. Inspect the deployed Spark version and physical plan instead of multiplying the number of business keys by the number of distinct expressions to infer a count of HashSets.
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. S, BigQuery, D, Redshift, Postgres, or another supported platform stores data and executes SQL. A slow query still needs platform-level SQL and storage optimization.
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.