data-scale · Stage 3

負荷曲線と実測profileから安全容量を判断する

simulated負荷と実際のlocal profileを混同せず、tail、queue、回復、headroomから安全な容量判断を組み立てる。

学習時間
320分
難易度
advanced
更新日
2026-07-30
到達証拠
成果物・説明・判断根拠・転用

到達目標

  1. offered、accepted、success、tail latency、resource、queueを同じ負荷曲線で比較できる

    • ボトルネック証拠、負荷曲線、容量限界を含む性能報告
    • plateau、tail、error、queue、回復、profileから次の測定を選ぶ回答
  2. simulated capacity evidenceと実環境で採取したprofileを区別し、bottleneck仮説を反証できる

    • ボトルネック証拠、負荷曲線、容量限界を含む性能報告
    • CPU使用率、最大RPS、平均latencyだけでは安全容量を決められない理由の5分説明
  3. Littleの式の適用条件、回復hysteresis、headroomを明示して安全容量を更新できる

    • plateau、tail、error、queue、回復、profileから次の測定を選ぶ回答
    • request mixを変えてbottleneckと安全容量を再測定した比較

能力の進行

  1. recognize

    offered load、throughput、percentile、utilization、queue、headroomを区別できる

    証拠: ボトルネック証拠、負荷曲線、容量限界を含む性能報告

  2. explain

    最大RPSと安全容量、profile上位関数と根本原因が同義でない理由を説明できる

    証拠: CPU使用率、最大RPS、平均latencyだけでは安全容量を決められない理由の5分説明

  3. apply

    低負荷から超過と回復までの曲線を作り、Littleの式を定常区間だけで照合できる

    証拠: ボトルネック証拠、負荷曲線、容量限界を含む性能報告

  4. diagnose

    tail増大、error、queue、downstream、profileを組み合わせてbottleneck仮説を絞れる

    証拠: plateau、tail、error、queue、回復、profileから次の測定を選ぶ回答

  5. lead

    request mixと成長予測を更新し、headroomと再測定条件を合意できる

    証拠: request mixを変えてbottleneckと安全容量を再測定した比較

なぜ重要か

性能は「速いコード」を探す作業ではなく、利用者が送る負荷、受理できた仕事、成功した仕事、待ち行列、依存先、resource、回復を同じ時間軸で結ぶ判断である。平均latencyやCPU使用率だけでは、tailで待たされる利用者、dropされたrequest、負荷を下げても残るqueueを見落とす。

最大RPSは観測した一点であり、安全容量ではない。通常のrequest mix、成長、障害時の縮退、測定誤差を含むheadroomを置き、どの観測で再評価するかを決めて初めて運用上の容量になる。

メンタルモデル

性能判断を、需要、受理、完了、待機、resource、依存、利用者結果のflowとして扱う。このfixtureは有限queue 30 requestを使い、request countの保存則Qnext=max(0,Qprev+admitted-completed)を各点で検査する。Δt=1 sでRPSをrequest countへ変換してから保存則へ入れる。acceptedはその区間に到着して即時処理または有限queueへadmittedされた仕事、successはその区間に既存backlogを含めてcompletedした仕事を表すため、回復区間ではsuccessがacceptedを上回り得る。

平均同時実行数L、平均到着または完了率λ、平均滞在時間Wの関係L = λWは、単位と観測窓を揃えた定常区間の整合確認に使う。式が合うことは、個別requestのtailや将来の容量を保証しない。

負荷を証拠へ変えて安全容量を更新する循環

注記

図を読む際の補足情報です。

  1. この注記は旧図の読み順を保持する補助です。
  2. Fixture: request mix、payload、依存、処理上限、環境を固定する。
  3. Curve: 低負荷、限界付近、超過、回復を同じ列で記録する。
  4. Diagnose: plateau、tail、error、queue、resource、downstreamを結ぶ。
  5. Profile: 仮説に合う実processの局所証拠を別に採取する。
  6. Capacity: observed kneeからheadroomを引き、再測定条件を残す。

固定した負荷条件から、どの証拠を経て安全容量を決めるか。

