A+B 複合 — 再代入防止 × 副作用排除 × 関数の影響範囲(在庫引当処理 Bad→Good 第4章)× inventory-reservation-service Memorystore Redis 分散ロックによる二重引当防止(fencing token × 冪等性キー × 指数バックオフ × Standard tier HA)

2026-08-22 (Day 137) 土曜複合問題 ★★★★☆ Python 3.12 / frozen dataclass / 不変の活用 / 第4章 GCP Memorystore for Redis / GKE Autopilot / Cloud Tasks

概要

🚫

ミュータブルなデフォルト引数は呼び出しをまたいで蓄積する

reserved_log=[]は関数定義時に1回だけ生成され、呼び出しをまたいで同じリストが使い回される。デフォルト値はNoneにし、関数内で新規作成する(第4章)。

🔒

副作用のある関数は戻り値だけでは何が起きたか分からない

warehouse_stockやorder["items"]を直接書き換える関数は、呼び出し側がソースを読まない限り変化に気づけない。結果は不変な戻り値に集約する(第4章)。

🏷️

再代入は「今どの値を指しているか」を追跡させるコストを生む

feeに異なる意味の値を順番に代入すると、途中で意味が変わったことが読み手に伝わらない。意味が変わるなら別の変数名を与える(第4章)。

🎫

分散ロックの安全性は「今も保持しているか」の証明で決まる

TTL切れは避けられない前提で設計し、fencing token(一意なトークン)で「自分が取得したロックであること」を解放時に検証する。

問題 A: コーディング — 再代入防止 × 副作用排除 × 関数の影響範囲(在庫引当処理 Bad→Good)

以下の「悪いコード」は、ECサイト MOps チームが運用する在庫引当(InventoryReservation)処理です。ミュータブルなデフォルト引数、呼び出し元のオブジェクトを直接書き換える副作用、意味の異なる値を同じ変数名に再代入する設計が同居しています。問題点を全て洗い出し、「良いコード・悪いコードで学ぶ設計入門」第4章(不変の活用: 再代入防止、副作用排除、関数の影響範囲)を使って Bad→Good にリファクタリングしてください。

制約・前提条件

  • Python 3.12+
  • warehouse_stock(呼び出し元の在庫辞書)・order["items"](呼び出し元の注文明細)を直接書き換えず、関数は新しい値を返すこと(副作用の排除)
  • reserved_log=[] のようなミュータブルなデフォルト引数を廃止すること
  • apply_backorder_fee の fee 引数への再代入をやめ、「呼び出し元から渡された優先配送料」と「アイテムごとの入荷待ち手数料合計」を別々の名前で扱うこと
  • 関数のシグネチャ(引数と戻り値の型)だけを見て、その関数が何を書き換え何を書き換えないかが分かるようにすること(関数の影響範囲の明確化)
  • @dataclass(frozen=True) を使い、引当結果を不変な値として返すこと
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章

悪いコード (Before) — カテゴリ A

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_inventory.py — mutable default × 副作用 × fee再代入
def reserve_items(order, warehouse_stock, reserved_log=[]):  # 問題1: mutable defaultが呼び出しをまたいで蓄積
    total_reserved = 0
    shortage_items = []
    for item in order["items"]:
        sku = item["sku"]
        qty = item["qty"]
        if sku in warehouse_stock:
            if warehouse_stock[sku] >= qty:
                warehouse_stock[sku] -= qty  # 問題2: 呼び出し元の辞書を直接書き換える(副作用)
                total_reserved += qty
                reserved_log.append({"sku": sku, "qty": qty})
            else:
                shortage_items.append(sku)
        else:
            shortage_items.append(sku)

    order["items"].sort(key=lambda i: i["sku"])  # 問題3: 呼び出し元のリストをin-placeソート(副作用)
    order["reserved_count"] = total_reserved      # 問題4: 呼び出し元のdictへ直接書き込み
    return shortage_items


def apply_backorder_fee(order, fee=0):
    fee = order.get("priority_fee", 0)  # 問題5: 引数feeへの再代入。呼び出し側が渡した値が握りつぶされる
    for item in order["items"]:
        fee = fee + item.get("backorder_fee", 0)  # 問題6: ループ変数への再代入で蓄積、影響範囲が読み取れない
    return fee
