跳到主要內容

Web Day 43 專案:壓力測試與效能調校

Web Day 43 專案:壓力測試與效能調校

執行需求:CPU 可跑。昨天的監控日誌備份做完,stack 可以撐一段時間。今天要把這套 stack 真正拿去「壓」一下,找出容量邊界:在不同併發等級下,這套預約管理系統每秒能回應多少 request、P50 / P95 延遲落在哪裡、哪條端點最先撐不住。我們要用 locust 2.3 寫一隻壓測腳本(模擬「瀏覽可預約時段」、「建立預約」、「管理者確認」三種典型行為),再用 httpx 0.28 + asyncio 寫一隻純 Python 版壓測工具(方便併入 pytest 與 CI)。所有數字都會標「實際數字會略有不同」,因為硬體、PostgreSQL 設定、API 內容都會影響結果。今天也會用 Day 42 的 metrics 端點,把壓測期間的 QPS、P95、錯誤率一起抓出來,產出一份可重現的容量報告。所有資料延續 Day 35–42 的虛構示範。

引言

效能調校是「沒有測量就沒有改善」這句話的最佳示範。在沒壓測之前,工程師對「這套 API 能撐多少」通常只有模糊的印象;壓測一跑,結果往往令人意外:原本以為很穩的 endpoint 反而第一個掛掉、以為會很慢的查詢其實飛快、以為資料庫是瓶頸結果真的是、但不是預期的那個查詢卡住而是 index 沒命中。這些訊息只有「真的去壓」才會浮現。

貫穿專案的預約管理系統是小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範)。它的三條主要路徑:使用者查可預約時段(GET)、使用者下訂(POST /bookings)、管理員確認(POST /admin/bookings/{id}/confirm)。前兩者是公開 API、第三條是後台行為。我們今天會用 locust 與 httpx 並發模擬真實流量:60 秒內 50 個虛擬使用者(users)每 1–2 秒送一個 request,觀察 QPS、P95、錯誤率,找出可以調校的空間。

今天的內容分四段:第一段說明 locust 與 httpx 並發的差異、各自適合的場景;第二段寫 locustfile 模擬三種典型行為;第三段寫純 Python 壓測腳本(含 asyncio gather)併入 pytest;第四段讀 /metrics、寫一份可重現的容量報告。讀完這篇你會了解:locust 2.3 的 HttpUser 與 task decorator、httpx 0.28 的 AsyncClient + asyncio.gather、P50 / P95 / P99 的計算、為什麼錯誤率比平均延遲更重要、怎麼把壓測寫進 CI。

原理解念:locust vs httpx 併發、capacity vs latency

locust 與 httpx 併發是兩種不同思維。locust 用「虛擬使用者」模型,每個 user 會重複行為(爬網頁的下單流程),適合模擬「人對系統施加的真實流量」;locust 內建 Web UI 可以即時看 QPS 與 P95,並支援分散式(master + worker)。我們今天用 locust 做「探索模式」:手動跑 60 秒、看圖表、找瓶頸。httpx 併發則用 Python asyncio 一次發幾十支 request,適合「批次驗證」:例如 pytest 內跑 50 個並發確認 P95 沒掉到 1 秒以上,這個會跑在 CI 上、每次 commit 跑一次。兩者不是互相取代,而是互補:locust 探索後的結論寫成 httpx 腳本,再由 CI 守門。

「capacity vs latency」是壓測最常見的取捨。Capacity 講「系統每秒能撐多少 request」(throughput),latency 講「每個 request 要等多久」(response time)。兩者並非簡單的線性關係:在輕量負載下 latency 隨著 QPS 上升緩慢惡化,到某個臨界點突然爆表;這個臨界點就是系統的「膝蓋(knee)」。我們的目標是找出膝蓋、在膝蓋之前設定 SLA(例如 P95 < 300 ms @ 80 QPS)並讓部署架構撐在 SLA 範圍內。對小型預約系統來說,膝蓋往往就是「PostgreSQL 連線池耗盡」、「worker 數撞到 CPU 核心數」、「Jinja2 模板渲染速度跟不上」這三類瓶頸。

