把一個 Firebase 專案整個搬到 Postgres:Skill Card 的重構筆記
當「AI 卡片生成器」想變成「問答社群」,Firestore 就擋路了。記錄一次資料庫遷移、一個 lost update bug,和一條解決排序的 SQL。
Skill Card 是我在做的一個科技面試問答平台。這篇記錄它最大的一次轉折——從 Firebase 搬到 PostgreSQL,以及搬家路上踩到的一個經典 bug。
一、背景:一個從 side project 長出來的東西
Skill Card 最初很單純:使用者輸入一道面試問題,AI 幫你拆解成結構化的「技能卡片」。資料量不大、關聯也不複雜,所以一開始很自然地用了 Firebase(Firestore)——免後端、認證現成、上手快,對一個 side project 來說很合理。
它撐了好一陣子。直到我想把它從「AI 卡片生成器」變成「問答社群」。
二、為什麼非搬不可?Q&A 投票是個關聯式問題
新的構想是這樣的:
- 每道題目底下可以有多份答案(包含 AI 生成的答案)
- 每份答案可以被 upvote / downvote
- 系統要能排出每題的最佳答案
寫下來你就會發現,這整組需求是標準的**關聯式(relational)**模型:
question 1 ── n answer 1 ── n vote
而「最佳答案」= 把某題的所有答案、各自加總票數、再排序取第一。這是一個需要 JOIN + 聚合 + 排序 的查詢。
這正是 Firestore 不擅長的地方。它是 document store,沒有 JOIN,跨 collection 的聚合排序得在 client 端撈一堆 document 自己算,或維護一堆冗餘欄位去同步票數——既難寫、又容易資料不一致。
需求的形狀變了,工具就不對了。於是決定搬到 PostgreSQL。
三、最痛的一個 bug:兩個人同時改答案
真正讓我下定決心的,其實是一個更具體的問題。
舊的資料結構裡,答案是直接掛在題目上的單一欄位,大概像這樣:
questions: {
id, question, full_answer // ← 一題一個「共編答案」
}
問題來了:如果 A 和 B 幾乎同時打開同一題、各自編輯 full_answer、再各自存檔——後存的人會把先存的人的內容整碗蓋掉。這就是經典的 lost update。在「單一共編答案」的設計下,這幾乎無解。
解法不是去加鎖,而是改變資料模型:把答案從題目裡拆出來,變成獨立的一張表。
-- 拆之前:一題一個共編答案 → 會互相蓋掉
-- 拆之後:一題多份獨立答案 → 各寫各的,還能投票
answers (
id, question_id, content, author_id, visibility, created_at
)
answer_votes (
answer_id, user_id, value -- +1 / -1
)
拆完之後,原本的 bug 自然消失了——每個人寫的是自己的一份 answer,不會去動到別人的。而且這個改動順帶把產品往前推了一大步:一題多答 + 投票 + 排序,正好就是「社群問答」需要的骨架。
很多時候,bug 不是叫你去修那行 code,而是在提示你「這個資料模型錯了」。
四、搬家怎麼搬的
技術選型上,配 Postgres 我用了:
- Drizzle ORM:型別安全、schema 即 TypeScript,migration 好管理
- Better Auth:取代原本 Firebase Auth 的帳號系統
- Neon:serverless 的 Postgres,跟 Vercel 部署很搭
實際 cutover 的順序是先後端、再前端:
- 先把 Postgres 的 schema、API、認證整套建起來,跟舊系統並存
- 前端再一次切換到新的 API(cutover)
- 確認沒問題後,最後才把 Firebase 的套件、設定、一次性 script 整個移除
分兩步走的好處是:搬遷過程中網站不會死,出事也比較好回頭。
五、最佳答案:一條 SQL 解決
搬到 Postgres 後,「算出每題的最佳答案」這件原本很麻煩的事,變成一條查詢就能搞定。重點是用 LATERAL 子查詢:對每一題,去它的答案裡加總票數、排序、取第一名。
SELECT
q.id,
q.question,
top.content AS top_answer -- 該題票數最高的答案
FROM questions q
LEFT JOIN LATERAL (
SELECT a.content,
COALESCE(SUM(av.value), 0) AS votes
FROM answers a
LEFT JOIN answer_votes av ON av.answer_id = a.id
WHERE a.question_id = q.id
AND a.visibility = 'public'
GROUP BY a.id
ORDER BY votes DESC, a.created_at ASC -- 票數同分時,先寫的優先
LIMIT 1
) top ON true;
同樣的邏輯在 Firestore 上,得在 client 端把所有答案撈下來、自己加總、自己排序,還要煩惱分頁和成本。在 Postgres,資料庫直接幫你算完。把資料放在對的形狀裡,很多複雜度會自己消失。
六、學到的事
- 技術選型沒有「對」,只有「對不對得上需求」。 Firebase 在原型期是對的;當需求變成關聯式問答,它就不對了。換掉它不代表當初選錯。
- 遇到 lost update / 同步地獄,先回頭看資料模型,而不是急著加鎖。 很多並發問題其實是模型把不該綁在一起的東西綁在一起了。
- 遷移要能「並存 + 漸進切換」,不要一刀斷。先建新的、再切過去、最後才拆舊的。
- 讓資料庫做它擅長的事。 JOIN、聚合、排序這些,交給 SQL 比在應用層自己刻可靠太多。
結語
這次重構讓 Skill Card 從一個「AI 工具」長成一個有社群互動的「問答平台」,而背後最關鍵的一步,其實只是把答案從一個欄位,拆成一張表。
接下來想寫的主題:Better Auth 的整合細節、以及怎麼用 pg_trgm 在建題前做相似題去重。有興趣的話歡迎之後回來看看。