原因
Fixture(負荷条件)
request mix、payload、依存、処理上限、環境を固定する。
機構
Bottleneck mechanism
resource上限、queue蓄積、downstream待機が処理率を制限し、tail latencyとerrorを増幅する。
結果
Curve
低負荷、限界付近、超過、回復を同じ列で記録する。
Profile
仮説に合う実processの局所証拠を別に採取する。
対策
Capacity
observed kneeからheadroomを引き、再測定条件を残す。
  • Fixture(負荷条件) → Bottleneck mechanism: 固定条件でresource、queue、downstreamの飽和を起こす
  • Bottleneck mechanism → Curve: plateau、tail、error、queueとして現れる
  • Bottleneck mechanism → Profile: 仮説に合う局所証拠を採る
  • Curve → Capacity: observed kneeからheadroomを引く
  • Profile → Capacity: 局所証拠を再測定条件へ反映する

負荷曲線のknee、tail、queue、局所profileを結び、headroomを引いた容量と再測定条件を示せる。

パラメータと選択肢
パラメータ選択肢既定値
負荷帯低負荷、限界付近、超過、負荷を戻した回復区間low
worker処理枠とrequest mixread 80%・capacity 100 RPS、write 70%・capacity 80 RPSbaseline
  1. baseline低負荷: Δt=1s;Qstart=0req;arrivals=40req;admitted=40req;immediate=40req;backlogDone=0req;completed=40req;Qend=0req;rejected=0req;failed=0req;p99=70ms.; 条件 load-band=lowworkers=baseline; node fixturecurve; edge fixture-exposes-mechanismmechanism-shapes-curve
  2. baseline限界付近: Δt=1s;Qstart=0req;arrivals=100req;admitted=100req;immediate=100req;backlogDone=0req;completed=100req;Qend=0req;rejected=0req;failed=0req;p99=100ms.; 条件 load-band=nearworkers=baseline; node curvecapacity; edge curve-bounds-capacity
  3. baseline飽和: Δt=1s;Qstart=0req;arrivals=150req;admitted=130req;immediate=100req;backlogDone=0req;completed=100req;Qend=30req;rejected=20req;failed=20req;p99=450ms.; 条件 load-band=overloadworkers=baseline; node bottleneck-mechanismcurvecapacity; edge mechanism-shapes-curvecurve-bounds-capacity
  4. baseline回復hysteresis: Δt=1s;Qstart=30req;arrivals=50req;admitted=50req;immediate=50req;backlogDone=30req;completed=80req;Qend=0req;rejected=0req;failed=0req;p99=180ms.; 条件 load-band=recoveryworkers=baseline; node curvecapacity; edge curve-bounds-capacity
  5. write中心低負荷: Δt=1s;Qstart=0req;arrivals=40req;admitted=40req;immediate=40req;backlogDone=0req;completed=40req;Qend=0req;rejected=0req;failed=0req;p99=90ms.; 条件 load-band=lowworkers=write; node fixturecurve; edge fixture-exposes-mechanismmechanism-shapes-curve
  6. write中心限界付近: Δt=1s;Qstart=0req;arrivals=80req;admitted=80req;immediate=80req;backlogDone=0req;completed=80req;Qend=0req;rejected=0req;failed=0req;p99=190ms.; 条件 load-band=nearworkers=write; node curvecapacity; edge curve-bounds-capacity
  7. write中心飽和: Δt=1s;Qstart=0req;arrivals=120req;admitted=110req;immediate=80req;backlogDone=0req;completed=80req;Qend=30req;rejected=10req;failed=10req;p99=900ms.; 条件 load-band=overloadworkers=write; node bottleneck-mechanismcurvecapacity; edge mechanism-shapes-curvecurve-bounds-capacity
  8. write中心回復hysteresis: Δt=1s;Qstart=30req;arrivals=50req;admitted=50req;immediate=50req;backlogDone=30req;completed=80req;Qend=0req;rejected=0req;failed=0req;p99=360ms.; 条件 load-band=recoveryworkers=write; node curvecapacity; edge curve-bounds-capacity
完全な遷移
イベント開始終了条件
観測結果
結果状態
baseline 40 arrivalsを全件admit・completeし、Qend=0で保存則が成立する。stable-load
baseline 100 arrivalsを全件即時completeするkneeで、safe capacityは20% headroom後の80 RPS。near-capacity
baseline超過では130 admit、100 complete、Qend=30、20 reject/failedとなる。saturation
baseline回復は30 backlogをdrainしてQend=0に戻すが、p99=180 msのhysteresisが残る。capacity-recovered
write中心40 arrivalsを全件admit・completeし、Qend=0で保存則が成立する。write-stable-load
write中心80 arrivalsを全件即時completeするkneeで、safe capacityは20% headroom後の64 RPS。write-near-capacity
write中心超過では110 admit、80 complete、Qend=30、10 reject/failedとなる。write-saturation
write中心回復は30 backlogをdrainしてQend=0に戻すが、p99=360 msのhysteresisが残る。write-capacity-recovered

