結論先講

大部分人不需要分片(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。


什麼時候不該擴展資料庫

在加機器之前,先問自己這些問題:

  1. 查詢優化了嗎? 一個缺索引的查詢從 3 秒優化到 3 毫秒,等於省了 1000 倍的機器
  2. 有不必要的查詢嗎? N+1 問題、重複查詢、沒用到的 JOIN
  3. 有快取嗎? 很多不常變的資料(商品列表、設定)不需要每次都查 DB
  4. 連接池設定正確嗎? 很多「DB 連不上」其實是連線數被用光
  5. 真的是 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 SQLGCP 的 managed DB$$
PlanetScaleMySQL 相容,自動 Sharding(Vitess)$-$$$
NeonServerless PostgreSQL,按用量計費$-$$
CockroachDB分散式 SQL,自動擴展$$-$$$
SupabasePostgreSQL + 即時訂閱 + 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 秒。如果這些都達標,你的資料庫還很健康,不用急著擴展。


Database 系列文章

  1. 為什麼需要資料庫
  2. MySQL:為什麼越來越多人覺得它不夠好?
  3. PostgreSQL 的崛起
  4. SQL vs NoSQL:怎麼選
  5. Redis 不只是快取
  6. MongoDB:什麼時候該用文件資料庫
  7. ORM:愛恨交織的抽象層
  8. 交易與隔離等級
  9. 備份與災難恢復
  10. SQL 效能調校
  11. 資料庫遷移策略
  12. 資料庫擴展模式 ← 你在這裡