Un lock che teneva una connessione: advisory lock e un pool di quattro
Un advisory lock occupa una connessione del pool finché è attivo. Quattro lock e un pool di quattro hanno bloccato un'istanza. Risolto con lease in una tabella.
Subito dopo il passaggio del backend sul nostro cluster, uno dei suoi due nodi applicativi è uscito dalla rotazione e l'altro ha retto da solo tutto il traffico. L'istanza su quel nodo girava, rispondeva su /up e falliva la readiness probe perché non riusciva a ottenere una connessione al database. Ne aveva quattro, e tutte e quattro tenevano un lock.
Ecco come succede, e cosa ha preso il posto dei lock: lease in una tabella, che non tengono nessuna connessione.
Il lavoro in background
Nel backend ci sono quattro tipi di lavoro che non devono girare su tutte le istanze contemporaneamente:
- la pulizia oraria, che rimuove le challenge delle passkey rimaste senza risposta, le richieste di single sign-on scadute e tutto ciò che le nostre regole di conservazione tengono solo per un certo tempo;
- la valutazione degli allarmi per i monitor di uptime, ogni 30 secondi;
- i controlli di sicurezza della posta, che ogni 15 minuti cercano i domini non controllati nelle ultime 23 ore;
- i controlli di uptime veri e propri, ogni 10 secondi, una volta per nodo, così che due istanze sullo stesso nodo non contino mai due volte lo stesso controllo.
Ogni giro cominciava allo stesso modo. Il vecchio TryExclusive prendeva una connessione dal pool, eseguiva su di essa pg_try_advisory_lock e teneva quella connessione fino alla fine del lavoro:
conn, err := p.Acquire(ctx)
// ...
err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock(hashtext($1))`, name).Scan(&ok)
// release: pg_advisory_unlock, then conn.Release()
L'istanza che otteneva il lock faceva il giro, le altre lo saltavano. È lo schema da manuale.
Perché un lock è una connessione
La documentazione di PostgreSQL lo dice in una frase: un advisory lock acquisito a livello di sessione resta attivo finché non viene rilasciato esplicitamente o finché la sessione non termina. Una sessione è una connessione. Per tenere il lock si tiene la connessione, e una connessione occupata è una connessione che il pool non può dare a nessun altro.
Sul cluster ogni istanza riceve quattro connessioni, da un budget condiviso da tutte le applicazioni del cluster: tre ruoli su due nodi, contati due volte per la sovrapposizione durante un deploy, ne occupano 48 su 120.
Ora contiamo i lock. Tre valgono per tutto il cluster: pulizia, allarmi, controlli della posta. Il quarto è il lock di uptime del nodo stesso. Un'istanza che si era aggiudicata tutti e tre i lock di cluster, più quello del proprio nodo, aveva quattro connessioni fuori dal pool. A quel punto:
- Il lavoro dentro ogni lock chiedeva al pool una connessione per eseguire le sue query, e aspettava.
- Le richieste chiedevano al pool una connessione, e aspettavano.
- La readiness probe eseguiva
SELECT 1con un limite di un secondo, non otteneva una connessione e segnalava il database come guasto. L'istanza rispondeva a/health/readycon 503, e gli edge la toglievano dalla rotazione.
Siccome il lavoro aspettava una connessione tenuta dal suo stesso lock, i lock non venivano mai rilasciati. Non era lento: era bloccato.
pg_locks lo rende visibile, se si sa dove guardare. Gli advisory lock compaiono con locktype = 'advisory', e un join con pg_stat_activity mostra da dove si connette ciascun detentore:
SELECT l.pid, a.client_addr, a.application_name, a.state
FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory';
Tutti e quattro i lock appartenevano a backend dello stesso nodo. Quei backend risultano inattivi, perché la chiamata che ha preso il lock si è conclusa da tempo, ed è proprio per questo che nessuno li sospetta.
La prima correzione, e perché non era la correzione
La correzione rapida è stata un pool di otto. Funzionava, occupava 96 delle 120 connessioni e legava la dimensione del pool al numero di tipi di lavoro in background: basta aggiungere un quinto job e il guasto ritorna. Un lock non dovrebbe costare nessuna connessione, quindi ho tolto il lock dalla connessione.
Lease in una tabella
Al loro posto c'è una tabella con una riga per tipo di lavoro:
CREATE TABLE leases (
name text PRIMARY KEY,
holder text NOT NULL,
taken_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL
);
Prendere un lease è una sola istruzione:
INSERT INTO leases (name, holder, taken_at, expires_at)
VALUES ($1, $2, now(), now() + make_interval(secs => $3))
ON CONFLICT (name) DO UPDATE
SET holder = excluded.holder, taken_at = excluded.taken_at, expires_at = excluded.expires_at
WHERE leases.expires_at < now()
RETURNING true
Se nessuno detiene il nome, la riga viene inserita. Se qualcuno lo detiene ma il lease è scaduto, il chiamante subentra. Se qualcuno lo detiene e il lease non è scaduto, il WHERE è falso, PostgreSQL non restituisce nessuna riga e il chiamante salta il giro. ON CONFLICT DO UPDATE garantisce uno dei due esiti in modo atomico, anche in concorrenza, quindi due istanze che chiedono nello stesso momento non possono vincere entrambe.
Un lease dura due minuti. Mentre il lavoro gira, una goroutine lo rinnova ogni 30 secondi, un quarto di quel tempo, con un solo UPDATE ... WHERE name = $1 AND holder = $2. Quando il lavoro finisce, un DELETE con la stessa condizione lo restituisce. Ogni istruzione prende in prestito una connessione per un attimo.
Contano tre dettagli:
- Ogni chiamata è un detentore a sé. Il detentore è il nome host, l'id del processo e 64 bit casuali, nuovi a ogni chiamata. Anche due goroutine nello stesso processo si escludono a vicenda, e un rilascio che arriva in ritardo non può mai cancellare un lease che nel frattempo ha preso qualcun altro.
- Conta solo l'orologio del database. Ogni orario viene da
now()sul server, quindi gli orologi dei nodi possono divergere senza conseguenze. - Un lock a livello di transazione non sarebbe servito.
pg_try_advisory_xact_lockrichiede una transazione aperta per tutto il giro: di nuovo una connessione tenuta, che il nostroidle_in_transaction_session_timeoutdi 30 secondi chiuderebbe a metà giro.
Il pool è tornato a quattro.
Quanto costa
Un detentore andato in crash ritarda il giro successivo fino a due minuti. Nessuno rilascia il suo lease, quindi deve scadere. Per la pulizia oraria non cambia nulla. Per la valutazione degli allarmi, che gira ogni 30 secondi, può significare fino a quattro giri saltati.
Un lavoro può girare due volte. Se il detentore perde il database per più di due minuti mentre un'altra istanza lo raggiunge ancora, il lease scade e l'altra istanza lo prende. La prima se ne accorge quando il rinnovo successivo non tocca nessuna riga; scrive un avviso nel log, e il giro in corso va avanti. Qui non c'è un fencing token, il meccanismo che Martin Kleppmann descrive proprio per questo caso.
Lo accetto perché nessuno dei quattro job subisce danni se gira due volte. La pulizia cancella ciò che è scaduto, e cancellarlo di nuovo non cancella nulla. Un controllo di uptime in più è un risultato in più. La valutazione degli allarmi confronta ciò che vede con lo stato che ha salvato, e i controlli della posta prendono solo domini non controllati da 23 ore, quindi un'esecuzione che arriva seconda trova il lavoro già registrato e non fa nulla; solo due esecuzioni nello stesso identico momento potrebbero inviare due volte lo stesso avviso. Un job in cui una seconda esecuzione fa danni avrebbe bisogno di un fencing token o di un vincolo di unicità sul risultato. Nessuno di questi quattro ne ha bisogno.
Il test
TestLeasesHoldNoConnection riproduce il guasto nella sua forma minima: un pool di una connessione, quattro lease presi (pulizia, allarmi, controlli della posta, runner di uptime), e poi la scrittura che fa la readiness probe, che deve andare in commit entro tre secondi. Con il vecchio TryExclusive il test non arriva mai fin lì: il secondo lock aspetta la connessione tenuta dal primo. Altri due test verificano che un lease abbia un solo detentore, che a un lease non rinnovato da nessuno subentri un altro detentore e che a uno rinnovato regolarmente no.
Dove restano gli advisory lock
Le migrazioni ne prendono ancora uno all'avvio, prima che l'istanza si dichiari pronta, e le transazioni brevi prendono quelli a livello di transazione. La regola è più stretta: nessun lock resta attivo durante un lavoro lungo su una connessione del pool. Con il transaction pooling di PgBouncer la questione non si pone, perché lì gli advisory lock a livello di sessione non funzionano affatto.
Fonti
- PostgreSQL documentation: Explicit Locking, Advisory Locks, PostgreSQL Global Development Group, consultato il
- PostgreSQL documentation: Advisory Lock Functions, PostgreSQL Global Development Group, consultato il
- PostgreSQL documentation: pg_locks, PostgreSQL Global Development Group, consultato il
- PostgreSQL documentation: pg_stat_activity, PostgreSQL Global Development Group, consultato il
- PostgreSQL documentation: INSERT, ON CONFLICT clause, PostgreSQL Global Development Group, consultato il
- PostgreSQL documentation: Client Connection Defaults (idle_in_transaction_session_timeout), PostgreSQL Global Development Group, consultato il
- pgxpool package documentation, pkg.go.dev, consultato il
- PgBouncer features: pooling modes and what they support, PgBouncer, consultato il
- How to do distributed locking, Martin Kleppmann, consultato il
Scritto da Samuel Krauss, Founder. Archiviato in postgresql, go, pgx, advisory-locks, connection-pool, background-jobs, leases.
Tradotto dall’inglese. Leggi l’originale inglese