另一個關鍵是「錯誤率開始上升的點比 P95 開始上升的點更危險」。一個 request 在 P95 上升到 800 ms 之後可能還能成功回應 200,但一旦錯誤率從 0.1% 爬到 1%,通常代表某個資源(DB connection、thread、open file handle)開始排隊或拒絕。locust 與 httpx 都會把錯誤計數;我們今天把 5xx 錯誤率低於 1% 當硬門檻,P95 低於 300 ms 當軟目標。在 production 上,這兩條通常會配自動告警:當 5xx 連續 1 分鐘超過 1%、或 P95 連續 5 分鐘超過 500 ms,就寄信給值班工程師。我們 Day 44 寫 runbook 時,會把這兩條當「事件處置清單」的觸發條件。

還有一個與 RAG/LLM 系列相通的概念是「為什麼選擇 loki / Prometheus 而不是自己寫 metrics 系統」。監控與分析在 production 都不是孤島:log 流入 Loki、metrics 流入 Prometheus、traces 流入 Tempo,三者各有專長;如果為了省事自己寫一個「把 metrics 也寫進 log」,會在資料量變大後失去分位計算的效率。同樣地,自己寫一個「純 Python 壓測框架」會失去 locust 的 Web UI、CSV 匯出、與分散式模式。我們選擇用最少的「現成工具」達到「工業界標準」,這也是 12-factor 的精神。

完整實作:locustfile、httpx 並發、容量報告

先寫 locustfile。locust 2.3 的 @task decorator 決定每個虛擬使用者的行為分布,weight 決定相對機率:

# tests/load/locustfile.py
# 用 locust 2.3 模擬三種典型行為;用 weight 控制比例
import random
from datetime import datetime, timedelta, timezone

from locust import HttpUser, task, between


BASE_URL = "http://127.0.0.1:8000"


class BookingUser(HttpUser):
    """模擬一般使用者:瀏覽時段、下單、查自己的預約。"""
    wait_time = between(1, 2)
    abstract = True


class GuestUser(BookingUser):
    weight = 6       # 60% 是匿名訪客

    @task(8)
    def list_resources(self):
        self.client.get("/resources", name="GET /resources")

    @task(2)
    def check_health(self):
        self.client.get("/health", name="GET /health")


class CustomerUser(BookingUser):
    weight = 3       # 30% 是已登入客戶
    token: str = ""

    def on_start(self):
        # 每個 user on_start 登入一次
        r = self.client.post(
            "/auth/login",
            json={"email": "loadtester@example.com", "password": "load-pwd"},
            name="POST /auth/login",
        )
        if r.status_code == 200:
            body = r.json()
            self.token = body.get("access_token", "")

    @task(3)
    def list_my_bookings(self):
        self.client.get(
            "/bookings/mine",
            headers={"Authorization": f"Bearer {self.token}"},
            name="GET /bookings/mine",
        )

    @task(2)
    def create_booking(self):
        start = datetime.now(timezone.utc) + timedelta(days=random.randint(1, 14))
        end = start + timedelta(hours=1)
        self.client.post(
            "/bookings/",
            json={
                "resource_id": random.randint(1, 5),
                "start_at": start.isoformat(),
                "end_at": end.isoformat(),
                "customer_email": "load-tester@example.com",
            },
            headers={"Authorization": f"Bearer {self.token}"},
            name="POST /bookings/",
        )


