【工程師的系統設計】#03 資料層設計:資料庫選型、Schema 與快取策略

測驗:資料層設計 – 資料庫選型、Schema 與快取策略

共 5 題,點選答案後會立即顯示結果

1. 在系統架構中,為什麼資料庫通常是系統瓶頸?

  • A. 資料庫的程式語言比較慢
  • B. 資料庫要讀寫磁碟、處理鎖定、維護索引,速度遠低於記憶體運算
  • C. 資料庫只能單執行緒運作,無法平行處理
  • D. 資料庫需要連網才能使用,網路延遲是主因

2. 如果你要建一個涉及金錢交易的電商系統(訂單、支付),最適合選擇哪種資料庫?

  • A. PostgreSQL,因為支援 ACID 確保交易安全
  • B. MongoDB,因為文件型結構更適合存訂單資料
  • C. Redis,因為速度最快,交易講究效率
  • D. 三種都可以,看團隊熟悉哪個就用哪個

3. 看到以下 SQLAlchemy 程式碼,ForeignKey("users.id") 代表什麼意思?

class Order(Base): __tablename__ = “orders” id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey(“users.id”))
  • A. 建立一個名為 users.id 的新欄位
  • B. 把 user_id 設為自動遞增的主鍵
  • C. user_id 的值必須是 users 表裡某個已存在的 id
  • D. 將 orders 表合併到 users 表中

4. 關於「正規化」和「反正規化」,以下敘述何者正確?

  • A. 正規化就是把所有資料放在同一張表,查詢比較方便
  • B. 反正規化適合「寫多讀少」的場景,因為只需要改一個地方
  • C. 實務上應該一開始就大量反正規化,效能優先
  • D. 先正規化設計,遇到效能問題再局部反正規化

5. 在 Cache-Aside Pattern 中,如果大量快取同時過期導致所有請求瞬間打到資料庫,這個問題叫什麼?常見的解法是什麼?

  • A. 快取穿透;解法是對不存在的資料也存一個空值
  • B. 快取擊穿;解法是用分散式鎖讓只有一個請求查 DB
  • C. 快取雪崩;解法是過期時間加上隨機值,避免同時失效
  • D. 快取溢位;解法是增加 Redis 的記憶體上限

**系列**:工程師的系統設計(第 3 篇,共 4 篇)
**難度**:L2-進階
**前置知識**:第 1 篇系統設計思維入門、第 2 篇 API 設計、基本 SQL 語法(SELECT, INSERT, JOIN)

一句話說明

資料層是系統的「記憶」,選對資料庫、設計好結構、搭配快取,系統才不會慢到崩潰。


你將學會看懂的事

讀完這篇,你能夠:

  • 看到專案用 PostgreSQL、MongoDB 或 Redis 時,理解為什麼選它
  • 看到 Schema 定義時,理解表跟表之間的關聯
  • 看到索引設定時,知道它在加速什麼
  • 看到快取邏輯時,理解 Cache-Aside 等常見模式在幹嘛

1. 為什麼資料層通常是系統瓶頸

你的系統架構長這樣:

使用者 → API 伺服器 → 資料庫
         (很快)      (很慢)

API 伺服器的運算在記憶體裡完成,速度是奈秒等級。但資料庫要讀寫磁碟、處理鎖定、維護索引,速度是毫秒等級。差了 一百萬倍

所以系統設計的核心問題之一就是:怎麼讓資料層不拖垮整個系統

答案有三個方向:

  1. 選對資料庫 — 不同場景用不同工具
  2. 設計好 Schema — 讓查詢盡量簡單
  3. 加上快取 — 減少直接打資料庫的次數

接下來我們逐一看懂。


2. SQL vs NoSQL 選型指南

一句話區分

類型 代表 一句話
關聯式(SQL) PostgreSQL, MySQL 資料有嚴格結構,表跟表之間有關聯
文件型(NoSQL) MongoDB 資料像 JSON,結構彈性大
鍵值型(NoSQL) Redis 超快的「key-value」存取,常當快取用

什麼時候選哪個

你的資料有明確的關聯嗎?(用戶 → 訂單 → 商品)
  → 是:PostgreSQL(關聯式)

