Ana içeriğe geç
Kesit Python
İleri · Veri mühendisliği

PySpark ile dağıtık veri işleme: partition, shuffle ve Spark SQL

Spark'ın sürücü, yürütücü ve partition modelini, tembel değerlendirmeyi, dar ve geniş dönüşümleri, shuffle'ın bedelini, broadcast join'i, çarpıklık ve salting'i, Spark SQL pencere fonksiyonlarını ve bölümlü Parquet gölünü Pedal İzmir verisiyle saf Python, polars ve DuckDB üzerinde kurarak öğrenirsin.

Uygulamada aç, 21 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden önemli? Veri tek makineye sığmadığında

Pedal İzmir'in bisiklet paylaşım sistemi üç yaz ayında 116.517 yolculuk üretti. Bu boyut bir dizüstü bilgisayar için çocuk oyuncağı: polars ya da DuckDB saniyeler içinde özetler (bunu polars, DuckDB ve Parquet: büyük veriye tek makinede dersinde gördün). Şimdi aynı şirketin 30 şehre yayıldığını, her bisikletin 5 saniyede bir GPS konumu gönderdiğini düşün: günde milyarlarca satır, yılda yüzlerce terabayt. Tek makinenin belleği, diski ve işlemcisi yetmez. Çözüm işi parçalara bölüp çok sayıda makineye dağıtmaktır. Apache Spark bu işin endüstri standardıdır; Databricks, AWS EMR, Google Dataproc ve Azure Synapse'in kalbinde o çalışır.

Spark, Java sanal makinesi (JVM) ve bir küme ister; tarayıcıda çalışmaz. Bu yüzden derste PySpark kodunu gri kutularda okuyacak, Spark'ın arka planda yaptığı işi ise saf Python, polars ve DuckDB ile kendin kuracaksın: bölümleme (partitioning), map-reduce, shuffle, broadcast join, salting ve pencere fonksiyonları (window function). Mülakatlarda sorulan da tam olarak bu iç işleyiştir; API'yi bir öğleden sonrada öğrenirsin, ama "bu iş neden 40 dakika sürüyor?" sorusunu iç işleyişi bilen cevaplar.

Dağıtık hesaplamanın parçaları: sürücü, yürütücü, partition

Bir Spark uygulamasında tek bir sürücü (driver) programı ve birçok yürütücü (executor) süreci vardır. Sürücü senin Python kodunu çalıştırır, planı çıkarır ve işi küçük görevlere (task) bölüp yürütücülere dağıtır. Veri, partition denen parçalara ayrılmıştır; her görev tek bir partition üzerinde çalışır. 200 partition ve 50 çekirdek varsa görevler dört dalgada biter.

KavramNe demek?Benzetme
Sürücü (driver)Planı yapar, görevleri dağıtır, sonucu toplarŞantiye şefi
Yürütücü (executor)Görevleri çalıştıran JVM süreci; bellek ve çekirdek sahibiUsta ekipleri
PartitionVerinin bağımsız işlenebilen parçasıBir ustaya verilen tuğla yığını
Görev (task)Bir partition üzerinde bir aşamanın işi"Bu yığını duvara ör"
Aşama (stage)Shuffle gerektirmeden art arda yapılabilen görevlerMalzeme taşınmadan yapılan iş bloğu
İş (job)Bir eylemin (action) tetiklediği aşamalar bütünüBir katın tamamı

Bölümlemenin en yaygın yolu hash bölümlemedir: anahtarın özeti (hash) partition sayısına bölünür, kalan partition numarasını verir. Aynı anahtar her zaman aynı partition'a düşer. Aşağıda Pedal İzmir yolculuklarını başlangıç istasyonuna göre 4 partition'a dağıtıyoruz (tamsayı anahtarda % n yeterli):

import veri

trips = veri.kayitlar("pedal_izmir", "bk_trips")
print(len(trips), "yolculuk")

