算法艺术创作
algorithmic-art
通过代码和可调参数创作生成艺术与交互式视觉效果。
pyspark-etl【设计与创意】Best practices for building performant, testable PySpark ETL pipelines with Spark SQL and Apache Iceberg. Use when writing or reviewing PySpark jobs, designing joins and window functions, working with map/array higher-order functions, or building idempotent cumulative/snapshot table merges.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
This skill covers patterns for building production-grade, testable ETL pipelines with PySpark, Spark SQL, and Apache Iceberg, including project structure, join and window-function idioms, and safe cumulative-table merge patterns.
SparkSession lifecycle, accepts an injectable session for testing, and exposes an abstract run_job method.sys.argv..transform() — Chain named methods (read_source().transform(self.enrich).transform(self.merge_with_existing)) so run_job stays pure orchestration.select over withColumn chains, explicit join types, explicit window frames, and native functions instead of UDFs..byName() when writing to Iceberg tables so column order doesn't matter.SparkSession with small, hand-built DataFrames.from abc import ABC, abstractmethod
import logging
from pyspark.sql import SparkSession
class BaseETL(ABC):
def __init__(self, config, app_name="ETL Job", spark_session=None):
self.spark = spark_session or SparkSession.builder.appName(app_name).getOrCreate()
self.config = config
self.logger = logging.getLogger(self.__class__.__name__)
@abstractmethod
def run_job(self): ...
def stop(self):
self.spark.stop()
Keep the dataclass as pure data; put CLI parsing in a standalone factory so configs are easy to build in tests.
import argparse
from dataclasses import dataclass
@dataclass
class MyConfig:
read_date: int = 20260101
def create_config() -> MyConfig:
parser = argparse.ArgumentParser()
parser.add_argument("--read_date", type=int, default=20260101)
args = parser.parse_args()
return MyConfig(read_date=args.read_date)
Build one generic reader for partition mechanics; keep domain-specific filters visible in the ETL, not buried in a one-off reader class.
import pyspark.sql.functions as F
class PartitionedReader:
@staticmethod
def read_latest(spark, table_name, partition_col):
row = spark.read.table(table_name).agg(F.max(partition_col)).first()
if row is None or row[0] is None:
return spark.createDataFrame([], spark.read.table(table_name).schema)
return spark.read.table(table_name).filter(F.col(partition_col) == row[0])
@staticmethod
def read_by_date(spark, table_name, partition_col, date_value):
return spark.read.table(table_name).filter(F.col(partition_col) == date_value)
events = PartitionedReader.read_by_date(spark, "catalog.my_table", "event_date", 20260319)
events = events.filter(F.col("event_type").isin("login", "purchase"))
import pyspark.sql.functions as F and always use F.col() instead of df.colA attribute access — attribute access binds a column to a specific DataFrame variable and breaks after joins or reassignment..filter() or F.when() into named variables once it exceeds 3 expressions.select over chains of withColumn — select states the output schema in one pass, while each withColumn call adds a projection to the query plan..alias() instead of withColumnRenamed.select/filter chains from withColumn chains from join chains by operation type.# BAD — 3 intermediate DataFrames, one projection per call
df = df.withColumn("a", F.col("a").cast("double"))
df = df.withColumn("b", F.upper(F.col("b")))
df = df.withColumn("c", F.lit(1))
# GOOD — one DataFrame, explicit schema contract
df = df.select(
F.col("a").cast("double"),
F.upper(F.col("b")).alias("b"),
F.lit(1).alias("c"),
)
how= explicitly — never rely on the default.how="right".withColumnRenamed.F.broadcast() when joining against a large fact table, especially after filters or transformations that prevent Spark from inferring the size automatically (spark.sql.autoBroadcastJoinThreshold only auto-broadcasts tables Spark can size, typically under 10MB). Confirm with df.explain() — look for BroadcastHashJoin vs SortMergeJoin..dropDuplicates() to mask unexpected duplicate rows — find the root cause; it also adds shuffle overhead.flights = flights.alias("f")
parking = parking.alias("p")
result = flights.join(F.broadcast(parking), "code", how="left").select(
F.col("f.start_time").alias("flight_start"),
F.col("p.total_time").alias("parking_total"),
)
Use from pyspark.sql import Window as W.
F.sum().over(w) behaves differently depending on whether orderBy is present (running sum vs. total).row_number() + filter (drops rows, keeps the best one) and first() over a window (overwrites a column, keeps all rows).ignorenulls=True to F.first()/F.last() — otherwise a null in the first row of a partition nulls the entire partition's result.partitionBy(); it forces all data into a single partition. Use .agg() for global aggregations instead.w = W.partitionBy("key").orderBy("num").rowsBetween(W.unboundedPreceding, W.unboundedFollowing)
df = df.withColumn("version", F.first("version", ignorenulls=True).over(w))
map_zip_with instead of map_concat when merging maps needs per-key conflict resolution (e.g., keep the entry with the later timestamp) rather than one side blindly winning.transform + array_max/array_min to extract values out of nested structs without a UDF.merged = F.map_zip_with(
new_map, existing_map,
lambda key, v1, v2: (
F.when(v1.isNull(), v2)
.when(v2.isNull(), v1)
.otherwise(F.when(v1.event_ts >= v2.event_ts, v1).otherwise(v2))
),
)
coalesce argument order.F.lit(None) for empty columns — never empty strings or sentinel values like "NA"..otherwise() as a catch-all in F.when() chains for categorical mappings; an unmapped value should surface as null, not silently collapse into "Other"..show(), .collect(), or .printSchema() in production code — they force full materialization or add driver overhead. .count() is fine when used intentionally for row-count logging or to force materialization before a DAG fork..persist() only when a DataFrame is referenced by multiple subsequent actions. Choose the storage level deliberately: MEMORY_AND_DISK (safe default), MEMORY_ONLY (fastest, risks recompute on eviction), DISK_ONLY (for DataFrames too large for memory)..byName() when writing so Spark matches columns by name, not position — this keeps writes safe across schema evolution.df.write.byName().mode("overwrite").insertInto("catalog.my_table")
__partitions Iceberg metadata table to find the latest snapshot instead of scanning the full table:partition_df = spark.read.table("catalog.my_table__partitions").select(
"partition.partition_date", "partition.partition_hour"
)
max_partition = partition_df.orderBy(
F.col("partition_date").desc(), F.col("partition_hour").desc()
).first()
if max_partition is None:
raise ValueError("No partitions found in catalog.my_table")
write.distribution-mode deliberately: "none" (fastest, no re-shuffle, file sizes depend on upstream partitioning), "hash" (shuffles by partition key for evenly sized files), "range" (sorts before writing, best scan performance but most expensive).