現在の状態: baseline低負荷 — Δt=1s;Qstart=0req;arrivals=40req;admitted=40req;immediate=40req;backlogDone=0req;completed=40req;Qend=0req;rejected=0req;failed=0req;p99=70ms.

このモデルは例示的かつ決定的であり、実システムの完全な再現ではありません。

動く例で考える

注文APIのsimulated load curveとactual local profileを分ける

前提
production計測ではない固定simulationを使う。read比率80%とwrite比率70%の二つのrequest mixへ、低負荷、限界付近、超過、回復のload pointを与える。各load pointはΔt=1 sで、queue上限は30 requestとする。各点のmeanはbounded latency sampleの算術平均、active concurrencyはLittleの式から作らない独立synthetic入力とする。profileだけは、この場のCPython 3.13 processで実際に採取する。cProfile/pstatsはPython 3.13標準libraryを対象とし、version固定のないGo Diagnosticsは2026-07-31確認時点の範囲として扱う。
入力
baselineは40、100、150、50 RPS、write中心transferは40、80、120、50 RPSの順で負荷を変える。各点に7件のlatency sampleを固定し、最後の点は超過後の回復区間とする。
操作
区間開始queue、arrivals、admitted、immediate work、backlog completed、completed、区間終了queue、rejected/failedを同じrequest単位で計算する。FIFOの既存backlogを先に処理し、残りcapacityで新規到着を即時処理し、overflowは30 requestまでadmitする。throughput plateau、tail growth、reject発生が初めて同時成立する点の直前をcurve上のkneeとし、20%のheadroomを引く。
観測
baseline curveは100 RPS、write中心curveは80 RPSが違反点直前のkneeとなる。超過区間のqueueは30 requestで上限に達し、回復区間で30 requestをdrainしてQend=0となる一方、p99のhysteresisは残る。両方のsafe capacityは、それぞれのcurveから導いたkneeへ同じ20% headroomを適用して80 RPSと64 RPSになる。
結論
安全容量は最大値ではなく、tailとerrorが急増する前のkneeから運用余力を引いた値である。simulationの数値は教材の因果を検査するfixtureであり、このserviceのproduction性能を表さない。
python3.13 - <<'PY'
import cProfile
import json
import math
import pstats

HARNESS = "performance_capacity_lab_v1"
CAPACITY_RPS = 100
INTERVAL_SECONDS = 1
QUEUE_LIMIT_REQUESTS = 30
HEADROOM_FRACTION = 0.2
KNEE_CRITERIA = [
    "throughput-plateau",
    "tail-growth",
    "error-growth",
]

BASELINE_POINT_INPUTS = [
    {
        "stage": "low",
        "offered_rps": 40,
        "observed_active_concurrency": 2.0,
        "latency_samples_ms": [30, 34, 36, 40, 45, 55, 70],
    },
    {
        "stage": "near-limit",
        "offered_rps": 100,
        "observed_active_concurrency": 6.2,
        "latency_samples_ms": [40, 45, 50, 55, 60, 75, 100],
    },
    {
        "stage": "overload",
        "offered_rps": 150,
        "observed_active_concurrency": 12.4,
        "latency_samples_ms": [80, 100, 120, 160, 220, 300, 450],
    },
    {
        "stage": "recovery",
        "offered_rps": 50,
        "observed_active_concurrency": 4.6,
        "latency_samples_ms": [45, 50, 60, 75, 95, 130, 180],
    },
]

TRANSFER_POINT_INPUTS = [
    {
        "stage": "low",
        "offered_rps": 40,
        "observed_active_concurrency": 2.4,
        "latency_samples_ms": [35, 40, 45, 50, 60, 75, 90],
    },
    {
        "stage": "near-limit",
        "offered_rps": 80,
        "observed_active_concurrency": 7.0,
        "latency_samples_ms": [55, 65, 75, 85, 110, 145, 190],
    },
    {
        "stage": "overload",
        "offered_rps": 120,
        "observed_active_concurrency": 15.0,
        "latency_samples_ms": [120, 160, 220, 300, 420, 600, 900],
    },
    {
        "stage": "recovery",
        "offered_rps": 50,
        "observed_active_concurrency": 6.8,
        "latency_samples_ms": [75, 90, 110, 140, 190, 260, 360],
    },
]


