cover

你有一個 PostgreSQL 跑在 EC2 上,半夜三點 disk full 你被叫起來修。或者你的 Redis 掛了整個 App 跟著掛。託管資料庫能救你的睡眠——但買了睡眠之後,還有三件事會在上線那週把你叫醒。

結論:選型三十秒,上線三件事

選型:大多數 App 的起點是 RDS PostgreSQL + ElastiCache Redis。需要 JOIN 就 RDS;查詢模式固定、流量大就 DynamoDB;session、快取、限流、排行榜就 Redis。三十秒可以決定。

上線後真正的問題:

  1. Multi-AZ 不是零停機,切換那一到兩分鐘 App 會斷,而且可能斷更久
  2. 容器一擴,資料庫連線數跟著爆
  3. 裝了 RDS Proxy 想解第二題,結果 PostgreSQL 一用 prepared statement 就全部釘住,等於沒裝

第三件是這篇的重點。多數教學講到「裝 RDS Proxy」就停了。

一、託管買的是睡眠

自己在 EC2 上裝 PostgreSQL,你要管 OS patch、每日備份、主庫掛了切備庫、加讀取複本、版本升級。RDS 把這些全包了,你專心寫 SQL。省下來的時間拿去優化 query,比半夜修 disk full 有價值。

aws rds create-db-subnet-group --db-subnet-group-name prod-db-subnet \
  --db-subnet-group-description "Production DB subnets" \
  --subnet-ids subnet-private-1a subnet-private-1c
 
aws rds create-db-instance \
  --db-instance-identifier prod-postgres \
  --db-instance-class db.t3.medium \
  --engine postgres --engine-version 16.4 \
  --master-username dbadmin \
  --manage-master-user-password \
  --allocated-storage 100 --storage-type gp3 \
  --vpc-security-group-ids sg-db \
  --db-subnet-group-name prod-db-subnet \
  --multi-az \
  --backup-retention-period 14 \
  --storage-encrypted \
  --performance-insights-enabled

兩個旗標一開始就要開、之後很難補:--storage-encrypted(建好之後不能對既有實例開加密)和 --multi-az。密碼用 --manage-master-user-password 讓 RDS 直接存進 Secrets Manager,不要寫在指令裡。

二、Multi-AZ 不是零停機

Multi-AZ 在另一個 AZ 維持一個同步的備庫。主庫掛了,RDS 自動切過去,你的 App 不用改連線字串——DNS 會指向新的主庫。

聽起來很美好,官方文件寫的是:切換通常 60–120 秒,大交易或冗長的復原會更久。這段時間 App 會看到連線錯誤。

所以 App 的 DB driver 一定要有重連和連線池重建的機制。沒有的話,切換完了 App 還抱著一堆已經死掉的連線。

還有一個坑,文件特別拉出來講:DNS 切了,但 JVM 會快取 DNS 查詢結果。某些 JVM 設定下預設是「永遠不刷新,直到重啟」——切換完了你的 Java App 還在連舊 IP,怎麼重試都連不上。官方建議把 networkaddress.cache.ttl(JVM 把 DNS 結果留幾秒)設在 60 秒以內。這不只是 Java 的問題,任何會快取 DNS 的層(自己包的連線池、某些 sidecar)都要檢查。

另一個常被誤會的:Multi-AZ 的備庫不能拿來讀。要分散讀流量是 Read Replica,那是另一個東西。

aws rds describe-db-instances --db-instance-identifier prod-postgres \
  --query "DBInstances[0].[MultiAZ,SecondaryAvailabilityZone]"

三、連線數爆炸,RDS Proxy 只解一半

這段是我最近整理架構卡、對著官方文件逐條看才搞懂的。

問題怎麼發生

PostgreSQL 每條連線是一個作業系統 process,貴。你的 App 跑在 ECS 上,每個 task 開一組連線池,假設 20 條。流量來了自動擴到 50 個 task——

50 個 task × 每 task 20 條 = 1,000 條連線打到資料庫
→ 超過 max_connections → 新連線全部被拒

資料庫沒壞、CPU 也沒滿,就是連不上。而且這是擴容造成的,流量越大擴越多、爆得越徹底。

標準答案:RDS Proxy

RDS Proxy 是 AWS 託管的連線池,站在 App 和資料庫中間。App 開一千條連線到 Proxy,Proxy 只用幾十條連到資料庫,靠的是同一條資料庫連線在不同交易之間給不同 App 連線輪流用。順帶解掉切換時間(Proxy 幫你擋住,App 不用等 DNS)和帳密不落地(Proxy 直接接 Secrets Manager)。

它不是免費的。照定價頁的算法,費用跟著 Proxy 後面那個資料庫的大小走。後面是 provisioned 執行個體(你先選好規格、一直開著的資料庫),就照那台的 vCPU 數每小時計費。後面是 Aurora Serverless,就照 ACU 每小時計費;ACU 是 Aurora Serverless 衡量容量的單位,代表一份 CPU 加記憶體的組合。以秒計費,但建立、啟動、修改這類狀態變更之後,至少收 10 分鐘。這筆錢只要 Proxy 開著就在算,所以後面才說要量過有用再留著。