你的資料結構經常變動嗎?(每篇文章欄位都不同)
  → 是:MongoDB(文件型)

你需要超高速讀取嗎?(快取、排行榜、Session)
  → 是:Redis(鍵值型)

最小範例:三種資料庫存同一筆「用戶」資料

PostgreSQL — 結構嚴格,先定義欄位才能存

CREATE TABLE users (
    id SERIAL PRIMARY KEY,       -- 自動遞增的 ID
    name VARCHAR(100) NOT NULL,  -- 名字,不能空
    email VARCHAR(255) UNIQUE    -- 信箱,不能重複
);

INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');
Code language: PHP (php)

MongoDB — 直接丟 JSON 進去

db.users.insertOne({
    name: "Alice",
    email: "alice@example.com",
    hobbies: ["reading", "coding"]   // 想多加欄位?直接加
})
Code language: CSS (css)

Redis — 用 key 對應 value,像字典一樣

SET user:1:name "Alice"
SET user:1:email "alice@example.com"
Code language: CSS (css)

翻譯對照

你會看到 意思
SERIAL PRIMARY KEY 自動產生的唯一編號
NOT NULL 這個欄位必填
UNIQUE 這個欄位的值不能重複
VARCHAR(100) 最多 100 字的文字
insertOne({...}) 塞一筆文件(JSON)進 MongoDB
SET key value 在 Redis 存一個鍵值對

ACID 是什麼(知道就好)

你會在資料庫文件裡常看到 ACID,翻譯一下:

  • Atomicity(原子性):一組操作要嘛全成功,要嘛全失敗
  • Consistency(一致性):資料永遠保持合理狀態
  • Isolation(隔離性):多人同時操作不會互相干擾
  • Durability(持久性):存進去就不會丟

PostgreSQL 天生支援 ACID,MongoDB 和 Redis 在這方面比較弱。所以涉及金錢的系統(電商、支付)幾乎都選關聯式資料庫。


3. Schema 設計實戰:電商系統

最小範例:用 Python + SQLAlchemy 定義 Schema

SQLAlchemy 是 Python 最常用的資料庫工具,讓你用 Python 類別定義資料表:

from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
from sqlalchemy.orm import declarative_base, relationship
from datetime import datetime

Base = declarative_base()  # 所有 Model 的基底類別

class User(Base):
    __tablename__ = "users"           # 對應資料庫裡的 users 表

    id = Column(Integer, primary_key=True)   # 主鍵
    name = Column(String(100), nullable=False)  # 必填
    email = Column(String(255), unique=True)    # 不能重複

    orders = relationship("Order", back_populates="user")
    # ↑ 這行的意思:一個 User 可以有多筆 Order(一對多)

class Product(Base):
    __tablename__ = "products"

    id = Column(Integer, primary_key=True)
    name = Column(String(200), nullable=False)
    price = Column(Float, nullable=False)

class Order(Base):
    __tablename__ = "orders"

    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"))   # 外鍵:指向 users 表
    product_id = Column(Integer, ForeignKey("products.id"))
    quantity = Column(Integer, default=1)
    created_at = Column(DateTime, default=datetime.utcnow)

    user = relationship("User", back_populates="orders")
    # ↑ 反向關聯:從 Order 可以找到對應的 User

逐行翻譯重點

Base = declarative_base()
# ↑ 建立一個「基底」,之後所有 Model 都繼承它

__tablename__ = "users"
# ↑ 這個 Python 類別對應資料庫裡叫 "users" 的表

Column(Integer, primary_key=True)
# ↑ 整數欄位,是這張表的主鍵(唯一識別碼)

Column(String(255), unique=True)
# ↑ 最多 255 字的文字,值不能重複

ForeignKey("users.id")
# ↑ 外鍵:這個欄位的值必須是 users 表裡某個 id

relationship("Order", back_populates="user")
# ↑ 定義關聯:讓你可以用 user.orders 取得該用戶的所有訂單
Code language: PHP (php)

關聯圖:看懂表跟表的關係