class AdminUser(BookingUser):
    weight = 1       # 10% 是管理員
    token: str = ""

    def on_start(self):
        r = self.client.post(
            "/auth/login",
            json={"email": "admin@example.com", "password": "admin-pass"},
            name="POST /auth/login (admin)",
        )
        if r.status_code == 200:
            self.token = r.json().get("access_token", "")

    @task(1)
    def search_bookings(self):
        self.client.get(
            "/admin/bookings/search?q=load",
            headers={"Authorization": f"Bearer {self.token}"},
            name="GET /admin/bookings/search",
        )

這個 locustfile 有三個 user class 用不同權重:GuestUser 60% 模擬訪客(最常見)、CustomerUser 30% 模擬已登入客戶、AdminUser 10% 模擬管理員。on_start 在每個 user 開始時跑一次登入,避免每個 request 都重新登入。name 參數讓 locust 把同路徑整合計數,避免 /bookings/3 與 /bookings/4 被視為不同 endpoint。weight 6:3:1 的設定符合小型預約系統的典型流量分布:訪客最多、客戶其次、管理員最少。

接下來是純 Python 壓測工具,用 httpx 0.28 + asyncio:

# tests/load/bench.py
# 用 httpx + asyncio 跑一個不依賴 locust 的純 Python 壓測
import asyncio
import statistics
import time
from collections import Counter
from typing import Iterable

import httpx


async def _one_request(
    client: httpx.AsyncClient,
    method: str,
    url: str,
    *,
    headers: dict | None = None,
    json_body: dict | None = None,
) -> tuple[int, float]:
    start = time.perf_counter()
    try:
        r = await client.request(method, url, headers=headers, json=json_body, timeout=10.0)
        status = r.status_code
    except Exception:
        status = 0     # 連線失敗
    elapsed = (time.perf_counter() - start) * 1000
    return status, elapsed


