All'avvio è stato eseguito un intenso inserimento da MongoDB, sono stati trasformati i documenti, è stato inserito un modello di lettura in ETS, dopodiché il nodo è passato al normale traffico Phoenix. La CPU è scesa, i log sembravano a posto, ma l'RSS è rimasto alto. Attivare il GC non ha cambiato nulla. Sembrava che la VM si rifiutasse di liberare memoria.
Questo non è quasi mai ciò che accade. Su BEAM, la domanda che conta è: cosa è ancora referenziato e dove è diventato a lunga durata.
Cosa sta effettivamente facendo BEAM con la tua memoria
BEAM non ha un unico heap globale che viene ripulito in una sola passata. Ogni processo ha il proprio heap ed è sottoposto a garbage collection in modo indipendente, utilizzando un generational copying collector. Questo è il motivo per cui un singolo processo a lunga durata può trattenere molta memoria anche se tutto il resto sembra inattivo.
Così ho iniziato rifiutandomi di indovinare. Ho controllato i bucket di memoria della VM stessa:
:erlang.memory()
Se :processes è grande, stai cercando grandi heap. Se :ets è grande, hai memorizzato molto. Se :binary è grande, di solito hai a che fare con la durata dei binari, ed è lì che risiedeva il mio vero problema.
La trappola dei binari che mantiene vivi i grandi buffer
Una volta che :binary è dominante, devi avere un concetto tatuato nel cervello: i sub-binari.
Un sub-binario è una slice che punta a un altro binario. È veloce perché evita la copia, ma può mantenere in vita il binario padre originale. Quel padre potrebbe essere un buffer molto grande proveniente da I/O, decodifica o interni del driver.
La mia pipeline ha reso facile attivare questo scenario. Un driver decodifica un payload grande, estraggo campi binari più piccoli e poi memorizzo quei valori da qualche parte a lunga durata. Se quei campi sono sub-binari, possono bloccare il buffer più grande. A quel punto il GC sta facendo il suo lavoro correttamente: i dati non sono spazzatura, perché ne detengo ancora i riferimenti.
Ecco perché “Ho forzato il GC e non è successo nulla” è un rapporto comune nei casi di ritenzione di binari.
Dimostrare chi detiene i binari
Prima di modificare il codice, volevo un proprietario concreto.
La prova più rapida è chiedere a ogni processo quali binari referenzia, sommarli e ordinarli. Questo non richiede alcun tool esterno ed è spesso sufficiente per identificare i processi colpevoli:
Process.list()
|> Enum.map(fn pid ->
info = Process.info(pid, [:registered_name, :memory, :binary, :message_queue_len]) || []
bins = Keyword.get(info, :binary, [])
bin_bytes = Enum.reduce(bins, 0, fn {size, _used, _refc}, acc -> acc + size end)
%{
pid: pid,
name: Keyword.get(info, :registered_name),
memory: Keyword.get(info, :memory, 0),
binary_bytes: bin_bytes,
queue: Keyword.get(info, :message_queue_len, 0)
}
end)
|> Enum.sort_by(& &1.binary_bytes, :desc)
|> Enum.take(20)
Nel mio caso, i principali colpevoli erano esattamente i processi coinvolti nel caricamento e i processi che ricevevano documenti completi e li mantenevano in memoria.
Quando hai bisogno di una lente più potente, recon è progettato per questo tipo di diagnosi e include bin_leak/1, che forza il GC e osserva quanti riferimenti binari vengono rilasciati per processo. Non è magia, è uno strumento di misurazione che rende visibile la ritenzione dei binari.
La vera soluzione: copiare i binari al confine dove i dati diventano a lunga durata
Il mio confine rigido era ETS. Una volta che un termine entra in ETS, è destinato a vivere. Se inserisco accidentalmente sub-binari in ETS, potrei mantenere vivi enormi buffer padre per tutta la durata della cache.
Così ho forzato i binari a diventare autonomi nel momento in cui superavano quel confine. Il cuore della soluzione era semplice: :binary.copy/1.
Ecco la funzione di compattazione che ho usato (semplificata). L'obiettivo è percorrere termini annidati e copiare i binari, lasciando intatte le struct:
def compact_binaries(term) when is_binary(term), do: :binary.copy(term)
def compact_binaries(list) when is_list(list),
do: Enum.map(list, &compact_binaries/1)
def compact_binaries(map) when is_map(map) and not is_struct(map) do
Map.new(map, fn {k, v} -> {compact_binaries(k), compact_binaries(v)} end)
end
def compact_binaries(tuple) when is_tuple(tuple) do
tuple
|> Tuple.to_list()
|> Enum.map(&compact_binaries/1)
|> List.to_tuple()
end
def compact_binaries(other), do: other
L'ho applicata in due punti importanti:
- subito prima di inserire i valori trasformati in ETS
- subito prima di inviare i payload completi dei documenti a processi a lunga durata tramite message passing
Dopo quella modifica, :binary ha smesso di stabilizzarsi dopo il caricamento. La memoria è tornata perché i grandi buffer transitori della decodifica non erano più bloccati dalle piccole slice che avevo salvato per dopo.
Perché le modifiche al GC non hanno aiutato prima della correzione strutturale
Questa è la parte che ti farà risparmiare giorni.
Il GC di BEAM è per processo e libera solo i dati irraggiungibili. Se memorizzi un binario in ETS o nello stato di un GenServer, è raggiungibile. Se quel binario è un sub-binario, anche il padre è raggiungibile. Chiamare manualmente il GC non cambia il fatto che detieni ancora riferimenti.
Solo dopo aver interrotto i riferimenti, le impostazioni del GC sono diventate utili, e solo per alcuni processi specifici a lunga durata che avevano un ciclo di vita prevedibile di “grande picco e poi prevalentemente inattivo”.
Due impostazioni valevano la pena di tenere nel mio arsenale:
:erlang.process_flag(:fullsweep_after, N)per i processi che allocano pesantemente e poi vivono per sempre, in modo che la vecchia spazzatura venga raccolta più spesso (con un certo costo di CPU).- ritornare
:hibernateda un GenServer dopo una fase di carico nota, in modo che il processo possa liberare un heap ingombrato una volta che ha veramente finito con quei dati temporanei.
La parte che è rimasta alta di proposito: ETS
Dopo aver risolto il pinning dei binari, la memoria non è scesa a livelli “bassi”, perché ETS era ora il legittimo proprietario del modello di lettura.
ETS riporta la memoria in parole, quindi la converto usando la dimensione della parola del runtime:
word_size = :erlang.system_info(:wordsize) ets_bytes = (:ets.info(:boats, :memory) || 0) * word_size ets_mb = ets_bytes / (1024 * 1024)
A quel punto l'ottimizzazione non riguarda il GC. Riguarda ciò che memorizzi. Ho rimodellato il payload della cache per corrispondere ai percorsi di lettura e ho smesso di memorizzare blob a forma di documento quando veniva utilizzato solo un piccolo sottoinsieme.
Monitoraggio: cosa uso in sviluppo e cosa mi fido in produzione
In sviluppo, voglio la massima visibilità, anche se gli strumenti sono pesanti. In produzione, voglio un overhead basso, strumenti remoti e metriche che mi permettano di rilevare la stessa classe di regressione precocemente.
Monitoraggio in sviluppo
Observer è ancora il modo più veloce per acquisire intuizione. Ti permette di vedere la memoria dei processi, le tabelle ETS, l'utilizzo dei binari, le code di messaggi e gli scheduler in un unico posto. Lo uso quando voglio confermare rapidamente un'ipotesi: la memoria è dominata da ETS, dai binari o da pochi processi 'gonfi'?
Quando ho bisogno di capire il comportamento del GC invece di indovinare, traccio gli eventi di garbage collection per un PID specifico e osservo il suo comportamento sotto carico:
pid = self() :erlang.trace(pid, true, [:garbage_collection, :monotonic_timestamp]) # run the code path that allocates heavily # then turn tracing off :erlang.trace(pid, false, [:garbage_collection])
Questo mi fornisce i timestamp per gli eventi di inizio e fine del GC e previene il classico errore di presumere che “il GC non sia in esecuzione” solo perché l'RSS è piatto.
Monitoraggio in produzione
In produzione evito gli strumenti basati su GUI a meno che non sia in una sessione di debug controllata. La mia base di partenza è costituita da metriche più snapshot mirati.
Innanzitutto, mi appoggio a Phoenix Telemetry e al supervisor Telemetry. Phoenix viene fornito con una configurazione Telemetry e può interrogare le metriche della VM a intervalli tramite :telemetry_poller. Questo ti fornisce segnali continui invece di interrogazioni shell occasionali.
Un modello semplice è allegare una misurazione del poller che emette periodicamente i bucket di memoria della VM, quindi esportarli al tuo backend di metriche:
# in your telemetry supervisor
children = [
{:telemetry_poller,
measurements: [
{__MODULE__, :vm_memory, []}
],
period: :timer.seconds(10)}
]
def vm_memory do
mem = :erlang.memory()
:telemetry.execute([:vm, :memory], mem, %{})
end
Una volta che hai gli eventi [:vm, :memory], puoi trasformarli in metriche (Prometheus, StatsD, OpenTelemetry) e impostare avvisi su tendenze come la crescita dei binari, la crescita di ETS o la crescita dei processi.
In secondo luogo, quando ho bisogno della verità a livello di processo in produzione, mi affido a recon e observer_cli. bin_leak/1 di recon è particolarmente utile quando la memoria binaria è alta e si desidera individuare i processi principali che rilasciano riferimenti binari dopo il GC. observer_cli racchiude questo tipo di diagnosi in un'interfaccia adatta alla produzione.
Il flusso di lavoro pratico è il seguente:
- i dashboard mi indicano quale bucket sta crescendo (binario, ETS, processi)
- se è binario, eseguo uno snapshot rapido del processo o
bin_leak/1per identificare i proprietari - quindi cerco il confine dove i dati diventano a lunga durata (inserimento in ETS, stato di GenServer, backlog della coda di messaggi)
- correggo prima i riferimenti, e solo allora considero la regolazione dei flag del GC per un piccolo numero di processi
Questo flusso di lavoro è ciò che ha impedito a questo problema di ripresentarsi in seguito, man mano che la codebase si evolveva.
Windsurf
All my projects and even this website is build using Windsurf Editor. Windsurf is the most intuitive AI coding experience, built to keep you and your team in flow.
Contattaci
Hai una domanda o vuoi collaborare? Lascia un messaggio qui sotto.