Scale-to-Zero-Postgres: eine Datenbank mitten im Connect aufwecken
Das Versprechen ist simpel: Jede App, die du baust, bekommt ihr eigenes echtes Postgres, und es kostet nichts, solange es niemand benutzt. In einer Welt, in der die meisten Apps halbfertige Experimente sind oder still auf ihren ersten Nutzer warten, kannst du nicht für leerlaufendes Datenbank-Compute bezahlen — davon gibt es einfach zu viel.
„Scale-to-Zero“ ist leicht gesagt und mühsam umgesetzt, denn in dem Moment, in dem du
das Compute entfernst, hast du den Vertrag gebrochen, auf den jeder
Postgres-Client baut: connecte und es antwortet. Also haben wir einen kleinen
Rust-Service davorgesetzt — neon-conn-proxy —, der die Lücke unsichtbar macht.
Er spricht das Postgres-Wire-Protokoll, und wenn ein Connect für eine schlafende
Datenbank eintrifft, parkt er den Client, skaliert das Compute von 0 auf 1 und
leitet die Query weiter, sobald Postgres läuft. Für die App ist das eine etwas
langsame erste Query. Für die Rechnung ist es nichts, solange die App ruhig ist.
Das funktioniert, weil Neon Storage und Compute trennt: Der pageserver hält
die Daten im Object Storage; das Compute ist ein zustandsloser Postgres-Pod,
der Pages bei Bedarf liest. Das Compute auf null zu skalieren, kostet nichts —
die Daten liegen im pageserver. Das Compute jedes Projekts ist ein K8s-Deployment,
das wir zwischen 0 und 1 Replicas skalieren.
Die interessanten Teile zerfallen in drei Anliegen innerhalb des Proxys: den TCP-/Wire-Pfad, die State Machine pro Projekt samt Single-Flight-Wakeup und das K8s-Skalieren. Gehen wir den Pfad eines Connects durch.
1. Genug vom Wire-Protokoll parsen, um zu routen
Der erste Zug eines Postgres-Clients ist die Startup-Message: ein
längenpräfixierter Blob aus Key/Value-Parametern, darunter database und user.
Es gibt kein TLS-Handshake, das wir per MITM mitschneiden müssten, und noch keine
Auth — wir brauchen nur ein Feld, den Datenbanknamen, den wir überladen haben,
damit er die Projekt-ID trägt: proj_{id}.
// handle_connection — the TCP/wire path
let first = postgres::read_startup(&mut client).await?;
match first {
ClientFirstMessage::Startup(startup) => {
let db_name = startup.database().unwrap_or("postgres");
let project_id = match db_name.strip_prefix("proj_") {
Some(id) => id.to_string(),
None => { /* send a Postgres ErrorResponse and bail */ }
};
queue.ensure_ready(&project_id).await?; // <-- may wake the compute
// ... connect upstream, then forward
}
ClientFirstMessage::Cancel { .. } => Ok(()), // cancel reqs carry no routing info
}
Zwei Wire-Protokoll-Details sind wichtig:
- Cancel-Requests (
CancelRequest) sehen aus wie Startup-Messages, tragen aber eine Backend-PID/-Secret statt Parametern. Sie enthalten keine Routing-Info, also verwerfen wir sie, statt zu raten. - Wir schreiben den Datenbanknamen vor dem Weiterleiten zurück auf
postgres. Die App connectet zuproj_abc12; das echte Postgres kennt nurpostgres. Also splicen wir nach dem Wakeup eine umgeschriebene Startup-Message auf den Upstream-Socket:
let rewritten = postgres::rewrite_database(&startup, &config.neon_db_name); // "postgres"
upstream.write_all(&rewritten).await?;
Der Proxy ist nie an der Auth beteiligt — er leitet das (umgeschriebene) Startup weiter und wird danach zum dummen Rohr. Der Client authentifiziert sich direkt gegen das Compute.
2. Single-Flight-Wakeup: eine winzige State Machine pro Projekt
Das Wakeup selbst darf nicht naiv sein. Wenn zehn Connects gleichzeitig auf eine
schlafende Datenbank treffen, willst du ein Wakeup, nicht zehn Rennen um das
Skalieren desselben Deployments. Jedes Projekt hat also einen Eintrag mit einer
Drei-Zustands-Machine und einem tokio::sync::Notify, auf dem Nachzügler warten:
// per-project state machine
struct ProjectEntry {
state: Mutex<(ProjectState, Option<String>)>, // state + last wake error, one lock
wake_notify: Notify,
active_conns: AtomicUsize,
queued_conns: AtomicUsize,
last_wake_at: Mutex<Option<Instant>>, // idle-sweep grace period
}
ensure_ready liest den Zustand und dispatcht:
- Running → verifizieren, dass es tatsächlich läuft (ein veralteter Pod könnte uns unter den Füßen weggeräumt worden sein), dann weitermachen.
- Sleeping → wir sind der Wecker:
do_wake. - Waking → jemand anderes weckt es gerade:
wait_for_wake(auf dem Notify parken).
Der Wecker (do_wake) kippt den Zustand auf Waking, führt den Wakeup-Body aus
und kippt bei Abschluss auf Running (oder mit einem Fehler zurück auf
Sleeping) und ruft notify_waiters(), um alle Geparkten freizugeben. Die
Zustandsübergänge leben an einer Stelle, sodass jeder Fehlerpfad — inklusive
?-Early-Returns bei fehlenden Credentials — den Eintrag sauber zurücklässt. (Eine
frühere Version reichte ? einfach durch und ließ Einträge für immer in Waking
hängen; das produzierte Zombie-Projekte, die nichts mehr aufwecken konnte.)
Connection-Limits werden per Compare-and-Swap durchgesetzt, damit Check-and-Increment unter Nebenläufigkeit atomar ist:
loop {
let current = entry.active_conns.load(Acquire);
if current >= max { return Err("too many connections".into()); }
if entry.active_conns.compare_exchange_weak(current, current+1, AcqRel, Relaxed).is_ok() {
break;
}
}
3. Der Wakeup-Body: das Deployment skalieren, auf Postgres pollen
do_wake_body holt die Branch-Credentials (Timeline + Passwort) und ruft die
Compute-Skalierungs-Schicht auf — alles unter einem wake_timeout (15 s):
// start_compute_inner — the K8s scaling
let state = check_state(cfg, project_id).await;
if state == ComputeState::Running {
wait_for_ready(cfg, project_id, 30s, /*skip_k8s_check*/ true).await?; // fast path
return Ok(());
}
let spec = build_compute_spec(cfg, tenant_id, timeline_id, password); // compute_ctl spec
update_configmap(&client, &ns, &spec_json).await?; // mount the spec
patch_replicas(&client, &ns, COMPUTE_DEPLOY_NAME, 1).await?; // scale 0 → 1
wait_for_ready(cfg, project_id, 60s, /*skip_k8s_check*/ false).await?;// TCP-poll :55432
wait_for_ready macht den Cold Start auf die clevere Art: Für einen frischen Pod
prüft es zuerst die K8s-Pod-Readiness (poll keinen Pod per TCP, der noch Pending
ist), dann connectet es per TCP, um zu bestätigen, dass Postgres tatsächlich
Verbindungen annimmt. Für ein cgroup-Thaw-Wakeup (Pod bereits Running)
überspringt es den K8s-Roundtrip und geht direkt auf TCP.
Sobald es bereit ist, wird der Upstream-Socket geöffnet, und der Proxy bridged bidirektional — mit einem Idle-Timeout, damit ein verstummter Connect das Compute nicht für immer wach hält und Scale-to-Zero blockiert:
// bridge loop, simplified
tokio::time::timeout(idle_timeout, async {
tokio::select! {
r = client_read.read(&mut buf) => { /* n==0 → closed; else write upstream */ }
r = upstream_read.read(&mut buf2)=> { /* n==0 → closed; else write client */ }
}
}).await // Err(_) → "connection idle timeout", close → lets the idle sweep reclaim it
Ein Hintergrund-Idle-Sweep erledigt die andere Hälfte: Jede Minute scannt er
Projekte im Zustand Running mit null aktiven Verbindungen, und wenn sie länger
als das Timeout untätig waren (und nicht gepinnt sind — z. B. mitten in einer
Generierung), skaliert er sie zurück auf 0. Eine kurze Gnadenfrist nach dem Wakeup
(last_wake_at) hält ihn davon ab, ein Compute, das gerade fertig aufgewacht ist,
sofort wieder abzuwürgen.
Single-Flight-Wakeup hat eine Korrektheitsregel
Die wartende Hälfte hängt an einer subtilen Eigenschaft von
tokio::sync::Notify, und es lohnt sich, hier präzise zu sein:
notify_waiters() weckt nur Futures, die zum Zeitpunkt des Aufrufs bereits
registriert sind.
Die naive Form also — das Notified-Future faul am .await zu erzeugen — hat
eine Lücke. Zwischen dem Moment, in dem ensure_ready state == Waking liest,
und dem, in dem der Waiter tatsächlich .notified() pollt, kann der Wecker
notify_waiters() feuern. Ein noch nicht registrierter Waiter verpasst es und
blockiert dann für den vollen wake_timeout:
// naive — the Notified future registers at the await point (too late)
let result = tokio::time::timeout(
wake_timeout + 5s,
entry.wake_notify.notified(),
).await;
Die korrekte Form ist das dokumentierte tokio-Idiom: das Notified-Future
binden und enable() aufrufen, bevor du die Bedingung erneut prüfst, damit du
zuerst ein registrierter Waiter bist und erst dann auf den Zustand schaust:
let notified = entry.wake_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable(); // register interest NOW
{
let guard = entry.state.lock().await;
match guard.0 {
ProjectState::Running => return Ok(()), // already done
ProjectState::Sleeping => return Err(/* wake error */), // already failed
ProjectState::Waking => {} // still waking → await below
}
}
let result = tokio::time::timeout(wake_timeout + 5s, notified).await;
Jetzt gibt es keine Lücke: Entweder sieht die erneute Zustandsprüfung, dass das
Wakeup bereits fertig ist (sofortiger Return), oder wir sind ein registrierter
Waiter, und notify_waiters() erreicht uns. Die Regel, schlicht formuliert: Bei
Notify zuerst Interesse registrieren, dann die Bedingung prüfen, dann awaiten —
vor dem Registrieren zu prüfen ist jedes Mal ein Race.
Warum der Aufwand: die Ökonomie
Storage und Compute zu trennen und einen Wake-Proxy davorzustellen, stellt das Kostenmodell auf den Kopf. Ein Projekt, das niemand benutzt, kostet null Compute — der Pod ist weg, nicht im Leerlauf. Starte hundert Experimente; bezahle für die Handvoll, die Traffic bekommt. Die erste Query nach einer Ruhephase frisst einen Cold Start (einstellige Sekunden); jede Query danach geht direkt durch (der Proxy ist ein Pass-through, sobald die Bridge steht). Genau dieser Kompromiss — eine langsame erste Query für eine im Leerlauf kostenlose Datenbank — ist der, den du willst, wenn ein KI-Agent Datenbanken zu Tausenden hochfährt. (Die App-Pods über diesen Datenbanken bekommen dieselbe Behandlung durch einen anderen Mechanismus — den cgroup-v2-Freezer —, denn für einen warmen Dev-Server ist, anders als bei einem zustandslosen Compute, das billigste Wakeup das, bei dem nichts neu booten muss.)
Kompromisse
- Cold-Start-Latenz. Der erste Connect nach dem Schlaf zahlt die Kosten für Scale-0→1 + Postgres-Boot. Das Parken des Connects versteckt sie, aber sie sind real; latenzempfindliche Pfade halten das Compute warm (oder pinnen es).
- Der Wake-Pfad ist der Hot Path. Jeder Connect zu jedem Projekt läuft durch
ensure_ready, sein Nebenläufigkeitsmodell ist also über jede Datenbank hinweg tragend — genau deshalb ist die Waiter-Registrierungsregel oben so präzise formuliert. - Idle-Sweep vs. langlebige Idle-Verbindungen. Eine offene, aber stumme Verbindung würde das Compute wach halten; der Per-Connection-Idle-Timeout in der Bridge ist das, was dem Sweep erlaubt, sie zurückzuholen — abgestimmt auf das Keepalive-Verhalten des Clients.