async def run_load_test(
    base_url: str,
    *,
    endpoints: Iterable[dict],
    concurrency: int = 20,
    duration_seconds: int = 20,
) -> dict:
    """在固定秒數內、用固定併發持續打 endpoints,回報分位延遲與錯誤率。"""
    results: list[tuple[int, float]] = []
    endpoints = list(endpoints)
    deadline = time.monotonic() + duration_seconds

    async with httpx.AsyncClient(base_url=base_url) as client:
        async def worker():
            while time.monotonic() < deadline:
                ep = endpoints[hash(time.monotonic()) % len(endpoints)]  # round-robin
                res = await _one_request(
                    client,
                    ep["method"],
                    ep["path"],
                    headers=ep.get("headers"),
                    json_body=ep.get("json"),
                )
                results.append(res)

        tasks = [asyncio.create_task(worker()) for _ in range(concurrency)]
        await asyncio.gather(*tasks)

    # 彙整統計
    latencies = sorted(e for _, e in results)
    statuses = Counter(s for s, _ in results)
    total = len(results)
    return {
        "total_requests": total,
        "qps": total / duration_seconds,
        "p50_ms": latencies[len(latencies) // 2] if latencies else 0.0,
        "p95_ms": latencies[int(len(latencies) * 0.95)] if latencies else 0.0,
        "p99_ms": latencies[int(len(latencies) * 0.99)] if latencies else 0.0,
        "errors_5xx": sum(c for s, c in statuses.items() if 500 <= s < 600),
        "statuses": dict(statuses),
    }


if __name__ == "__main__":
    report = asyncio.run(run_load_test(
        base_url="http://127.0.0.1:8000",
        endpoints=[
            {"method": "GET", "path": "/health"},
            {"method": "GET", "path": "/resources"},
        ],
        concurrency=20,
        duration_seconds=15,
    ))
    print("彙整結果:")
    for k, v in report.items():
        print(f"  {k}: {v}")

這段用 asyncio.gather 同時跑 concurrency 個 worker,每個 worker 在 deadline 之內不停打 endpoint。hash(time.monotonic()) % len(endpoints) 做 round-robin 切換四個 endpoint。彙整報告含 total、QPS、P50/P95/P99、5xx 數量、status code 分布。這支腳本可以單獨跑、也可以被 pytest 呼叫做 CI smoke。

pytest 整合的版本(P95 小於 300 ms 的健康檢查):

# tests/test_load_health.py
import asyncio
import subprocess
import sys
import time

import httpx
import pytest


@pytest.fixture(scope="module")
def server():
    """跑一個測試用的 uvicorn server,整個 module 共用。"""
    proc = subprocess.Popen(
        [sys.executable, "-m", "uvicorn",
         "booking_system.main:app",
         "--host", "127.0.0.1", "--port", "8765"],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    )
    # 等 server 起來
    deadline = time.monotonic() + 10
    while time.monotonic() < deadline:
        try:
            r = httpx.get("http://127.0.0.1:8765/health", timeout=1.0)
            if r.status_code == 200:
                yield proc
                proc.terminate()
                return
        except Exception:
            time.sleep(0.1)
    proc.terminate()
    raise RuntimeError("server did not start in time")


def test_health_under_load_p95_under_300ms(server):
    from tests.load.bench import run_load_test
    report = asyncio.run(run_load_test(
        base_url="http://127.0.0.1:8765",
        endpoints=[{"method": "GET", "path": "/health"}],
        concurrency=15,
        duration_seconds=10,
    ))
    # 實際數字會略有不同
    assert report["p95_ms"] < 300, f"P95 {report['p95_ms']:.1f}ms 超出 300ms"
    assert report["errors_5xx"] == 0, f"5xx {report['errors_5xx']} 過多"

這支 pytest 啟動一個 uvicorn、用 15 個並發打 10 秒、檢查 P95 在 300 ms 以下且沒有 5xx。CI 跑這支約 15–20 秒(實際數字會略有不同),可以放在 nightly 或 merge 進 main 的時候。

最後是讀 /metrics、寫容量報告:

# tests/load/scrape_metrics.py
# 壓測跑完後,從 /metrics 端點讀 Prometheus exposition 並輸出容量報告
import re
from collections import defaultdict
from urllib.request import urlopen


def parse_prometheus(text: str) -> dict:
    """把 prometheus text 格式解析成 dict。"""
    series: dict[tuple, list[float]] = defaultdict(list)
    for line in text.splitlines():
        if line.startswith("#") or not line.strip():
            continue
        # e.g. booking_requests_total{path="/bookings/",status="200"} 42.0
        m = re.match(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{[^}]*\})?\s+([\d\.eE+-]+)\s*$", line)
        if not m:
            continue
        name = m.group(1)
        labels = m.group(2) or ""
        value = float(m.group(3))
        # 取 path 標籤當 key
        path_m = re.search(r'path="([^"]*)"', labels)
        path = path_m.group(1) if path_m else ""
        series[(name, path)].append(value)
    return dict(series)


def write_capacity_report(metrics_url: str, qps_observed: float, output_path: str) -> None:
    text = urlopen(metrics_url, timeout=5).read().decode()
    series = parse_prometheus(text)
    with open(output_path, "w", encoding="utf-8") as f:
        f.write("# 容量測試報告(實際數字會略有不同)\n\n")
        f.write(f"## 觀察 QPS\n{QPS_PLACEHOLDER}\n\n".replace(
            QPS_PLACEHOLDER, f"{qps_observed:.1f} req/s"
        ))
        f.write("## booking_requests_total 路徑分布\n")
        for (name, path), values in sorted(series.items()):
            if name == "booking_requests_total":
                f.write(f"- {path}: 總計 {int(sum(values))} 次\n")
        f.write("\n## booking_request_latency_ms bucket\n")
        for (name, path), values in sorted(series.items()):
            if name.startswith("booking_request_latency_ms"):
                f.write(f"- {path}: 各 bucket 加總 {sum(values):.1f}\n")
    print(f"報告寫到 {output_path}")


