

1/1/1970
วิธีย้ายจาก Firestore ไป Postgres ภายใน 2 วัน
Firebase. แค่คำเดียวก็ทำให้คุณจ่ายแพงกว่าฐานข้อมูลทั่วไปถึง 10-20 เท่าได้สบายๆ แถมยังจำกัดและทำให้การออกแบบฐานข้อมูลซับซ้อนขึ้นอีก
คุณคงพอเดาได้ว่าผมโล่งใจแค่ไหนตอนที่ย้ายออกจาก Firestore เสร็จภายในแค่ 2 วัน เอาจริงๆ ก็ 3 วันถ้านับเวลาที่ผมใช้สร้าง "PostgresSynchronizer" ตัวแรก หรือ 3.5 วันถ้านับเวลาย้ายระบบเวิร์กชอปด้วย ยังไงก็ตาม มันเร็ว เร็วมากๆ
ตอนแรกผมประเมินว่าโปรเจกต์นี้จะใช้เวลาเป็น เดือน เลย! แต่ขอบคุณ prompt เจ๋งๆ ของ LLM และเพื่อนที่สอนเรื่อง CDC ให้ ผมเลยทำได้เร็วกว่าที่คิดมาก
ถ้าคุณกำลังมองหา pub/sub อยู่ Firebase หรือ Supabase อาจฟังดูน่าสนใจ เพราะฐานข้อมูลที่รองรับ pub/sub แบบ out of the box ก็มีไม่เยอะ แต่สถาปัตยกรรมง่ายๆ ด้านล่างนี้คือทั้งหมดที่คุณต้องการสำหรับเคสธุรกิจขนาดเล็กส่วนใหญ่ (และน่าจะ scale ได้ดีถึงประมาณ 10k-100k ops / วินาที)
ก่อนจะลงรายละเอียดว่า pub/sub ทำงานยังไง ผมจะเล่าขั้นตอนที่ทำตามลำดับก่อนนะครับ
อย่างแรก ผมสร้าง prompt ใน Cursor เพื่อสร้าง PostgresSynchronizer คลาสนี้มีฟังก์ชัน handleWrite ที่รับ Firestore path, ประเภทของ operation (create, update, delete) และข้อมูล แล้วทำ upsert ข้อมูลลง Postgres จากนั้นผมห่อ instance ของ Firestore store ไว้ เพื่อให้ทุกการเปลี่ยนแปลงใน Firestore เรียก handleWrite ด้วย
แน่นอนว่ามันไม่ได้สมบูรณ์ 100% ถ้าเซิร์ฟเวอร์หลุดหรือ crash ก็มีโอกาสที่บางการเปลี่ยนแปลงเล็กๆ จะไม่ถูกบันทึก แต่ก็เพียงพอสำหรับการใช้งานของ Foony และ schema ของ Postgres ก็ให้การรับประกันความสมบูรณ์ของข้อมูลได้ดีกว่า Firestore (เช่น foreign key constraints)
ขั้นต่อไป ผมสร้างและรัน backfill ที่วนลูปอ่านทุก collection ของ Firestore แล้วเรียก handleWrite สำหรับเอกสารแต่ละชิ้น เพื่อให้แน่ใจว่าได้ข้อมูลย้อนหลังทั้งหมดจาก Firestore เพื่อความรวดเร็ว ผมจำกัดให้ทำเฉพาะผู้ใช้ที่ลงทะเบียนแล้ว (ขอโทษด้วยนะแขก คุณน่าจะ สมัครสมาชิก ตั้งนานแล้ว) วิธีนี้ใช้ได้ดี และรันซ้ำได้อย่างปลอดภัย
ตอนนี้ผมมีวิธีเติมข้อมูลใน Postgres และซิงค์ (เกือบ) ตลอดเวลากับ Firestore แล้ว ก็เลยถึงเวลาจัดการกับปัญหาใหญ่และน่ากลัวที่สุด: pub/sub
จะทำ pub/sub กับ Postgres ได้ ยังไง กันแน่? หรือฐานข้อมูล SQL ตัวไหนก็เถอะ?
Postgres Change Data Capture (CDC) มาช่วยกู้สถานการณ์!
CDC เป็นคำหรูๆ ที่แปลว่า "อ่านการเปลี่ยนแปลงที่เกิดขึ้นกับฐานข้อมูล แล้วเผยแพร่ไปยังระบบ pub/sub" ในธุรกิจขนาดใหญ่ คุณอาจใช้อะไรอย่าง Debezium + Kafka แต่ Kafka ตั้งค่ายากมาก เรามี Redis อยู่แล้ว และฐานข้อมูลของเราก็ได้แค่ประมาณ 30 ops / วินาที ต่อให้เรา scale ขึ้น 100 เท่า เป็น ผู้ใช้พร้อมกันราว 100,000 คน ก็น่าจะยังใส่ใน Redis instance เดียวได้ ผมเลยทำแบบนั้นแหละ
(คุณสามารถเสริม pub/sub เข้ากับ Postgres ผ่าน LISTEN/NOTIFY ได้เหมือนกัน แต่มันไม่รอดถ้า reconnect และพังเมื่อต้องทำ fanout จริงๆ CDC คือคำตอบที่น่าเบื่อแต่ทนทาน)
เพื่อนของผม Eric สอนเรื่อง CDC ให้ และเปิดซอร์ส โค้ด CDC ของเขา ที่อ่าน WAL ของ Postgres (Write-Ahead Log คือที่ที่ Postgres เขียนทุกการเปลี่ยนแปลงที่ทำกับฐานข้อมูล) ผ่าน logical replication slot แล้วบันทึกไปยัง sink (เช่น Redis)
ด้วย prompt ที่เรียบง่ายและละเอียดใน Cursor ผมสามารถสร้างโค้ด CDC แบบเรียลไทม์และ gateway ได้แทบจะ one-shot (มีปรับเล็กน้อยและรีวิวโค้ดอย่างละเอียด) ซึ่งประกอบด้วยสองบริการง่ายๆ:
- บริการ CDC แบบ singleton ที่อ่าน WAL แล้วเผยแพร่ไปยัง Redis Pub/Sub (จะใช้ Streams เพื่อความทนทานก็ได้ แต่แลกมาด้วยความซับซ้อน)
- บริการ gateway ที่ autoscale ในแนวนอน รับผิดชอบ JWT auth และการเชื่อมต่อ websocket จากไคลเอนต์เว็บ
ทั้งสองบริการเขียนด้วย golang และผมชอบส่วน autoscaling gateway เป็นพิเศษ ไคลเอนต์เว็บ subscribe ในแบบเดียวกับที่เคยทำกับ Firestore และได้ข้อมูลกลับมาในฟอร์แมตเดิม ไคลเอนต์เห็น "usersPublic/" และ "usersPrivate/" เป็นสอง collection แยกกันที่มีสิทธิ์ต่างกัน gateway มีหน้าที่แปลคำขอนั้นไปยังตาราง Postgres ที่อยู่เบื้องหลัง (ในที่นี้คือ users) และตรวจสอบว่าไคลเอนต์มีสิทธิ์เข้าถึงข้อมูลนั้น
มันทำงานได้ดีอย่าง ไม่น่าเชื่อ ระบบ pub/sub ทั้งหมดเสียค่าใช้จ่ายประมาณ $0.50 / เดือน สำหรับ compute + ค่า egress และไม่มีส่วนประกอบที่ยุ่งยากมากนัก (โค้ดของ CDC, gateway และไคลเอนต์ล้วนเรียบง่าย ไม่มี dependency ภายนอกอื่นนอกจาก Redis / Postgres)
จากมุมมอง DevEx ระบบใหม่ก็ถือว่าง่ายกว่าด้วยซ้ำ นักพัฒนาไม่ต้องรู้วิธีออกแบบข้อมูลใน Firestore หรือเขียน Firestore security rules แค่เข้าใจ SQL ก็แก้ไข schema.sql ของเราได้รวดเร็ว เพิ่มหรือปรับ route ใน all.go แล้วก็พร้อมใช้งาน เอกสารก็แน่นพอที่ LLM จะทำตามและแก้ไขได้ง่ายๆ จากมุมมองความปลอดภัยก็ถือเป็นชัยชนะเช่นกัน เพราะเราไม่เปิดช่องให้โดน denial-of-wallet (Wallet DoS) ที่อันตรายอีกแล้ว เพราะ infra ใหม่ของเราคิดค่าใช้จ่ายแบบคงที่และถูกกว่ามาก
โดยรวมแล้ว รวม Postgres instance และ backup รายวันใน S3 เราจัดการลดค่า infra ฐานข้อมูลจาก $550 / เดือน เหลือแค่ $40 / เดือน ได้ แถมยังลดขนาด bundle ของไคลเอนต์ไปได้อีก ราว 100KB ด้วย เยี่ยมเลย
พอได้ระบบใหม่ ผมก็ทดสอบในเครื่องเพื่อให้แน่ใจว่าทุกอย่างทำงานตามที่คาดหวัง แล้วจึง deploy ขึ้น live ถ้ามีปัญหาอะไร ก็ rollback ไคลเอนต์ได้ง่าย เพราะ ณ จุดนี้ Firestore ยังเป็น source of truth อยู่
ขึ้น Live
การ cutover ฝั่งเซิร์ฟเวอร์ค่อนข้างน่ากลัว (การ migrate ฐานข้อมูลมักจะเป็นแบบนั้นเสมอ) จนถึงตอนนี้ Firestore ยังเป็น source of truth อยู่ ระบบ CDC pub/sub แค่อ่านจาก Postgres mirror ที่ตัว synchronizer ทำการอัปเดตให้เป็นปัจจุบัน การ deploy game server ตัวใหม่นี่แหละที่จะเปลี่ยน Postgres ให้กลายเป็น authoritative สำหรับการเขียนจริงๆ
นี่คือวิธีที่ผม roll out:
- อัปเดตพาร์ตเนอร์ก่อน Foony มีการเชื่อมต่อกับพาร์ตเนอร์อย่าง FRVR เราเลย deploy ไคลเอนต์ build ใหม่ล่วงหน้าไม่กี่วันเพื่อให้ใช้ CDC gateway ตัวใหม่
- แจ้งเตือนผู้เล่น ประมาณสิบนาทีก่อน cutover เราโพสต์แจ้งล่วงหน้าเกี่ยวกับช่วงปิดปรับปรุง (ที่หวังว่าจะ) สั้นๆ
- สำรองฐานข้อมูลใหม่อีกรอบ เป็นความคิดที่ดีเสมอกับการ migrate ฐานข้อมูลใหญ่ๆ LLM เรียกสิ่งนี้ว่า "belt and suspenders" ซึ่งโดยพื้นฐานแล้วก็คือการระวังเป็นพิเศษและมีแผนสำรอง
- Deploy ทั้งสองคลัสเตอร์พร้อมกัน คราวนี้ไม่ใช้ blue/green ผมอยากให้แน่ใจว่าทุกคลัสเตอร์เขียนไปที่ฐานข้อมูลเดียวกัน เพื่อหลีกเลี่ยงความไม่สอดคล้องที่อาจเกิดขึ้น
ผมยังมีแผน rollback เตรียมไว้ด้วย ซึ่งง่ายมาก: ถ้าเกิดอะไรพลาด ผมก็จะ redeploy ไคลเอนต์ตัวก่อน (ที่ยังอ่านจาก Firestore), redeploy เซิร์ฟเวอร์, รัน backfill ใหม่ แล้วลองอีกครั้งภายหลัง วิธีนี้จะทำให้เกิด downtime ประมาณ 5 นาทีระหว่างที่เซิร์ฟเวอร์ redeploy และประมาณครึ่งวันในการทำให้ฐานข้อมูล Postgres ตามทัน Firestore อีกครั้ง
การ cutover จริงๆ ใช้เวลา downtime เพียงแค่ 1 นาที เท่านั้น บั๊กที่ร้ายแรงเพียงตัวเดียวที่โผล่มาคือเรื่องการอัปเดต experience: LOWER() ของ Postgres บนคอลัมน์ bigint มันแอบ cast เป็น text โดยปริยาย เฮ้อ แต่ก็แก้ง่ายแค่ cast กลับเป็น bigint ผมเลย deploy เซิร์ฟเวอร์อีกครั้งเพื่อทำ migration ต่อ
ผมตกใจที่ทุกอย่างดูเหมือนจะราบรื่นมาก มีคนรายงานปัญหาน้อยมาก และก็เป็นเรื่องเล็กๆ ทั้งนั้น (ยกเว้นเรื่อง experience) ที่ตกใจเป็นพิเศษคือ migration นี้ราบรื่นแค่ไหน ทั้งๆ ที่ส่วนใหญ่เป็น vibe-coded ค่อนข้างต่างจาก เรื่องน่ากลัวที่อาจอ่านเจอตามข่าว ทีเดียว
เขียน Workshop ใหม่
เมื่อ Postgres เป็น authoritative สำหรับการเขียนแล้ว มีโค้ดเบสก้อนใหญ่เหลืออยู่เพียงส่วนเดียวที่ยังพันกับ Firestore: ระบบ workshop ผู้เล่นใช้มันแชร์แผนที่กำหนดเองใน Dino-Might Bomber Online, รายการคำใน Draw & Guess และอื่นๆ คล้ายกับ Steam Workshop แต่สำหรับเกมของเรา มันเป็นฟีเจอร์ที่พันกับ Firestore มากที่สุดที่เหลืออยู่ ทั้งฝั่งไคลเอนต์และเซิร์ฟเวอร์ และยังมีโครงสร้างที่อึดอัดเพราะข้อจำกัดของ data model ใน Firestore ที่ผมต้องทำให้เรียบง่ายขึ้น
เริ่มต้น ผมให้ prompt นี้กับ Cursor (ใช้ Opus 4.7 high ใน plan mode):
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`, ...).
จากที่ผมเห็นความสามารถของ Opus 4.7 กับ harness ของ Cursor ผมคาดว่า migration นี้น่าจะเสร็จข้ามคืน
สิ่งใหญ่ๆ ที่ผมพลาดไปใน prompt แรก ที่ agent ขอให้ผมอธิบายเพิ่มคือ เราควรจัดการการเรียงลำดับแบบ "วันล่าสุด", "สัปดาห์ล่าสุด", "เดือนล่าสุด", และ "ตลอดกาล" อย่างมีประสิทธิภาพได้ยังไง agent พยายามผลักดันแนวทาง timestamp-bucketed (หรือไม่ก็ตัดการเรียงตามเวลาออกไปเลย) แต่ co-founder ยืนยันว่าต้องเก็บการเรียงตามเวลาไว้ ทั้งๆ ที่มันเพิ่มความซับซ้อน
หลังคิดอยู่พักหนึ่ง ผมก็คิดวิธีแก้ที่ง่ายๆ ออกมาได้ โดยใช้ decay factor ทุก workshop item มีคอลัมน์ played_count_day, played_count_week, played_count_month, และ played_count_all และมี cron job รายชั่วโมงที่คูณคอลัมน์แบบ rolling ด้วย 23/24, 167/168, และ (720-1)/720 ตามลำดับ เมื่อรวมกับ partial indexes (WHERE private = false AND played_count_day >= 0.368) ในแต่ละแกนการเรียง เราก็ได้ query "ยอดนิยมตามช่วงเวลา" ที่ราคาถูกแสนถูกโดยไม่ต้องมี infra เพิ่มเลย นี่คือสิ่งที่ผมคงไม่ทำกับ Firestore เพราะค่าอ่านเขียนแพงเว่อร์ แต่ใน Postgres มันแทบจะฟรี
จัดการเสร็จ ผมก็ไปนอน ตื่นขึ้นมาด้วยความตื่นเต้นมาเช็คผลงาน! agent ได้เพิ่มตาราง workshop_items, workshop_item_votes, และ user_subscriptions ใหม่ เชื่อมไคลเอนต์ให้อ่านแต่ละ item ผ่าน CDC gateway (realtime.use('workshopItems/{id}')) และเขียน workshop action ทั้งหกตัวบน backend ใหม่หมดให้คุยกับ Postgres โดยตรง มีจุดเล็กๆ ที่ต้องเก็บกวาดอยู่บ้าง (query ไปยัง Firestore หนึ่งจุดบนเซิร์ฟเวอร์, error ใน backfill ที่เกิดจากข้อมูลขาดหายใน Firestore เป็นต้น) แต่โดยรวมโค้ดเกือบจะสมบูรณ์แบบ
หลังจากรัน backfill และทดสอบในเครื่องว่าทุกอย่างใช้งานได้ ผมก็ deploy การเปลี่ยนแปลงขึ้น live ในที่สุดโค้ดเบสก็ปลอด Firestore เสียที สวยงาม
งานในอนาคต
ในอนาคต ผมอยากรองรับ patch ใน gateway ด้วย ตอนนี้ gateway ส่งกลับเอกสารทั้งฉบับเป็น JSON สำหรับทุกการอัปเดต ค่อนข้างเปลืองอยู่บ้าง แต่ egress ของเราแทบไม่จำกัดเพราะอยู่บน Hetzner ผมอยากทำตอนนี้เลย แต่ยังไม่สามารถ justify ความซับซ้อนที่เพิ่มขึ้นได้