第13章:冪等キー保存(Redis/キャッシュ等)発展⚡

13.1 この章でできるようになること🎯✨
- Redis(分散キャッシュ)で 冪等キーの保存・再利用 をする目的がわかる🔁
- DB方式 vs Redis方式 のメリデメを、理由つきで説明できるようになる📊
- ASP.NET Core で 「同じIdempotency-Keyなら同じ結果」 を返すミニ実装ができる🛠️
- Redisならではの落とし穴(揮発・TTL・並行・運用) を避けられるようになる🧯
13.2 Redis / 分散キャッシュって何?🧊🏎️
分散キャッシュは、複数台のWebサーバーで 同じキャッシュを共有 できる仕組みだよ📦 「1台のメモリに貯める」のと違って、サーバーを増やしてもキャッシュが共有できるのが強み✨ (Microsoft Learn)
Redisは、その分散キャッシュの代表格で 超高速(メモリ中心) なストアだよ⚡
.NETだと StackExchange.Redis が定番クライアントで、Redis互換サーバーにも広く対応してるよ(例:Redis/Valkey/Garnetなど)🔌 (NuGet)
13.3 まず結論:Redisは「速い」けど「正しさの最終防衛ライン」にはしにくい🧊⚠️
冪等性って、どちらかというと 速度より正しさが大事 な分野だよね🔒 Redisは速いけど、運用次第でこうなる可能性があるよ👇
- メモリ逼迫で 古いキーが消える(eviction) 😱
- フェイルオーバー等で 一時的に読み書きが不安定 🌀
- TTLや掃除が雑だと 想定より早く消える/残り続ける 🧹⏳
なのでおすすめは「Redisだけで完結」より、 “DBを本命”にしてRedisで高速化(二層構え)にする設計が多いよ🥐✨
13.4 DB方式 vs Redis方式 比較表📊✨
| 観点 | DB保存(第12章)🗃️ | Redis保存(第13章)⚡ |
|---|---|---|
| 速度 | 普通(最適化次第)🚶 | 速い(基本メモリ)🏎️ |
| 耐久性 | 強い(永続)🧱 | 弱くなりがち(揮発/運用依存)🫧 |
| 正しさの担保 | 一意制約・トランザクションが強い🛡️ | 設計しないと「消えたら負け」になりがち⚠️ |
| TTL/掃除 | 仕組みは自分で作る🧹 | TTLが得意(自然に期限切れ)⏳ |
| 運用 | DBだけで完結しやすい🙂 | Redis運用が増える(監視/容量/障害対応)🧯 |
| 向く用途 | 正しさ最優先(課金・在庫・予約)💳📦 | 短時間の再送対策(連打・リトライ吸収)🔁 |
13.5 Redisが刺さるケース✅ / やめたほうがいいケース⚠️
✅ Redisが刺さる(気持ちよく効く)✨
- 同じリクエストが 短時間に集中 して来る(連打・自動リトライ)📱🔁
- 冪等キーの保持が 数分〜数時間 で十分(超長期保持しない)⏳
- “2回目以降は前回レスポンスを返す” を 高速化 したい📮⚡
⚠️ Redisだけに寄せるのは危ない(特に初心者は要注意)😵
- 課金・在庫・予約など ミスが致命傷 の領域💥
- “数日〜数ヶ月保持したい” みたいな 長TTL前提 🗓️
- Redis障害時に「二重実行してもOK」みたいな 逃げ道がない 😭
13.6 設計パターン3つ(おすすめ順)🥇🥈🥉
🥇 パターンA:DB本命 + Redis高速化(二層)🍰
- Redisに結果があれば即返す⚡
- なければDBを見る🗃️
- DBにあったらRedisにも載せる(次から速い)✨
→ 正しさはDB、速度はRedis のいいとこ取り💕
🥈 パターンB:Redisで「処理中ロック」®️だけやる🔒
- Redisの
SET NX(= まだ無ければセット)で「今処理してるよ」を表現 - 結果保存はDB、またはRedis(状況次第)
→ 並行実行の暴走 を抑えるのに強い🏎️💥
🥉 パターンC:Redis完結(結果も全部Redis)⚡
- 超高速だけど、運用や障害時の設計が難しい😇
- 初学者教材では「知っておく」止まりが安全🧯
13.7 実装ハンズオン:ASP.NET Core + Redisで冪等キーを保存する🛠️✨
13.7.1 使うライブラリ(本日時点の代表例)📦
Microsoft.Extensions.Caching.StackExchangeRedis(分散キャッシュ実装) (NuGet)StackExchange.Redis(Redisクライアント本体) (NuGet)
メモ:
IDistributedCacheは便利だけど、冪等の“ロック(SET NX)” は直接StackExchange.Redisを使うのが分かりやすいよ🔑
13.7.2 Redisをローカルで起動(Dockerがラク)🐳🪟
Docker Desktop を使うと、Windowsでもサクッと動くよ✨
docker run --name redis -p 6379:6379 -d redis:8
13.7.3 依存パッケージ追加📦✨
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
dotnet add package StackExchange.Redis
13.7.4 appsettings.json(接続文字列だけ)🔌
{
"ConnectionStrings": {
"Redis": "localhost:6379"
}
}
13.7.5 実装の考え方(超重要)🧠🔑
今回はキーを2種類に分けるよ👇
idem:res:{key}… 結果(レスポンス) を保存📮idem:lock:{key}… 処理中ロック(並行実行の暴走止め)🔒
そして流れはこう👇
idem:resがあれば、それを返す(再利用)🔁- なければ
idem:lockをSET NXで取りに行く🔒 - 取れた人だけ本処理 → 結果を
idem:resに保存📮 - 取れなかった人は「処理中」を返す(または少し待って再取得)🌀
13.7.6 Program.cs(Minimal APIサンプル)🧪✨
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
// Distributed Cache (Redis)
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "IdemDemo:"; // prefix
});
// Redis client (for SET NX lock)
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
var cs = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379";
return ConnectionMultiplexer.Connect(cs);
});
builder.Services.AddSingleton<RedisIdempotencyStore>();
builder.Services.AddSingleton<OrderService>();
var app = builder.Build();
app.MapPost("/orders", async (HttpContext ctx, CreateOrderRequest req, RedisIdempotencyStore idem, OrderService orders) =>
{
// 1) Idempotency-Key header
if (!ctx.Request.Headers.TryGetValue("Idempotency-Key", out var keyValues) ||
string.IsNullOrWhiteSpace(keyValues.ToString()))
{
return Results.BadRequest(new { message = "Idempotency-Key header is required." });
}
var idemKey = keyValues.ToString().Trim();
// 2) request hash (same key used for different body = NG)
var requestHash = HashRequest(req);
// 3) if cached response exists -> return it
var cached = await idem.TryGetCachedResponseAsync(idemKey);
if (cached is not null)
{
if (!string.Equals(cached.RequestHash, requestHash, StringComparison.Ordinal))
{
return Results.Conflict(new { message = "Same Idempotency-Key used with a different request body." });
}
return Results.Text(cached.Body, contentType: cached.ContentType, statusCode: cached.StatusCode);
}
// 4) try acquire "in-progress" lock (SET NX)
var token = Guid.NewGuid().ToString("N");
var lockResult = await idem.TryBeginAsync(idemKey, token, requestHash);
if (!lockResult.Acquired)
{
// someone else is processing (or already processed but not yet visible)
// if request hash differs, return Conflict early
if (lockResult.LockedRequestHash is not null &&
!string.Equals(lockResult.LockedRequestHash, requestHash, StringComparison.Ordinal))
{
return Results.Conflict(new { message = "Same Idempotency-Key is currently used by another request with different body." });
}
// "processing" response (simple)
ctx.Response.Headers["Retry-After"] = "1";
return Results.StatusCode(StatusCodes.Status409Conflict);
}
try
{
// 5) perform the real side effect
var created = await orders.CreateOrderAsync(req);
var response = new CachedResponse(
RequestHash: requestHash,
StatusCode: StatusCodes.Status201Created,
ContentType: "application/json; charset=utf-8",
Body: JsonSerializer.Serialize(created)
);
// 6) save result to redis with TTL
await idem.SaveCachedResponseAsync(idemKey, response);
return Results.Text(response.Body, contentType: response.ContentType, statusCode: response.StatusCode);
}
finally
{
// 7) release lock safely (delete only if token matches)
await idem.EndAsync(idemKey, token);
}
});
app.Run();
static string HashRequest<T>(T obj)
{
var json = JsonSerializer.Serialize(obj);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(bytes);
}
public sealed record CreateOrderRequest(string CustomerId, int Amount);
public sealed record OrderCreated(string OrderId, string CustomerId, int Amount, DateTimeOffset CreatedAt);
public sealed class OrderService
{
public async Task<OrderCreated> CreateOrderAsync(CreateOrderRequest req)
{
// demo: pretend DB write / external call
await Task.Delay(300);
return new OrderCreated(
OrderId: Guid.NewGuid().ToString("N"),
CustomerId: req.CustomerId,
Amount: req.Amount,
CreatedAt: DateTimeOffset.UtcNow
);
}
}
public sealed record CachedResponse(string RequestHash, int StatusCode, string ContentType, string Body);
public sealed class RedisIdempotencyStore
{
private readonly IDistributedCache _cache;
private readonly IDatabase _db;
// results keep longer, lock is short
private static readonly TimeSpan ResultTtl = TimeSpan.FromHours(24);
private static readonly TimeSpan LockTtl = TimeSpan.FromMinutes(2);
public RedisIdempotencyStore(IDistributedCache cache, IConnectionMultiplexer mux)
{
_cache = cache;
_db = mux.GetDatabase();
}
private static string ResKey(string idemKey) => $"idem:res:{idemKey}";
private static string LockKey(string idemKey) => $"idem:lock:{idemKey}";
public async Task<CachedResponse?> TryGetCachedResponseAsync(string idemKey)
{
var json = await _cache.GetStringAsync(ResKey(idemKey));
if (string.IsNullOrWhiteSpace(json)) return null;
return JsonSerializer.Deserialize<CachedResponse>(json);
}
public async Task SaveCachedResponseAsync(string idemKey, CachedResponse response)
{
var json = JsonSerializer.Serialize(response);
await _cache.SetStringAsync(
ResKey(idemKey),
json,
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ResultTtl }
);
}
public async Task<(bool Acquired, string? LockedRequestHash)> TryBeginAsync(string idemKey, string token, string requestHash)
{
// lock value = "token|hash"
var value = $"{token}|{requestHash}";
var acquired = await _db.StringSetAsync(
LockKey(idemKey),
value,
expiry: LockTtl,
when: When.NotExists
);
if (acquired) return (true, null);
// someone has the lock -> read hash to detect mismatch
var existing = await _db.StringGetAsync(LockKey(idemKey));
if (!existing.HasValue) return (false, null);
var parts = existing.ToString().Split('|', 2);
return (false, parts.Length == 2 ? parts[1] : null);
}
public async Task EndAsync(string idemKey, string token)
{
// delete lock only if token matches (avoid deleting someone else's lock)
const string lua = """
if redis.call("GET", KEYS[1]) then
local v = redis.call("GET", KEYS[1])
local token = string.match(v, "^(.-)|")
if token == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
end
return 0
""";
await _db.ScriptEvaluateAsync(lua, new RedisKey[] { LockKey(idemKey) }, new RedisValue[] { token });
}
}
13.7.7 動作確認(PowerShellで“連打”再現)🔁🧪
同じ Idempotency-Key で2回叩くよ👇
$k = "demo-001"
$body = '{ "customerId": "C001", "amount": 1200 }'
# 1回目(作成される)
Invoke-RestMethod -Method Post "http://localhost:5000/orders" `
-Headers @{ "Idempotency-Key" = $k } `
-ContentType "application/json" `
-Body $body
# 2回目(同じ結果が返るのが理想!)
Invoke-RestMethod -Method Post "http://localhost:5000/orders" `
-Headers @{ "Idempotency-Key" = $k } `
-ContentType "application/json" `
-Body $body
さらに「同じキーで別ボディ」をやると 409 Conflict になるはずだよ🧨✨(事故防止!)
13.8 Redis方式の落とし穴10個🕳️😵💫
- TTL短すぎ:再送が来た頃に消えてる⏳
- TTL長すぎ:メモリを食い続ける🍔
- キーの粒度ミス:エンドポイントやテナントを混ぜると地獄🌀
- 同じキーで別ボディ:別注文が同じ結果として返る😱
- ロック無し:並行で2回実行される🏎️💥
- ロックTTL短すぎ:処理中にロックが切れて別リクエストが走る🧨
- ロック削除が雑:他人のロックを消してしまう(トークン必須)🔑
- レスポンス保存が重い:大きいJSONを入れると辛い📦
- 個人情報の保存:キャッシュに残るものは最小限に🫣
- セキュリティ更新放置:Redisはセキュリティ修正が入るので、更新をサボると危険になりやすいよ🧯(例:8.0系でもセキュリティ修正が案内されてる) (Redis)
13.9 1分で説明チャレンジ🎤💡
次を「1分で」言えたら勝ち🏆✨
- Redis方式は 短時間の重複を速く潰す のが得意⚡
- でもRedisは 消える/揺れる可能性 があるから、正しさ最優先ならDBが本命🗃️
- 実装は 結果キャッシュ + 処理中ロック(SET NX) がセット🔒📮
13.10 小テスト📝🌸
Q1. Redisで冪等キー保存をする一番のメリットは? A) 一意制約が強い B) とにかく速い C) 永続化が簡単
Q2. 「同じIdempotency-Keyで別のリクエストボディ」が危険なのはなぜ?😱
Q3. ロック削除にトークンが必要な理由は?🔑
13.11 章末課題🎁✨
次を満たすように改造してみよう💪
409 Processingのとき、1秒待ってから結果を再取得 する(最大3回)⏳🔁- 結果保存に
OrderIdだけじゃなく、Locationヘッダ相当(例:/orders/{id})も返せるようにする📍 - TTLを「1時間」に変えて、動きを確認する(短すぎ問題も観察👀)
13.12 AI活用プロンプト集🤖✨
- 「この冪等実装、並行実行で壊れるパターンを列挙して🌀」
- 「RedisのSET NXロックで、期限切れ時に起きる事故を説明して😵」
- 「同じIdempotency-Keyで別ボディを弾く方法を3つ提案して🔑」
- 「このコードを“読みやすい命名”にして、責務分割して(Service/Store)🧩」
- 「Redis障害時のフォールバック案(DBを見る等)を設計して🧯」