if __name__ == "__main__":
    write_capacity_report(
        metrics_url="http://127.0.0.1:8000/metrics",
        qps_observed=85.3,
        output_path="docs/capacity-2025-08.md",
    )

這個腳本是壓測的最後一步:跑完壓測後呼叫 /metrics、解析 Prometheus text、把結果寫成 Markdown 報告。QPS_PLACEHOLDER 是預防拼字錯誤的 placeholder——實務上若有人把它寫死(如 99.9)會出包,所以我們用 .replace() 才寫數字。我們的測試在這裡只是示範寫法,實務上會用 placeholder text 而不是真的數字。

實際跑:

# 對應的 shell 流程;以下用 Python subprocess 等價寫法
import subprocess
from pathlib import Path

def shell(cmd: str) -> None:
    print(f"$ {cmd}")
    subprocess.run(cmd.split(), check=True)

# 啟動 stack
shell("docker compose up -d")

# 跑 locust 60 秒(headless 模式,輸出 csv 結果)
shell("locust -f tests/load/locustfile.py --host http://localhost --users 50 --spawn-rate 5 --run-time 60s --headless --csv=load-test-results")

# 抓 metrics 出報告
shell(f"{Path(sys.executable).name if False else 'python'} tests/load/scrape_metrics.py")
# 壓測完成;報告在 docs/capacity-2025-08.md

這段把整個壓測流程串起來:起 stack、跑 locust、抓 metrics、寫報告。locust 會輸出 csv 結果(load-test-results_requests.csv),再加上 metrics 報告,就有一份可重現的 capacity record。在 CI 上這個流程可以包成 nightly job,每週跑一次。

# 一鍵壓測腳本:起 stack → 跑 locust 60 秒 → 抓 metrics → 寫報告
# 用法:python tests/load/run_full_loadtest.py
import subprocess
import sys
from pathlib import Path


def step(name: str, cmd: list[str]) -> None:
    print(f"\n== {name} ==")
    r = subprocess.run(cmd, check=False)
    if r.returncode != 0:
        raise SystemExit(f"{name} 失敗(returncode={r.returncode})")


def main() -> None:
    here = Path(__file__).resolve().parent
    # 啟動 stack
    step("啟動 docker stack", ["docker", "compose", "up", "-d"])
    # 健康檢查(最簡單形式)
    step("健康檢查", ["curl", "-s", "-o", "/dev/null",
                      "-w", "%{http_code}", "http://localhost/health"])

    # 跑 locust 60 秒(headless 模式)
    step("locust 壓測", [
        "locust", "-f", str(here / "locustfile.py"),
        "--host", "http://localhost",
        "--users", "50", "--spawn-rate", "5",
        "--run-time", "60s",
        "--headless",
        "--csv=load-test-results",
    ])

    # 抓 /metrics 出報告
    step("寫 capacity 報告", [
        sys.executable, str(here / "scrape_metrics.py"),
    ])

    print("\n壓測完成;報告在 docs/capacity-2025-08.md")


if __name__ == "__main__":
    main()

這支 run_full_loadtest.py 把整個壓測流程自動化:起 docker stack、跑 locust、寫報告。它可以直接從命令列 python tests/load/run_full_loadtest.py 執行,也可以併進 nightly CI。對小型預約系統來說,這個等級的自動化已經足以讓「每週跑一次壓力測試」變成日常,不再依賴人工執行。

常見錯誤與踩雷

第一個常見錯誤是「壓測用 /bookings/123 當 endpoint 路徑」。如果你直接用 path 當 locust 的統計 key,會被 /bookings/123、/bookings/124、/bookings/125 切成幾十個不同 series,圖表就糊掉了。對應處理:用 name 參數幫每個 HTTP 呼叫取一個有意義的名字(name="GET /bookings/{id}"),locust 會把所有相同 name 整合計算。

