Lezioni di Database scaling dal design di Cloudflare D1
Database scaling è la lezione centrale nella descrizione di Cloudflare di D1, il suo servizio SQL database costruito attorno a molti piccoli database invece che a un unico grande database condiviso. Il design separa il Control plane, cioè il lato di gestione che crea, elimina e traccia i database, dall'Hot path, cioè il percorso live che una query utente segue quando un'applicazione attende una risposta.
Quando un utente crea un database D1, Cloudflare afferma che avvengono diversi passaggi prima che le query possano essere eseguite. Il sistema verifica se l'account è autorizzato a creare un altro database entro il suo piano gratuito o i limiti dell'account. Poi genera un UUID univoco del database D1, cioè un identificatore univoco, crea un Durable Object ID univoco usando newUniqueId(), inizializza il Durable Object sottostante, memorizza una riga di metadati in Postgres e scrive una mappatura dal database ID al Durable Object ID in Workers KV. I Durable Objects sono gli oggetti di calcolo stateful di Cloudflare, usati qui come punto di esecuzione per singolo database. Workers KV è un key-value store distribuito globalmente usato per i dati di routing.
La collocazione di questi passaggi è importante. Cloudflare afferma che l'Edge API Worker iniziale viene eseguito vicino alla request in ingresso, ma le request di creazione che effettuano più round trip verso Postgres vengono inoltrate a data center vicini al cluster Postgres. Il motivo è la latency: un round trip da lontano può richiedere centinaia di millisecondi, mentre uno vicino può richiedere solo pochi millisecondi. Per impostazione predefinita, un database D1 viene creato vicino alla posizione della request, anche se un utente può fornire un location hint o una jurisdiction, usando le stesse opzioni supportate da Durable Objects. Per mantenere il database vicino all'utente riducendo comunque la latency verso Postgres, Cloudflare inoltra internamente l'esecuzione vicino a Postgres, poi invoca il Durable Object da un data center vicino alla posizione della request originale.
Cloudflare tratta la scrittura in Postgres come il checkpoint di creazione. Dopo che il Durable Object è stato inizializzato, i metadati del database vengono scritti nel primary Postgres centrale tramite Hyperdrive, il livello di connettività database di Cloudflare. Se l'inizializzazione del Durable Object o la scrittura in Postgres fallisce, il worker riprova con identificatori diversi. Una volta che la riga è in Postgres, il database è considerato creato anche se la risposta finale non raggiunge l'utente. Una chiamata finale di conferma al Durable Object viene eseguita in waitUntil(), quindi può fallire senza bloccare la creazione. Questa chiamata comunica al Durable Object che il database è attivo e gli consente di aggiornare Workers KV in modo che le query successive possano trovare rapidamente i metadati di routing.
Un'alternativa allettante sarebbe inserire l'intero flusso di creazione in una singola transaction Postgres, cioè un'operazione database che o si completa interamente o viene interamente annullata. Cloudflare afferma che questo non scalerebbe perché le connessioni database resterebbero aperte mentre un Durable Object viene inizializzato, potenzialmente dall'altra parte del mondo. La lezione più ampia è che un database centrale non dovrebbe essere costretto ad attendere lavoro esterno a lunga distanza quando la platform deve creare grandi numeri di resource.
D1 usa anche heartbeat per riparare lo stato dopo failure parziali. Ogni Durable Object D1 contatta periodicamente il Control plane per recuperare lo stato più recente del database, attivo o eliminato, e i limiti dell'account. Cloudflare afferma che l'intervallo è di 30 secondi, con jitter per database, e che dispone di controlli per modificare l'intervallo per database o account o disabilitare completamente gli heartbeat. Poiché gli heartbeat possono tollerare dati leggermente obsoleti, possono essere serviti da cache Hyperdrive o da Read replica Postgres, che sono copie read-only del database principale.
Il design di retry spiega perché vengono usati nuovi identificatori dopo un tentativo di creazione fallito. Se Cloudflare riutilizzasse lo stesso UUID database con un nuovo Durable Object ID, più di un Durable Object potrebbe credere di possedere lo stesso database. Nell'esempio di Cloudflare, il Durable Object A può inizializzarsi correttamente ma fallire prima della scrittura in Postgres. Il retry inizializza il Durable Object B e scrive correttamente B in Postgres. Gli utenti possono interrogare solo B perché Postgres è la source of truth. Entro 30 secondi, A invia un heartbeat, apprende che il suo database non esiste e si marca come eliminato. Lo stesso schema autocorrettivo si applica anche alla deletion, e gli heartbeat alla fine riparano Workers KV se un aggiornamento best-effort fallisce.
Per le query SQL live, la regola principale di Cloudflare è evitare Postgres o qualsiasi altro componente centralizzato in una singola region. Una query effettuata tramite HTTP API raggiunge prima l'Edge API Worker, che gestisce authentication, authorization, rate limiting, validation della request e controlli di entitlement. Poi prova a caricare la mappatura del database da Workers KV. Se quell'item manca, il sistema fa fallback su Hyperdrive verso le replica Postgres. Hyperdrive controlla prima una cache nella posizione del chiamante, poi una cache vicino al cluster Postgres e solo dopo interroga le replica.
Dopo che la mappatura è stata trovata, l'Edge API Worker chiama l'Eyeball Worker tramite Worker Service Bindings, un meccanismo che consente a un Worker di chiamarne un altro. L'Eyeball Worker valida la request e la inoltra al Durable Object D1, che esegue la query SQL e restituisce il risultato. Le query da un user Worker con un D1 Binding non devono risolvere la mappatura dell'UUID D1, perché il binding contiene già le informazioni necessarie, quindi vanno direttamente all'Eyeball Worker e al Durable Object.
Cloudflare spiega anche perché D1 usa Workers KV anche se Hyperdrive ha caching. Workers KV è usato come datastore durevole, non solo come cache temporanea. Se un item esce dalla edge cache di KV, la read proviene dallo storage proprio di KV invece che da Postgres. Cloudflare avverte che, in uno scenario catastrofico di purge della cache, un milione di database che recuperano informazioni di routing da Postgres potrebbe creare failure metastabili, in cui il carico causato dal recovery impedisce al recovery di riuscire.
Gli usage limits sono gestiti in modo asincrono. Cloudflare afferma che D1 ha, al momento della scrittura, un limite di storage di 5 GB per account sul piano Free e di 1 TB su Workers Paid, con il limite paid aumentabile su richiesta. Ogni Durable Object pubblica aggiornamenti sull'utilizzo dello storage in una queue simile a Kafka, cioè un sistema per bufferizzare eventi in modo che i consumer possano elaborarli in seguito. I queue consumer vengono eseguiti nei data center centrali di Cloudflare, mentre i Durable Objects sono distribuiti sull'edge. Questo rende più uniformi e aggrega in batch le scritture prima che raggiungano Postgres.
Per l'enforcement, D1 ha bisogno dei totali di storage a livello di account, non solo delle dimensioni per database. Cloudflare afferma che eseguire SELECT sum(storage) su ogni database di un account scalerebbe linearmente e non funzionerebbe per account con milioni di database D1. Invece, D1 scrive le dimensioni per database in database_stats, usa un trigger per scrivere i delta in account_database_stats_changes ed esegue un job schedulato ogni secondo per aggregare quei delta in account_database_stats. La query usa DELETE ...