問題点サマリー(7点)
1reserved_log=[]というミュータブルなデフォルト引数(Ch4/Ch10) — デフォルト値は定義時に1回だけ生成され、呼び出しをまたいで同じリストが使い回される
2warehouse_stock[sku] -= qtyで呼び出し元の辞書を直接書き換える副作用(Ch4) — 戻り値だけを見ても在庫辞書が書き換わることは読み取れない
3order["items"].sort(...)による呼び出し元リストのin-placeソート(Ch4) — 呼び出し側が意図せず注文明細の順序を変えられてしまう
4order["reserved_count"] = total_reservedという呼び出し元dictへの直接書き込み(Ch4) — 戻り値以外の経路で結果が伝播している
5fee引数への再代入(Ch4) — 呼び出し側が渡したfeeの値が関数内部で黙って上書きされる
6ループ内でfee = fee + ...と同じ変数名に加算し続ける再代入(Ch4) — 異なる意味の値が同じ変数名に同居する
7在庫不足時の分岐が2箇所に重複 — SKU未登録と数量不足が同じ処理に流れ込み意図が読み取りにくい

ヒント A(段階的開示)

ヒント1 — 方向性
reserve_items は「戻り値として shortage_items を返す」という表向きの契約の裏で、warehouse_stock・order["items"]・order["reserved_count"] という3つの呼び出し元オブジェクトを黙って書き換えている。関数のシグネチャだけを見た人は、この副作用に気づけない。apply_backorder_fee はさらに厄介で、fee という同じ名前に「引数として渡された値」と「関数内で計算した別の値」を順番に代入しており、途中でどのfeeを指しているか読み手が追えなくなる。第4章の要点は「変数は一度代入したらその意味を変えない」「関数は受け取った引数を書き換えず、必要な値はすべて戻り値として返す」という2点に尽きる。
ヒント2 — アプローチ
  • reserved_log: list | None = None にし、関数内で reserved_log = [] と新規作成する(ミュータブルデフォルト引数の廃止)
  • warehouse_stock はコピー(dict(warehouse_stock))してから減算するか、減算後の新しい辞書を戻り値として返す
  • order["items"].sort(...) の代わりに sorted(order["items"], key=...) で新しいリストを作る
  • 引当結果を @dataclass(frozen=True) の ReservationResult(updated_stock・sorted_items・reserved_count・shortage_skus)にまとめて返す
  • apply_backorder_fee は priority_fee と total_backorder_fee を別変数にし、最後に合算した値だけを返す
ヒント3 — コードの骨格
@dataclass(frozen=True, slots=True)
class ReservationResult:
    updated_stock: dict[str, int]
    sorted_items: list[dict]
    reserved_count: int
    shortage_skus: tuple[str, ...]

def reserve_items(
    order_items: list[dict],
    warehouse_stock: dict[str, int],
) -> ReservationResult:
    ...  # warehouse_stockを書き換えず、コピーした新しい辞書を返す

問題点分析 — カテゴリ A

#問題点分類改善方法
1reserved_log=[]のミュータブルデフォルト引数再代入防止 Ch4/Ch10None許容にし関数内で新規作成
2warehouse_stockを直接書き換え副作用排除 Ch4コピーしてから減算
3order["items"]のin-placeソート副作用排除 Ch4sorted()で新規リスト
4order["reserved_count"]への直接書き込み影響範囲の明確化 Ch4ReservationResultへ集約
5fee引数への再代入再代入防止 Ch4priority_feeとして固定利用
6ループ内でfeeへ加算し続ける再代入再代入防止 Ch4total_backorder_feeに分離
7在庫不足分岐の重複条件の重複ガード条件を1つに統合

模範解答 A

Before — mutable default・副作用・fee再代入
def reserve_items(order, warehouse_stock, reserved_log=[]):  # 蓄積するmutable default
    total_reserved = 0
    shortage_items = []
    for item in order["items"]:
        ...
        warehouse_stock[sku] -= qty  # 呼び出し元を直接書き換え
        ...
    order["items"].sort(key=lambda i: i["sku"])   # in-placeソート
    order["reserved_count"] = total_reserved       # dictへ直接書き込み
    return shortage_items