n = 4
bolumler = [[] for _ in range(n)]
for t in trips:
    bolumler[t["start_station"] % n].append(t)

for i, b in enumerate(bolumler):
    print(f"partition {i}: {len(b):>6} kayıt")
Metin anahtarda hash() tuzağıPython'da hash("Konak") her süreçte farklı değer üretir (PYTHONHASHSEED rastgeledir). Spark kendi kararlı hash fonksiyonunu (Murmur3) kullanır. Kendi simülasyonunda metin anahtarı bölmek istersen zlib.crc32(anahtar.encode()) gibi kararlı bir özet kullan.

SparkSession, DataFrame API ve tembel değerlendirme

PySpark'ta her şey bir SparkSession ile başlar. DataFrame API'si pandas'tan çok polars'ın lazy API'sine benzer: filter, select, groupBy gibi dönüşümler (transformation) hiçbir şey hesaplamaz, yalnız plan kurar. count, show, collect, write gibi eylemler (action) gelince Catalyst iyileştiricisi planı optimize eder ve küme çalışmaya başlar.

from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("pedal-izmir").getOrCreate()

trips = spark.read.csv("s3://pedal/bk_trips/", header=True, inferSchema=True)
stations = spark.read.parquet("s3://pedal/bk_stations/")

gunluk = (trips
          .filter(F.col("distance_km") > 0)                 # dönüşüm: plan
          .withColumn("gun", F.to_date("started_at"))       # dönüşüm: plan
          .groupBy("gun")                                   # dönüşüm: plan (geniş)
          .agg(F.count("*").alias("yolculuk"),
               F.round(F.avg("fare"), 2).alias("ort_ucret")))

gunluk.explain()          # fiziksel planı yazdırır, veri okumaz
gunluk.show(5)            # EYLEM: iş burada başlar
Kendi bilgisayarında denePySpark için Java 17 gerekir. pip install pyspark ile kurulur; spark = SparkSession.builder.master("local[*]").getOrCreate() tek makinede tüm çekirdekleri yürütücü gibi kullanır. Çalışırken http://localhost:4040 adresinde Spark UI açılır. Kurulumsuz denemek için Databricks Free Edition ya da Google Colab'da !pip install pyspark yeterli.

Aynı tembel mantığı tarayıcıda polars ile görebilirsin: .lazy() (bilgisayarında doğrudan dosyadan scan_csv) plan kurar, collect çalıştırır.

import polars as pl

plan = (pl.read_csv(veri.yol("pedal_izmir", "bk_trips")).lazy()   # bilgisayarında: pl.scan_csv(...)
        .filter(pl.col("distance_km") > 0)
        .with_columns(pl.col("started_at").str.slice(0, 7).alias("ay"))
        .group_by("ay")
        .agg(pl.len().alias("yolculuk"), pl.col("fare").mean().round(2).alias("ort_ucret"))
        .sort("ay"))
print(type(plan).__name__)      # henüz veri yok, yalnız plan
print(plan.collect())           # eylem

Dar ve geniş dönüşümler: shuffle'ın bedeli

Dar dönüşüm (narrow) her çıktı partition'ının yalnız bir girdi partition'ına bağlı olduğu işlemdir: filter, select, withColumn. Ağ trafiği yoktur, her yürütücü kendi parçasını işler. Geniş dönüşüm (wide) ise aynı anahtara sahip kayıtların aynı yere toplanmasını gerektirir: groupBy, join, distinct, orderBy. Bu toplanmaya shuffle denir: veriler diske yazılır, ağdan taşınır, yeniden okunur. Spark işlerinin yavaşlığının bir numaralı sebebi shuffle'dır.

DönüşümTürShuffle?Not
filter, select, withColumnDarHayırAynı aşamada zincirlenir
groupBy().agg()GenişEvetÖnce partition içinde kısmi toplam (combiner) yapılır
joinGenişÇoğunluklaKüçük tablo yayınlanırsa (broadcast) shuffle olmaz
orderByGenişEvetAralık bölümleme için önce örnekleme yapar
repartition(n)GenişEvetTam karıştırma; coalesce(n) ise azaltırken shuffle yapmaz

