background blurbackground mobile blur

1/1/1970

Firestore'dan Postgres'e 2 Günde Nasıl Geçilir

Firebase. Bu tek kelime, daha ucuz bir veritabanının maliyetinin kolayca 10-20 katına mal olabilir, ayrıca veritabanı tasarımınızı hem kısıtlayabilir hem de karmaşıklaştırabilir.

O yüzden Firestore'dan geçişi sadece 2 günde tamamladığımda ne kadar rahatladığımı hayal edebilirsiniz. Eh, ilk "PostgresSynchronizer"ı oluşturmak için harcadığım zamanı sayarsak 3 gün. Ya da workshop sistemini taşıma sürecini de eklerseniz 3,5 gün. Her halükarda hızlıydı. Hem de çok hızlı.

Bu proje için ilk tahminim koca bir ay sürecek bir iş yüküydü! Ama harika bazı LLM promptları ve bana CDC'yi öğreten bir arkadaş sayesinde, bu işi çok daha hızlı bitirebildim.

Pub/sub arıyorsanız Firebase veya Supabase çok cazip gelebilir. Sonuçta kutudan çıktığı haliyle pub/sub destekleyen pek fazla veritabanı yok. Ama aşağıdaki bu basit mimari, çoğu küçük işletme senaryosu için ihtiyacınız olan her şeyi karşılar (ve saniyede yaklaşık 10 bin ile 100 bin işleme kadar güzelce ölçeklenir).

Foony'nin pub/sub mimarisi Postgres kendi Hetzner sunucusunda çalışır. CDC ve Gateway servisleri bir Hetzner k3s cluster'ı içinde çalışır. CDC, Postgres'in Write-Ahead Log'unu okur ve değişiklikleri Redis Pub/Sub'a (ayrı bir Hetzner sunucusu) yayınlar. Gateway, Redis'e abone olur ve güncellemeleri WebSocket istemcilerine dağıtır.

{/* Postgres host (left). Standalone Hetzner server. */} Postgres sunucusu (Hetzner) Postgres WAL

{/* k3s cluster (middle). Contains the CDC and Gateway services as pods. */} k3s cluster (Hetzner)

{/* CDC service (singleton). */} CDC (singleton)

{/* Gateway service. Two layered rects convey "horizontally autoscaling" multiple replicas. */} Gateway (otomatik ölçeklenir)

{/* Redis host (below k3s). Standalone Hetzner server. */} Redis sunucusu (Hetzner) Redis Pub/Sub

{/* Data flow: Postgres WAL -> CDC. /} {/ CDC -> Redis (down through the bottom of the k3s frame). /} {/ Redis -> Gateway (back up into the k3s frame). */}

{/* WebSocket clients (outside everything). Three stacked cards convey fan-out. */} WebSocket istemcisi

WebSocket istemcisi WebSocket istemcisi

{/* Gateway -> WebSocket clients (fan-out across the public internet). */}

Postgres WAL (Hetzner) → CDC (k3s) → Redis Pub/Sub (Hetzner) → Gateway (k3s) → WebSocket istemcileri

Pub/sub'ın nasıl çalıştığının detaylarına girmeden önce, sırasıyla attığım adımları anlatacağım.

İlk olarak Cursor'da PostgresSynchronizer'ı oluşturmak için bir prompt yazdım. Bu sınıfın handleWrite adında bir fonksiyonu var. Bu fonksiyon bir Firestore yolu, işlem türü (create, update, delete) ve veriyi alıyor. Sonra veriyi Postgres'e upsert ediyor. Ardından Firestore store örneğimi sardım, böylece Firestore'daki her değişiklik aynı zamanda handleWrite'ı da çağırıyor.

Tabii bu %100 mükemmel değil. Sunucu bağlantısı koparsa veya çökerse bazı küçük değişikliklerin kalıcı olmama ihtimali var. Ama Foony'nin kullanım senaryosu için yeterince iyi ve Postgres şeması bize Firestore'dan daha iyi veri bütünlüğü garantileri sunuyor (örneğin yabancı anahtar kısıtlamaları).

