The databasePart 2 of 6

Postgres с масштабированием до нуля: Rust-прокси, который будит базу прямо посреди подключения

Каждое приложение на платформе получает собственный Postgres, который ничего не стоит, пока простаивает — compute масштабируется до нуля. Весь фокус в том, чтобы прозрачно разбудить его на следующем подключении: Rust-прокси говорит на wire-протоколе Postgres, паркует клиента, масштабирует K8s Deployment 0→1 и пробрасывает запрос, как только база поднялась. Разберём парсинг wire-протокола, single-flight-пробуждение и дисциплину регистрации на tokio Notify, которая держит пробуждение корректным при конкурентном доступе.

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 репликой.

scale 0→1

forward (rewritten)

app / agent
conn to proj_abc12

neon-conn-proxy
(Rust, :5432)

K8s API

Deployment neon-compute
(adorable-abc12 ns)

neon-compute pod
stateless Postgres

pageserver (data)

Интересные части распадаются на три задачи внутри прокси: путь 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, на котором ждут опоздавшие:

first connection (do_wake)

compute ready → notify_waiters()

wake failed → notify_waiters(err)

idle sweep (0 conns, timed out)

subsequent connections (verify + forward)

Sleeping

Waking

Running

// 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;
late waiterwaker (do_wake)late waiterwaker (do_wake)naive — registers too lateensure_ready sees state=Wakingcompute ready → notify_waiters()now calls .notified() — misses the signalblocks until wake_timeout

Правильная форма — это задокументированная 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 сначала зарегистрируй интерес, потом проверь условие, потом жди — проверять до регистрации означает гонку каждый раз.

late waiterwakerlate waiterwakercorrect — register, then checkalt[already Running][still Waking]notified().enable() — registeredre-check statereturn immediatelynotify_waiters() → reaches registered waiter

Зачем заморачиваться: экономика

Если разделить хранилище и compute и поставить перед ними прокси-будильщик, модель издержек переворачивается с ног на голову. Проект, которым никто не пользуется, стоит ноль compute — под исчез, а не простаивает. Запустите сотню экспериментов; платите за ту горстку, что получает трафик. Первый запрос после паузы съедает холодный старт (единицы секунд); каждый следующий запрос идёт напрямую (прокси — это сквозной проброс, как только мост поднят). Этот компромисс — медленный первый запрос ради базы, бесплатной, пока она простаивает, — ровно тот, который вам нужен, когда AI-агент поднимает базы тысячами. (Под-приложения над этими базами получают такое же обращение, но другим механизмом — морозилка cgroup v2, — потому что для тёплого dev-сервера, в отличие от stateless-compute, самое дешёвое пробуждение — это то, которому ничего не нужно перезагружать.)

Компромиссы

  • Латентность холодного старта. Первое подключение после сна платит за масштабирование 0→1 + загрузку Postgres. Парковка подключения это прячет, но издержки реальны; чувствительные к латентности пути держат compute тёплым (или пиннят его).
  • Путь пробуждения — это горячий путь. Каждое подключение к каждому проекту проходит через ensure_ready, так что его модель конкурентности — несущая для всех баз сразу — именно поэтому правило регистрации ждущего выше сформулировано настолько точно.
  • Idle-зачистка vs. долгоживущие простаивающие подключения. Открытое, но молчащее подключение держало бы compute бодрствующим; idle-таймаут на подключение в мосту — это то, что позволяет зачистке его вернуть, и он настроен с учётом поведения keepalive у клиента.

Создавай на той самой платформе, о которой эти посты.

Опиши своё приложение простыми словами — Adorable напишет код, настроит базу данных и выкатит его в онлайн.

Начать бесплатно