def apply_backorder_fee(order, fee=0):
    fee = order.get("priority_fee", 0)  # 引数への再代入
    for item in order["items"]:
        fee = fee + item.get("backorder_fee", 0)  # 累積の再代入
    return fee
After — ReservationResultに結果を集約、引数は一切書き換えない
"""inventory_reservation.py — 再代入防止 × 副作用排除 × 関数の影響範囲の明確化(Ch4)"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Final

_DEFAULT_PRIORITY_FEE: Final[int] = 0


@dataclass(frozen=True, slots=True)
class OrderItem:
    """注文明細1行分(不変)。"""
    sku: str
    qty: int
    backorder_fee: int = 0


@dataclass(frozen=True, slots=True)
class ReservationResult:
    """在庫引当の結果(不変)。呼び出し元の状態は一切書き換えず、すべてここに集約する。

    Args:
        updated_stock: 引当後の在庫状態(呼び出し元のwarehouse_stockはコピーされ書き換わらない)。
        sorted_items: SKU順にソート済みの注文明細(呼び出し元のorder["items"]は書き換わらない)。
        reserved_count: 引当に成功した総数量。
        shortage_skus: 在庫不足または未登録だったSKUの一覧。
    """
    updated_stock: dict[str, int]
    sorted_items: tuple[OrderItem, ...]
    reserved_count: int
    shortage_skus: tuple[str, ...]


def reserve_items(
    order_items: list[OrderItem],
    warehouse_stock: dict[str, int],
) -> ReservationResult:
    """注文明細に対して在庫を引き当てる(引数は一切書き換えず、結果は戻り値のみで返す)。

    Args:
        order_items: 引当対象の注文明細一覧。
        warehouse_stock: 引当前のSKU別在庫数(このdict自体は変更されない)。

    Returns:
        引当結果(ReservationResult)。呼び出し元は戻り値だけを見れば全ての変化が分かる。
    """
    working_stock = dict(warehouse_stock)  # 修正2: コピーしてから減算し、元のdictを書き換えない
    reserved_count = 0
    shortage_skus: list[str] = []

    for item in order_items:
        available = working_stock.get(item.sku)
        if available is None or available < item.qty:
            shortage_skus.append(item.sku)
            continue
        working_stock[item.sku] = available - item.qty
        reserved_count += item.qty

    sorted_items = tuple(sorted(order_items, key=lambda i: i.sku))  # 修正3: 新しいtupleとして返す

    return ReservationResult(
        updated_stock=working_stock,
        sorted_items=sorted_items,
        reserved_count=reserved_count,
        shortage_skus=tuple(shortage_skus),
    )


def calculate_total_fee(order_items: list[OrderItem], priority_fee: int = _DEFAULT_PRIORITY_FEE) -> int:
    """優先配送料と入荷待ち手数料の合計を計算する(引数への再代入を排除)。

    Args:
        order_items: 注文明細一覧。
        priority_fee: 呼び出し元が指定する優先配送料(円)。関数内で上書きされない。

    Returns:
        優先配送料 + 全明細の入荷待ち手数料合計(円)。
    """
    total_backorder_fee = sum(item.backorder_fee for item in order_items)  # 別名の新しい変数に集約
    return priority_fee + total_backorder_fee
items = [
    OrderItem(sku="SKU-B", qty=3, backorder_fee=200),
    OrderItem(sku="SKU-A", qty=10, backorder_fee=0),
]
stock = {"SKU-A": 5, "SKU-B": 20}

result = reserve_items(items, stock)
print(result.reserved_count)      # → 3 (SKU-Bのみ引当成功)
print(result.shortage_skus)       # → ("SKU-A",) (在庫5に対し要求10で不足)
print(result.updated_stock)       # → {"SKU-A": 5, "SKU-B": 17}
print(stock)                      # → {"SKU-A": 5, "SKU-B": 20} (元のdictは無変更, 修正2の確認)
print([i.sku for i in result.sorted_items])  # → ["SKU-A", "SKU-B"] (SKU順)

fee = calculate_total_fee(items, priority_fee=500)
print(fee)  # → 700 (500 + 200 + 0)
ポイント適用した設計原則/パターン書籍対応章
reserved_log=[]を廃止し引数をNone許容にしない設計に変更ミュータブルデフォルト引数の排除Ch4/Ch10
warehouse_stockをコピーしてから減算引数の書き換え排除(副作用の排除)Ch4
order["items"].sortをsorted()に変更呼び出し元リストの不変性維持Ch4
ReservationResult(frozen=True)に結果を集約戻り値による結果伝播、影響範囲の明確化Ch4/Ch3
fee引数への再代入をやめtotal_backorder_feeに分離再代入防止Ch4
# tests/test_inventory_reservation.py
import pytest
from inventory_reservation import OrderItem, reserve_items, calculate_total_fee


class TestReserveItems:
    def test_does_not_mutate_input_stock(self):
        stock = {"SKU-A": 5}
        items = [OrderItem(sku="SKU-A", qty=2)]
        reserve_items(items, stock)
        assert stock == {"SKU-A": 5}  # 修正2: 元のdictは無変更

    def test_shortage_sku_is_reported(self):
        stock = {"SKU-A": 1}
        items = [OrderItem(sku="SKU-A", qty=10)]
        result = reserve_items(items, stock)
        assert result.shortage_skus == ("SKU-A",)

    def test_reserved_count_sums_only_successful_items(self):
        stock = {"SKU-A": 5, "SKU-B": 20}
        items = [OrderItem(sku="SKU-A", qty=10), OrderItem(sku="SKU-B", qty=3)]
        result = reserve_items(items, stock)
        assert result.reserved_count == 3


class TestCalculateTotalFee:
    def test_priority_fee_is_not_overwritten(self):
        items = [OrderItem(sku="X", qty=1, backorder_fee=100)]
        assert calculate_total_fee(items, priority_fee=500) == 600

問題 B: インフラ — inventory-reservation-service の Memorystore Redis 分散ロックによる二重引当防止

問題Aの在庫引当処理は inventory-reservation-service として GKE Autopilot 上で稼働し、複数Podが同時に同じSKUの在庫を引き当てます。Cloud Tasks経由で注文確定イベントを受け取り引当処理を実行していますが、以下の課題があります。

現状の課題:
  • 在庫の排他制御に Memorystore for Redis(Basic tier、単一インスタンス)へのSET key 1 NX EX 10という単純なロックのみを使っており、ロック保持者を識別するトークン(fencing token)が存在しない。GCによる一時停止でTTLが切れた後も処理が続行され、別Podが同じロックを取得すると二重に在庫を減算してしまう
  • ロック解放が単純なDEL keyであり、自分が取得したロックかどうかを確認せずに削除している。TTL切れ後に別Podが取得した新しいロックを誤って解放してしまう競合状態がある
  • Cloud Tasksは同一タスクを最大5回まで自動リトライするが、引当処理に冪等性キーがなく、同じ注文が複数回引き当てられ在庫が二重に減算されるケースが本番で確認されている
  • ロック取得のリトライ間隔が固定100msで、バックオフもジッターもないため、セール開始直後にPodが一斉にロック取得を試み、Redisへのリクエストが瞬間的に急増する(thundering herd)
  • MemorystoreがBasic tier(単一ノード、レプリカなし)で構成されており、メンテナンスやゾーン障害時にRedisが数分間利用不能になり、その間ロック取得ができず引当処理全体が停止する
  • 引当の成功・失敗・二重引当検知のイベントが構造化ログとして残っておらず、障害調査時に「いつ・どのSKUで・どのPodが」二重引当を起こしたか追跡できない

要件

#要件
1MemorystoreをStandard tier(レプリカ + 自動フェイルオーバー)に変更すること
2ロック取得時に一意なトークン(UUID)を値としてセットし、解放はLuaスクリプトで「自分のトークンと一致する場合のみDEL」を原子的に実行すること(fencing tokenパターン)
3Cloud Tasks側に冪等性キー(注文ID + 引当バージョン)を付与し、処理の先頭で処理済みかを確認してから在庫を減算すること
4ロック取得のリトライは指数バックオフ + ジッターで実装し、thundering herdを回避すること
5引当処理をOpenTelemetryでトレーシングし、ロック取得待ち時間・失敗率・二重引当検知件数をDataDogで可視化すること
6上記をTerraform(Memorystore Standard tier構成)とPython(Lua解放スクリプト + 冪等性チェック + バックオフ付きロック取得)で実装すること
期待する回答形式: Terraform(Memorystore Standard tier + 監視)+ Python(fencing token付きロック取得/解放 + 冪等性チェック)+ Bad vs Good 比較表 + 確認コマンド

ヒント B(段階的開示)

ヒント1 — 方向性
分散ロックの罠は「ロックを取得できたこと」と「ロックを今も保持していること」を同一視してしまう点にある。TTLが切れれば誰でも新しいロックを取得できるため、古いロックの保持者は「自分がまだロックを持っている」という誤った前提で処理を続けてしまう。これを防ぐのがfencing token(ロックごとの一意な識別子)で、「解放するのは自分が取得したロックだけ」という条件を明示的にチェックする。冪等性キーはこれとは別の問題で、「同じリクエストが複数回届いても結果が変わらない」ことを保証する仕組みであり、リトライが前提のCloud Tasksのような配信では必須になる。
ヒント2 — アプローチ
  • SET lock:{sku} {token} NX PX {ttl_ms}でロック取得、token = str(uuid4())
  • 解放用Luaスクリプト: if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end
  • 冪等性チェックはSET idempotency:{order_id}:{version} 1 NX EX 86400の結果(新規作成できたかどうか)で判定
  • バックオフはbase_delay * (2 ** attempt) + random.uniform(0, jitter)で計算し、最大リトライ回数を設ける
  • Memorystoreはtier: "STANDARD_HA"で構成
ヒント3 — リソースの骨格
ロック取得/解放の骨格(Python)
_RELEASE_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
"""