Sonra, tüm Firestore koleksiyonlarını dolaşan ve her belge için handleWrite çağıran bir backfill oluşturup çalıştırdım. Bu, Firestore'daki tüm geçmiş verileri almamızı sağlıyor. Hız için bunu sadece kayıtlı kullanıcılarla sınırladım (misafirler, kusura bakmayın, kayıt olmalıydınız). Bu güzel çalışıyor ve birden fazla kez çalıştırmak güvenli.

Artık Postgres'teki veriyi doldurma ve onu Firestore ile (büyük ölçüde) senkronize tutma yöntemim olduğuna göre, büyük ve korkutucu sorunla başa çıkmaya hazırdım: pub/sub.

Postgres ile pub/sub nasıl yapılır? Hatta herhangi bir SQL veritabanıyla?

İmdada Postgres Change Data Capture (CDC) yetişir!

CDC, "veritabanında yapılan değişiklikleri oku ve bir pub/sub sistemine yayınla" demenin süslü bir yolu. Büyük bir şirkette Debezium + Kafka gibi bir şey kullanabilirsiniz. Ama Kafka'yı kurmak baş ağrısı, biz zaten Redis kullanıyoruz ve veritabanımız saniyede yaklaşık 30 işlem alıyor. Ölçeğimizi 100 katına, yani ~100.000 eş zamanlı kullanıcıya çıkarsak bile büyük ihtimalle bunu tek bir Redis örneğine sığdırabiliriz. Ben de tam olarak bunu yaptım.

(Pub/sub'ı Postgres'e LISTEN/NOTIFY ile de iliştirebilirsiniz, ama yeniden bağlantıları atlatamaz ve gerçekten fanout gerektiğinde çöker. CDC sıkıcı ama dayanıklı çözüm.)

Arkadaşım Eric, bana CDC'yi öğretti ve CDC kodunu açık kaynak olarak paylaştı. Bu kod, Postgres'in WAL'ını (Write-Ahead Log, yani Postgres'in veritabanında yaptığı her değişikliği yazdığı yer) mantıksal replikasyon slotu üzerinden okuyor ve bir sink'e (örneğin Redis'e) kalıcı olarak aktarıyor.

Cursor'a yazdığım basit ve detaylı bir promptla, gerçek zamanlı CDC ve gateway kodunu çoğunlukla tek seferde yazabildim (küçük ayarlamalar ve titiz bir kod incelemesi ile). Bu, iki çok basit servisi içeriyor:

  • WAL'ı okuyup Redis Pub/Sub'a yayınlayan tek örnekli (singleton) bir CDC servisi (Streams ile daha fazla dayanıklılık elde edebilirsiniz ama karmaşıklık pahasına)
  • JWT kimlik doğrulamasını ve web istemcilerinden gelen websocket bağlantılarını yöneten, yatay olarak otomatik ölçeklenen bir gateway servisi.

Her iki servis de golang'da yazıldı ve özellikle otomatik ölçeklenen gateway kısmını çok seviyorum. Web istemcisi, Firestore ile yaptığı gibi abone oluyor ve aynı formatta veri alıyor. İstemci, "usersPublic/" ve "usersPrivate/"ı farklı izinlere sahip iki ayrı koleksiyon olarak görüyor. Gateway, bu isteği temeldeki Postgres tablolarına (bu durumda users) çevirmekten ve istemcinin o veriye erişim izni olup olmadığını doğrulamaktan sorumlu.

Bu şaşırtıcı derecede iyi çalışıyor. Tüm pub/sub sistemi bize işlem ve egress maliyetleri dahil aylık sadece yaklaşık 0,50 dolara mal oluyor ve bozulabilecek çok fazla hareketli parça yok (CDC, gateway ve istemci kodu, Redis / Postgres dışında üçüncü parti bağımlılığı olmadan hepsi basit).

DevEx açısından yeni sistem tartışmasız daha basit: geliştiricilerin Firestore'da veri modellemeyi veya Firestore güvenlik kurallarını bilmesi gerekmiyor. SQL bildikleri sürece schema.sql dosyamızda hızlıca düzenleme yapabilir, all.go içine route ekleyebilir veya değiştirebilir ve işleri biter. Dokümantasyon da LLM'lerin onu takip edip değişiklik yapmasını kolaylaştıracak kadar güçlü. Güvenlik açısından da bir kazanç, çünkü yeni altyapımız sabit maliyetli ve inanılmaz derecede daha ucuz olduğundan artık tehlikeli bir cüzdan tüketme (Wallet DoS) saldırısına maruz değiliz.