users          orders              products
┌─────────┐    ┌──────────────┐    ┌──────────┐
│ id (PK) │◄───│ user_id (FK) │    │ id (PK)  │
│ name    │    │ product_id(FK)│───►│ name     │
│ email   │    │ quantity     │    │ price    │
└─────────┘    │ created_at   │    └──────────┘
               └──────────────┘

PK = Primary Key(主鍵,唯一識別)
FK = Foreign Key(外鍵,指向另一張表)

一個 User 可以有很多 Order(一對多),一個 Order 對應一個 Product。


4. 正規化 vs 反正規化

一句話區分

  • 正規化:拆表,消除重複資料(適合「寫多讀少」)
  • 反正規化:合表,冗餘儲存加快查詢(適合「讀多寫少」)

最小範例:訂單要顯示商品名稱

正規化做法(拆表):

-- 查訂單時要 JOIN 才能拿到商品名稱
SELECT orders.id, products.name, products.price
FROM orders
JOIN products ON orders.product_id = products.id
WHERE orders.user_id = 1;

翻譯:「從 orders 表和 products 表裡,用 product_id 把它們關聯起來,找出用戶 1 的所有訂單。」

反正規化做法(合表,冗餘儲存):

-- 訂單表直接存商品名稱和價格(不用 JOIN)
SELECT id, product_name, product_price
FROM orders
WHERE user_id = 1;

翻譯:「直接從 orders 表拿,因為商品名稱和價格已經複製一份存在裡面了。」

怎麼選

你的場景是「讀多寫少」嗎?(商品頁面、文章列表)
  → 傾向反正規化:查詢快,少做 JOIN

你的場景是「寫多讀少」嗎?(後台管理、資料匯入)
  → 傾向正規化:修改一處就生效,不用到處同步

你的場景涉及金錢或庫存嗎?
  → 正規化:避免資料不一致(商品改價格後訂單還顯示舊價格)

實務上的做法:先正規化設計,遇到查詢效能問題再局部反正規化。不要一開始就為了效能犧牲資料一致性。


5. 索引策略:讓查詢快一百倍

一句話說明

索引像書的「目錄」,讓資料庫不用翻遍整本書就能找到你要的頁面。

沒有索引 vs 有索引

沒有索引(全表掃描):
  從第 1 筆掃到第 100 萬筆,找 email = 'alice@example.com'
  → 很慢

有索引:
  直接跳到 'alice@example.com' 所在的位置
  → 很快
Code language: JavaScript (javascript)

B-Tree 索引原理(概念層面)

你不需要理解演算法細節,只需要知道:

B-Tree 索引像一棵倒過來的樹:

         [M]
        /   \
    [D,H]   [Q,T]
    / | \    / | \
  資料 資料 資料 資料

要找 "H":
  1. 看根節點 → H < M → 走左邊
  2. 看左節點 → H = H → 找到了!

100 萬筆資料只要比對約 20 次就能找到(不是 100 萬次)
Code language: HTML, XML (xml)

用 SQLAlchemy 建立索引

from sqlalchemy import Column, Integer, String, Index

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    name = Column(String(100))
    email = Column(String(255), unique=True, index=True)
    #                                       ↑ 加上 index=True 就建了索引
    city = Column(String(100))
    age = Column(Integer)

# 複合索引:同時用兩個欄位查詢時很有用
Index("idx_city_age", User.city, User.age)
# ↑ 當查詢是 WHERE city = 'Taipei' AND age > 20 時,這個索引會加速

逐行翻譯

index=True
# ↑ 替這個欄位建立索引,用這個欄位查詢時會變快

Index("idx_city_age", User.city, User.age)
# ↑ 建立「複合索引」,名字叫 idx_city_age
#   當你同時用 city 和 age 查詢時會加速
Code language: PHP (php)

什麼時候該加索引、什麼時候不該

該加索引的情況:
  - WHERE 條件常用的欄位(如 email, user_id)
  - JOIN 的外鍵欄位
  - ORDER BY 排序的欄位

不該加索引的情況:
  - 表很小(幾百筆),全表掃描反而更快
  - 很少用來查詢的欄位
  - 經常大量寫入的表(索引會拖慢寫入速度)

覆蓋索引(知道就好)