def acquire_lock(client, sku, ttl_ms):
    token = str(uuid4())
    ok = client.set(f"lock:{sku}", token, nx=True, px=ttl_ms)
    return token if ok else None

def release_lock(client, sku, token):
    return bool(client.eval(_RELEASE_SCRIPT, 1, f"lock:{sku}", token))
Memorystore Standard tierの骨格(Terraform)
resource "google_redis_instance" "inventory_lock_cache" {
  name           = "inventory-lock-cache"
  tier           = "STANDARD_HA"
  memory_size_gb = 4
  redis_version  = "REDIS_7_2"
  redis_configs = {
    maxmemory-policy = "noeviction"
  }
}

アーキテクチャ図 — fencing token × 冪等性キー × 指数バックオフ

GKE Autopilot — inventory-reservation-service (複数Pod) Pod A token_A = uuid4() 修正2: fencing token 修正4: 指数backoff+jitter Pod B token_B = uuid4() 修正2: fencing token 修正4: 指数backoff+jitter 修正3: 冪等性チェック(先頭で実行) SET idempotency:{order_id}:{version} 1 NX EX 86400 既にキーが存在 → 処理をスキップし二重引当を防止 修正5: OpenTelemetry トレーシング acquire_latency_ms / acquire_failed / duplicate_reservation_count DataDogでp99・失敗率を可視化 Cloud Tasks 最大5回リトライ(at-least-once) 冪等性キー: order_id+version 修正3で二重実行を無害化 Memorystore Redis 修正1: tier=STANDARD_HA lock:{sku} = token (NX PX ttl) idempotency:{order_id}:{version} レプリカ+自動フェイルオーバー Cloud Monitoring acquire p99 > 500ms でアラート duplicate_reservation_count > 0 PagerDuty通知 解放の安全性 Luaスクリプトで get(key)==token の時のみ DELを原子的に実行 他者のロックを誤解放しない Before → After Before: fencing tokenなしのSET/DELロック SET key 1 NX EX 10 → DEL keyで無条件解放 TTL切れ後、他Podのロックを誤って解放しうる Cloud Tasksリトライで冪等性なく二重引当が発生 固定100ms間隔リトライ → thundering herd Basic tier単一ノード → 障害時にロック取得不能 After: fencing token × 冪等性キー × HA構成 UUIDトークン + Luaスクリプトで安全な解放 冪等性キーでリトライによる二重処理を無害化 指数バックオフ+ジッターでリトライを分散 Standard tier(HA)でフェイルオーバー対応 OTel+DataDogで取得待ち時間・二重引当を可視化 ✓ 「ロック取得=保持」という誤った前提を排除 ✓ at-least-once配信を前提にした冪等性設計を確立