Toplamda, Postgres örneği ve günlük S3 yedeklemeleri dahil, veritabanı altyapı maliyetlerimizi aylık 550 dolardan sadece 40 dolara düşürmeyi başardık. Ayrıca istemci paket boyutunu da yaklaşık 100 KB azalttık, ki bu da güzel.

Artık yeni sisteme sahip olduğum için her şeyin beklendiği gibi çalıştığından emin olmak için lokalde test ettim. Sonra canlıya aldım. Herhangi bir sorun çıkarsa istemciyi geri almak kolaydı çünkü Firestore bu noktada hâlâ tek doğru kaynaktı.

Canlıya Geçiş

Sunucu geçişi biraz korkutucuydu (veritabanı geçişleri her zaman öyledir). Bu noktaya kadar Firestore hâlâ tek doğru kaynaktı; CDC pub/sub sistemi sadece synchronizer'ın güncel tuttuğu bir Postgres aynasından okuyordu. Yeni oyun sunucularını deploy etmek, aslında Postgres'i yazma işlemleri için yetkili kaynak yapan adımdı.

İşte nasıl yayına aldığım:

  1. Önce partnerleri güncelledim. Foony, FRVR gibi partnerlerle entegre, bu yüzden birkaç gün önceden yeni CDC gateway'imizi kullanan yeni bir istemci build'i deploy ettik.
  2. Oyuncuları uyardım. Geçişten yaklaşık on dakika önce, (umarım) kısa bir bakım penceresi hakkında uyarı paylaştık.
  3. Yeni bir veritabanı yedeği aldım. Bu, büyük veritabanı geçişlerinde her zaman iyi bir fikirdir. LLM'ler buna "kemer ve pantolon askısı" diyor, bu da temelde fazladan dikkatli olmak ve yedek bir plana sahip olmak anlamına geliyor.
  4. Her iki cluster'ı aynı anda deploy ettim. Bu sefer blue/green yok. Olası tutarsızlıkları önlemek için tüm cluster'ların aynı veritabanına yazdığından emin olmak istedim.

Ayrıca çok basit bir geri alma planım da vardı: bir şeyler ters giderse önceki istemciyi (hâlâ Firestore'dan okuyan) tekrar deploy ederdim, sunucuları yeniden deploy ederdim, backfill'i tekrar başlatırdım ve daha sonra yeniden denerdim. Bu, sunucular yeniden deploy edilirken yaklaşık 5 dakikalık kesinti ve Postgres veritabanını Firestore'a yetiştirmek için yaklaşık yarım gün demek olurdu.

Asıl geçiş yalnızca yaklaşık 1 dakikalık kesintiye mal oldu. Yüzeye çıkan tek ciddi hata deneyim puanı güncellemesindeydi: Postgres'in LOWER() fonksiyonu bir bigint sütununu örtük olarak text'e dönüştürüyordu. Of. Bunu basit bir bigint cast'iyle kolayca düzelttim, sonra başka bir sunucu deploy'u ile geçişe devam ettim.

Her şeyin yolunda gidiyor görünmesi karşısında şoke olmuştum. Sorun bildiren çok az kişi vardı ve bunlar da çok küçüktü (deneyim puanı meselesi hariç). Ama beni özellikle şaşırtan şey, geçişin büyük ölçüde vibe-coding ile yapılmış olmasına rağmen ne kadar sorunsuz geçtiğiydi. Haberlerde okuyabileceğiniz korkunç hikayelerden oldukça farklıydı.

Workshop'un Yeniden Yazılması

Postgres artık yazma işlemleri için yetkili olduğuna göre, kod tabanının sadece bir büyük parçası hâlâ Firestore'a dolanmış durumdaydı: workshop sistemi. Oyuncular bunu, Dino-Might Bomber Online'da özel haritaları paylaşmak, Draw & Guess'te kelime listeleri paylaşmak vb. için kullanıyor. Steam Workshop'a benzer ama bizim oyunlarımız için. Hem istemcide hem de sunucuda en çok Firestore'a dolanmış özellikti ve Firestore'un veri modelindeki kısıtlamalardan kaynaklanan, basitleştirmem gereken garip bir yapısı vardı.

Başlangıç olarak Cursor'a şu promptu verdim (Opus 4.7 high'ı plan modunda kullanarak):