def nearest_rank(samples, quantile):
    ordered = sorted(samples)
    index = math.ceil(quantile * len(ordered)) - 1
    return ordered[max(0, min(index, len(ordered) - 1))]

def simulate(
    stage,
    offered_rps,
    capacity,
    previous_queue,
    write_fraction,
    observed_active_concurrency,
    latency_samples_ms,
):
    interval_seconds = INTERVAL_SECONDS
    queue_start_requests = previous_queue
    arrivals_requests = offered_rps * interval_seconds
    service_budget_requests = capacity * interval_seconds
    # Existing FIFO backlog consumes service capacity first. This ordering makes
    # recovery drain explicit and keeps every request in one conservation ledger.
    backlog_completed_requests = min(
        queue_start_requests,
        service_budget_requests,
    )
    remaining_service_requests = (
        service_budget_requests - backlog_completed_requests
    )
    immediate_work_requests = min(
        arrivals_requests,
        remaining_service_requests,
    )
    arrival_overflow_requests = arrivals_requests - immediate_work_requests
    remaining_backlog_requests = (
        queue_start_requests - backlog_completed_requests
    )
    queue_space_requests = QUEUE_LIMIT_REQUESTS - remaining_backlog_requests
    queued_current_requests = min(
        arrival_overflow_requests,
        queue_space_requests,
    )
    admitted_requests = immediate_work_requests + queued_current_requests
    completed_requests = immediate_work_requests + backlog_completed_requests
    queue_end_requests = remaining_backlog_requests + queued_current_requests
    rejected_requests = arrivals_requests - admitted_requests
    failed_requests = rejected_requests
    assert queue_end_requests == max(
        0,
        queue_start_requests + admitted_requests - completed_requests,
    )
    assert arrivals_requests == admitted_requests + rejected_requests
    accepted_rps = admitted_requests / interval_seconds
    success_rps = completed_requests / interval_seconds
    samples = list(latency_samples_ms)
    # Bounded samples keep this local harness deterministic and cheap. The
    # arithmetic mean intentionally has a separate derivation from percentiles
    # so a plausible-looking p50/p95 midpoint cannot masquerade as observation.
    assert 5 <= len(samples) <= 20
    assert all(0 < sample <= 10_000 for sample in samples)
    mean_ms = sum(samples) / len(samples)
    p50_ms = nearest_rank(samples, 0.50)
    p95_ms = nearest_rank(samples, 0.95)
    p99_ms = nearest_rank(samples, 0.99)
    return {
        "stage": stage,
        "interval_seconds": interval_seconds,
        "queue_start_requests": queue_start_requests,
        "arrivals_requests": arrivals_requests,
        "admitted_requests": admitted_requests,
        "immediate_work_requests": immediate_work_requests,
        "backlog_completed_requests": backlog_completed_requests,
        "completed_requests": completed_requests,
        "queue_end_requests": queue_end_requests,
        "rejected_requests": rejected_requests,
        "failed_requests": failed_requests,
        "offered_rps": offered_rps,
        "accepted_rps": accepted_rps,
        "success_rps": success_rps,
        "p50_ms": p50_ms,
        "p95_ms": p95_ms,
        "p99_ms": p99_ms,
        "mean_ms": mean_ms,
        "mean_source": "bounded-latency-samples-arithmetic-mean",
        "latency_samples_ms": samples,
        "observed_active_concurrency": observed_active_concurrency,
        "cpu_percent": min(99.0, 20.0 + success_rps * 0.65),
        "memory_mb": 128 + queue_end_requests * 2,
        "queue_depth": queue_end_requests,
        "downstream_ms": 15 + int(write_fraction * 50) + queue_end_requests // 2,
    }

def safe_capacity(observed_knee_rps, headroom_fraction):
    return int(observed_knee_rps * (1 - headroom_fraction))

def processing_model_for_mix(write_fraction):
    # Request mix changes the simulated processing envelope. The reported knee
    # is not taken from this branch: detect_knee derives it from the resulting
    # curve so the decision remains inspectable and mutation-sensitive.
    if write_fraction > 0.5:
        return {"bottleneck": "storage-write", "capacity_rps": 80}
    return {"bottleneck": "cpu-transform", "capacity_rps": CAPACITY_RPS}