模範解答 B

# 修正1/5: Basic tier(単一ノード)からStandard tier(レプリカ+自動フェイルオーバー)へ変更
resource "google_redis_instance" "inventory_lock_cache" {
  name           = "inventory-lock-cache"
  tier           = "STANDARD_HA"
  memory_size_gb = 4
  region         = "asia-northeast1"

  redis_version      = "REDIS_7_2"
  authorized_network = google_compute_network.mops_vpc.id

  redis_configs = {
    maxmemory-policy = "noeviction"  # ロック情報を勝手に追い出させない
  }
}

# 修正5: ロック取得待ち時間・取得失敗率を監視
resource "google_monitoring_alert_policy" "lock_acquire_latency" {
  display_name = "inventory-lock-cache acquire latency p99"
  combiner      = "OR"
  conditions {
    display_name = "lock acquire p99 > 500ms"
    condition_threshold {
      filter          = "metric.type=\"custom.googleapis.com/inventory_lock/acquire_latency_ms\" AND metric.labels.percentile=\"p99\""
      comparison      = "COMPARISON_GT"
      threshold_value = 500
      duration        = "60s"
    }
  }
  notification_channels = [google_monitoring_notification_channel.mops_pagerduty.id]
}

resource "google_monitoring_alert_policy" "duplicate_reservation_detected" {
  display_name = "inventory duplicate reservation detected"
  combiner      = "OR"
  conditions {
    display_name = "duplicate_reservation_count > 0"
    condition_threshold {
      filter          = "metric.type=\"custom.googleapis.com/inventory_lock/duplicate_reservation_count\""
      comparison      = "COMPARISON_GT"
      threshold_value = 0
      duration        = "0s"
    }
  }
  notification_channels = [google_monitoring_notification_channel.mops_pagerduty.id]
}
"""inventory_lock.py — fencing token(修正2) × 冪等性チェック(修正3) × 指数バックオフ(修正4)"""
from __future__ import annotations