Now that we've finished most of the migration to Postgres, we have the last big part of the migration: rewriting the workshop system.

We'll need to update schema.sql to support the new workshop tables.

Think of the workshop sort of like Steam's workshop.

In other words, the workshop must:
 - Support multiple games.
 - Support different data formats (e.g. maps in dinomight, word lists in paintjob)
 - Support favoriting
 - Support like / dislike
 - Support a description

Use best practices to ensure that the SQL is efficient.

After updating schema.sql, you will need to update both the backend and the frontend to use the new workshop system. You will also need to either use the `realtime.use` hook or the API for workshop info (up to you which you choose--realtime requires modifying all.go, API calls require updates to Action.ts, etc.). I'd probably go with the cdc gateway (realtime.use / all.go).

Keep your implementation simple where possible. At the end of this migration, we should no longer have `firestore.use` or any mention of firestore on the client. We should also no longer need firestore on the backend.

As part of this change, you will need to also create a backfill migration (there's already a migration backfill--just modify that to work with these firestore workshop collections) that migrates the firestore data to the postgres schema you decide on.

You shouldn't run the backfill--I will do that myself manually once you're done with your code. You can leave a call to the backfill (commented out) at the bottom of server/src/index.ts for me. The backfill should only handle migration of these remaining tables--it shouldn't do any backfilling of old tables that we've already migrated (e.g. no `userItems`, `usersPublic`, ...).

Cursor harness'ı ile Opus 4.7'nin yeteneklerini gördükten sonra bu geçişin gece boyunca tamamlanacağını bekliyordum.

Orijinal promptumda kaçırdığım büyük şey, ajanın açıklamamı istediği gibi, "Son Gün", "Son Hafta", "Son Ay" ve "Tüm Zamanlar" sıralamasını verimli şekilde nasıl yöneteceğimizdi. Ajan, zaman damgalı kova yaklaşımını (veya zaman damgalı sıralamayı tamamen kaldırmayı) öneriyordu, ama kurucu ortak ekstra karmaşıklığa rağmen zaman bazlı sıralamayı korumakta ısrar etti.

Biraz düşündükten sonra, bir azalma faktörü içeren basit bir çözüm buldum. Her workshop öğesinin played_count_day, played_count_week, played_count_month ve played_count_all sütunları var ve saatlik bir cron işi, kayan sütunları sırasıyla 23/24, 167/168 ve (720-1)/720 ile çarpıyor. Her sıralama eksenindeki kısmi indekslerle (WHERE private = false AND played_count_day >= 0.368) birleştirildiğinde, bu bize ekstra altyapı olmadan çok ucuza "zaman dilimine göre en popüler" sorguları sağlıyor. Bu, Firestore'da okuma ve yazma için aşırı maliyetler nedeniyle yapamayacağım bir şeydi, ama Postgres'te neredeyse bedava.

Bununla yatağa girdim. Uyandım ve heyecanla işini kontrol ettim! Ajan yeni workshop_items, workshop_item_votes ve user_subscriptions tablolarını ekledi, istemciyi CDC gateway üzerinden tekil öğeleri okuyacak şekilde bağladı (realtime.use('workshopItems/{id}')) ve backend'deki altı workshop aksiyonunu doğrudan Postgres ile konuşacak şekilde yeniden yazdı. Toparlamam gereken birkaç pürüz vardı (sunucuda Firestore'a giden bir sorgu, Firestore'da eksik veriden kaynaklanan backfill hatası vb.), ama genel olarak kod neredeyse kusursuzdu.

Backfill'i çalıştırdıktan ve her şeyin lokalde çalıştığını test ettikten sonra değişikliği canlıya aldım. Böylece kod tabanı sonunda Firestore'dan tamamen arındırıldı. Mükemmel.

İleride Yapılacaklar

Gelecekte, gateway'de patch desteği de eklemek istiyorum. Şu anda gateway her güncelleme için tüm dokümanı JSON olarak geri gönderiyor. Biraz savurgan ama Hetzner'da olduğumuz için egress'imiz pratikte sınırsız. Bunu şimdi uygulardım, ama eklenen karmaşıklığı henüz haklı çıkaramıyorum.

8 Ball Pool online multiplayer billiards icon