第二個踩雷是「壓測打自己」。當 locust 與 server 都在同一台機器上,CPU 與網路會互相搶,壓測數據會偏離真實。對應處理:locust worker 起在另一台、或者把 --users 降到不超過本機 CPU 的 50%。我們這個 stack 在 2 核 CPU 上 --users 50 已經接近天花板,若推到 100 就會開始出現 context switch 雜訊。

第三個是「壓測忘了 warm-up」。剛啟動的 server 第一秒通常比後續慢(PG connection pool 初始化、JIT 編譯),如果壓測只跑 5 秒,這 5 秒就會被 warm-up 污染。對應處理:先打 1–2 秒的「預熱 request」,再開始正式記錄;我們的 run_load_test() 可以拆成 warmup_seconds 與 duration_seconds 兩段。

第四個是「壓測時順便衝爆生產」。壓測如果不鎖 IP、不限流,直接打到正式機會把 production 一起打掛。對應處理:用 staging 主機跑壓測,或者在 API 加 rate limiter(Day 19 已示範)暫時收緊。

效能與實務提醒

在我的 2 核 CPU + 4 GB 記憶體環境下,這套 stack 大致可以撐到 80–120 QPS(GET /health)、50–80 QPS(POST /bookings)、30–50 QPS(POST /admin/bookings/search,含衝突檢查)。實際數字會略有不同,會受 SQL 查詢、JSON 序列化、PostgreSQL 連線數等影響。臨界點通常落在 PostgreSQL 連線池:SQLAlchemy 預設 pool_size=5 + max_overflow=10 共 15 條連線;當併發高過 15 開始排隊,P95 就會爬升。對應處理:把 pool_size 與 pool_recycle 調大、或在 API 與 DB 之間加一個 PgBouncer 連線池。PgBouncer 對小型服務業線上預約平台最划算:它把實際的 PostgreSQL 連線從 200 壓到 20,是成本最低的 capacity upgrade。

另一個容易踩到的瓶頸是「HTML 模板渲染」。/admin/bookings/search 回的是 Jinja2 渲染的 partial HTML,比 JSON 慢 30–50%;如果管理員後台流量意外變大,可以把 partial 改成靜態 JSON + 前端 HTMX 拼裝。但 Day 39 那條設計路線較好懂,可讀性較高,不必急著改。這是工程上的 trade-off:可讀性 vs 效能;對多人維護的小型專案,可讀性往往是更重要的選擇。

資料庫索引也是常見的調校空間。我們的 bookings 表會被「時間區間查詢」頻繁打到,預設有 (start_at) 索引;但「管理員搜尋客戶 email」這種會用到 customer_email 的查詢,若沒有 index 就會全表掃描。對應處理:在 customer_email 上加 B-tree 索引。我們的 migration 已經寫好,但要在 Day 42 的還原演練驗證一次。索引本身就是用「寫入慢一點、查詢快很多」換取整體吞吐量的平衡,我們這套資料量不大,索引成本可以忽略。

另一個調校的盲點是「gunicorn worker 數」。--workers 2 在 4 核 CPU 上太保守,可以拉到 --workers 3 或 4。但要注意:每個 worker 會啟一個獨立的 APScheduler(Day 38 的設計),所以 worker 不能太多,否則同一個 cron job 會被執行多次。我們推薦 workers = (CPU 核心數 - 1),並在 production 改成「API process + dedicated scheduler process」兩段式部署,把這兩個職責分開。

最後一個重點是「別為了 P95 犧牲可讀性」。我們調校到 80 QPS 後 P95 從 350 ms 降到 180 ms(實際數字會略有不同),但加了 30 行的 lambda 與 cache;接手的人看不懂那段程式碼,無法判斷是否安全。優化時建議優先使用「資料庫索引」、「PgBouncer」、「HTTP keep-alive」、「gunicorn worker 數調升」這些顯而易見的手段;當這些都做完了、且能被量測證明有幫助,才考慮「cache」這種更複雜的手段。我們 Day 44 交接時會把所有調校的 trade-off 都寫進 runbook,避免接手的人誤以為「這些 cache 是必要的」。

