メインコンテンツまでスキップ

第04章:分散が難しい理由(まずここで転ぶ)😵‍💫📡

この章でできるようになること🎯✨

  • 「遅い」「切れる」「順番が変わる」「二重に来る」って何が“つらい”のかを体感できる😇
  • “ただのHTTP呼び出し”が、なぜ設計の話に直結するのかがわかる🧠⚡
  • 次章以降のCAP判断に必要な「現実のクセ」を、先に身体で覚える💪📚

4.1 まず最初に:ネット越しは、ローカル呼び出しじゃない🙅‍♀️🌐

同じ await でも…

  • ローカル関数:だいたい速い・壊れにくい・順番はわりと期待できる
  • ネット越し:遅い・切れる・順番が変わる・二重に来る(普通に)😵‍💫

この “勘違いあるある” は有名で、たとえば「ネットワークは信頼できる」「遅延はゼロ」みたいな思い込みが落とし穴になるよ〜って話が整理されています。 (ウィキペディア)


4.2 分散の4大つらい(この章の主役)😇📦

① 遅い🐢⏱️

  • 1回だけ遅い、じゃなくて 毎回ブレる(ジッター) のが地味に痛い
  • すると…タイムアウト→リトライ→二重処理、が起きる😵‍💫

② 切れる💥📴

  • 503が返る、接続が途中で落ちる、返事が来ない…
  • 「失敗した」のか「成功したけど返事だけ落ちた」のかが曖昧になる😇

③ 順番が変わる🔀😵‍💫

  • A→Bの順で投げても、返ってくるのはB→Aだったりする
  • 並列処理・キュー・リトライが絡むとさらにぐちゃぐちゃ🌪️

④ 二重に来る📨📨

  • タイムアウトや再送で「同じ依頼」がもう一回届くのは自然現象
  • 特に POST(作成系) はそのままだと二重作成になりやすい⚠️ (Microsoft Learn)

4.3 ミニ実験:わざと不安定な在庫APIを作って転んでみる😇🧪

題材はCampusCafeの 在庫サービス(InventoryService)注文サービス(OrderService) の2つだけでOK✨ 最小限で「遅い・切れる・順番・二重」を全部体験するよ〜☕📱

この章のサンプルは .NET 10(LTS) / C# 14 の想定だよ〜🧁 (Microsoft)


4.4 作るもの:2つのMinimal API(超ミニ)🧩✨

① プロジェクト作成(Solutionに2プロジェクト)📁

ターミナルでも作れるよ(Visual StudioのUIで作ってもOK)😊 dotnet new webapi は最近のテンプレだと Minimal APIが既定 になってるよ〜🪄 (Microsoft Learn)

mkdir CampusCafe-Ch4
cd CampusCafe-Ch4

dotnet new sln -n CampusCafe

dotnet new webapi -n InventoryService
dotnet new webapi -n OrderService

dotnet sln add InventoryService/InventoryService.csproj
dotnet sln add OrderService/OrderService.csproj

4.5 InventoryService:わざと遅くしたり落ちたりする在庫API🧨📦

InventoryService の Program.cs を置き換え✍️

using System.Collections.Concurrent;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

// 在庫(超ミニ:メモリ)
var stock = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["cake"] = 10,
["coffee"] = 20
};

// 予約(超ミニ:メモリ)
var reservations = new ConcurrentDictionary<string, Reservation>();

// 疑似カオス(遅延/失敗/中断)
static async Task<IResult?> MaybeChaosAsync(HttpContext ctx, string? chaos)
{
// chaos: none / slow / fail / slowfail / abort / flaky
chaos = (chaos ?? "none").Trim().ToLowerInvariant();

if (chaos == "abort")
{
// “途中で切れる”っぽい再現(クライアント側は HttpRequestException になりがち)
ctx.Abort();
return Results.Empty;
}

if (chaos == "fail")
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);

if (chaos == "slow")
await Task.Delay(2000); // あえてキャンセルトークンを使わない(=クライアントが諦めても処理が続く感じを再現)