def build_load_curve(point_inputs, processing_capacity_rps, write_fraction):
    curve = []
    previous_queue = 0
    for point_input in point_inputs:
        point = simulate(
            point_input["stage"],
            point_input["offered_rps"],
            processing_capacity_rps,
            previous_queue,
            write_fraction,
            point_input["observed_active_concurrency"],
            point_input["latency_samples_ms"],
        )
        curve.append(point)
        previous_queue = point["queue_depth"]
    return curve


def detect_knee(load_curve):
    # Capacity is the last passing offered-load point before all three failure
    # signals first coincide, never a constant copied from the simulator input.
    for previous, current in zip(load_curve, load_curve[1:]):
        criteria = {
            "throughput-plateau": (
                current["success_rps"] <= previous["success_rps"]
            ),
            "tail-growth": current["p99_ms"] > previous["p99_ms"],
            "error-growth": current["rejected_requests"] > 0,
        }
        if all(criteria.values()):
            return {
                "observed_knee_rps": previous["offered_rps"],
                "first_violation_stage": current["stage"],
                "criteria": criteria,
            }
    raise AssertionError("load-curve-causal-invariant: no measured knee")


def profile_target(values):
    total = 0
    for value in values:
        for multiplier in (1, 3, 5, 7):
            total += (value * multiplier) % 97
    return total


