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的調校指南。
留言
張貼留言