-- 假設有索引 (city, age, name)
SELECT name FROM users WHERE city = 'Taipei' AND age > 20;
-- ↑ 所有需要的欄位都在索引裡,資料庫連表都不用查
--   這叫「覆蓋索引」,是最快的查詢方式
Code language: JavaScript (javascript)

6. 快取層設計

為什麼需要快取

沒有快取:
  每次請求 → 查資料庫 → 回應
  100 個人同時看同一個商品 → 資料庫被查 100 次

有快取:
  第 1 次請求 → 查資料庫 → 存到快取 → 回應
  後續 99 次 → 直接從快取拿 → 回應
  資料庫只被查 1 次

Redis 是最常見的快取工具,讀取速度在微秒等級(比資料庫快 100 倍以上)。

Cache-Aside Pattern(最常見)

這是你在程式碼裡最常看到的快取模式:

import redis
import json

r = redis.Redis(host="localhost", port=6379, db=0)
# ↑ 連接本機的 Redis

def get_product(product_id: int) -> dict:
    cache_key = f"product:{product_id}"
    # ↑ 快取的 key,例如 "product:42"

    # 1. 先查快取
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
        # ↑ 快取命中!直接回傳(不查資料庫)

    # 2. 快取沒有,查資料庫
    product = db.query(Product).filter(Product.id == product_id).first()

    # 3. 寫入快取,設定 300 秒過期
    r.setex(cache_key, 300, json.dumps(product.to_dict()))
    # ↑ setex = SET + EXpire,存值並設定過期時間

    return product.to_dict()
Code language: PHP (php)

逐行翻譯

r = redis.Redis(host="localhost", port=6379, db=0)
# ↑ 連到 Redis 伺服器(預設 port 6379,用第 0 號資料庫)

r.get(cache_key)
# ↑ 從 Redis 拿資料,沒有的話回傳 None

r.setex(cache_key, 300, json.dumps(data))
# ↑ 存資料到 Redis,300 秒後自動過期刪除
#   json.dumps 是把 Python 字典轉成 JSON 字串
Code language: PHP (php)

三種快取策略比較

策略 運作方式 適用場景
Cache-Aside 讀:先查快取,沒有才查 DB 再寫入快取 最常用,適合大部分讀多場景
Write-Through 寫:同時寫 DB 和快取 需要快取永遠最新的場景
Write-Behind 寫:先寫快取,之後再批次寫 DB 寫入量極大的場景(如計數器)

最小範例:三種策略的 Python 寫法

Cache-Aside(上面已經展示過,這是最常見的)

Write-Through

def update_product(product_id: int, data: dict):
    # 同時更新資料庫和快取
    db.query(Product).filter(Product.id == product_id).update(data)
    db.commit()
    # ↑ 先寫資料庫

    r.setex(f"product:{product_id}", 300, json.dumps(data))
    # ↑ 再寫快取(保持快取最新)
Code language: PHP (php)

Write-Behind

def increment_view_count(product_id: int):
    r.incr(f"product:{product_id}:views")
    # ↑ 先只寫 Redis(超快)
    # 資料庫由背景排程定期同步(例如每 5 分鐘)
Code language: PHP (php)

快取常見問題

你在看系統設計文件時會遇到這三個術語:

快取穿透(Cache Penetration)

問題:查詢不存在的資料,快取永遠沒有,每次都打 DB
例如:惡意請求 product_id = -1(根本不存在)

解法:查不到也存一個空值到快取
def get_product_safe(product_id: int):
    cached = r.get(f"product:{product_id}")
    if cached == b"NULL":
        return None          # 已知不存在,不查 DB
    if cached:
        return json.loads(cached)

    product = db.query(Product).filter(Product.id == product_id).first()
    if product is None:
        r.setex(f"product:{product_id}", 60, "NULL")
        # ↑ 不存在也存起來,60 秒過期
        return None

    r.setex(f"product:{product_id}", 300, json.dumps(product.to_dict()))
    return product.to_dict()
Code language: PHP (php)

快取雪崩(Cache Avalanche)

問題:大量快取同時過期,瞬間所有請求都打 DB
例如:所有商品快取都設 300 秒,300 秒後全部同時失效

解法:過期時間加上隨機值
import random

