Learning Platform
Глоссарий Troubleshooting
Урок 15.04 · 32 мин
Продвинутый
JVMG1GCGC TuningOff-heapHeartbeatMemoryProduction

Тюнинг 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
WARNING

В 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 MB
  • 222.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 vs Off-heap allocation

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.
vs

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
DANGER

Увеличение spark.network.timeout до бесконечных значений — антипаттерн. Это скрывает симптомы, но не лечит причину. Реальное решение — устранить Full GC через правильный heap sizing, G1HeapRegionSize и снижение allocation rate (off-heap, меньше кэширования, рефакторинг UDF).


Диагностический workflow: от симптома к решению

GC debugging flowchart

Симптом: медленные 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 TBJDK 15+, overhead throughput ~5%, большой RSS
Shenandoah (-XX:+UseShenandoahGC)Experimental для SparkLow-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
Проверка знанийKnowledge check
Production executor (8 GB heap) теряется каждые несколько часов. Driver log показывает: "Executor heartbeat timed out after 120000 ms". GC-лог executor'а: за 2 минуты до потери — три Full GC паузы по 45, 38, 41 секунд. Heap после каждого Full GC: 7.8 GB -> 7.7 GB (очистили < 2%). Объясни точную цепочку событий и предложи конкретный план исправления с параметрами.
ОтветAnswer
Цепочка событий: 1) Executor заполняет Old Generation до предела (7.8+ GB из 8 GB). 2) G1GC инициирует Full GC (Stop-The-World): все threads JVM полностью останавливаются на 45 секунд. Heartbeat thread тоже остановлен. 3) После Full GC очищается < 2% — большинство объектов Long-lived (RDD cache, broadcast переменные, или утечка памяти в UDF). 4) Через несколько минут ситуация повторяется: аллоцируются новые объекты, опять Full GC. 5) Суммарное STW время за ~3 минуты = 45+38+41 = 124 секунды > spark.network.timeout=120s. Driver объявляет executor потерянным. Диагностика root cause: запустить async-profiler -e alloc на executor до следующей потери, найти что аллоцирует long-lived objects. Конкретный план: (1) Увеличить G1HeapRegionSize: -XX:G1HeapRegionSize=32m, чтобы Parquet/shuffle буферы не становились Humongous. (2) Снизить InitiatingHeapOccupancyPercent до 25 (-XX:InitiatingHeapOccupancyPercent=25) — начинать concurrent marking раньше, до критического заполнения. (3) Включить off-heap для execution memory: spark.memory.offHeap.enabled=true spark.memory.offHeap.size=3g — снизить давление на heap. (4) Если есть RDD cache — явно unpersist после использования. (5) Если UDF — проверить на утечки объектов. (6) Кратковременная мера: увеличить spark.network.timeout=600s, spark.executor.heartbeatInterval=30s — даст буфер пока исправляется root cause. НЕ делать это постоянным решением.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 4. Executor (16 GB heap, G1GC) показывает в GC-логе: многочисленные Humongous Object allocations размером 5-12 MB, за которыми следуют Full GC. Какой JVM флаг устранит эту проблему и почему?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4