def main():
    baseline_write_fraction = 0.2
    transfer_write_fraction = 0.7
    baseline_model = processing_model_for_mix(baseline_write_fraction)
    transfer_model = processing_model_for_mix(transfer_write_fraction)
    curve = build_load_curve(
        BASELINE_POINT_INPUTS,
        baseline_model["capacity_rps"],
        baseline_write_fraction,
    )
    transfer_curve = build_load_curve(
        TRANSFER_POINT_INPUTS,
        transfer_model["capacity_rps"],
        transfer_write_fraction,
    )

    # This boundary is separate from the authored eight scenario states. With
    # total work above capacity, it proves FIFO backlog receives service before
    # current arrivals; aggregate Qend alone cannot distinguish arrival-first.
    fifo_boundary_point = simulate(
        "fifo-backlog-first-boundary",
        90,
        CAPACITY_RPS,
        30,
        baseline_write_fraction,
        10.0,
        [40, 45, 50, 55, 60, 75, 100],
    )
    fifo_backlog_first_boundary = {
        "interval_seconds": fifo_boundary_point["interval_seconds"],
        "queue_start_requests": fifo_boundary_point["queue_start_requests"],
        "arrivals_requests": fifo_boundary_point["arrivals_requests"],
        "service_capacity_requests": CAPACITY_RPS * INTERVAL_SECONDS,
        "queue_limit_requests": QUEUE_LIMIT_REQUESTS,
        "backlog_completed_requests": fifo_boundary_point[
            "backlog_completed_requests"
        ],
        "immediate_work_requests": fifo_boundary_point[
            "immediate_work_requests"
        ],
        "admitted_requests": fifo_boundary_point["admitted_requests"],
        "completed_requests": fifo_boundary_point["completed_requests"],
        "queue_end_requests": fifo_boundary_point["queue_end_requests"],
        "rejected_requests": fifo_boundary_point["rejected_requests"],
        "failed_requests": fifo_boundary_point["failed_requests"],
    }
    assert fifo_backlog_first_boundary == {
        "interval_seconds": 1,
        "queue_start_requests": 30,
        "arrivals_requests": 90,
        "service_capacity_requests": 100,
        "queue_limit_requests": 30,
        "backlog_completed_requests": 30,
        "immediate_work_requests": 70,
        "admitted_requests": 90,
        "completed_requests": 100,
        "queue_end_requests": 20,
        "rejected_requests": 0,
        "failed_requests": 0,
    }

    by_stage = {point["stage"]: point for point in curve}
    assert by_stage["overload"]["immediate_work_requests"] == CAPACITY_RPS
    assert by_stage["overload"]["queue_depth"] > 0
    assert by_stage["recovery"]["p99_ms"] > by_stage["low"]["p99_ms"]

    little_source = by_stage["near-limit"]
    throughput_per_second = little_source["success_rps"]
    mean_response_seconds = little_source["mean_ms"] / 1_000
    observed_concurrency = little_source["observed_active_concurrency"]
    calculated_concurrency = (
        throughput_per_second * mean_response_seconds
    )
    relative_error = abs(
        observed_concurrency - calculated_concurrency
    ) / observed_concurrency

    profiler = cProfile.Profile()
    profiler.enable()
    profile_result = profile_target(range(400))
    profiler.disable()
    stats = pstats.Stats(profiler)
    assert profile_result > 0
    measured_entries = [
        {
            "function": function_key[2],
            "primitive_calls": values[0],
            "total_calls": values[1],
            "cumulative_seconds": values[3],
        }
        for function_key, values in stats.stats.items()
    ]
    measured_top = max(
        measured_entries,
        key=lambda entry: (
            entry["cumulative_seconds"],
            entry["total_calls"],
            entry["function"],
        ),
    )

    baseline_knee_evidence = detect_knee(curve)
    transfer_knee_evidence = detect_knee(transfer_curve)
    baseline_knee = baseline_knee_evidence["observed_knee_rps"]
    mutated_knee = transfer_knee_evidence["observed_knee_rps"]
    baseline_safe_capacity = safe_capacity(
        baseline_knee,
        HEADROOM_FRACTION,
    )
    mutated_safe_capacity = safe_capacity(
        mutated_knee,
        HEADROOM_FRACTION,
    )
    request_mix_mutation = {
        "changed_assumption": "request-mix",
        "baseline_request_mix": {
            "read_fraction": 1 - baseline_write_fraction,
            "write_fraction": baseline_write_fraction,
        },
        "mutated_request_mix": {
            "read_fraction": 1 - transfer_write_fraction,
            "write_fraction": transfer_write_fraction,
        },
        "baseline_bottleneck": baseline_model["bottleneck"],
        "mutated_bottleneck": transfer_model["bottleneck"],
        "baseline_observed_knee_rps": baseline_knee,
        "mutated_observed_knee_rps": mutated_knee,
        "baseline_safe_capacity_rps": baseline_safe_capacity,
        "mutated_safe_capacity_rps": mutated_safe_capacity,
        "mutated_load_curve": transfer_curve,
        "baseline_knee_evidence": baseline_knee_evidence,
        "mutated_knee_evidence": transfer_knee_evidence,
        "knee_criteria": KNEE_CRITERIA,
        "recomputed": (
            baseline_model["bottleneck"] != transfer_model["bottleneck"]
            and baseline_knee != mutated_knee
            and baseline_safe_capacity != mutated_safe_capacity
        ),
    }
    assert request_mix_mutation["recomputed"]

    report = {
        "harness": HARNESS,
        "fixture": "order-api-load-v1",
        "simulation_metadata": {
            "kind": "simulated",
            "provenance": "lesson-defined deterministic capacity model",
            "purpose": "負荷変化と容量判断の因果を反証する",
            "limitations": (
                "production latency、hardware、network、vendor性能を表さない"
            ),
        },
        "load_curve": curve,
        "fifo_backlog_first_boundary": fifo_backlog_first_boundary,
        "curve_analysis": {
            "throughput_plateau": (
                by_stage["near-limit"]["success_rps"]
                == by_stage["overload"]["success_rps"]
            ),
            "tail_growth": (
                by_stage["overload"]["p99_ms"]
                > by_stage["near-limit"]["p99_ms"]
            ),
            "error_growth": by_stage["overload"]["rejected_requests"] > 0,
            "recovery_hysteresis": (
                by_stage["recovery"]["p99_ms"]
                > by_stage["low"]["p99_ms"]
            ),
        },
        "capacity": {
            "observed_knee_rps": baseline_knee,
            "safe_capacity_rps": baseline_safe_capacity,
            "headroom_fraction": HEADROOM_FRACTION,
            "knee_evidence": baseline_knee_evidence,
            "decision": "80 RPSを通常運用上限として再測定する",
        },
        "little_law": {
            "scope": "near-limit steady-state average only",
            "source_stage": little_source["stage"],
            "throughput_per_second": throughput_per_second,
            "mean_response_seconds": mean_response_seconds,
            "mean_response_source": little_source["mean_source"],
            "observed_concurrency": observed_concurrency,
            "observed_concurrency_source": (
                "independent-simulated-fixture-input"
            ),
            "calculated_concurrency": calculated_concurrency,
            "relative_error": relative_error,
        },
        "local_profile": {
            "evidence_kind": "actual-local-measurement",
            "profiler": "cProfile",
            "declared_target": profile_target.__name__,
            "total_calls": stats.total_calls,
            "measured_top_function": measured_top["function"],
            "measured_functions": sorted(
                {entry["function"] for entry in measured_entries}
            ),
            "measurement_derived": True,
            "tool_scope": {
                "python": "3.13",
                "profiler": "cProfile/pstats standard library",
                "go_docs": "rolling; reviewed 2026-07-31",
            },
            "environment_limitations": (
                "このCPython processのbounded fixtureだけを測り、"
                "production bottleneckを主張しない"
            ),
        },
        "request_mix_mutation": request_mix_mutation,
        "runtime_bound": {
            "iterations": 1656,
            "subprocesses": 0,
        },
        "mastery_evidence": {
            "lab_steps": [
                {"step": 1, "evidence": "simulation metadataとrequest mix"},
                {"step": 2, "evidence": "4段階load_curveの全観測列"},
                {"step": 3, "evidence": "curve_analysisとcapacity"},
                {"step": 4, "evidence": "Little照合とcProfile"},
                {"step": 5, "evidence": "request_mix_mutation"},
            ],
            "assessments": [
                {"assessment": 1, "evidence": "queueとdownstreamを含む診断"},
                {"assessment": 2, "evidence": "Littleの式のscopeと誤差"},
            ],
            "rubric_dimensions": [
                "technical-correctness",
                "judgment",
                "evidence",
                "communication",
            ],
            "transfer": {
                "task": (
                    "参照中心から書込み中心へ変わるrequest mixで"
                    "bottleneckと安全容量を再測定する"
                ),
                "changed_assumption": "request-mix",
                "evidence": "bottleneckとsafe capacityの再計算",
            },
        },
        "external_network_used": False,
    }
    assert all(report["curve_analysis"].values())
    assert baseline_knee in {point["offered_rps"] for point in curve}
    assert mutated_knee in {
        point["offered_rps"] for point in transfer_curve
    }
    assert 0 < report["little_law"]["relative_error"] <= 0.05
    return report