if (chaos == "slowfail")
{
await Task.Delay(2000);
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}

if (chaos == "flaky")
{
var r = Random.Shared.Next(0, 100);
if (r < 35) return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
if (r < 70) await Task.Delay(1500);
}

return null; // 何も起きない
}

app.MapGet("/inventory/state", () =>
{
return Results.Ok(new
{
stock = stock.OrderBy(kv => kv.Key).ToDictionary(kv => kv.Key, kv => kv.Value),
reservations = reservations.Count
});
});

app.MapGet("/inventory/ping", async (int? delayMs) =>
{
var ms = Math.Clamp(delayMs ?? 0, 0, 5000);
await Task.Delay(ms);
return Results.Ok(new { ok = true, delayMs = ms, at = DateTimeOffset.UtcNow });
});

app.MapPost("/inventory/reserve", async (HttpContext ctx, ReserveRequest req, string? chaos) =>
{
var chaosResult = await MaybeChaosAsync(ctx, chaos);
if (chaosResult is not null) return chaosResult;

if (req.Quantity <= 0) return Results.BadRequest(new { message = "quantity must be > 0" });

// 在庫チェック&確保(超ミニ:排他の雰囲気だけ)
stock.AddOrUpdate(req.ItemId,
_ => 0,
(_, current) => current); // キー保証

var currentStock = stock[req.ItemId];
if (currentStock < req.Quantity)
return Results.Conflict(new { message = "out of stock", itemId = req.ItemId, currentStock });

stock[req.ItemId] = currentStock - req.Quantity;

var reservationId = Guid.NewGuid().ToString("N");
reservations[reservationId] = new Reservation(reservationId, req.ItemId, req.Quantity, DateTimeOffset.UtcNow);

// リクエスト相関用(あればログに出す用)
var requestId = ctx.Request.Headers["X-Request-Id"].ToString();

app.Logger.LogInformation("Reserved: {ItemId} x{Qty} => reservationId={ResId}, X-Request-Id={ReqId}",
req.ItemId, req.Quantity, reservationId, requestId);

return Results.Ok(new { reservationId, itemId = req.ItemId, quantity = req.Quantity });
});

app.Run();

record ReserveRequest(string ItemId, int Quantity);
record Reservation(string ReservationId, string ItemId, int Quantity, DateTimeOffset ReservedAt);

4.6 OrderService:在庫APIを呼ぶだけなのに、地獄が始まる😇☎️

OrderService の Program.cs を置き換え✍️

  • タイムアウト1秒にする
  • タイムアウトしたら 同じ注文をリトライしてみる(←これが“二重”を生む)
  • ついでに、順番が変わるデモも付ける🔀✨
using System.Net.Http.Json;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// IHttpClientFactoryでHttpClientを作る(基本はこのやり方が安心)🧰✨
builder.Services.AddHttpClient("inventory", client =>
{
client.BaseAddress = new Uri("http://localhost:5101"); // InventoryService
client.Timeout = TimeSpan.FromSeconds(1); // わざと短い
});

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

app.MapPost("/orders", async (IHttpClientFactory factory, CreateOrderRequest req) =>
{
var orderId = Guid.NewGuid().ToString("N");
var xRequestId = $"order-{orderId}";

var client = factory.CreateClient("inventory");

async Task<ReserveResult> CallReserveAsync()
{
// chaos は Inventory 側のクエリに渡す(slow/fail/abort/flaky など)
var url = $"/inventory/reserve?chaos={Uri.EscapeDataString(req.Chaos ?? "none")}";

using var message = new HttpRequestMessage(HttpMethod.Post, url);
message.Headers.TryAddWithoutValidation("X-Request-Id", xRequestId);
message.Content = JsonContent.Create(new { itemId = req.ItemId, quantity = req.Quantity });

var response = await client.SendAsync(message);
response.EnsureSuccessStatusCode();

var body = await response.Content.ReadFromJsonAsync<ReserveResult>();
return body ?? throw new InvalidOperationException("Invalid response body");
}

try
{
var result = await CallReserveAsync();
return Results.Ok(new { orderId, reserved = result, note = "reserved (first try) ✅" });
}
catch (TaskCanceledException) when (req.RetryOnTimeout)
{
// タイムアウト=失敗…とは限らないのがポイント😇
// でも「失敗した気がする」のでリトライすると…?
try
{
var result2 = await CallReserveAsync();
return Results.Ok(new
{
orderId,
reserved = result2,
note = "reserved (after timeout retry) ⚠️"
});
}
catch (Exception ex2)
{
return Results.Problem(title: "Retry also failed 💥", detail: ex2.Message);
}
}
catch (Exception ex)
{
return Results.Problem(title: "Order failed 💥", detail: ex.Message);
}
});