import random
import time
from dataclasses import dataclass
from typing import Final
from uuid import uuid4

import redis
from opentelemetry import trace  # 修正5: トレーシング

tracer = trace.get_tracer(__name__)

_LOCK_TTL_MS: Final[int] = 5_000
_MAX_RETRY_ATTEMPTS: Final[int] = 5
_BASE_BACKOFF_SECONDS: Final[float] = 0.05
_JITTER_SECONDS: Final[float] = 0.03
_IDEMPOTENCY_TTL_SECONDS: Final[int] = 86_400

# 修正2: 自分のトークンと一致する場合のみ削除する原子的なLuaスクリプト
_RELEASE_LOCK_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
"""


@dataclass(frozen=True, slots=True)
class LockHandle:
    """取得したロックのfencing token(不変)。"""
    sku: str
    token: str


def acquire_lock(client: redis.Redis, sku: str) -> LockHandle | None:
    """SKU単位のロックをfencing token付きで取得する(修正2)。

    Args:
        client: Memorystore Redisクライアント。
        sku: ロック対象のSKU。

    Returns:
        取得に成功した場合はLockHandle、取得できなかった場合はNone。
    """
    with tracer.start_as_current_span("inventory_lock.acquire") as span:
        for attempt in range(_MAX_RETRY_ATTEMPTS):
            token = str(uuid4())  # 修正2: ロックごとに一意なトークンを発行
            acquired = client.set(f"lock:{sku}", token, nx=True, px=_LOCK_TTL_MS)
            if acquired:
                span.set_attribute("lock.attempts", attempt + 1)
                return LockHandle(sku=sku, token=token)

            # 修正4: 指数バックオフ + ジッターでthundering herdを回避
            delay = _BASE_BACKOFF_SECONDS * (2 ** attempt) + random.uniform(0, _JITTER_SECONDS)
            time.sleep(delay)

        span.set_attribute("lock.acquire_failed", True)
        return None


def release_lock(client: redis.Redis, handle: LockHandle) -> bool:
    """自分が取得したロックであることを確認した上で解放する(修正2)。

    Args:
        client: Memorystore Redisクライアント。
        handle: acquire_lockで取得したLockHandle。

    Returns:
        解放に成功した場合True。既にTTL切れ等で他者のロックに置き換わっていた場合False。
    """
    released = client.eval(_RELEASE_LOCK_SCRIPT, 1, f"lock:{handle.sku}", handle.token)
    return bool(released)


def check_and_mark_processed(client: redis.Redis, order_id: str, version: int) -> bool:
    """Cloud Tasksのリトライによる二重引当を防ぐ冪等性チェック(修正3)。

    Args:
        client: Memorystore Redisクライアント。
        order_id: 注文ID。
        version: 引当バージョン(注文更新のたびにインクリメント)。

    Returns:
        初回実行の場合True(処理を続行してよい)。既に処理済みの場合False(スキップすべき)。
    """
    key = f"idempotency:{order_id}:{version}"
    is_first_time = client.set(key, "1", nx=True, ex=_IDEMPOTENCY_TTL_SECONDS)
    return bool(is_first_time)

Bad vs Good 設計比較

観点Bad(現状)Good(改善後)
Memorystore tierBasic tier(単一ノード、レプリカなし)Standard tier(レプリカ + 自動フェイルオーバー)
ロックの識別SET key 1 NX EX 10のみ、保持者を識別できないUUIDトークンによるfencing token方式
ロック解放DEL keyで無条件削除、他者のロックを誤削除しうるLuaスクリプトで自分のトークン一致時のみDEL
リトライと冪等性Cloud Tasksのリトライで同一処理が複数回実行される冪等性キー(注文ID+バージョン)で処理済み判定
ロック取得の再試行固定100ms間隔、thundering herdのリスク指数バックオフ + ジッター
可観測性二重引当の発生を追跡する手段がないOTelトレーシング + DataDogでロック取得待ち時間・二重引当件数を可視化

確認コマンド

# 1. Memorystore がStandard tier(HA構成)であることを確認
gcloud redis instances describe inventory-lock-cache --region=asia-northeast1 \
  --format="value(tier)"
# Expected: STANDARD_HA

# 2. fencing tokenが正しく機能し、他者のロックを誤って解放しないことを確認
kubectl exec -n mops deploy/inventory-reservation-service -c app -- \
  python -c "
from inventory_lock import acquire_lock, release_lock
import redis
c = redis.Redis(host='127.0.0.1', port=6379)
h1 = acquire_lock(c, 'SKU-TEST')
c.set('lock:SKU-TEST', 'other-token', xx=True)  # TTL切れ後に別Podが取得したと仮定
print(release_lock(c, h1))  # Expected: False (自分のトークンと不一致のため解放されない)
"

# 3. 冪等性チェックにより同一注文の二重処理がスキップされることを確認
kubectl exec -n mops deploy/inventory-reservation-service -c app -- \
  python -c "
from inventory_lock import check_and_mark_processed
import redis
c = redis.Redis(host='127.0.0.1', port=6379)
print(check_and_mark_processed(c, 'ORDER-1', 1))  # Expected: True (初回)
print(check_and_mark_processed(c, 'ORDER-1', 1))  # Expected: False (2回目はスキップ)
"

# 4. DataDogでロック取得待ち時間p99と二重引当検知件数を確認
datadog-ci metrics query "avg:inventory_lock.acquire_latency_ms{service:inventory-reservation-service}.rollup(p99)"
# Expected: p99が閾値500ms以内で安定

ポイント解説

カテゴリ A

1 副作用のある関数は「戻り値を読むだけでは何が起きたか分からない」関数になる(Ch4)
reserve_items は shortage_items を返しているが、実際には warehouse_stock・order["items"]・order["reserved_count"] という3つの外部状態も書き換えていた。呼び出し側はソースコードを全部読まない限りこれに気づけず、意図しない箇所で在庫やリストが変化するバグを生む。
2 ミュータブルなデフォルト引数はPythonの「デフォルト値は関数定義時に1回だけ評価される」という仕様に起因する古典的な罠(Ch4/Ch10)
reserved_log=[] は呼び出しごとに新しいリストが作られると誤解されがちだが、実際は同一のリストオブジェクトが使い回され、複数回の呼び出しでログが蓄積し続けてしまう。
3 同じ変数名への再代入は「その変数が今どの値を指しているか」を読み手に追跡させるコストを生む(Ch4)
fee に「引数として渡された値」を代入した直後、別の意味(累計値)で上書きしていたため、関数の途中で fee の意味が変わっていた。意味が変わるなら別の変数名を与えるべきで、これによりコードは「上から下に読むだけで理解できる」状態になる。

カテゴリ B

4 分散ロックの安全性は「ロックを取得できたか」ではなく「今もそのロックを保持しているか」で決まる(fencing token)
TTL切れは避けられない前提として設計し、「自分が取得したロックであることをトークンで証明できて初めて解放/更新を許可する」という一段階のチェックを挟むことで、GCポーズやネットワーク遅延による誤動作を防げる。
5 冪等性はロックとは独立した問題であり、「同じリクエストが何度届いても結果が同じ」であることを保証する仕組み(修正3)
リトライを前提とするCloud Tasksのような配信基盤では、ロックだけでは同一タスクの重複実行を防げない。処理対象そのもの(注文+バージョン)に対して「処理済みかどうか」を記録する必要がある。
6 バックオフとジッターは「一斉に同じタイミングでリトライする」ことによる二次障害を防ぐ(修正4)
固定間隔リトライは、ロック競合が解消されるタイミングでPod群が再び一斉に押し寄せる同期現象を生みやすく、ジッターを加えることでリトライタイミングを分散させる。

実務への応用

  • ReservationResultのように「関数の全ての出力を1つの不変オブジェクトに集約する」パターンは、複数の外部状態を更新しがちなバッチ処理(在庫調整・ポイント一括付与・クーポン一括発行など)全般に横展開できる: 「この関数は引数を書き換えるか」をコードレビューのチェック項目に加えると発見しやすい
  • ミュータブルデフォルト引数のバグはruffのB006ルールで静的に検出できるため、CIのlintルールに追加しておくと同種の再発を防げる
  • fencing tokenパターンは在庫引当に限らず、複数Podが同じ外部リソース(在庫・クーポン残数・予約枠)を奪い合う全てのMOpsサービスに適用できる標準対策: Redlockのような分散合意プロトコルを使わずとも、単一Redisインスタンス+トークン検証で多くの実運用要件は満たせる
  • 冪等性キーの設計(リソースID+バージョン)は、Cloud Tasks・Pub/Subなど「少なくとも1回配信(at-least-once)」を保証する基盤全般で必須のパターン: 決済確定・出荷指示・メール送信など「二重実行が事故につながる処理」には常にこの設計を検討する

今日のまとめ

カテゴリAでは、呼び出し元の在庫辞書・注文リスト・注文dictを黙って書き換え、さらに同じ変数名に異なる意味の値を再代入していた在庫引当処理を、ReservationResultという不変な戻り値に全ての結果を集約し、引数は一切書き換えない設計に改めた(第4章)。

カテゴリBでは、その引当処理を支える inventory-reservation-service のRedisロックを、fencing tokenによる「自分のロックかどうかの検証」と、冪等性キーによる「Cloud Tasksリトライへの耐性」で堅牢化した。両者に共通するのは「今保持していると思っている状態(変数の値、ロックの所有権)が、実は別の経路で書き換えられている可能性を前提に設計する」という視点である。

次のステップ

  • 発展問題: reserve_items に「複数SKUをまたぐ引当のロールバック(一部SKUが不足した場合に全体を取り消す)」を、ReservationResultの不変性を崩さずに実装する
  • 発展問題: fencing tokenをRedisだけでなく、複数リージョンにまたがるMemorystoreインスタンス間で一貫させる場合の設計(Redlockアルゴリズムの複数ノード合意)を検討する
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第4章(不変の活用)、Redis公式ドキュメント「Distributed locks with Redis」、ruff B006ルール、Cloud Tasks 冪等性設計ガイド

自己評価(あとで記入)

自分の回答

気づき・メモ