概要
ミュータブルなデフォルト引数は呼び出しをまたいで蓄積する
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
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | reserved_log=[]のミュータブルデフォルト引数 | 再代入防止 Ch4/Ch10 | None許容にし関数内で新規作成 |
| 2 | warehouse_stockを直接書き換え | 副作用排除 Ch4 | コピーしてから減算 |
| 3 | order["items"]のin-placeソート | 副作用排除 Ch4 | sorted()で新規リスト |
| 4 | order["reserved_count"]への直接書き込み | 影響範囲の明確化 Ch4 | ReservationResultへ集約 |
| 5 | fee引数への再代入 | 再代入防止 Ch4 | priority_feeとして固定利用 |
| 6 | ループ内でfeeへ加算し続ける再代入 | 再代入防止 Ch4 | total_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が」二重引当を起こしたか追跡できない
要件
| # | 要件 |
|---|---|
| 1 | MemorystoreをStandard tier(レプリカ + 自動フェイルオーバー)に変更すること |
| 2 | ロック取得時に一意なトークン(UUID)を値としてセットし、解放はLuaスクリプトで「自分のトークンと一致する場合のみDEL」を原子的に実行すること(fencing tokenパターン) |
| 3 | Cloud 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 × 冪等性キー × 指数バックオフ
模範解答 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 tier | Basic 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ポーズやネットワーク遅延による誤動作を防げる。
TTL切れは避けられない前提として設計し、「自分が取得したロックであることをトークンで証明できて初めて解放/更新を許可する」という一段階のチェックを挟むことで、GCポーズやネットワーク遅延による誤動作を防げる。
5
冪等性はロックとは独立した問題であり、「同じリクエストが何度届いても結果が同じ」であることを保証する仕組み(修正3)
リトライを前提とするCloud Tasksのような配信基盤では、ロックだけでは同一タスクの重複実行を防げない。処理対象そのもの(注文+バージョン)に対して「処理済みかどうか」を記録する必要がある。
リトライを前提とするCloud Tasksのような配信基盤では、ロックだけでは同一タスクの重複実行を防げない。処理対象そのもの(注文+バージョン)に対して「処理済みかどうか」を記録する必要がある。
6
バックオフとジッターは「一斉に同じタイミングでリトライする」ことによる二次障害を防ぐ(修正4)
固定間隔リトライは、ロック競合が解消されるタイミングでPod群が再び一斉に押し寄せる同期現象を生みやすく、ジッターを加えることでリトライタイミングを分散させる。
固定間隔リトライは、ロック競合が解消されるタイミングでPod群が再び一斉に押し寄せる同期現象を生みやすく、ジッターを加えることでリトライタイミングを分散させる。
実務への応用
- ReservationResultのように「関数の全ての出力を1つの不変オブジェクトに集約する」パターンは、複数の外部状態を更新しがちなバッチ処理(在庫調整・ポイント一括付与・クーポン一括発行など)全般に横展開できる: 「この関数は引数を書き換えるか」をコードレビューのチェック項目に加えると発見しやすい
- ミュータブルデフォルト引数のバグはruffのB006ルールで静的に検出できるため、CIのlintルールに追加しておくと同種の再発を防げる
- fencing tokenパターンは在庫引当に限らず、複数Podが同じ外部リソース(在庫・クーポン残数・予約枠)を奪い合う全てのMOpsサービスに適用できる標準対策: Redlockのような分散合意プロトコルを使わずとも、単一Redisインスタンス+トークン検証で多くの実運用要件は満たせる
- 冪等性キーの設計(リソースID+バージョン)は、Cloud Tasks・Pub/Subなど「少なくとも1回配信(at-least-once)」を保証する基盤全般で必須のパターン: 決済確定・出荷指示・メール送信など「二重実行が事故につながる処理」には常にこの設計を検討する
今日のまとめ
カテゴリAでは、呼び出し元の在庫辞書・注文リスト・注文dictを黙って書き換え、さらに同じ変数名に異なる意味の値を再代入していた在庫引当処理を、
カテゴリBでは、その引当処理を支える
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 冪等性設計ガイド