ttl = 300 + random.randint(0, 60)
# ↑ 過期時間在 300~360 秒之間隨機
#   這樣不同商品的快取不會同時失效
r.setex(cache_key, ttl, data)
Code language: PHP (php)

快取擊穿(Cache Breakdown)

問題:某個超熱門的快取過期,瞬間大量請求同時查 DB
例如:首頁推薦商品的快取剛好過期

解法:用鎖讓只有一個請求去查 DB,其他請求等待
def get_hot_product(product_id: int):
    cached = r.get(f"product:{product_id}")
    if cached:
        return json.loads(cached)

    lock_key = f"lock:product:{product_id}"
    if r.set(lock_key, "1", nx=True, ex=5):
        # ↑ nx=True 表示「只有當 key 不存在時才設定」
        #   ex=5 表示鎖最多存在 5 秒(防止死鎖)
        #   只有一個請求能拿到這把鎖
        product = db.query(Product).filter(Product.id == product_id).first()
        r.setex(f"product:{product_id}", 300, json.dumps(product.to_dict()))
        r.delete(lock_key)
        return product.to_dict()
    else:
        import time
        time.sleep(0.1)
        return get_hot_product(product_id)
        # ↑ 沒拿到鎖的請求,等一下再試
Code language: PHP (php)

7. 資料一致性與 CAP 定理

CAP 定理的直觀理解

分散式系統(多台機器)只能三選二:

C - Consistency(一致性):每台機器看到的資料都一樣
A - Availability(可用性):每次請求都能得到回應
P - Partition Tolerance(分區容忍):網路斷線時系統還能運作

        C
       / \
      /   \
     A --- P

現實中 P 是必選的(網路一定會出問題),
所以實際上是在 C 和 A 之間取捨。

翻譯對照

選擇 白話 代表
CP(選一致性) 寧可回應慢,也不能給錯誤資料 銀行轉帳、庫存扣減
AP(選可用性) 寧可資料稍微過期,也要快速回應 社群媒體動態、商品瀏覽數

最終一致性 vs 強一致性

強一致性:
  寫入後,所有人立刻看到新資料
  例:銀行轉帳,轉完錢餘額立刻更新

最終一致性:
  寫入後,可能有幾秒延遲才同步到所有節點
  例:社群貼文的按讚數,不同人可能短暫看到不同數字

看到程式碼裡有「eventual consistency」的註解,就是在說「這邊允許短暫的資料不一致」。


8. 完整實戰:電商商品查詢系統

把以上所有概念整合成一個完整範例:

"""
電商商品查詢系統
用到:SQLAlchemy(資料庫)+ Redis(快取)+ Cache-Aside Pattern
"""
from sqlalchemy import create_engine, Column, Integer, String, Float, Index
from sqlalchemy.orm import declarative_base, Session
import redis
import json
import random

# --- 資料庫設定 ---
engine = create_engine("postgresql://user:pass@localhost/shop")
# ↑ 連接 PostgreSQL 資料庫,資料庫名稱叫 shop
Base = declarative_base()

# --- Redis 設定 ---
cache = redis.Redis(host="localhost", port=6379, db=0)
# ↑ 連接 Redis,用來做快取

# --- Schema 定義 ---
class Product(Base):
    __tablename__ = "products"

    id = Column(Integer, primary_key=True)
    name = Column(String(200), nullable=False)
    price = Column(Float, nullable=False)
    category = Column(String(100), index=True)
    # ↑ category 加了索引,用分類查詢時會快很多

# 複合索引:常一起查的欄位放在同一個索引
Index("idx_category_price", Product.category, Product.price)
# ↑ 加速 WHERE category = 'electronics' ORDER BY price 這類查詢