print(json.dumps(main(), ensure_ascii=False, indent=2))
PY
負荷帯ごとの観測値とcapacity判断の比較

注記

図を読む際の補足情報です。

  1. 値はlesson内のbaseline synthetic fixtureであり、本番環境の保証値ではない。
  2. offered loadだけでなく、completed、queue、rejected、p99を同じ行で読む。
  3. recoveredはqueueが0でもp99が低負荷値へ即時には戻らないhysteresisを示す。

同じbaseline fixtureで負荷帯を変えたとき、latencyとqueueのどの変化をcapacity判断へ使うか。

選択肢の比較
項目load / throughputoffered、admitted、completed、rejectedを分ける。p99 latency平均値では隠れるtailと回復遅延を見る。queue区間終了時の未処理件数で飽和とdrainを確認する。capacity判断knee、headroom、再測定条件を明記する。
stable40 RPSの低負荷でqueueを作らず完了する基準点。40 offered / 40 completed / 0 rejectedp99 70 msQend 0低負荷の基準点。容量上限とはみなさない
saturation150 RPSを投入し、処理上限を超えてqueueとrejectが生じる。150 offered / 100 completed / 20 rejectedp99 450 msQend 30knee 100 RPSを超過。safe capacityはheadroom 20%後の80 RPS
recovered負荷を50 RPSへ戻し、30件のbacklogをdrainした直後。50 offered / 80 completed / 0 rejectedp99 180 msQstart 30 → Qend 0queue回復だけで完了にせずtailの再測定を続ける

stable、saturation、recoveredを横並びにし、回復後も残るtail latencyを含めてobserved kneeからheadroomを引く理由を説明できる。

トレードオフと失敗モード

性能証拠と次の行動を選ぶ decision table
観測 次の行動 代償 停止条件
CPU上昇とthroughput plateauが同時 CPU profileと実行経路を採取する profile overheadと環境差が入る 別resourceまたは負荷生成側が先に飽和した時
CPU余裕があるがqueueとdownstreamが増加 依存先、pool、lock、I/O待機を分解する cross-systemの相関計測が増える offeredとacceptedの差が負荷生成側で生じた時
超過後に負荷を戻してもtailが残る queue drainを容量とrunbookへ含める 平常時の利用率を下げる 回復時間が利用者目標内で安定した時
  • 誤診: CPU profileの最大関数を最適化すればsystem bottleneckは必ず消える。反証: 待機、lock、downstream、負荷生成器の限界はCPU profileだけでは見えず、変更後のend-to-end曲線が必要である。
  • 誤診: 一度達成した最大RPSがproductionの安全容量である。反証: request mix、tail、error、queue、障害時縮退、成長とheadroomを含まず、回復可能性も示していない。
  • 失敗モード: simulationをmeasured production値として共有すると、hardwareと依存先の差を隠した架空の容量計画になる。
  • 失敗モード: 平均latencyだけでは少数の遅いrequestとretry増幅を隠すため、p95とp99、goodputを併記する。