// 順番が変わるデモ:同時に投げたpingが、遅延次第で戻り順が変わる🔀
app.MapGet("/demo/out-of-order", async (IHttpClientFactory factory, int? count) =>
{
var n = Math.Clamp(count ?? 6, 2, 20);
var client = factory.CreateClient("inventory");

// わざとバラバラな遅延を作る
var delays = Enumerable.Range(0, n)
.Select(i => (i, delayMs: Random.Shared.Next(0, 1800)))
.ToList();

var tasks = delays.Select(async x =>
{
var res = await client.GetFromJsonAsync<PingResult>($"/inventory/ping?delayMs={x.delayMs}");
return new { x.i, x.delayMs, at = res?.at };
}).ToList();

// “完了した順”で取り出す
var completedOrder = new List<object>();
while (tasks.Count > 0)
{
var finished = await Task.WhenAny(tasks);
tasks.Remove(finished);
completedOrder.Add(await finished);
}

return Results.Ok(new { planned = delays, completedOrder });
});

app.Run();

record CreateOrderRequest(string ItemId, int Quantity, string? Chaos, bool RetryOnTimeout);
record ReserveResult(string reservationId, string itemId, int quantity);
record PingResult(bool ok, int delayMs, DateTimeOffset at);

IHttpClientFactory は、ASP.NET CoreでのHTTP呼び出しを扱いやすくする推奨パターンとして整理されてるよ〜🧰 (Microsoft Learn)


4.7 実行:2つのサービスを別ポートで起動🚀💻

ターミナルを2つ開いてね😊

InventoryService(ポート5101)

dotnet run --project InventoryService --urls http://localhost:5101

OrderService(ポート5201)

dotnet run --project OrderService --urls http://localhost:5201

Swaggerを開くとテストしやすいよ〜🧁✨(各サービスで自動表示されるUI) Minimal APIの基本操作(Swagger含む)は公式チュートリアルにもあるよ📘 (Microsoft Learn)


4.8 実験タイム🧪✨(転ぶほど理解が深まるやつ😇)

実験①「遅い」🐢⏱️:タイムアウトは“曖昧”を生む

  1. まず在庫を見る
  • GET http://localhost:5101/inventory/state
  1. 次に注文(遅延させる)
  • POST http://localhost:5201/orders
  • Body例👇
{
"itemId": "cake",
"quantity": 1,
"chaos": "slow",
"retryOnTimeout": false
}

✅ 期待する観察ポイント

  • OrderServiceは タイムアウトで失敗っぽくなることがある
  • でもInventoryService側ログを見ると、予約が進んでることがある😇(=「失敗」じゃないかも)

ここが分散のいちばんイヤなところ: タイムアウトは「失敗」じゃなくて「わからない(Unknown)」 を生むの🥹


実験②「切れる」💥📴:503 / 接続中断

次は chaos を変えるだけでOK✨

  • 503を返す:chaos: "fail"
  • 途中で落とす:chaos: "abort"
{
"itemId": "cake",
"quantity": 1,
"chaos": "abort",
"retryOnTimeout": true
}