# --- 商品查詢(含快取) ---
def get_product_by_id(product_id: int) -> dict | None:
    """
    Cache-Aside Pattern:
    1. 先查 Redis 快取
    2. 沒有就查 PostgreSQL
    3. 查到後寫回快取
    """
    cache_key = f"product:{product_id}"

    # 步驟 1:查快取
    cached = cache.get(cache_key)
    if cached:
        if cached == b"NULL":
            return None  # 已知不存在的商品(防快取穿透)
        return json.loads(cached)

    # 步驟 2:查資料庫
    with Session(engine) as session:
        product = session.query(Product).filter(
            Product.id == product_id
        ).first()

    # 步驟 3:寫入快取
    if product is None:
        cache.setex(cache_key, 60, "NULL")
        # ↑ 商品不存在也快取 60 秒(防穿透)
        return None

    result = {
        "id": product.id,
        "name": product.name,
        "price": product.price,
        "category": product.category,
    }

    ttl = 300 + random.randint(0, 60)
    # ↑ 隨機過期時間 300~360 秒(防雪崩)
    cache.setex(cache_key, ttl, json.dumps(result))

    return result

# --- 依分類查詢商品列表 ---
def get_products_by_category(category: str) -> list[dict]:
    """
    列表查詢通常不快取每一筆,而是快取整個查詢結果
    """
    cache_key = f"products:category:{category}"

    cached = cache.get(cache_key)
    if cached:
        return json.loads(cached)

    with Session(engine) as session:
        products = session.query(Product).filter(
            Product.category == category
        ).order_by(Product.price).all()
        # ↑ 這個查詢會用到 idx_category_price 複合索引

    result = [
        {"id": p.id, "name": p.name, "price": p.price}
        for p in products
    ]

    ttl = 120 + random.randint(0, 30)
    cache.setex(cache_key, ttl, json.dumps(result))

    return result

# --- 更新商品(Write-Through) ---
def update_product(product_id: int, name: str, price: float):
    """
    Write-Through:同時更新資料庫和快取
    """
    with Session(engine) as session:
        product = session.query(Product).filter(
            Product.id == product_id
        ).first()

        if product:
            product.name = name
            product.price = price
            session.commit()
            # ↑ 先更新資料庫

            result = {
                "id": product.id,
                "name": product.name,
                "price": product.price,
                "category": product.category,
            }
            cache.setex(
                f"product:{product_id}",
                300,
                json.dumps(result)
            )
            # ↑ 再更新快取(確保快取跟 DB 一致)

            # 清除分類列表快取(因為商品資料變了)
            cache.delete(f"products:category:{product.category}")

這段程式碼做了什麼

  1. 用 SQLAlchemy 定義了 Product 表的 Schema,包含索引設定
  2. get_product_by_id 實作了 Cache-Aside Pattern,並防範快取穿透和雪崩
  3. get_products_by_category 展示了列表查詢的快取策略
  4. update_product 實作了 Write-Through,更新時同步 DB 和快取

Vibe Coder 檢查點

看到資料層相關的程式碼時,確認這些重點:

資料庫選型

  • [ ] 有錢相關的邏輯嗎?應該用關聯式資料庫(PostgreSQL/MySQL)
  • [ ] Schema 會經常變動嗎?可以考慮 MongoDB
  • [ ] 需要超快讀取嗎?Redis 適合做快取或排行榜

Schema 設計

  • [ ] 表之間的關聯(ForeignKey)合理嗎?
  • [ ] 有沒有必要的 NOT NULL 和 UNIQUE 約束?
  • [ ] 是正規化還是反正規化?跟使用場景匹配嗎?

索引

  • [ ] WHERE 常用的欄位有加索引嗎?
  • [ ] 是不是加太多索引了?(會拖慢寫入)
  • [ ] 複合索引的欄位順序對嗎?(最常過濾的放前面)

快取

  • [ ] 用了什麼快取策略?(Cache-Aside / Write-Through / Write-Behind)
  • [ ] 有設過期時間嗎?(沒設的話快取會永遠不更新)
  • [ ] 有處理快取穿透嗎?(不存在的資料也要快取)
  • [ ] 過期時間有加隨機值嗎?(防雪崩)

必看懂 vs 知道就好

必看懂(系統設計核心)

  • SQL vs NoSQL 的選型邏輯
  • Schema 的 ForeignKey 和 relationship
  • Cache-Aside Pattern(最常見的快取模式)
  • 索引的基本概念(為什麼能加速查詢)

