Når din applikation har brug for opdateringer i realtid, er standardsvaret i 2025 at vælge en administreret tjeneste: Firebase Realtime Database, Supabase Realtime, Pusher, Ably eller et hostet WebSocket-lag. Disse tjenester abstraherer kompleksiteten ved at sende opdateringer til tilsluttede klienter væk. De fungerer godt — indtil de ikke gør.
Hos Atlas Verified begyndte vi med en administreret databaseplatform, som omfattede abonnementer på opdateringer i realtid. Efterhånden som vores system voksede, nåede vi denne abstraktions grænser. Begrænsninger i forbindelsespuljer, problemer med realtidspålidelighed, behovet for geografiske PostGIS-forespørgsler og ønsket om tilpasset triggerlogik skubbede os alle i samme retning: Tag kontrol over databaselaget.
Vi migrerede til selvhostet PostgreSQL og byggede vores realtidssystem ved hjælp af PostgreSQLs indbyggede LISTEN/NOTIFY-mekanisme sammen med Server-Sent Events. Dette indlæg gennemgår, hvorfor vi traf den beslutning, hvordan arkitekturen fungerer, og hvad vi lærte.
Hvorfor vi forlod den administrerede platform
Administrerede databaseplatforme tilbyder en attraktiv samlet løsning: autentificering, sikkerhed på rækkeniveau, abonnementer på opdateringer i realtid, automatisk genererede API'er og et dashboard — alt sammen klar til brug. For produkter i den tidlige fase er dette virkelig værdifuldt. Du kan bygge en fungerende applikation uden at skrive en eneste linje backendkode.
Problemerne opstår, efterhånden som din applikation modnes, og dine krav afviger fra platformens antagelser.
Forbindelsesgrænser. Administrerede platforme fører ofte databaseforbindelser gennem en puljestyring, der pålægger sine egne grænser. Når du har flere backendtjenester, som hver har brug for deres egne forbindelsespuljer — en API-server, et baggrundsarbejdersystem og en realtidslytter — opbruger du hurtigt de tilgængelige forbindelser. Vi endte med at udvikle løsninger uden om puljestyringens begrænsninger frem for at løse faktiske produktproblemer.
Pålidelighed i realtid. Den administrerede platforms realtidssystem fungerede gennem Change Data Capture på PostgreSQLs Write-Ahead Log. Dette er arkitektonisk elegant, men introducerede fejltyper, vi ikke kunne kontrollere. WAL-baseret replikering har en iboende forsinkelse. Realtidstjenesten kom lejlighedsvis bagud og leverede forældede opdateringer. Genopkobling efter netværksafbrydelser var upålidelig. Og fejlsøgning af disse problemer krævede adgang til infrastruktur, vi ikke kontrollerede.
Krav til udvidelser. Vores applikation behandler handelsdokumenter med geografiske data: havneplaceringer, sejlruter og skibspositioner. PostGIS — den geografiske udvidelse til PostgreSQL — er afgørende for disse forespørgsler. Den administrerede platform understøttede PostGIS, men vi havde brug for specifikke versioner og konfigurationer, der ikke altid var tilgængelige.
Tilpasset triggerlogik. Vi havde brug for databasetriggere, der udløste applikationsspecifikke hændelser — ikke blot generelle meddelelser om "række ændret". Når et dokuments behandlingsstatus ændres, skal vi underrette den specifikke tilsluttede klient, som uploadede det. Den administrerede platforms realtidsfunktion arbejdede på tabelniveau; vi havde brug for postniveau.
Beslutningen om migrering handlede ikke om omkostninger eller principper. Den var pragmatisk: Vi brugte mere tid på at arbejde uden om platformens begrænsninger, end vi ville have brugt på selv at bygge funktionerne.
LISTEN/NOTIFY: PostgreSQLs skjulte pub/sub
PostgreSQL har haft en indbygget publicerings-/abonnementsmekanisme siden version 9.0, udgivet i 2010. Trods en alder på over 15 år er LISTEN/NOTIFY bemærkelsesværdigt underudnyttet. De fleste udviklere ved ikke, at den findes.
Mekanismen er enkel. Enhver databaseforbindelse kan udføre LISTEN på en navngiven kanal for at abonnere. Enhver forbindelse kan udføre NOTIFY for at sende en meddelelse til alle lyttere på denne kanal. Meddelelsens indhold er en streng, begrænset til 8,000 bytes.
Det, der gør LISTEN/NOTIFY stærk til realtidsapplikationer:
Ingen ekstra infrastrukturomkostning. Der er ingen separat meddelelsesmægler, som skal udrulles, overvåges eller skaleres. Har du PostgreSQL, har du pub/sub. Ingen Redis, ingen RabbitMQ, ingen Kafka — til netop dette anvendelsesområde.
Transaktionsgarantier. NOTIFY-meddelelser leveres kun, når den omgivende transaktion gennemføres. Hvis en transaktion indsætter en række og sender en meddelelse, og transaktionen rulles tilbage, leveres meddelelsen aldrig. Denne egenskab får du ikke fra eksterne meddelelsesmæglere uden omhyggelig koordinering.
Triggerintegration. PostgreSQL-triggere kan kalde pg_notify som en del af deres udførelse. Det betyder, at databasen automatisk kan underrette lyttere, når bestemte data ændres, uden applikationskode i skriveprocessen.
Begrænsningerne er reelle og værd at forstå:
Ingen vedvarende lagring. Hvis ingen lytter, når en NOTIFY udløses, går meddelelsen tabt. Der er ingen genafspilning, ingen meddelelseskø og ingen leveringsgarantier for afbrudte klienter. Dine lyttere skal håndtere genopkobling og gendannelse af tilstand.
Størrelsen af meddelelsesindhold. Grænsen på 8,000 bytes for meddelelsesindhold betyder, at du ikke kan sende store objekter gennem NOTIFY. Vi sender minimalt indhold — tabelnavn, operationstype og primærnøgle — og lader klienten hente de fulde data, hvis det er nødvendigt.
Afgrænset til én database. LISTEN/NOTIFY fungerer inden for én PostgreSQL-instans. Den spænder ikke over replikaer eller klynger. For arkitekturer, hvor alle skrivninger går til én primær instans, er dette ikke en begrænsning.
Vores arkitektur: Triggere, SSE og kanalmultipleksning
Sådan hænger delene sammen.
Databasetriggere installeres på tabeller, hvor opdateringer i realtid betyder noget: dokumenter, meddelelser, samtaler, notifikationer, forsendelsesverifikationer og flere. Når en række indsættes, opdateres eller slettes, aktiveres triggeren og kalder pg_notify med JSON-indhold, som indeholder tabelnavn, operationstype, primærnøgle og eventuelle filterkolonner.
En dedikeret lytterforbindelse i vores API-server udfører LISTEN på meddelelseskanalen og modtager alle triggerhændelser. Dette er en langvarig, dedikeret PostgreSQL-forbindelse — adskilt fra forespørgslernes forbindelsespulje — som ikke gør andet end at lytte efter meddelelser.
En hændelsesbus modtager meddelelser fra lytteren og sender dem videre til tilsluttede klienter baseret på deres abonnementer. En klient, som abonnerer på dokumenter, hvor et bestemt forsendelses-id matcher, modtager kun meddelelser om dokumenter i netop denne forsendelse.
Server-Sent Events leverer meddelelser til browseren. Hver tilsluttet klient åbner én SSE-forbindelse til API-serveren og angiver, hvilke kanaler og filtre den ønsker. Hændelsesbussen matcher indkommende meddelelser mod klientabonnementer og sender matchende hændelser gennem de relevante SSE-forbindelser.
Hvorfor SSE frem for WebSockets
Dette er en beslutning, vi gennemgående har været tilfredse med. Server-Sent Events er enklere end WebSockets i alle de henseender, der betyder noget for vores anvendelse.
Ensrettet af design. Vores realtidsbehov går fra server til klient: Serveren fortæller klienten, når data ændres. Klienten behøver ikke sende realtidsmeddelelser til serveren — den bruger almindelige HTTP-forespørgsler til det. SSE's ensrettede model matcher vores faktiske krav.
Indbygget i HTTP. SSE-forbindelser er almindelige HTTP-forespørgsler med en specifik indholdstype. De fungerer gennem enhver HTTP-proxy, load balancer og CDN uden særlig konfiguration. WebSockets kræver HTTP Upgrade, som nogle proxyer ikke håndterer korrekt, og nogle virksomheders firewalls blokerer helt.
Automatisk genopkobling. Browserens EventSource API håndterer genopkobling indbygget. Hvis forbindelsen falder ud, genopretter browseren den automatisk med eksponentielt stigende ventetid. Med WebSockets implementerer du selv genopkoblingslogikken — og gør det forkert mindst én gang.
Det ene område, hvor WebSockets vinder — tovejskommunikation — har vi simpelthen ikke brug for. Til applikationer, der kræver meddelelser fra klient til server i realtid, er WebSockets det rigtige valg. Til applikationer, der sender meddelelser fra server til klient, er SSE det rigtige valg.
Opbyg et forespørgselslag uden en ORM
Da vi forlod den administrerede platform, forlod vi også dens automatisk genererede forespørgsels-API. Vi havde brug for et databaseadgangslag til vores API-server.
Standardmulighederne er ORM'er som Prisma, TypeORM eller Drizzle — eller rå SQL med en driver. Vi valgte en tredje vej: En let forespørgselsbygger, der giver en API-grænseflade med kædede kald, mens den genererer og udfører rå SQL under overfladen.
Velkendt API. Vores eksisterende kodebase var skrevet mod den administrerede platforms klientbibliotek. En kompatibel API-grænseflade betød minimale kodeændringer under migreringen. Forespørgsler, der tidligere kørte gennem platformens klient, kunne køre gennem vores forespørgselsbygger med identisk syntaks.
Ingen magi. ORM'er genererer SQL, som du ikke kan se og nogle gange ikke kan styre. Når en forespørgsel er langsom, skal du læse den genererede SQL, som ofte kun har lidt lighed med det ORM-kald, der producerede den. Vores forespørgselsbygger genererer forudsigelig SQL — hver kædet metode svarer til en specifik SQL-klausul, og du kan altid undersøge den genererede forespørgsel.
Indlejrede joins. Handelsdata er relationelle. En forsendelse har dokumenter, som har noder, som har verifikationskontroller. At kunne udtrykke indlejrede udvælgelser og få et indlejret resultat tilbage — uden hver gang at skrive en kompleks JOIN-forespørgsel i hånden — er en betydelig forbedring af udvikleroplevelsen.
Resultatet er et forespørgselslag på nogle få hundrede kodelinjer, som håndterer langt de fleste af vores databaseinteraktioner og producerer SQL, vi altid kan læse og optimere.
Budgettering af forbindelsespuljer
Når du selv hoster PostgreSQL, ejer du forbindelsesbudgettet. PostgreSQLs max_connections-indstilling definerer det absolutte loft, og hver forbindelse bruger hukommelse.
Vi opdagede, at det at tænke på forbindelser som et budget — en begrænset ressource delt mellem tjenester — forhindrer en type problemer, som rammer teams, der ikke planlægger for det.
Hver tjeneste angiver sit maksimale antal forbindelser. Summen af alle maksimumtal skal ligge klart under PostgreSQLs grænse med plads til ad hoc-forbindelser — psql-sessioner, migreringsscripts og overvågningsværktøjer. Dette lyder indlysende, men de fleste teams gør det ikke, før de løber tør for forbindelser i produktion. Ved at budgettere forbindelser på forhånd har vi undgået netop den alarm kl. 3 om natten.
Sikre migreringer i stor skala
Migrering fra en administreret platform til selvhostet PostgreSQL betød flytning af hundredvis af tabeller, funktioner, triggere og indekser. Men den større udfordring var at bygge et migreringssystem, som ville blive ved med at fungere, efterhånden som applikationen udviklede sig.
Vores automatiske migreringssystem fungerer sådan: Migreringsfiler navngives med tidsstempler og ligger i en dedikeret mappe. En migreringskører scanner mappen, sammenligner med en sporingstabel, der registrerer, hvilke migreringer der er anvendt, og udfører eventuelle afventende migreringer i rækkefølge. Hver migrering kører inden for en transaktion — hvis den fejler, rulles den rent tilbage og blokerer udrulningen, indtil problemet er rettet.
Migreringskøreren udføres automatisk ved hver udrulning, før applikationen starter. Det betyder, at skemaændringer anvendes som del af udrulningsprocessen uden manuelle trin. En udvikler opretter en tidsstemplet SQL-fil, sender den til kodearkivet, og næste udrulning anvender den.
Centrale designbeslutninger, der holder dette sikkert: Migreringer bruger idempotente beskyttelser, så de ikke fejler, hvis de er delvist anvendt. Hver migrering skal være et selvstændigt SQL-script, som kører uden menneskeligt input. Og hvis en migrering fejler, rulles transaktionen helt tilbage — applikationen starter ikke med en delvist anvendt skemaændring.
Hvad vi opnåede
Skiftet til selvhostet PostgreSQL med et tilpasset realtidssystem var en betydelig teknisk investering. Men gevinsterne har været betydelige:
Pålidelighed. Realtidsmeddelelser ankommer inden for millisekunder efter den underliggende dataændring. Ingen forsinkelse fra WAL-replikering, ingen nedetid hos tredjepartstjenester i den kritiske proces.
Mulighed for fejlsøgning. Når noget går galt, kan vi spore hele vejen: Triggeren aktiveres, meddelelsen sendes, hændelsesbussen videresender, og SSE leverer. Hvert trin er i vores kode, i vores logfiler og på vores infrastruktur.
Fleksibilitet. Tilføjelse af realtidsopdateringer til en ny tabel er én SQL-migrering, der installerer en trigger. Ingen platformskonfiguration, intet tredjepartsdashboard, ingen supportsag.
Ydeevne. Direkte PostgreSQL-forbindelser med en tilpasset pulje klarer sig bedre end forbindelser, der routes gennem en administreret platforms infrastruktur. Forespørgslernes ventetid faldt mærkbart efter migreringen.
Afvejningen er driftsansvaret. Vi administrerer databasen, håndterer sikkerhedskopier, overvåger replikering og planlægger kapacitet. For et team, der er fortroligt med PostgreSQL-drift, er dette håndterbart. For et team, der ikke er det, kan den administrerede platforms abstraktioner være deres begrænsninger værd.
Atlas Verified kører på selvhostet PostgreSQL med tilpasset realtidsinfrastruktur og behandler dagligt tusindvis af verifikationer af handelsregler på tværs af den globale forsyningskæde.