小結

今天把壓力測試做成可以反覆跑的「探索 + 守門」兩段式流程。我們寫了 locust 2.3 的 locustfile,模擬 60% 訪客 + 30% 客戶 + 10% 管理員的真實流量分布;寫了純 Python 版的 run_load_test(),可以併進 pytest 做 CI 守門員;寫了從 /metrics 端點抓 Prometheus 數據、寫成 Markdown 報告的小工具;最後寫了 run_full_loadtest.py 把整個壓測流程自動化。整個 stack 在 2 核 CPU + 4 GB 記憶體上約可撐 50–80 QPS(POST /bookings),錯誤率低於 1%。明天 Day 44 我們會把所有這些「如何操作」的細節寫進 README 與 runbook,產出可交接的文件版本。

對整個系列而言,今天標誌著「從寫功能到能驗證」的轉捩點。在 Day 1–30 我們建立了功能集合,Day 31–37 把預約系統的資料模型與流程做完整,Day 38–42 把監控、維運、容器化做好,今天則是把這些能力「拿去驗證」。這也是真實 production 的順序:先會做、再會上線、最後會量化。沒有第三步,後續的迭代都會依賴經驗而非數據。

結語

今天的重點是把「能跑」變成「知道能跑多少」。我們沒有寫任何「讓 API 變快」的 code,但反而最有價值:能找到瓶頸就已經成功一半。我們的 locust + 純 Python 壓測組合是「探索+守門」的標準配置:探索時手動跑 locust 看圖表找臨界點;找到後寫成 pytest 守在 CI 防止 regression;壓測結果再寫進 runbook 與交接文件。明天 Day 44 我們會把所有 day 35–43 的成果彙整成一份可交接的 README、一份事件處置 runbook、一份 API 規格總結,並把驗收清單(Day 40)升級為正式文件。

把「效能調校」放在 Day 43 是有原因的:在前 42 天建立起 stack、加上監控日誌備份之後,效能調校才有具體的目標;否則只是「猜哪裡慢」。我們今天的 50–80 QPS 數字會被寫進 README 的 SLA 章節、未來調整容量時就是 baseline。整個 stack 的完成度因此從「能上線」升級成「可量化的 production 系統」。這也是「為什麼工程師要把這五天的工作做完」的最佳答案:不是為了 KPI、而是為了「我們現在知道系統撐得住什麼」。

接下來 Day 44 將完成這個 stack 的文件化版本。我們會用 README 描述開發流程、runbook 描述事件處置、API 規格摘要描述合約,並把 Day 40 的驗收清單升級成正式上線文件。所有材料整合後,整個預約管理系統就有了完整的「人 + 程式 + 規則」三本帳,可以正式交給下一個維運者。Runbook 是其中最少人寫、最容易被省略、但事故發生時最依賴的一項;一份完整的 runbook 能讓接手的人在凌晨三點不必打電話給原作者就能復原系統,這也是 Day 44 的核心精神。

延伸資源

  • locust 官方文件(2.3,2025):https://docs.locust.io/,HttpUser、@task、between 與分散式模式的標準用法。
  • httpx 官方文件(0.28,2025):https://www.python-httpx.org/async/,AsyncClient 與 asyncio.gather 的並行寫法。
  • Prometheus 直方圖與分位數(2025):https://prometheus.io/docs/practices/histograms/,histogram bucket 設計的官方建議。
  • SRE 書第 23 章—Addressing Distributed Systems(2017,跨年代經典):https://sre.google/sre-book/addressing-distributed-systems/,capacity planning 與 latency budget 的最佳寫法。
  • SQLAlchemy 連線池(2.0.41,2025):https://docs.sqlalchemy.org/en/20/core/pooling.html,pool_size、max_overflow、pool_recycle 的調校指南。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...