多數教學到這裡就停了。

沒講的那一半:pinning

輪流用的前提是連線上沒有殘留狀態。一旦 App 做了會改變 session 狀態的事,Proxy 就把那條資料庫連線釘死給那個 App 連線,直到它斷開為止。釘住的連線不能共用——釘住的比例一高,Proxy 等於沒裝,你還多付了錢和一跳延遲。

官方文件把會釘住的情況逐引擎列出來,PostgreSQL 那份特別長:

會釘住的操作PostgreSQLMySQL / MariaDB
SET 設定 session 變數✅ 釘✅ 釘(但可以用過濾器豁免)
Prepared statement✅ 釘(PREPARE / EXECUTE / DEALLOCATE)✅ 釘
暫存表✅ 釘✅ 釘
游標✅ 釘—
Session 層級的 advisory lock✅ 釘(交易層級的不釘)GET_LOCK 釘
連線池用 DISCARD ALL 當 reset query✅ 每次歸還連線都釘—
語句文字超過 16 KB✅ 釘✅ 釘

看到第二列了嗎。多數 ORM 和驅動預設就用 prepared statement。 你什麼都沒做,Proxy 就已經在釘了。

最陰的是倒數第二列:很多 PostgreSQL 連線池函式庫用 DISCARD ALL 當「歸還連線時的清理指令」——結果每一次歸還都觸發釘住。裝了 Proxy、指標一看全是釘住的,等於白裝。

而且MySQL 有「session pinning filter」可以把 SET 類操作豁免掉,PostgreSQL 沒有。官方原文直說:對 PostgreSQL,設定變數就會導致釘住。

所以怎麼辦

先量,再決定。 CloudWatch 有一個指標叫 DatabaseConnectionsCurrentlySessionPinned,裝完 Proxy 第一件事是看它:

aws cloudwatch get-metric-statistics \
  --namespace AWS/RDS --metric-name DatabaseConnectionsCurrentlySessionPinned \
  --dimensions Name=ProxyName,Value=prod-postgres-proxy \
  --start-time "$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 300 --statistics Average

接近 0 才算有在共用。如果一半以上被釘住,三條路:

  1. 把每條連線都要跑的 SET 搬進 Proxy 的初始化查詢設定,從 App 拿掉——這是官方建議的做法
  2. 驅動層關掉 prepared statement(多數驅動有開關,代價是每次查詢都重新 parse)
  3. 放棄 RDS Proxy,自己跑 PgBouncer 用 transaction mode(每個交易一結束,就把資料庫連線還回池子給別人用)——它對這些狀況的處理更成熟,代價是要自己維護

我的看法:PostgreSQL + 重度 ORM 的專案,先假設 RDS Proxy 幫不上忙,量了指標證明它有用再留。反過來假設會讓你在尖峰那天才發現連線還是爆。

四、ElastiCache:不需要 proxy,需要的是四件事

講完 RDS Proxy 你可能會問 Redis 要不要也放一個。不用。 一般自己選節點規格建起來的 ElastiCache(官方叫 node-based cluster,相對於 Serverless),AWS 沒有一個像 RDS Proxy 那樣能單獨掛在前面的產品。(未查證:官方文件裡沒找到對應產品,但沒有做系統性查證。)有 proxy 的是 ElastiCache Serverless,而且 proxy 是內建的,下面講。

原因是連線模型不同:PostgreSQL 每條連線一個 process,Redis 是單執行緒事件迴圈,連線只是一個 file descriptor,一萬條同時連著也還好。RDS Proxy 要解的「連線太貴」在 Redis 這邊不成立。

真的會用到 Redis proxy 的場景,多半跟 cluster 模式有關。cluster 模式是把資料按 key 分散到好幾組節點上:每個 key 算出來會落在一個固定的 slot(槽位),每個 slot 由某一組節點負責。客戶端要自己算出 key 在哪個 slot、該連哪台,這叫 slot 路由。

會需要 proxy 的是這三種:

  1. 用 cluster 模式,但客戶端函式庫不懂 slot 路由(老舊語言或自製 client)
  2. 多租戶要在 proxy 層做 key 前綴隔離
  3. 連線數真的破萬,要讓很多條客戶端連線共用少數幾條到 Redis 的連線

要自己做就是自架 Envoy 的 Redis 代理,或 twemproxy(已經很舊)。場景 1 也可以換支援 cluster 的客戶端函式庫解掉——ioredis、go-redis、Lettuce 都內建 slot 路由和重導向處理(連錯節點時,Redis 會回覆「去另一台找」,函式庫會自動改連)。