Spark'ın groupBy'ı akıllıdır: shuffle'dan önce her partition kendi içinde kısmi toplam çıkarır (map-side combine), ağdan yalnız bu özetler gider. Aşağıda dosyanın ardışık parçalara bölündüğü durumu (HDFS ya da S3 blokları gibi) taklit ediyoruz ve combiner'ın ağ trafiğini ne kadar düşürdüğünü sayıyoruz:

from collections import Counter

def parcala(kayitlar, n):
    boy = -(-len(kayitlar) // n)              # yukarı yuvarlayan bölme
    return [kayitlar[i * boy:(i + 1) * boy] for i in range(n)]

parcalar = parcala(trips, 4)

# MAP + COMBINE: her parça kendi içinde sayar
kismi = [Counter(t["start_station"] for t in p) for p in parcalar]
print("combiner olmasa ağdan giden kayıt:", sum(len(p) for p in parcalar))
print("combiner ile ağdan giden kayıt   :", sum(len(k) for k in kismi))

# SHUFFLE + REDUCE: aynı anahtarlar birleşir
toplam = Counter()
for k in kismi:
    toplam.update(k)
print("en yoğun 3 istasyon:", toplam.most_common(3))

116 binden 248'e: combiner sayesinde shuffle'a binlerce kat daha az kayıt girer. Ortalama gibi birleştirilebilir olmayan ölçülerde ise kısmi sonuç olarak (toplam, adet) çifti taşınır; ortalamaların ortalaması yanlış sonuç verir (alıştırmalarda bu hatayı avlayacaksın).

Join stratejileri, veri çarpıklığı ve salting

İki büyük tabloyu birleştirmek için Spark varsayılan olarak sort-merge join kullanır: iki taraf da anahtara göre shuffle edilip sıralanır, sonra fermuar gibi birleştirilir. Taraflardan biri küçükse (varsayılan eşik 10 MB, spark.sql.autoBroadcastJoinThreshold) broadcast hash join seçilir: küçük tablo her yürütücüye kopyalanır, büyük tablo yerinden kıpırdamaz. 62 satırlık istasyon tablosu bunun ders kitabı örneğidir.

from pyspark.sql.functions import broadcast

zengin = trips.join(broadcast(stations),
                    trips.start_station == stations.station_id, "left")
zengin.groupBy("district").count().orderBy(F.desc("count")).show()
stations = veri.kayitlar("pedal_izmir", "bk_stations")
ilce = {s["station_id"]: s["district"] for s in stations}   # her yürütücüye giden küçük sözlük

sayac = Counter(ilce[t["start_station"]] for t in trips)     # büyük tablo yerinde kalır
for ad, n in sayac.most_common(3):
    print(ad, n)

Veri çarpıklığı (skew), bazı anahtarların diğerlerinden çok daha sık görülmesidir. Alsancak Çarşı istasyonu tek başına 4.257 yolculukla öndedir; gerçek hayatta bu oran 1'e 1000 olabilir (ör. "bilinmiyor" müşteri kimliği). Hash bölümlemede o anahtarın bütün kayıtları tek göreve düşer; 199 görev 10 saniyede biterken bir görev 40 dakika sürer. Spark UI'da bu durum "görev sürelerinin max değeri medyanın çok üstünde" diye görünür.

Çözümlerden biri salting (tuzlama): sıcak anahtara yapay bir son ek eklenir (12#0, 12#1...), böylece yük birkaç göreve yayılır. İlk aşamada tuzlu anahtarlarla kısmi toplam alınır, ikinci aşamada tuz atılıp yeniden toplanır. Spark 3'ün AQE (adaptive query execution) özelliği join'lerdeki çarpıklığı çoğu zaman kendisi böler (spark.sql.adaptive.skewJoin.enabled), ama groupBy çarpıklığında ve eski sürümlerde salting hâlâ elle yapılır.

sade = Counter(t["start_station"] for t in trips)
sicak = {k for k, _ in sade.most_common(2)}
tuzlu = Counter((t["start_station"], t["trip_id"] % 4 if t["start_station"] in sicak else 0) for t in trips)
print("tuzsuz en büyük grup:", max(sade.values()))
print("tuzlu en büyük grup :", max(tuzlu.values()))

Spark SQL ve pencere fonksiyonları

DataFrame'i geçici görünüm olarak kaydedip SQL yazabilirsin; Spark SQL ve DataFrame API aynı iyileştiriciye gider, performans farkı yoktur. Pencere fonksiyonları (ROW_NUMBER, RANK, LAG, kayan toplam) da aynıdır. PARTITION BY ifadesi Spark'ta gerçekten bir shuffle'a dönüşür: aynı ilçenin satırları aynı göreve toplanır.

trips.createOrReplaceTempView("trips")
stations.createOrReplaceTempView("stations")
spark.sql("""
    WITH s AS (
        SELECT st.district, st.name, COUNT(*) AS n
        FROM trips t JOIN stations st ON t.start_station = st.station_id
        GROUP BY st.district, st.name)
    SELECT * FROM (
        SELECT *, ROW_NUMBER() OVER (PARTITION BY district ORDER BY n DESC) AS sira
        FROM s) WHERE sira = 1
""").show()

Aynı sorguyu DuckDB ile tarayıcıda çalıştırabilirsin; SQL neredeyse harfi harfine aynı:

import duckdb

t_yol = veri.yol("pedal_izmir", "bk_trips")
s_yol = veri.yol("pedal_izmir", "bk_stations")
sorgu = f"""
WITH s AS (
    SELECT st.district, st.name, COUNT(*) AS n
    FROM read_csv('{t_yol}') t JOIN read_csv('{s_yol}') st ON t.start_station = st.station_id
    GROUP BY st.district, st.name)
SELECT district, name, n FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY district ORDER BY n DESC, name) AS sira FROM s)
WHERE sira = 1 ORDER BY n DESC LIMIT 3
"""
for satir in duckdb.sql(sorgu).fetchall():
    print(satir)

Parquet, partitionBy, Delta ve Iceberg

Spark'ın doğal dosya biçimi Parquet'tir: sütunsal, sıkıştırılmış ve şemalı. partitionBy ile yazınca veri hive tarzı klasörlere ayrılır (ay=2024-06/). Sorgu WHERE ay = '2024-07' içerirse Spark öteki klasörleri hiç açmaz; buna bölüm budama (partition pruning) denir. Bölüm sütunu, filtrede sık kullanılan ve az sayıda farklı değeri olan bir sütun olmalıdır: gün ya da ay iyi, müşteri kimliği felakettir (milyonlarca küçük dosya).

(trips.withColumn("ay", F.substring("started_at", 1, 7))
      .repartition("ay")                       # her ay tek görevde: az ve büyük dosya
      .write.mode("overwrite")
      .partitionBy("ay")
      .parquet("s3://pedal/lake/trips/"))

# Delta Lake: Parquet + işlem günlüğü (ACID, MERGE, zaman yolculuğu)
trips.write.format("delta").mode("append").save("s3://pedal/delta/trips")
spark.read.format("delta").option("versionAsOf", 3).load("s3://pedal/delta/trips")
BiçimNe ekler?Nerede görürsün?
ParquetSütunsal depolama, sıkıştırma, istatistikHer yerde
Delta LakeACID işlem günlüğü, MERGE/UPSERT, zaman yolculuğuDatabricks
Apache IcebergTablo biçimi, gizli bölümleme, şema evrimi, motor bağımsızSnowflake, AWS Athena, Trino
Apache HudiArtımlı upsert, CDC odaklıUber kökenli göller

UDF'nin bedeli ve pandas UDF

Spark'ın hazır fonksiyonları (F.when, F.regexp_extract...) JVM içinde, Catalyst'in optimize ettiği kodla çalışır. Python ile yazdığın sıradan bir UDF ise her satırı JVM'den Python sürecine taşır, sonucu geri getirir: serileştirme maliyeti işi 10 kat yavaşlatabilir ve iyileştirici fonksiyonun içini göremez. Önce hazır fonksiyon ara; yoksa Arrow ile toplu (vektörel) çalışan pandas UDF kullan.

from pyspark.sql.functions import pandas_udf
import pandas as pd

@F.udf("double")                       # yavaş: satır satır
def kdv_ekle(tutar):
    return tutar * 1.2

@pandas_udf("double")                  # hızlı: Arrow ile toplu
def kdv_ekle_vek(tutar: pd.Series) -> pd.Series:
    return tutar * 1.2

trips.select(F.col("fare") * 1.2)      # en hızlı: hazır ifade, UDF yok

Ne zaman Spark gereksizdir?

Veri boyutuÖnerilen araçNeden?
Belleğe rahat sığar (birkaç GB)pandas, polarsKurulum yok, en hızlı geliştirme
Tek makine diskine sığar (10 GB – 1 TB)DuckDB, polars lazy/streamingTek düğümde Spark'tan hızlı, küme maliyeti yok
Çok TB, sürekli büyüyen göl, çok ekipSpark (Databricks, EMR), TrinoYatay ölçek, hata toleransı, yönetişim
Spark UI'yı okumakBir iş yavaşsa önce Spark UI'nın Stages sekmesine bak: en uzun aşamada "Shuffle Read" ve "Shuffle Write" boyutları shuffle'ın bedelini, görev sürelerinin min/medyan/max dağılımı çarpıklığı, "Spill (disk)" sütunu bellek yetmezliğini gösterir. SQL sekmesindeki plan grafiğinde "BroadcastHashJoin" ile "SortMergeJoin" ayrımını görürsün.
Gerçek işteVeri mühendisliği ekiplerinde Spark işinin süresini yarıya indiren değişiklikler çoğu zaman koddaki bir döngü değil, plandaki bir tercihtir: küçük boyut tablosunu broadcast etmek, filtreyi join'den önceye almak, çarpık anahtarı ayrı işlemek, gereksiz orderBy ve collect'i kaldırmak, küçük dosya sorununu repartition ile çözmek. Kod incelemesinde (code review) ilk sorulan soru "Bu iş kaç kez shuffle yapıyor?" olur.
Uzmanlaş

Partition sayısını ayarlamak

Shuffle sonrası partition sayısı spark.sql.shuffle.partitions ile belirlenir (varsayılan 200). Kural olarak partition başına 100–200 MB hedeflenir: 1 TB'lık shuffle için 200 çok azdır (her görev 5 GB, belleğe sığmaz, diske taşar), 1 GB için fazladır (5 MB'lık 200 minik görev, zamanlama yükü). AQE açıkken Spark küçük partition'ları kendisi birleştirir (coalescePartitions).

cache ve persist

Aynı DataFrame'i birden çok eylemde kullanıyorsan her eylem planı baştan çalıştırır. df.cache() sonucu yürütücü belleğinde tutar; ama gereksiz cache belleği yer ve diske taşmaya yol açar. Yalnız gerçekten tekrar kullanılan, pahalı hesaplanan ara sonucu önbelleğe al, işin bitince unpersist() çağır.

collect() ve toPandas() tuzağı

collect bütün veriyi sürücüye çeker. 50 GB'lık sonucu sürücüye çekmek OutOfMemory demektir. Sürücüye yalnız küçük özetler gelmeli; büyük sonuçlar write ile göle yazılmalı.

Alıştırmalara geç (21)