知道就好(遇到再查)

  • B-Tree 的具體實作細節
  • 覆蓋索引的最佳化技巧
  • Write-Behind 的批次同步實作
  • CAP 定理的數學證明
  • 分散式鎖的各種實作方式(Redlock 等)

總結

資料層設計三件事:

1. 選對資料庫
   └ 有關聯 → SQL、結構彈性 → NoSQL、要快 → Redis

2. 設計好 Schema
   └ 先正規化 → 遇到效能問題再局部反正規化
   └ 常查的欄位加索引

3. 搭配快取
   └ Cache-Aside 是最常見的模式
   └ 注意穿透、雪崩、擊穿三個問題

下一篇我們將進入系統設計的最後一塊拼圖:可擴展性與部署架構,學會如何讓系統從單機擴展到分散式。

進階測驗:資料層設計 – 資料庫選型、Schema 與快取策略

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你正在設計一個部落格平台,每篇文章的欄位可能不同(有些有影片、有些有程式碼區塊、有些有投票)。文章寫入後幾乎不修改,但每天有數萬次閱讀。你會怎麼選擇資料庫? 情境題

  • A. PostgreSQL,因為文章之間有分類和標籤的關聯
  • B. MongoDB,因為文章結構經常變動且彈性 Schema 適合此場景
  • C. Redis,因為讀取量很大需要最快的速度
  • D. 用 PostgreSQL 儲存文章,每次新增欄位時修改表結構

2. 你的電商系統首頁有一個「今日推薦」商品區塊,每秒有上千次請求。某天早上突然資料庫 CPU 飆到 100%,排查後發現該商品的快取剛好過期。這是什麼問題?你應該怎麼修? 情境題

  • A. 快取穿透;在查不到時存一個 NULL 值到快取
  • B. 快取雪崩;把所有快取的過期時間加上隨機值
  • C. 快取擊穿;用分散式鎖讓只有一個請求查 DB,其他請求等待
  • D. 快取雪崩;增加 Redis 的記憶體上限

3. 你負責的商品頁面查詢很慢。資料庫有 500 萬筆商品,使用者常用「城市 + 價格範圍」搜尋。目前只有 city 欄位有單一索引。你應該怎麼優化? 情境題

— 目前的慢查詢 SELECT name, price FROM products WHERE city = ‘Taipei’ AND price BETWEEN 100 AND 500 ORDER BY price;
  • A. 再單獨為 price 欄位加一個索引
  • B. 建立 (city, price) 的複合索引,同時加速 WHERE 和 ORDER BY
  • C. 把所有商品載入 Redis 快取,完全繞過資料庫
  • D. 把 products 表反正規化,把城市名稱直接存在商品名中

4. 同事寫了以下快取邏輯,但上線後發現:惡意使用者持續請求不存在的商品 ID(如 -999),每次都直接查資料庫,導致 DB 負載過高。這段程式碼的問題在哪? 錯誤診斷

def get_product(product_id: int) -> dict: cache_key = f”product:{product_id}” cached = r.get(cache_key) if cached: return json.loads(cached) product = db.query(Product).filter( Product.id == product_id ).first() if product: r.setex(cache_key, 300, json.dumps(product.to_dict())) return product.to_dict() return None
  • A. r.setex 的過期時間 300 秒太短,應改為永久快取
  • B. 當商品不存在時沒有快取 NULL 值,導致每次都穿透到資料庫
  • C. 應該用 Write-Through 模式取代 Cache-Aside
  • D. json.loads 無法處理 Redis 回傳的二進位資料

5. 系統上線一段時間後,每天凌晨 3:00 都會出現資料庫瞬間高峰。排查後發現所有商品的快取都是在每天凌晨 2:55 左右被寫入的,過期時間統一設為 300 秒。以下哪段修正程式碼能解決這個問題? 錯誤診斷

# 原本的程式碼(有問題) r.setex(cache_key, 300, json.dumps(data))
  • A. 把過期時間從 300 改為 3600,延長快取存活時間
  • B. 改用 r.set(cache_key, data) 不設過期時間,讓快取永遠存在
  • C. 改為 ttl = 300 + random.randint(0, 60),加隨機值錯開過期時間
  • D. 在凌晨 2:55 執行排程預先刷新所有快取

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *