Тюнинг JVM и GC для Spark
GC-паузы — одна из самых коварных проблем в production Spark. Они не падают с ошибкой. Они просто делают всё медленнее: задачи занимают больше времени, executor теряет связь с driver, jobs завершаются через час вместо десяти минут. При этом CPU Usage выглядит нормально, выдавая ложное ощущение здоровья.
В Spark 4.0 по умолчанию используется JDK 17, что делает G1GC коллектором по умолчанию. Этот урок — исчерпывающий playbook по диагностике и устранению GC-проблем в executor.
Как Spark использует память JVM
Прежде чем туниговать GC, нужно понять, что конкретно попадает на heap executor.
spark.executor.memory = 8g (total heap = 8 GB)
┌─────────────────────────────────────────────────────────┐
│ JVM Heap (8 GB) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Spark Memory Pool (spark.memory.fraction = 0.6) │ │
│ │ = 8GB * 0.6 = 4.8 GB │ │
│ │ ┌────────────────────┬──────────────────────┐ │ │
│ │ │ Execution Memory │ Storage Memory │ │ │
│ │ │ (sort, hash, join) │ (RDD cache, bcst) │ │ │
│ │ │ unified pool │ unified pool │ │ │
│ │ └────────────────────┴──────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ User Memory (1 - 0.6 - 0.3 = 0.3 fraction) │ │
│ │ = 8GB * 0.3 = 2.4 GB │ │
│ │ UDFs, Spark internal structures, RDD objects │ │
│ └─────────────────────────────────────────────────┘ │
│ Reserved: 300MB (spark.memory.storageFraction = 0.5 │
│ overhead buffer) │
└─────────────────────────────────────────────────────────┘
spark.executor.memoryOverhead = 0.1 * 8g = 800MB (off-heap)
-> Netty buffers, JVM overhead, native libraries, Python worker
Ключевое разграничение: spark.executor.memory — это JVM heap. spark.executor.memoryOverhead — это память вне heap (off-heap JVM: code cache, metaspace, native threads, Netty direct buffers). Для Spark 4.0 с активным Spark Connect и Arrow-форматом memoryOverhead часто нужно увеличивать до 1–2 GB.
G1GC: архитектура и ключевые параметры
G1GC (Garbage-First GC) разбивает heap на множество regions одинакового размера (обычно 1–32 MB). Это ключевое отличие от CMS/Parallel GC с фиксированными Young/Old generation зонами.
Базовая конфигурация для executor
spark.executor.extraJavaOptions=\
-XX:+UseG1GC \
-XX:G1HeapRegionSize=32m \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1NewSizePercent=20 \
-XX:G1MaxNewSizePercent=40 \
-XX:ConcGCThreads=4 \
-XX:ParallelGCThreads=8 \
-XX:+ParallelRefProcEnabled \
-Xlog:gc*:file=/tmp/spark-gc-%p.log:time,uptime,level,tags:filecount=5,filesize=20m
Разберём каждый параметр:
-XX:G1HeapRegionSize=32m
По умолчанию размер региона определяется автоматически: heap / 2048. Для 8 GB heap это 4 MB. Для Spark это слишком мало: executor обрабатывает large objects (UnsafeRow, byte[] shuffle-буферы, Parquet страницы). Объект, превышающий 50% размера региона, становится Humongous Object и аллоцируется в Old Generation напрямую, минуя Young GC. Это вызывает преждевременные Full GC. С -XX:G1HeapRegionSize=32m большинство рабочих объектов Spark помещается в Young region и коллектируется быстро.
-XX:MaxGCPauseMillis=200
Target pause time. G1GC пытается не превышать это значение. Дефолт 250ms. Уменьшение до 100ms снизит паузы, но увеличит GC overhead.
-XX:InitiatingHeapOccupancyPercent=35
При заполнении Old Generation на 35% G1 начинает concurrent marking (concurrent с приложением, без паузы). Дефолт 45%. Снижение до 35% даёт G1 больше времени на marking до того, как потребуется Full GC. Критично для Spark: executor заполняет heap быстро при больших shuffle/sort операциях.
-XX:ConcGCThreads=4
Число фоновых GC-потоков для concurrent marking. Дефолт: max(1, ParallelGCThreads/4). Увеличение помогает при большом heap и высокой allocation rate.
GC-логирование: что включать и как читать
# Современный флаг (JDK 9+, включая JDK 17):
-Xlog:gc*:file=/tmp/spark-gc-%p.log:time,uptime,level,tags:filecount=5,filesize=20m
# %p = PID executor'а (уникальный файл на каждый executor)
# filecount=5,filesize=20m = ротация: 5 файлов по 20 MB
В Spark 4.0 (JDK 17) старые флаги -XX:+PrintGCDetails -XX:+PrintGCDateStamps устарели. Используй -Xlog:gc*. Они работают, но генерируют deprecation warnings.
Чтение GC-лога
# Пример строк из GC-лога:
[2024-01-15T14:23:01.234+0000][10.234s][info][gc,start] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
[2024-01-15T14:23:01.456+0000][10.456s][info][gc ] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 4832M->3210M(8192M) 222.123ms
Разбор:
Pause Young (Normal)— обычный Young GC (нормально)4832M->3210M(8192M)— было 4832 MB, стало 3210 MB, heap total 8192 MB222.123ms— пауза 222ms (близко к target 200ms)
Тревожные паттерны:
# Full GC — катастрофа:
GC(85) Pause Full (G1 Evacuation Pause) 7934M->7801M(8192M) 8456.234ms
^^^^^^^^^^^^
# 8.4 секунды STW! Executor был мёртв 8 секунд.
# Почти весь heap занят после Full GC — OOM неизбежен.
# Частые Young GC с малой очисткой:
GC(101) Pause Young 7100M->7050M(8192M) 180ms # освободили только 50 MB из 7 GB
GC(102) Pause Young 7180M->7140M(8192M) 195ms # и опять. Long-lived objects.
GC(103) Pause Full 7800M->7750M(8192M) 4200ms # Full GC неизбежен
On-heap vs Off-heap: когда и зачем
Spark поддерживает два режима использования памяти для execution:
On-heap (дефолт): объекты на Java heap, управляются GC. Простота. Overhead GC.
Off-heap (Tungsten off-heap): напрямую через sun.misc.Unsafe.allocateMemory. JVM не видит эти объекты — GC не сканирует, не коллектирует. Критично: утечка памяти = процесс убит OOMkiller.
spark.memory.offHeap.enabled=true
spark.memory.offHeap.size=4g
При включении off-heap Spark allocation pool разделяется: часть execution memory идёт в off-heap. UnsafeFixedWidthAggregationMap, UnsafeExternalSorter — ключевые компоненты, использующие off-heap при включённом Tungsten.
Когда включать off-heap:
- Executor memory > 16 GB: GC overhead при большом heap становится неприемлемым (Full GC по 10+ секунд)
- Workload с большими sort/aggregate операциями и частыми spill
- ZGC/Shenandoah — если переходите на pauseless GC, off-heap снижает pressure
Риски off-heap:
spark.executor.extraJavaOptions=\
-XX:MaxDirectMemorySize=6g # Лимит Direct ByteBuffer (Netty + off-heap pool)
Без -XX:MaxDirectMemorySize прямая память ограничена только OS. Комбинация: spark.executor.memory=8g + spark.memory.offHeap.size=4g + Netty direct buffers (1 GB) + Metaspace (256 MB) требует как минимум 14 GB RSS на worker-ноду.
On-heap (дефолт)
On-heap: UnsafeRow, InternalRow, byte[] для shuffle. Managed by GC. При больших объёмах вызывает GC pressure. Проще в debugging: heap dumps, MAT analysis.Young GC (быстро)
Young Generation: большинство рабочих объектов. Быстрая очистка 10-200ms. G1 evacuation pause.Off-heap (Tungsten)
Off-heap: MemoryBlock через sun.misc.Unsafe. GC не видит. Нет GC pressure. Требует явного освобождения (TaskMemoryManager.free). Memory leak = процесс OOMkilled.OOMkiller risk
OOMkiller: если RSS процесса превышает cgroup limit (Kubernetes) или физическую память ноды — Linux OOMkiller убивает процесс без предупреждения. Spark не успевает записать причину.GC-паузы и потеря executor по heartbeat
Это наиболее опасный сценарий: длинная GC-пауза -> executor не отправляет heartbeat -> driver считает executor умершим -> задачи переназначаются.
Механизм heartbeat
Executor отправляет heartbeat каждые spark.executor.heartbeatInterval (дефолт 10 секунд). Если driver не получает heartbeat в течение spark.network.timeout (дефолт 120 секунд) — executor объявляется потерянным.
При GC-паузе executor полностью останавливается (Stop-The-World). Heartbeat thread тоже. Если пауза > 120 секунд — executor потерян. Но даже паузы по 5-10 секунд суммируются: несколько Full GC подряд = executor потерян.
# Признаки в driver log:
ERROR scheduler.TaskSetManager: Lost executor 3 on worker-4:7077
Reason: Executor heartbeat timed out after 120000 ms
# В GC-логе этого executor:
GC(34) Pause Full 7890M->7820M(8192M) 45678ms # 45 секунд!
GC(35) Pause Full 7920M->7850M(8192M) 38912ms # ещё 39 секунд!
# Суммарно 84 секунды STW -> executor потерян через 120s timeout
Параметры для предотвращения
# Увеличить heartbeat interval (даёт буфер при паузах):
spark.executor.heartbeatInterval=30s
# Увеличить network timeout:
spark.network.timeout=600s
# ЛУЧШЕЕ решение: исправить GC вместо увеличения таймаутов:
spark.executor.extraJavaOptions=\
-XX:+UseG1GC \
-XX:G1HeapRegionSize=32m \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:MaxGCPauseMillis=500 \
-Xlog:gc*:file=/tmp/spark-gc-%p.log:time,level:filecount=3,filesize=50m
Увеличение spark.network.timeout до бесконечных значений — антипаттерн. Это скрывает симптомы, но не лечит причину. Реальное решение — устранить Full GC через правильный heap sizing, G1HeapRegionSize и снижение allocation rate (off-heap, меньше кэширования, рефакторинг UDF).
Диагностический workflow: от симптома к решению
Симптом: медленные tasks / executor lost
Симптом: jobs медленные или executor потерян. Начало диагностики.Проверь GC Time в UI
Spark UI -> Executors tab -> GC Time колонка. Если GC Time > 10% от task duration — GC проблема подтверждена.Прочти GC-логи
GC-лог executor: grep для Full GC, долгих Pause Young. Если Full GC есть — heap sizing неправильный или allocation rate слишком высокий.Частые Young GC?
Если Pause Young частые и освобождается мало памяти — Long-lived objects. Увеличить G1NewSizePercent или G1HeapRegionSize.Full GC?
Если Full GC — Old Gen заполнен. Решения: уменьшить spark.memory.fraction (больше heap для user objects), включить off-heap, профилировать allocations.Конкретные сценарии и решения
Сценарий 1: Humongous Object allocations.
Симптом: в GC-логах to-space exhausted или частые Full GC при heap 50-60%.
Диагностика:
# Включить логирование Humongous allocations:
-Xlog:gc+humongous=debug:file=/tmp/gc-humongous.log
Вывод покажет размер и stack trace объектов > 50% G1HeapRegionSize. Типичные виновники: Parquet page буферы (часто 4–16 MB), shuffle write буферы, broadcast variables.
Решение:
# Увеличить G1HeapRegionSize, чтобы рабочие объекты Spark не стали Humongous:
-XX:G1HeapRegionSize=32m # объекты < 16 MB — не Humongous
Сценарий 2: RDD cache вытесняет execution memory.
Симптом: Full GC после df.cache(), задачи с sort/join начинают spill.
# Проблема: кэшируем слишком много:
df.cache()
result = df.groupBy("key").agg(...) # join/sort не хватает execution memory
result.show()
# spark.memory.fraction = 0.6 (60% heap shared between execution + storage)
# Если storage (cache) занял всё — execution spill'ит.
# Решение 1: снизить storage fraction:
spark.memory.storageFraction=0.3 # дефолт 0.5
# Решение 2: явно unpersist после использования:
df.unpersist()
Сценарий 3: GC пауза убивает executor на Kubernetes.
На K8s executor запущен в pod с лимитом memory. Если RSS превышает limit — pod убивается OOMkiller.
# Правильное соотношение для Spark 4.0 на K8s:
spark.executor.memory: 8g
spark.executor.memoryOverhead: 2g # = 10g total pod memory
# Pod resource limit должен быть >= executor.memory + memoryOverhead:
resources:
limits:
memory: "10Gi" # = 8g + 2g
requests:
memory: "10Gi"
# JVM flags для K8s-specific оптимизации:
spark.executor.extraJavaOptions=\
-XX:+UseContainerSupport \ # JDK 11+: правильно читает cgroup limits
-XX:MaxRAMPercentage=75.0 \ # heap = 75% container memory = 7.5 GB (< 8g)
-XX:+UseG1GC \
-XX:G1HeapRegionSize=32m \
-XX:InitiatingHeapOccupancyPercent=35
-XX:+UseContainerSupport (включён по умолчанию в JDK 11+) заставляет JVM использовать cgroup memory limits вместо физической памяти машины для расчёта heap. Это критично на K8s: без этого флага JVM видит 128 GB RAM ноды и ставит heap 32 GB, который не помещается в pod.
Сравнение GC коллекторов для Spark 4.0
| Коллектор | Подходит для Spark | Плюсы | Минусы |
|---|---|---|---|
| G1GC (дефолт в JDK 9+) | Да, лучший выбор | Хорошо на 4-32 GB heap, настраиваемые pause targets | Требует тюнинга для Spark-специфичных paterns |
ZGC (-XX:+UseZGC) | Для heap > 32 GB | Паузы < 1 ms, работает с heap до 16 TB | JDK 15+, overhead throughput ~5%, большой RSS |
Shenandoah (-XX:+UseShenandoahGC) | Experimental для Spark | Low-pause alternative to ZGC | Не входит в Oracle JDK, только OpenJDK |
Parallel GC (-XX:+UseParallelGC) | Только для batch | Максимальный throughput | Большие STW паузы, неприемлемо для heartbeat |
Для большинства production Spark workloads на 8-32 GB executor heap G1GC с правильным тюнингом — оптимальный выбор. ZGC стоит рассмотреть при executor memory > 32 GB или если SLA требует pause < 100ms.
Полный рекомендуемый набор JVM флагов
# Production-ready executor JVM flags для Spark 4.0 (heap 8-16 GB):
spark.executor.extraJavaOptions=\
-XX:+UseG1GC \
-XX:G1HeapRegionSize=32m \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1NewSizePercent=20 \
-XX:G1MaxNewSizePercent=40 \
-XX:ParallelGCThreads=8 \
-XX:ConcGCThreads=4 \
-XX:+ParallelRefProcEnabled \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=85.0 \
-XX:+PreserveFramePointer \
-XX:+UnlockDiagnosticVMOptions \
-XX:+DebugNonSafepoints \
-Xlog:gc*:file=/tmp/spark-gc-%p.log:time,uptime,level,tags:filecount=5,filesize=20m
# Heartbeat и network timeouts (согласованы с G1GC targets):
spark.executor.heartbeatInterval=20s
spark.network.timeout=300s
spark.storage.blockManagerSlaveTimeoutMs=300000
# Off-heap для больших workloads (опционально, если heap > 16 GB):
spark.memory.offHeap.enabled=true
spark.memory.offHeap.size=4g
-XX:+PreserveFramePointer и -XX:+DebugNonSafepoints — не влияют на производительность значимо (< 1%), но критичны для корректной работы async-profiler при последующей диагностике.
Попробуй сам
1. Включи GC-логирование и найди проблему.
# Запусти Spark приложение с GC логами:
./bin/spark-submit \
--master local[4] \
--conf "spark.driver.extraJavaOptions=\
-XX:+UseG1GC \
-XX:G1HeapRegionSize=32m \
-Xlog:gc*:file=/tmp/local-gc.log:time,uptime,level,tags" \
--class org.apache.spark.examples.SparkPi \
examples/jars/spark-examples_*.jar 10000
# Проанализируй лог:
grep "Pause Full" /tmp/local-gc.log | wc -l # число Full GC
grep "Pause Young" /tmp/local-gc.log | \
awk '{print $NF}' | sort -n | tail -5 # топ-5 долгих Young GC
2. Сравни поведение с маленьким и большим G1HeapRegionSize.
# Маленький регион (дефолт для 1g heap = 512k):
./bin/spark-submit \
--conf "spark.driver.memory=1g" \
--conf "spark.driver.extraJavaOptions=\
-XX:+UseG1GC \
-Xlog:gc+humongous=debug:file=/tmp/gc-small-region.log" \
examples/jars/spark-examples_*.jar 100
# Большой регион:
./bin/spark-submit \
--conf "spark.driver.memory=1g" \
--conf "spark.driver.extraJavaOptions=\
-XX:+UseG1GC \
-XX:G1HeapRegionSize=8m \
-Xlog:gc+humongous=debug:file=/tmp/gc-large-region.log" \
examples/jars/spark-examples_*.jar 100
# Сравни число Humongous allocations:
grep -c "Humongous" /tmp/gc-small-region.log
grep -c "Humongous" /tmp/gc-large-region.log
3. Симулируй потерю executor через GC.
# Создай UDF, который аллоцирует много памяти:
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType
def memory_pressure_udf(x):
# Аллоцируем большой список и сразу отпускаем
waste = [i * 2 for i in range(100000)]
return str(x * 2)
udf_func = udf(memory_pressure_udf, StringType())
df = spark.range(1000000)
df.withColumn("result", udf_func("id")).count()
# Наблюдай GC Time в Spark UI -> Executors