另一條路是 ElastiCache Serverless。官方文件寫,它給你單一個 DNS 端點,所有連線先經過一層 proxy(一組 proxy 節點,前面是 network load balancer)。底層擴縮、換節點、升版時,客戶端都不用重連,也不用自己重新探索拓撲(有哪些節點、哪個 slot 在哪台)。

但它一律跑 cluster 模式,只接受懂 cluster 協定的客戶端。多 key 操作、交易、Lua 腳本裡用到的 key,都要落在同一個 hash slot;不然會回 CROSSLOT 錯誤,意思是「這幾個 key 分散在不同 slot,不能一起處理」。所以它不能當單機 Redis 用。

繞了一圈,結論還是開頭那句:除了上面三種情況,Redis 前面不用另外放 proxy。

aws elasticache create-replication-group \
  --replication-group-id prod-redis \
  --replication-group-description "Production Redis" \
  --engine redis --engine-version 7.1 \
  --cache-node-type cache.t3.medium \
  --num-cache-clusters 2 --automatic-failover-enabled \
  --cache-subnet-group-name prod-redis-subnet \
  --security-group-ids sg-redis \
  --at-rest-encryption-enabled --transit-encryption-enabled

ElastiCache 真正該顧的是這四件,每一件都是程式碼問題不是設定問題:

  • 故障切換時客戶端會不會自己重連——跟 RDS 那段同一個坑,DNS 換了 App 要跟上。還有一條:BLPOP 這類 blocking read(佇列沒資料時,指令會停著等到有資料或逾時)要用獨立連線,不要跟一般指令共用,否則一條卡住全部跟著卡
  • 快取同時過期造成的驚群——一批 key 同秒過期,全部打穿到資料庫。TTL 加隨機抖動(jitter,每個 key 的過期時間各多加一點隨機秒數),把過期時間打散。同一個 key 要重建時用 single-flight(同時進來的請求只放一個去做,其他人等它的結果),只讓一個請求去回源(回資料庫撈資料)
  • 記憶體滿了怎麼逐出——maxmemory-policy 要配用途:當快取用可以隨便丟,當佇列用不能丟
  • 多台 App 共用 session——這是 Redis 在這種架構裡最基本的用途,也是最容易漏的:session 寫在容器本地檔,換一個 task 就登出

這四件在 Database Landscape 有更完整的展開。

快取的讀寫順序只記結論:

  • 讀用 cache-aside:先讀 cache;miss(cache 裡沒有)就讀 DB,再把結果寫回 cache,這步叫回填。回填時一定要給 TTL(過期時間)
  • 寫是先更新 DB,再刪掉 cache,不是更新 cache

寫入為什麼是刪、不是更新?因為兩個寫入可能同時發生。A 把 DB 改成 1,B 接著改成 2,DB 最後是 2,沒問題。但如果接著各自去更新 cache,B 可能先寫入 cache=2,A 慢一步才寫入 cache=1,cache 就留著舊值 1。改成刪掉 cache,兩個人都只是刪,下一次讀會從 DB 拿到 2 再回填。

五、DynamoDB 與 Aurora:一段講完

DynamoDB 適合查詢模式固定、流量高、要自動擴的場景。不適合需要 JOIN、查詢模式還沒定、要事後 ad-hoc 分析的。新專案先用 On-Demand 計費,流量穩了再考慮 Provisioned。

Aurora 是 RDS 的自研儲存版,相容 PostgreSQL 和 MySQL。什麼時候選?資料量或讀取複本數大到標準 RDS 撐不住的時候。先別為了「比較快」選它——快多少沒有可靠的公開數字,所以這篇不寫倍數。

選型樹:

需要 JOIN / 複雜查詢?
├─ 是 → RDS PostgreSQL;撐不住再看 Aurora
└─ 否 → 查詢模式固定?
   ├─ 是 → DynamoDB
   └─ 否 → 還是 RDS,彈性最大
 
另外需要快取 / session / 限流 → ElastiCache Redis
全文搜尋 → OpenSearch

實戰 Checklist

  • RDS 建立時就開 --storage-encrypted 和 --multi-az,之後補不了
  • App 的 DB driver 有重連機制;Java 的 DNS TTL 設 60 秒以內
  • 算過「task 數 × 連線池大小」會不會超過 max_connections
  • 裝了 RDS Proxy 就看 DatabaseConnectionsCurrentlySessionPinned,接近 0 才算有效
  • Redis 的 session 外置、TTL 打散、逐出策略配用途

從 K8s 過來的話

K8s 上跑 StatefulSet PostgreSQL 可以,但備份、切換、升版全要自己來——在 AWS 上用 RDS 然後讓 Pod 連 RDS endpoint 是最省心的。連線數爆炸那題在 K8s 一模一樣:Pod 擴 50 個一樣打爆資料庫,差別只是你會用 PgBouncer 而不是 RDS Proxy——而 PgBouncer 對 PostgreSQL 反而比 RDS Proxy 成熟,這是少數「自架比託管好」的例子。

相關文章


資料庫是你的 App 最值錢的部分——省什麼都不要省 DB 的穩定性。