結論先講
大部分人不需要分片(Sharding)。 真的。在你考慮 Sharding 之前,先確認你做了以下這些事:查詢優化、加索引、連接池、快取、讀寫分離、表分區。90% 的效能問題在這些階段就解決了。Sharding 是最後手段,它帶來的複雜度會讓你後悔很久。
擴展資料庫的正確順序:先讓現有的跑快一點,再考慮加機器。
擴展決策流程
在開始之前,先看這個決策路徑:
資料庫慢了
│
├─ 查詢本身慢? → 優化查詢(見第 10 篇)
│
├─ 連線數太多? → 連接池(PgBouncer / ProxySQL)
│
├─ 讀取負載高? → Read Replica + 快取(Redis)
│
├─ 單表太大? → 表分區(Partitioning)
│
├─ 寫入負載高? → CQRS / 分片(Sharding)
│
└─ 以上都做了還不夠? → 你可能是下一個獨角獸,恭喜
垂直擴展(Vertical Scaling)
最簡單粗暴的方式:換更大的機器。
| 資源 | 影響 | 常見瓶頸訊號 |
|---|---|---|
| CPU | 查詢運算速度 | %CPU 持續 > 80% |
| RAM | 快取命中率 | cache hit ratio < 95% |
| Disk I/O | 讀寫速度 | iowait 高 |
| Disk Space | 儲存容量 | 磁碟空間告警 |
-- PostgreSQL 檢查 cache hit ratio
SELECT
sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) as cache_hit_ratio
FROM pg_statio_user_tables;
-- 低於 0.95 代表記憶體不夠天花板: 單機最大就是那樣了。AWS 最大的 RDS instance(db.r6g.16xlarge)是 64 vCPU + 512 GB RAM。如果這還不夠,就需要水平擴展了。
連接池(Connection Pooling)
很多人忽略這個,但它可能是 CP 值最高的優化。
為什麼需要連接池?
每個資料庫連線都會佔用記憶體(PostgreSQL 每個連線約 5-10 MB)。如果你有 100 個 application server,每個開 20 個連線,就是 2000 個連線,光記憶體就吃掉 10-20 GB,而且大部分時間連線都是閒置的。
PgBouncer(PostgreSQL)
; pgbouncer.ini
[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
pool_mode = transaction ; 重點!
max_client_conn = 1000 ; 接受最多 1000 個客戶端連線
default_pool_size = 20 ; 實際只用 20 個 DB 連線Pool Mode 比較
| 模式 | 行為 | 適用場景 |
|---|---|---|
| Session | 連線綁定到整個 session | 需要 prepared statement、LISTEN/NOTIFY |
| Transaction | 連線只在 transaction 期間綁定 | 大部分 Web 應用(推薦) |
| Statement | 每個 SQL 語句用完就還 | 簡單查詢,不支援 transaction |
Transaction mode 的限制: 不能用 SET 設定 session 級別參數、不能用 PREPARE / DEALLOCATE、不能用 LISTEN / NOTIFY。大部分 ORM 預設用 prepared statement,要特別設定關閉。
// Prisma 配合 PgBouncer
// schema.prisma
datasource db {
provider = "postgresql"
url = "postgresql://user:pass@localhost:6432/myapp?pgbouncer=true"
}ProxySQL(MySQL)
-- ProxySQL 設定讀寫分離
INSERT INTO mysql_servers (hostgroup_id, hostname, port) VALUES
(10, 'master.db', 3306), -- 寫入
(20, 'replica1.db', 3306), -- 讀取
(20, 'replica2.db', 3306); -- 讀取
INSERT INTO mysql_query_rules (rule_id, match_pattern, destination_hostgroup) VALUES
(1, '^SELECT.*FOR UPDATE', 10), -- SELECT FOR UPDATE 走 master
(2, '^SELECT', 20); -- 其他 SELECT 走 replica讀寫分離(Read Replicas)
大部分應用的讀寫比大約是 80:20 甚至 95:5。把讀取分散到 replica 可以大幅降低主庫壓力。
架構
Application
├── 寫入 ──→ Primary(Master)
└── 讀取 ──→ Replica 1 / Replica 2 / Replica 3
↑
非同步複製(有延遲)
同步 vs 非同步複製
| 同步(Synchronous) | 非同步(Asynchronous) | |
|---|---|---|
| 延遲 | 增加寫入延遲 | 寫入不受影響 |
| 資料一致性 | Replica 保證有最新資料 | Replica 可能落後數毫秒~數秒 |
| 可用性 | Replica 掛了主庫也寫不進去 | Replica 掛了不影響主庫 |
| 適用 | 金融系統 | 大部分 Web 應用 |
Read-After-Write Consistency 問題
// ❌ 經典坑:寫入後立刻讀,從 replica 讀到舊資料
await primaryDb.query("UPDATE users SET name = 'New Name' WHERE id = 1");
const user = await replicaDb.query("SELECT * FROM users WHERE id = 1");
// user.name 可能還是舊的!(replica 還沒同步)
// ✅ 解法 1:寫入後短時間內強制讀 primary
async function updateAndRead(userId, newName) {
await primaryDb.query("UPDATE users SET name = $1 WHERE id = $2", [newName, userId]);
// 寫入後 5 秒內的讀取走 primary
setReadFromPrimary(userId, 5000);
const user = await getUser(userId); // 這個函式會檢查是否要走 primary
return user;
}
// ✅ 解法 2:回傳寫入結果,不要再讀一次
async function updateUser(userId, data) {
const result = await primaryDb.query(
"UPDATE users SET name = $1 WHERE id = $2 RETURNING *",
[data.name, userId]
);
return result.rows[0]; // 直接用寫入的回傳值
}表分區(Partitioning)
當單表超過幾千萬甚至上億筆資料時,即使有索引,查詢和維護(VACUUM、REINDEX)都會變慢。分區把一張邏輯表拆成多個物理子表。
分區類型
| 類型 | 適用場景 | 範例 |
|---|---|---|
| Range | 時間序列資料 | 按月份分區 |
| Hash | 均勻分散 | 按 user_id 分區 |
| List | 離散值 | 按地區或狀態分區 |
PostgreSQL Range Partition 範例
-- 建立分區表
CREATE TABLE orders (
id BIGSERIAL,
user_id INTEGER NOT NULL,
amount DECIMAL(10,2),
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- 建立子分區
CREATE TABLE orders_2026_q1 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE orders_2026_q2 PARTITION OF orders
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
CREATE TABLE orders_2026_q3 PARTITION OF orders
FOR VALUES FROM ('2026-07-01') TO ('2026-10-01');
CREATE TABLE orders_2026_q4 PARTITION OF orders
FOR VALUES FROM ('2026-10-01') TO ('2027-01-01');
-- 查詢自動路由到正確的分區
SELECT * FROM orders WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
-- 只掃描 orders_2026_q1,其他分區完全不碰(Partition Pruning)自動建立分區
# 每季自動建立下一季的分區
import psycopg2
from datetime import date, timedelta
def create_next_partition(conn):
today = date.today()
# 計算下一季的開始和結束
quarter = (today.month - 1) // 3 + 1
next_q_start = date(today.year, quarter * 3 + 1, 1) if quarter < 4 else date(today.year + 1, 1, 1)
next_q_end = date(next_q_start.year, next_q_start.month + 3, 1) if next_q_start.month <= 9 else date(next_q_start.year + 1, 1, 1)
partition_name = f"orders_{next_q_start.year}_q{(next_q_start.month - 1) // 3 + 1}"
cur = conn.cursor()
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {partition_name}
PARTITION OF orders
FOR VALUES FROM ('{next_q_start}') TO ('{next_q_end}')
""")
conn.commit()
print(f"Created partition: {partition_name}")分區 vs 分片的差異: 分區是在同一台資料庫內的優化,對應用層透明。分片是把資料分散到不同的資料庫伺服器,應用層需要知道。
分片(Sharding)
到了這一步,代表前面的方法都不夠用了。Sharding 把資料水平切分到多台資料庫。
Shard Key 的選擇
| Shard Key | 優點 | 缺點 |
|---|---|---|
user_id | 同用戶資料在同一 shard,查詢簡單 | 大用戶可能造成 hot shard |
order_id | 分布均勻 | 查「某用戶所有訂單」要跨 shard |
region | 地理相關查詢高效 | 地區大小不均 |
// 簡單的 shard routing
const SHARD_COUNT = 4;
function getShardId(userId) {
return userId % SHARD_COUNT;
}
function getShardConnection(userId) {
const shardId = getShardId(userId);
return shardConnections[shardId];
}
// 使用
async function getUserOrders(userId) {
const db = getShardConnection(userId);
return db.query("SELECT * FROM orders WHERE user_id = $1", [userId]);
}
// 跨 shard 查詢(很痛)
async function getRecentOrders(limit) {
const results = await Promise.all(
shardConnections.map(db =>
db.query("SELECT * FROM orders ORDER BY created_at DESC LIMIT $1", [limit])
)
);
// 合併排序
return results.flat()
.sort((a, b) => b.created_at - a.created_at)
.slice(0, limit);
}Resharding 的痛
當你需要從 4 個 shard 增加到 8 個 shard 時,幾乎所有資料都要搬移。這就是為什麼很多公司選擇 Consistent Hashing 或直接用 managed 方案(如 Vitess、CockroachDB、PlanetScale)。
快取層(Caching)
在資料庫前面擋一層 Redis 或 Memcached,是 CP 值最高的擴展方式之一。
// Cache-Aside Pattern
async function getProduct(productId) {
// 1. 先查快取
const cached = await redis.get(`product:${productId}`);
if (cached) return JSON.parse(cached);
// 2. 快取沒有,查資料庫
const product = await db.query("SELECT * FROM products WHERE id = $1", [productId]);
// 3. 寫入快取(TTL 5 分鐘)
await redis.set(`product:${productId}`, JSON.stringify(product), 'EX', 300);
return product;
}
// 更新時記得清快取
async function updateProduct(productId, data) {
await db.query("UPDATE products SET name = $1 WHERE id = $2", [data.name, productId]);
await redis.del(`product:${productId}`); // 刪除快取,下次讀取會重新載入
}快取策略比較:
| 策略 | 行為 | 適用場景 |
|---|---|---|
| Cache-Aside | 應用層管理快取 | 大部分場景 |
| Write-Through | 寫入時同步更新快取 | 讀多寫少,一致性要求高 |
| Write-Behind | 寫入快取後非同步寫 DB | 高寫入量,可容忍短暫不一致 |
CQRS(Command Query Responsibility Segregation)
讀寫分離的進階版:用不同的資料模型來處理讀取和寫入。
寫入(Command) 讀取(Query)
│ │
▼ ▼
PostgreSQL Elasticsearch / Redis
(正規化資料) (非正規化、查詢優化)
│ ▲
└──── 事件/同步機制 ──────────────┘
// 寫入端:正規化儲存
async function createOrder(data) {
const order = await db.query(`
INSERT INTO orders (user_id, product_id, quantity, amount)
VALUES ($1, $2, $3, $4) RETURNING *
`, [data.userId, data.productId, data.quantity, data.amount]);
// 發布事件,讓讀取端更新
await eventBus.publish('order.created', order);
return order;
}
// 讀取端:非正規化,查詢最快
async function getOrderDashboard(userId) {
// 從 Redis 讀取預先計算好的儀表板資料
return JSON.parse(await redis.get(`dashboard:${userId}`));
}
// 事件處理器:維護讀取端的資料
eventBus.on('order.created', async (order) => {
// 更新用戶儀表板
const dashboard = JSON.parse(await redis.get(`dashboard:${order.userId}`)) || {};
dashboard.totalOrders = (dashboard.totalOrders || 0) + 1;
dashboard.totalSpent = (dashboard.totalSpent || 0) + order.amount;
await redis.set(`dashboard:${order.userId}`, JSON.stringify(dashboard));
// 更新 Elasticsearch(給後台搜尋用)
await es.index({ index: 'orders', body: order });
});CQRS 適合什麼場景? 讀寫負載差異很大(讀 >> 寫)、讀取需要跨多張表聚合、需要不同的查詢引擎(如全文搜尋)。大部分 CRUD 應用不需要 CQRS。
什麼時候不該擴展資料庫
在加機器之前,先問自己這些問題:
- 查詢優化了嗎? 一個缺索引的查詢從 3 秒優化到 3 毫秒,等於省了 1000 倍的機器
- 有不必要的查詢嗎? N+1 問題、重複查詢、沒用到的 JOIN
- 有快取嗎? 很多不常變的資料(商品列表、設定)不需要每次都查 DB
- 連接池設定正確嗎? 很多「DB 連不上」其實是連線數被用光
- 真的是 DB 瓶頸嗎? 也許是應用層的問題(記憶體洩漏、CPU 密集運算)
-- 檢查是否真的有慢查詢
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
-- 檢查是否有未使用的索引(可能佔空間拖慢寫入)
SELECT indexrelid::regclass, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;Managed 服務選擇
如果你不想自己管理資料庫擴展,這些服務能幫你處理大部分事情:
| 服務 | 特色 | 價格帶 |
|---|---|---|
| AWS RDS | 最成熟,支援多種引擎 | $$ |
| Cloud SQL | GCP 的 managed DB | $$ |
| PlanetScale | MySQL 相容,自動 Sharding(Vitess) | $-$$$ |
| Neon | Serverless PostgreSQL,按用量計費 | $-$$ |
| CockroachDB | 分散式 SQL,自動擴展 | $$-$$$ |
| Supabase | PostgreSQL + 即時訂閱 + Auth | $-$$ |
個人建議: 小專案用 Neon 或 Supabase(免費方案夠用)。中型專案用 RDS 或 Cloud SQL。需要全球分散式的用 CockroachDB 或 PlanetScale。
FAQ
Q1: 讀寫分離一定需要用中間件嗎?
不一定。很多 ORM 和框架支援直接設定多個資料庫連線。例如 Django 的 DATABASE_ROUTERS、Rails 的 connects_to、Prisma 的 readReplicas extension。但如果你有多個微服務,用 ProxySQL 或 PgBouncer 統一管理會比較好。
Q2: Redis 快取和讀寫分離哪個先做?
通常先加 Redis 快取。原因:(1)實作更簡單、(2)效果更明顯(命中快取的查詢直接不打 DB)、(3)不用處理 replication lag。讀寫分離要等快取搞定後、DB 讀取負載還是太高再做。
Q3: 分區表的索引怎麼處理?
PostgreSQL 的分區表會自動在每個分區建立索引(12+ 版本支援 CREATE INDEX ON ONLY 控制)。注意 UNIQUE 約束必須包含分區鍵,否則無法跨分區保證唯一性。
Q4: Sharding 之後 JOIN 怎麼辦?
很痛。跨 shard JOIN 通常要在應用層做。常見策略:(1)把常 JOIN 的表複製到每個 shard(如 categories)、(2)在應用層做多次查詢再合併、(3)用專門的跨 shard 查詢引擎(如 Vitess 的 VTGate)。
Q5: 怎麼判斷現在的資料庫到底撐不撐得住?
看這幾個指標:CPU 使用率 < 70%、連線數 < max_connections 的 70%、cache hit ratio > 95%、最慢查詢 < 1 秒、replication lag < 1 秒。如果這些都達標,你的資料庫還很健康,不用急著擴展。