知識チェック

  1. offered、accepted、success throughputを分けると何を診断できるか。
  2. Littleの式を回復区間へそのまま適用できない理由を説明せよ。
  3. CPU使用率が低いままp99が増える時の候補と追加証拠を三つ挙げよ。
  4. request mix変更を容量計画の再評価条件へどう組み込むか。

出典と次の学習

profileとtraceの選択はGo Diagnostics、実行したcProfile/pstatsの意味はPython 3.13のThe Python Profilers、overload制御はGoogle SRE Chapter 21を一次資料として照合する。Goの文書はrolling URLのため2026-07-31に再確認した範囲である。平均queueの整合にはLittleの原論文、tailのsystem効果にはThe Tail at Scaleを使う。いずれも特定fixtureの最大RPSを保証する資料ではない。

次のcore-15では、容量曲線を利用者journeyのgood event、SLO、error budget、multi-window alertへ接続し、性能劣化をpageとticketへ分ける。

実践ラボ

注文APIの負荷曲線とlocal profileを分離して検証する

提出成果物: ボトルネック証拠、負荷曲線、容量限界を含む性能報告

  1. read中心の固定request mixと処理容量をsimulated fixtureとして明示する
  2. 低負荷、限界付近、超過、回復のoffered、accepted、success、p50、p95、p99、resource、queue、downstreamを計算する
  3. throughput plateau、tailとerrorの増加、回復hysteresisからobserved kneeとheadroom付き安全容量を求める
  4. 定常区間だけでLittleの式を照合し、stdlib cProfileによるactual local measurementを別の証拠として採取する
  5. write中心へrequest mixを変え、bottleneckと安全容量が再計算されることをmutationで検証する

説明して理解を確かめる

5分で、CPUが最大の関数が常にbottleneckではない理由、Littleの式が定常平均を前提にする理由、最大RPSを安全容量にできない理由を説明する。

アセスメント

  1. 問い: throughputは横ばいだがCPUは70%でp99とqueueが増えた。何をbottleneck候補にし、どの証拠を追加するか。

    期待する証拠: offeredとacceptedの差、downstream latency、queue、tail、memory、profile、負荷生成側限界を分離する診断

  2. 問い: 平均latencyからLittleの式が合ったため超過負荷でも容量予測は正しい、という主張を評価する。

    期待する証拠: 定常性、平均値、観測窓、dropとretry、回復hysteresis、誤差を使った適用限界

別問題へ転用する

参照中心から書込み中心へ変わるrequest mixでbottleneckと安全容量を再測定する

復習スケジュール

  1. 1日後

    offered loadとaccepted throughputが分かれる時に何を観測するか

  2. 7日後

    Littleの式を使えない非定常な反例を挙げる

  3. 30日後

    request mix変更でbottleneck仮説をどう更新するか

  4. 90日後

    offered loadとaccepted throughputが分かれる時に何を観測するか

評価ルーブリック

4段階の評価基準
観点未達発展途上熟達卓越
technical-correctnessoffered loadと成功throughputを同一視し平均だけを記録するpercentileとresourceはあるがqueue、error、回復を結び付けない負荷曲線、Littleの式、profile、headroomを適用条件付きで整合させる負荷生成側と依存先の限界、hysteresis、測定overheadまで反証する
judgment最大RPSをそのままproduction容量にするheadroomを置くが根拠と再評価条件がないtail、error、queue、成長、運用余力から安全容量を選ぶrequest mixと障害時縮退を含む複数容量境界を運用判断へ接続する
evidencesimulated値または単発profileをproduction実測として提示するsimulationとprofileは分けるがbottleneck判断を再現できないfixture、計算、actual local profile、mutationを再実行可能に残す環境差とprofile overheadを明示し、別手法の証拠で仮説を交差検証する
communication一つの最大値だけを示して利用者影響を説明しない曲線は示すが観測値と判断を区別しない観測、推論、限界、決定、再測定条件を同じ報告で追える開発、運用、事業がrequest mixとheadroomのtrade-offを更新できる

出典

以下の外部資料は利用者が選択したときだけ開きます。