Postgres с масштабированием до нуля: будим базу прямо посреди подключения
Идея проста: каждое приложение, которое вы собираете, получает собственный настоящий Postgres, и он ничего не стоит, пока им никто не пользуется. В мире, где большинство приложений — это наполовину доделанные эксперименты или проекты, тихо ждущие своего первого пользователя, платить за простаивающий compute базы данных нельзя — его слишком много.
«Масштабировать до нуля» легко сказать, а делать муторно: ведь в тот
момент, когда вы убираете compute, вы нарушаете контракт, на который рассчитывает
любой Postgres-клиент: подключись — и он ответит. Поэтому мы поставили перед
базой небольшой Rust-сервис — neon-conn-proxy, — который делает этот разрыв
невидимым. Он говорит на wire-протоколе Postgres, и когда приходит подключение к
спящей базе, он паркует клиента, масштабирует compute с 0 до 1 и пробрасывает
запрос, как только Postgres поднялся. Для приложения это всего лишь чуть
медленный первый запрос. Для счёта — ноль, пока приложение простаивает.
Это работает, потому что Neon разделяет хранилище и compute: pageserver
держит данные в объектном хранилище; compute — это stateless-под Postgres,
который читает страницы по требованию. При масштабировании compute до нуля ничего
не теряется — данные лежат в pageserver. Compute каждого проекта — это K8s
Deployment, который мы масштабируем между 0 и 1 репликой.
Интересные части распадаются на три задачи внутри прокси: путь TCP/wire, state-машина на проект вместе с single-flight-пробуждением и масштабирование в K8s. Пройдём по пути одного подключения.
1. Распарсить ровно столько wire-протокола, сколько нужно для маршрутизации
Первое, что делает Postgres-клиент, — это startup-сообщение: blob с
префиксом длины, набор пар ключ/значение, в том числе database и user. Тут
нет TLS-рукопожатия, которое нужно было бы перехватывать по схеме MITM, и нет ещё
никакой аутентификации — нам нужно лишь одно поле, имя базы, которое мы
перегрузили, чтобы оно несло id проекта: 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
}
Важны две детали wire-протокола:
- Cancel-запросы (
CancelRequest) выглядят как startup-сообщения, но несут backend PID/secret вместо параметров. Маршрутной информации в них нет, поэтому мы их отбрасываем, а не гадаем. - Мы переписываем имя базы обратно в
postgresперед пробросом. Приложение подключается кproj_abc12; настоящий Postgres знает толькоpostgres. Поэтому после пробуждения мы вклеиваем переписанное startup-сообщение в сокет апстрима:
let rewritten = postgres::rewrite_database(&startup, &config.neon_db_name); // "postgres"
upstream.write_all(&rewritten).await?;
Прокси никогда не участвует в аутентификации — он пробрасывает startup (переписанный) и дальше становится тупой трубой. Клиент аутентифицируется напрямую с compute.
2. Single-flight-пробуждение: крошечная state-машина на проект
Само пробуждение не может быть наивным. Если в спящую базу одновременно прилетят
десять подключений, вам нужно одно пробуждение, а не десять гонок за
масштабирование одного и того же Deployment. Поэтому у каждого проекта есть
запись с машиной на три состояния и tokio::sync::Notify, на котором ждут
опоздавшие:
// 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 читает состояние и диспатчит:
- Running → проверить, что он действительно работает (устаревший под мог быть подчищен прямо у нас из-под ног), затем продолжить.
- Sleeping → будим мы:
do_wake. - Waking → его уже будит кто-то другой:
wait_for_wake(запарковаться на Notify).
Тот, кто будит (do_wake), переводит состояние в Waking, выполняет тело
пробуждения и по завершении переводит в Running (или обратно в Sleeping с
ошибкой) и вызывает notify_waiters(), чтобы освободить всех запаркованных.
Переходы состояний живут в одном месте, так что любой путь с ошибкой —
включая ранние возвраты по ? из-за отсутствующих учётных данных — оставляет
запись чистой. (Более ранняя версия пробрасывала ? насквозь и оставляла записи
навечно застрявшими в Waking; так получались зомби-проекты, которые ничто не
могло разбудить.)
Лимиты на количество подключений соблюдаются через compare-and-swap, так что проверка-и-инкремент атомарны при конкурентном доступе:
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. Тело пробуждения: масштабировать Deployment, поллить Postgres
do_wake_body забирает учётные данные ветки (timeline + пароль) и обращается к
слою масштабирования compute — всё это под wake_timeout (15 с):
// 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 делает холодный старт по-умному: для свежего пода он сначала
проверяет readiness пода в K8s (не надо TCP-поллить под, который всё ещё
Pending), затем подключается по TCP, чтобы убедиться, что Postgres действительно
принимает подключения. Для пробуждения через cgroup-thaw (под уже Running) он
пропускает обращение к K8s и идёт сразу на TCP.
Как только всё готово, открывается сокет апстрима, и прокси соединяет потоки в обе стороны — с idle-таймаутом, чтобы замолчавшее подключение не держало compute бодрствующим вечно и не блокировало масштабирование до нуля:
// 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
Фоновая idle-зачистка делает вторую половину работы: каждую минуту она
сканирует проекты в состоянии Running с нулём активных подключений, и если они
простаивали дольше таймаута (и не запиннены — например, посреди генерации), она
масштабирует их обратно в 0. Короткий грейс-период после пробуждения
(last_wake_at) не даёт ей мгновенно прибить compute, который только что
закончил просыпаться.
У single-flight-пробуждения есть одно правило корректности
Ждущая половина держится на тонком свойстве tokio::sync::Notify, и о нём стоит
сказать точно: notify_waiters() будит только те futures, которые уже
зарегистрированы на момент вызова.
Поэтому у наивной формы — лениво создать future Notified прямо в .await —
есть дыра. В промежутке между тем, как ensure_ready прочитал state == Waking, и
тем, как ждущий действительно начнёт поллить .notified(), будильщик может вызвать
notify_waiters(). Ещё не зарегистрированный ждущий это пропустит и затем
заблокируется на весь 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;
Правильная форма — это задокументированная tokio-идиома: связать future
Notified и вызвать на нём enable() до повторной проверки условия, чтобы сначала
стать зарегистрированным ждущим, а потом уже смотреть на состояние:
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;
Теперь дыры нет: либо повторная проверка состояния увидит, что пробуждение уже
завершилось (немедленный возврат), либо мы — зарегистрированный ждущий, и
notify_waiters() до нас дойдёт. Правило, сформулированное прямо: с Notify
сначала зарегистрируй интерес, потом проверь условие, потом жди — проверять до
регистрации означает гонку каждый раз.
Зачем заморачиваться: экономика
Если разделить хранилище и compute и поставить перед ними прокси-будильщик, модель издержек переворачивается с ног на голову. Проект, которым никто не пользуется, стоит ноль compute — под исчез, а не простаивает. Запустите сотню экспериментов; платите за ту горстку, что получает трафик. Первый запрос после паузы съедает холодный старт (единицы секунд); каждый следующий запрос идёт напрямую (прокси — это сквозной проброс, как только мост поднят). Этот компромисс — медленный первый запрос ради базы, бесплатной, пока она простаивает, — ровно тот, который вам нужен, когда AI-агент поднимает базы тысячами. (Под-приложения над этими базами получают такое же обращение, но другим механизмом — морозилка cgroup v2, — потому что для тёплого dev-сервера, в отличие от stateless-compute, самое дешёвое пробуждение — это то, которому ничего не нужно перезагружать.)
Компромиссы
- Латентность холодного старта. Первое подключение после сна платит за масштабирование 0→1 + загрузку Postgres. Парковка подключения это прячет, но издержки реальны; чувствительные к латентности пути держат compute тёплым (или пиннят его).
- Путь пробуждения — это горячий путь. Каждое подключение к каждому проекту
проходит через
ensure_ready, так что его модель конкурентности — несущая для всех баз сразу — именно поэтому правило регистрации ждущего выше сформулировано настолько точно. - Idle-зачистка vs. долгоживущие простаивающие подключения. Открытое, но молчащее подключение держало бы compute бодрствующим; idle-таймаут на подключение в мосту — это то, что позволяет зачистке его вернуть, и он настроен с учётом поведения keepalive у клиента.