✅ 期待する観察ポイント

  • クライアント視点だと「失敗」に見える
  • でもサーバー側が“どこまでやったか”はケース次第😇
  • だから「安全にリトライできる設計(冪等性)」が後で超重要になる🛡️🔑 (Amazon Web Services, Inc.)

実験③「順番が変わる」🔀😵‍💫:完了順がバラける

  • GET http://localhost:5201/demo/out-of-order?count=8

✅ 期待する観察ポイント

  • planned(予定の遅延)と
  • completedOrder(完了した順) がズレるよ〜🔀✨

「送った順 = 受け取る順」「処理した順 = 反映される順」って思い込みが壊れる瞬間😇


実験④「二重に来る」📨📨:タイムアウト→リトライ→二重予約(やばい)

ここが一番の“転びポイント”💥

  1. まず在庫を確認
  • GET http://localhost:5101/inventory/state(cakeが10とか)
  1. 注文を投げる(遅延&リトライON)
{
"itemId": "cake",
"quantity": 1,
"chaos": "slow",
"retryOnTimeout": true
}
  1. もう一度在庫を見る
  • GET http://localhost:5101/inventory/state

✅ 期待する観察ポイント

  • cake が 2減ってたりする😇(= 予約が2回走った)
  • 「1回押したのに2回減る」って、ユーザーから見たらホラー👻

この現象は “リトライが悪い” というより、 リトライが必要になる世界で、二重でも壊れないように作ってないのが問題 なんだよね🧠✨ (安全にリトライするには「冪等性キー」などが定番) (Amazon Web Services, Inc.)


4.9 失敗パターン図鑑(超ミニ版)📚✨

🐢 遅延(Latency / Jitter)

  • 症状:遅い・たまにタイムアウト
  • 罠:タイムアウト→二重リクエスト
  • まずやる:タイムアウト・リトライの方針を“仕様”として決める📝

📴 切断(Connection drop / 503)

  • 症状:たまに落ちる、たまに復帰する
  • 罠:「失敗」に見えても、裏で成功してるかも
  • まずやる: “わからない” 状態を前提に、後で整合させる作戦を持つ🧩

🔀 順序入れ替わり(Reordering)

  • 症状:更新順・通知順が前後する
  • 罠:古い状態に戻る/通知が逆順で混乱
  • まずやる:順序が必要なところだけ、番号(バージョン)や状態遷移で守る🚦

📨 重複(Duplicate)

  • 症状:同じ処理が2回走る
  • 罠:二重請求・二重予約・在庫が消える👻
  • まずやる:作成/決済/予約のような操作は “同じ依頼が複数回来る” 前提にする🛡️ (Amazon Web Services, Inc.)

4.10 ミニクイズ(答えはすぐ下)🧠✨

Q1:タイムアウトは「失敗」が確定?⏱️

Q2:POSTをリトライすると何が起きやすい?📨

Q3:順番が変わるのは“バグ”?それとも“自然現象”?🔀

答え✅

  • A1:確定じゃない(“わからない” が増える)😇
  • A2:二重作成・二重予約・二重請求が起きやすい⚠️ (Microsoft Learn)
  • A3:自然現象(遅延のブレと並列で起きる)🔀 (ウィキペディア)

4.11 AI活用:失敗パターン“図鑑”を自分の言葉で作る🤖📚✨

Copilot / Codex にこう投げるとめっちゃ整理できるよ〜😊🪄

プロンプト例①(図鑑化)

  • 「CampusCafeで起きる “遅延/切断/順序入れ替わり/重複” を、症状・原因・対策の3列テーブルでまとめて」

プロンプト例②(ログの読み方)

  • 「このログ(貼る)から、どの失敗パターンが起きてるか推理して。根拠も書いて」

プロンプト例③(安全なリトライの準備)

  • 「POST /orders を安全にリトライできるようにするには、どんな“キー”と“保存”が必要?最小構成で案を出して」

AWSやStripeのような現場でも「安全なリトライ=冪等性」が超重要って整理されてるよ〜🛡️✨ (Amazon Web Services, Inc.)