概要
省略形の命名は「読む人が定義元へ戻る回数」を増やすコスト
cd/tp/v/flg/tmpのような省略形は書く側には楽だが、読む側は毎回コメントに戻る必要がある。discount_type/has_applied_couponのような完全な名前はコメントなしで意味が通る(Ch1-2)。
データクラス問題はデータと判断ロジックを引き離す
CouponDataは8フィールドを持つがメソッドを持たない「入れ物」だった。有効性判定・割引計算・残数消費をCoupon自身のメソッドに集約する(Ch1-2)。
ガード節で「条件の数」と「本体の深さ」を切り離す
4段ネストの奥にあった本体処理を、早期continueで常にトップレベル1段に保つ。条件が増えてもネストは深くならない(Ch1-2)。
コネクションプーラーは並行度とDB上限のギャップを吸収する
30レプリカが直接primaryへ接続するとmax_connectionsを超過する。PgBouncerのtransaction poolingで実コネクション数を固定上限に抑える。
問題 A: コーディング — 意味不明な命名 × ネスト × データクラス問題(クーポン適用ロジック Bad→Good)
以下の「悪いコード」は、ECサイト MOps チームが運用するクーポン適用ロジックです。省略形だらけの命名、深いネスト、そして「データを持つだけでロジックを持たない」クラス(データクラス問題)という3つの問題が同居しています。問題点を全て洗い出し、「良いコード・悪いコードで学ぶ設計入門」第1-2章(悪しき構造・設計の初歩: 意味不明な命名、ネスト、データクラス問題)を使って Bad→Good にリファクタリングしてください。今回は書籍ローテーションの2周目1問目(Ch1-2)です。
制約・前提条件
- Python 3.12+
CouponDataのような「フィールドを持つだけでロジックを持たないクラス」を廃止し、有効性判定・割引計算・残数消費をクーポン自身のメソッドとして持たせること(データクラス問題の解消)cd/tp/v/mn/mx/st/exp/ns/d/flg/tmp/c/cs等の省略形変数名は、目的が一目でわかる名前に改めることforループ内の多段ifネストは、ガード節(早期continue)でフラットにすることapply関数の未使用引数(mbr)は削除するか、使う場合は用途を明示すること- 割引種別(
rate/fixed)は文字列比較ではなくEnumで表現すること - Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード + 実行例(input→output)+ 適用した設計パターン名と書籍対応章
悪いコード (Before) — カテゴリ A
このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_coupon.py — 省略形命名×多段ネスト×データクラス問題(CouponDataがロジックを持たない)
class CouponData:
def __init__(self, cd, tp, v, mn, mx, st, exp, ns):
self.cd = cd # クーポンコード
self.tp = tp # "rate" or "fixed"
self.v = v # 割引率 or 固定額
self.mn = mn # 最低購入金額
self.mx = mx # 割引上限額
self.st = st # 併用可能フラグ
self.exp = exp # 有効期限
self.ns = ns # 残り利用可能回数
def apply(cart, cs, mbr, now):
d = 0
flg = False
for c in cs:
if c.exp is not None:
if now > c.exp:
continue
if c.mn is not None:
if cart["total"] < c.mn:
continue
if c.ns is not None:
if c.ns <= 0:
continue
if flg:
if not c.st:
continue
tmp = 0
if c.tp == "rate":
tmp = cart["total"] * c.v / 100
if c.mx is not None:
if tmp > c.mx:
tmp = c.mx
else:
if c.tp == "fixed":
tmp = c.v
d = d + tmp
flg = True
c.ns = c.ns - 1 if c.ns is not None else None
return d
問題点サマリー(7点)
1CouponDataが8フィールドを持ちメソッドを一切持たない(データクラス問題, Ch1-2) — 有効性判定・割引計算・残数消費が外部のapply関数に漏れ出している
2cd/tp/v/mn/mx/st/exp/nsという省略形フィールド名(意味不明な命名, Ch1-2) — __init__のコメントを読まないと意味が分からない
3d/flg/tmp/c/csという省略形ローカル変数名(意味不明な命名, Ch1-2) — 特にflgは何のフラグか名前から読み取れない
4forループ内で4段以上ネストしたif(ネスト, Ch1-2) — 早期continueを使わずすべての条件をネストで表現
5文字列比較 + 冗長なelse-if連鎖(Ch1-2/Ch10) — タイプミスを実行時まで検知できない
6未使用のmbr引数 — 会員ランク別クーポン制限を意図したと推測されるデッドパラメータ
7呼び出し元がc.nsを直接書き換え(カプセル化の欠如, Ch1-2/Ch3) — 残数消費という振る舞いがクーポン自身のメソッドになっていない
ヒント A(段階的開示)
ヒント1 — 方向性
CouponData は8個のフィールドを持つが、メソッドを一切持たない「ただの入れ物」になっている。一方で「有効期限切れか」「最低購入金額を満たすか」「割引額はいくつか」というクーポン自身が答えられるべき問いへの答えが、すべて外部の apply 関数に散らばっている。これが「データクラス問題」で、データと振る舞いが引き離された結果、apply 関数だけを読んでもクーポンのルールが理解しにくくなっている。また cd/tp/v/mn/mx のような省略形は、定義元(__init__のコメント)を見ないと意味が分からず、コードの自己説明性を破壊している。
ヒント2 — アプローチ
DiscountTypeをStrEnum(RATE/FIXED)にするCoupon(@dataclass(slots=True))にis_applicable(cart_total, now)・calculate_discount(cart_total)・consume_one_use()の3メソッドを持たせ、apply_coupons関数からロジックを吸収するapply_coupons内のネストは、if not coupon.is_applicable(...): continueのようなガード節で1段に平坦化する- 「併用済みフラグ」は
has_applied_coupon: boolのような意味の通る名前にする - 未使用の
mbr引数は削除する
ヒント3 — コードの骨格
class DiscountType(StrEnum):
RATE = "rate"
FIXED = "fixed"
@dataclass(slots=True)
class Coupon:
code: str
discount_type: DiscountType
value: Decimal
minimum_purchase_amount: Decimal | None = None
maximum_discount_amount: Decimal | None = None
is_stackable: bool = False
expires_at: datetime | None = None
remaining_uses: int | None = None
def is_applicable(self, cart_total: Decimal, now: datetime) -> bool: ...
def calculate_discount(self, cart_total: Decimal) -> Decimal: ...
def consume_one_use(self) -> None: ...
def apply_coupons(cart_total: Decimal, coupons: list[Coupon], now: datetime) -> Decimal:
total_discount = Decimal("0")
has_applied_coupon = False
for coupon in coupons:
if not coupon.is_applicable(cart_total, now):
continue
if has_applied_coupon and not coupon.is_stackable:
continue
total_discount += coupon.calculate_discount(cart_total)
coupon.consume_one_use()
has_applied_coupon = True
return total_discount
問題点分析 — カテゴリ A
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | CouponDataがロジックを持たない | データクラス問題 Ch1-2 | is_applicable/calculate_discount/consume_one_useへ集約 |
| 2 | cd/tp/v/mn/mx/st/exp/nsの省略形フィールド名 | 意味不明な命名 Ch1-2 | discount_type等の完全な名前へ |
| 3 | d/flg/tmp/c/csの省略形変数名 | 意味不明な命名 Ch1-2 | has_applied_coupon等へ |
| 4 | 4段以上のネストしたif | ネスト Ch1-2 | ガード節(早期continue)でフラット化 |
| 5 | 文字列比較+冗長なelse-if | マジックストリング Ch1-2/Ch10 | StrEnum比較 |
| 6 | 未使用のmbr引数 | デッドパラメータ | 削除 |
| 7 | 呼び出し元がc.nsを直接書き換え | カプセル化の欠如 Ch1-2/Ch3 | consume_one_use()に集約 |
模範解答 A
Before — 省略形命名・多段ネスト・データクラス問題
class CouponData:
def __init__(self, cd, tp, v, mn, mx, st, exp, ns):
self.cd = cd
self.tp = tp
... # ロジックを一切持たない8フィールド
def apply(cart, cs, mbr, now): # mbrは未使用
d = 0
flg = False
for c in cs:
if c.exp is not None: # 多段ネスト開始
if now > c.exp:
continue
if c.mn is not None:
if cart["total"] < c.mn:
continue
...
if c.tp == "rate":
tmp = cart["total"] * c.v / 100
...
else:
if c.tp == "fixed": # 冗長なelse-if
tmp = c.v
d = d + tmp
flg = True
c.ns = c.ns - 1 if c.ns is not None else None # 呼び出し元が直接書き換え
return d
After — Couponにis_applicable/calculate_discount/consume_one_useを集約、ガード節でフラット化
"""coupon.py — 意味不明な命名の解消 × ネストのフラット化 × データクラス問題の解消(Ch1-2)"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
class DiscountType(StrEnum):
"""クーポンの割引種別。"""
RATE = "rate"
FIXED = "fixed"
@dataclass(slots=True)
class Coupon:
"""クーポン(単なるデータの入れ物ではなく、自身の妥当性判定・割引計算・
残数消費を担う, Ch1-2)。
Args:
code: クーポンコード。
discount_type: 割引種別(定率/定額)。
value: 割引率(%)または割引額(円)。
minimum_purchase_amount: 適用に必要な最低購入金額(円)。Noneなら制限なし。
maximum_discount_amount: 割引額の上限(円)。Noneなら上限なし。
is_stackable: 他のクーポンと併用可能か。
expires_at: 有効期限。Noneなら無期限。
remaining_uses: 残り利用可能回数。Noneなら無制限。
"""
code: str
discount_type: DiscountType
value: Decimal
minimum_purchase_amount: Decimal | None = None
maximum_discount_amount: Decimal | None = None
is_stackable: bool = False
expires_at: datetime | None = None
remaining_uses: int | None = None
def is_applicable(self, cart_total: Decimal, now: datetime) -> bool:
"""クーポンが今このカート合計に適用可能かを判定する。"""
if self.expires_at is not None and now > self.expires_at:
return False
if self.minimum_purchase_amount is not None and cart_total < self.minimum_purchase_amount:
return False
if self.remaining_uses is not None and self.remaining_uses <= 0:
return False
return True
def calculate_discount(self, cart_total: Decimal) -> Decimal:
"""割引額を計算する(定率の場合はmaximum_discount_amountでクリップ)。"""
if self.discount_type is DiscountType.RATE:
raw_discount = cart_total * self.value / Decimal("100")
else:
raw_discount = self.value
if self.maximum_discount_amount is not None:
return min(raw_discount, self.maximum_discount_amount)
return raw_discount
def consume_one_use(self) -> None:
"""残り利用可能回数を1減らす(呼び出し元による直接書き換えを廃止, Ch1-2/Ch3)。"""
if self.remaining_uses is not None:
self.remaining_uses -= 1
def apply_coupons(cart_total: Decimal, coupons: list[Coupon], now: datetime) -> Decimal:
"""カート合計にクーポンを適用し、合計割引額を返す。
Args:
cart_total: 割引適用前のカート合計金額(円)。
coupons: 適用candidateのクーポン一覧(適用順に評価される)。
now: 有効期限判定に使う現在時刻。
Returns:
合計割引額(円)。併用不可クーポンは最初に適用された1件のみが有効。
"""
total_discount = Decimal("0")
has_applied_coupon = False # 意味の通る名前(旧flg): 既に併用不可クーポンを適用済みか
for coupon in coupons:
if not coupon.is_applicable(cart_total, now):
continue
if has_applied_coupon and not coupon.is_stackable:
continue # ガード節でネストをフラット化(旧: 多段if)
total_discount += coupon.calculate_discount(cart_total)
coupon.consume_one_use()
has_applied_coupon = True
return total_discount
now = datetime(2026, 8, 15, 10, 0)
coupons = [
Coupon(
code="SUMMER10", discount_type=DiscountType.RATE, value=Decimal("10"),
minimum_purchase_amount=Decimal("3000"), maximum_discount_amount=Decimal("1000"),
is_stackable=False, expires_at=datetime(2026, 8, 31), remaining_uses=100,
),
Coupon(
code="FREESHIP500", discount_type=DiscountType.FIXED, value=Decimal("500"),
is_stackable=True, remaining_uses=50,
),
]
discount = apply_coupons(Decimal("8000"), coupons, now)
print(discount)
# SUMMER10: 8000*10/100=800円(上限1000円未満なのでそのまま) + FREESHIP500: 500円(併用可なので加算)
# → Decimal("1300")
print(coupons[0].remaining_uses) # → 99 (consume_one_useで内部的に減算)
print(coupons[1].remaining_uses) # → 49
| ポイント | 適用した設計原則/パターン | 書籍対応章 |
|---|---|---|
| Couponにis_applicable/calculate_discount/consume_one_useを実装 | データクラス問題の解消(データと振る舞いの一体化) | Ch1-2 |
| cd/tp/v/flg/tmp等を目的が読み取れる名前に変更 | 意味不明な命名の解消 | Ch1-2 |
| ガード節(早期continue)で多段ifを解消 | ネストのフラット化 | Ch1-2 |
| discount_type=="rate"をStrEnum比較に変更 | マジックストリングの排除 | Ch1-2/Ch10 |
| consume_one_use()で残数減算をクーポン自身に閉じ込め | カプセル化 | Ch1-2/Ch3 |
# tests/test_coupon.py
import pytest
from datetime import datetime
from decimal import Decimal
from coupon import Coupon, DiscountType, apply_coupons
class TestCouponIsApplicable:
def test_expired_coupon_is_not_applicable(self):
coupon = Coupon(
code="EXPIRED", discount_type=DiscountType.FIXED, value=Decimal("100"),
expires_at=datetime(2026, 1, 1),
)
assert coupon.is_applicable(Decimal("5000"), datetime(2026, 8, 15)) is False
def test_below_minimum_purchase_is_not_applicable(self):
coupon = Coupon(
code="MIN3000", discount_type=DiscountType.FIXED, value=Decimal("100"),
minimum_purchase_amount=Decimal("3000"),
)
assert coupon.is_applicable(Decimal("2000"), datetime(2026, 8, 15)) is False
class TestCouponCalculateDiscount:
def test_rate_discount_is_clipped_by_maximum(self):
coupon = Coupon(
code="RATE10", discount_type=DiscountType.RATE, value=Decimal("10"),
maximum_discount_amount=Decimal("500"),
)
assert coupon.calculate_discount(Decimal("10000")) == Decimal("500")
class TestApplyCoupons:
def test_non_stackable_coupon_applied_once_only(self):
now = datetime(2026, 8, 15)
coupons = [
Coupon(code="A", discount_type=DiscountType.FIXED, value=Decimal("100"), is_stackable=False),
Coupon(code="B", discount_type=DiscountType.FIXED, value=Decimal("200"), is_stackable=False),
]
assert apply_coupons(Decimal("5000"), coupons, now) == Decimal("100")
def test_remaining_uses_is_decremented(self):
now = datetime(2026, 8, 15)
coupon = Coupon(code="C", discount_type=DiscountType.FIXED, value=Decimal("100"), remaining_uses=1)
apply_coupons(Decimal("5000"), [coupon], now)
assert coupon.remaining_uses == 0
問題 B: インフラ — coupon-service の Cloud SQL コネクション枯渇対策(PgBouncer × リードレプリカ × Workload Identity)
問題Aのクーポン適用ロジックは coupon-service として GKE Autopilot 上で稼働し、Cloud SQL for PostgreSQL に接続しています。セール当日は HPA で最大30レプリカまでスケールしますが、以下の課題があります。
現状の課題:
coupon-serviceの各Podが Cloud SQL primary へ直接コネクションを張っており、コネクションプーラーが存在しない。30レプリカまでスケールするとmax_connectionsを容易に超過する- クーポン検索(読み取り、全トラフィックの約9割)と残数消費(書き込み)がすべてprimaryに集中しており、リードレプリカが活用されていない
- DB接続情報がKubernetes Secretに平文で格納されており、Secret Managerによる一元管理・自動ローテーションの対象外
statement_timeoutが未設定で、遅いクエリがコネクションを握り続け枯渇を悪化させる- 残数消費処理に排他制御がなく、在庫僅少のクーポンへの同時リクエストでLost Updateにより残数がマイナスまで消費されうる
- Cloud SQLのアクティブコネクション数と
max_connectionsの比率を監視するアラートが未設定
要件
| # | 要件 |
|---|---|
| 1 | PgBouncer(transaction poolingモード)をCloud SQL Auth Proxyと組み合わせ、レプリカ数が増えてもmax_connectionsを超えないプールサイズに固定すること |
| 2 | クーポン検索はリードレプリカへ、残数消費はprimaryへ、という読み書き分離のコネクション経路を用意すること |
| 3 | DB認証情報をSecret Managerで管理し、GKEWorkload Identity Federation経由でアクセスさせること |
| 4 | コネクション/プールレベルでstatement_timeoutを設定すること |
| 5 | 残数減算はUPDATE...WHERE remaining_uses>0 RETURNING...の原子的な1クエリで行い、Lost Updateを防ぐこと |
| 6 | Cloud SQLのpostgresql/num_backendsをCloud Monitoringで監視しアラートすること |
期待する回答形式: Terraform(Cloud SQLリードレプリカ + Secret Manager + Workload Identityバインディング)+ Kubernetesマニフェスト(PgBouncer + Cloud SQL Auth Proxy構成)+ Python(読み書き分離リポジトリ + 原子的な残数減算)+ Bad vs Good比較表 + 確認コマンド
ヒント B(段階的開示)
ヒント1 — 方向性
コネクション枯渇は「入口を絞らないまま出口(レプリカ数)を増やし続けた」結果として起きる典型的な障害パターン。対処は「コネクションプーラーで実際のDBコネクション数に上限を設ける」「読み取りと書き込みを別の経路に分ける」「遅いクエリでコネクションを塞がせない」という3点の組み合わせ。Lost Updateはこれとは別の問題で、「読んでから書く」という2ステップ操作を並行実行すると起きる典型的な競合状態であり、1つの原子的なSQL文に凝縮することで解決する。
ヒント2 — アプローチ
google_sql_database_instanceのreplica_configurationでリードレプリカを作成- PgBouncerを primary用/replica用の2インスタンスに分け、
pool_mode: transaction、default_pool_sizeはレプリカ数上限×プールサイズがmax_connectionsを超えない値に設定 - Cloud SQL Auth Proxyはサイドカーとして配置し、Workload Identity FederationでIAM認証(サービスアカウントキー不要)
psycopgの接続オプションに-c statement_timeout=3000を付与- 残数減算は
UPDATE coupons SET remaining_uses = remaining_uses - 1 WHERE code = %s AND remaining_uses > 0 RETURNING remaining_usesの1文で行い、RETURNINGの有無で成功/失敗を判定
ヒント3 — リソースの骨格
PgBouncerの骨格(K8sマニフェスト)
- name: pgbouncer-primary
image: edoburu/pgbouncer:1.22.1
env:
- name: DATABASE_URL
value: "postgresql://coupon_app@127.0.0.1:5432/coupon"
- name: POOL_MODE
value: "transaction"
- name: DEFAULT_POOL_SIZE
value: "5" # 30レプリカ×5=150 < max_connections 200
ports: [{ containerPort: 6432 }]
原子的な残数減算(SQL)
UPDATE coupons
SET remaining_uses = remaining_uses - 1
WHERE code = %s AND remaining_uses > 0
RETURNING remaining_uses;
-- RETURNINGに行が返らなければ「既に残数0」= 減算失敗
アーキテクチャ図 — PgBouncer プールサイズ固定 × 読み書き分離 × Workload Identity
模範解答 B
resource "google_sql_database_instance" "coupon_db_primary" {
name = "coupon-db-primary"
database_version = "POSTGRES_16"
region = "asia-northeast1"
settings {
tier = "db-custom-4-16384"
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.mops_vpc.id
}
database_flags {
name = "max_connections"
value = "200"
}
}
}
# 修正2: 読み取り専用トラフィック(検索の約9割)をprimaryから分離するリードレプリカ
resource "google_sql_database_instance" "coupon_db_replica" {
name = "coupon-db-replica"
master_instance_name = google_sql_database_instance.coupon_db_primary.name
database_version = "POSTGRES_16"
region = "asia-northeast1"
replica_configuration {
failover_target = false
}
settings {
tier = "db-custom-4-16384"
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.mops_vpc.id
}
}
}
# 修正3: DB認証情報をSecret Managerで一元管理(K8s Secretへの平文格納を廃止)
resource "google_secret_manager_secret" "coupon_db_password" {
secret_id = "coupon-db-password"
replication { auto {} }
}
resource "google_service_account" "coupon_service_sa" {
account_id = "coupon-service"
}
resource "google_secret_manager_secret_iam_member" "coupon_service_secret_access" {
secret_id = google_secret_manager_secret.coupon_db_password.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.coupon_service_sa.email}"
}
resource "google_project_iam_member" "coupon_service_cloudsql_client" {
project = var.project_id
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.coupon_service_sa.email}"
}
# 修正3(続き): GKE Workload Identity Federationバインディング(サービスアカウントキー不要)
resource "google_service_account_iam_member" "workload_identity_binding" {
service_account_id = google_service_account.coupon_service_sa.name
role = "roles/iam.workloadIdentityUser"
member = "serviceAccount:${var.project_id}.svc.id.goog[mops/coupon-service]"
}
# 修正6: アクティブコネクション数がmax_connectionsに対して80%を超えたらアラート
resource "google_monitoring_alert_policy" "coupon_db_connection_saturation" {
display_name = "coupon-db-primary connection saturation"
combiner = "OR"
conditions {
display_name = "num_backends / max_connections > 0.8"
condition_threshold {
filter = "resource.type=\"cloudsql_database\" AND resource.labels.database_id=\"${var.project_id}:coupon-db-primary\" AND metric.type=\"cloudsql.googleapis.com/database/postgresql/num_backends\""
comparison = "COMPARISON_GT"
threshold_value = 160 # max_connections=200の80%
duration = "60s"
}
}
notification_channels = [google_monitoring_notification_channel.mops_pagerduty.id]
}
apiVersion: apps/v1
kind: Deployment
metadata:
name: coupon-service
namespace: mops
spec:
replicas: 2 # HPAで最大30まで
selector:
matchLabels: { app: coupon-service }
template:
metadata:
labels: { app: coupon-service }
spec:
serviceAccountName: coupon-service # 修正3: Workload Identity Federation
containers:
- name: app
image: asia-northeast1-docker.pkg.dev/PROJECT_ID/mops/coupon-service@sha256:abc123
env:
- name: DB_WRITE_DSN
value: "postgresql://coupon_app@127.0.0.1:6432/coupon" # 修正1/2: PgBouncer経由(primary=書き込み)
- name: DB_READ_DSN
value: "postgresql://coupon_app@127.0.0.1:6433/coupon" # 修正2: PgBouncer経由(replica=読み取り)
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "250m", memory: "512Mi" }
# 修正1: primary用PgBouncer(transaction pooling)
- name: pgbouncer-primary
image: edoburu/pgbouncer:1.22.1
env:
- name: DATABASE_URL
value: "postgresql://coupon_app@127.0.0.1:5432/coupon" # cloud-sql-proxy(primary)経由
- name: POOL_MODE
value: "transaction" # トランザクション単位でコネクションを使い回す
- name: MAX_CLIENT_CONN
value: "1000"
- name: DEFAULT_POOL_SIZE
value: "5" # 30レプリカ×5=150 < max_connections 200(replicaと合わせても余裕を残す)
ports: [{ containerPort: 6432 }]
# 修正2: replica用PgBouncer(読み取り専用トラフィック)
- name: pgbouncer-replica
image: edoburu/pgbouncer:1.22.1
env:
- name: DATABASE_URL
value: "postgresql://coupon_app@127.0.0.1:5433/coupon"
- name: POOL_MODE
value: "transaction"
- name: DEFAULT_POOL_SIZE
value: "5"
ports: [{ containerPort: 6433 }]
# 修正3: Cloud SQL Auth Proxy(primary) — Workload IdentityによるIAM認証、SAキー不要
- name: cloud-sql-proxy-primary
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.0
args:
- "--structured-logs"
- "--port=5432"
- "PROJECT_ID:asia-northeast1:coupon-db-primary"
resources:
requests: { cpu: "100m", memory: "128Mi" }
# 修正3: Cloud SQL Auth Proxy(replica)
- name: cloud-sql-proxy-replica
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.0
args:
- "--structured-logs"
- "--port=5433"
- "PROJECT_ID:asia-northeast1:coupon-db-replica"
resources:
requests: { cpu: "100m", memory: "128Mi" }
"""coupon_repository.py — 読み書き分離(修正2) × statement_timeout(修正4) × 原子的な残数減算(修正5)"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
import psycopg
# 名前付き定数: 遅いクエリでコネクションを握り続けさせない上限(修正4)
_STATEMENT_TIMEOUT_MS: Final[int] = 3000
@dataclass(frozen=True, slots=True)
class CouponRecord:
code: str
remaining_uses: int | None
class CouponRepository:
"""クーポンの検索(読み取りレプリカ)と残数消費(primary)を分離するリポジトリ。
Args:
write_dsn: 書き込み用(primary経由PgBouncer)のDSN。
read_dsn: 読み取り用(replica経由PgBouncer)のDSN。
"""
def __init__(self, write_dsn: str, read_dsn: str) -> None:
self._write_dsn = write_dsn
self._read_dsn = read_dsn
def find_by_code(self, code: str) -> CouponRecord | None:
"""クーポンコードで検索する(修正2: 読み取りレプリカへルーティング)。"""
with psycopg.connect(
self._read_dsn, options=f"-c statement_timeout={_STATEMENT_TIMEOUT_MS}" # 修正4
) as conn:
row = conn.execute(
"SELECT code, remaining_uses FROM coupons WHERE code = %s",
(code,),
).fetchone()
return CouponRecord(code=row[0], remaining_uses=row[1]) if row else None
def consume_one_use(self, code: str) -> bool:
"""残数を原子的に1減算する(修正5: SELECT→アプリ判定→UPDATEの2段階操作をやめ、
1つのUPDATE文でLost Updateを防ぐ)。
Args:
code: 消費対象のクーポンコード。
Returns:
減算に成功した場合True。残数が既に0で減算できなかった場合False。
"""
with psycopg.connect(
self._write_dsn, options=f"-c statement_timeout={_STATEMENT_TIMEOUT_MS}" # 修正4
) as conn:
row = conn.execute(
"""
UPDATE coupons
SET remaining_uses = remaining_uses - 1
WHERE code = %s AND remaining_uses > 0
RETURNING remaining_uses
""",
(code,),
).fetchone()
conn.commit()
return row is not None
Bad vs Good 設計比較
| 観点 | Bad(現状) | Good(改善後) |
|---|---|---|
| コネクション経路 | 各Podがprimaryへ直接接続、30レプリカでmax_connections超過 | PgBouncer(transaction pooling)経由でプールサイズを固定上限化 |
| 読み書き分離 | 読み取り9割もprimaryに集中 | 検索はreplica経由PgBouncer、残数消費のみprimary経由 |
| DB認証情報 | K8s Secretに平文パスワード | Secret Manager + Workload Identity Federation(IAM認証) |
| 遅いクエリ対策 | statement_timeoutなし、コネクション握り続け | statement_timeout=3秒で強制打ち切り |
| 残数消費の競合安全性 | SELECT→アプリ判定→UPDATE(Lost Updateの危険) | UPDATE...WHERE remaining_uses>0 RETURNINGの1文で原子化 |
| 監視 | コネクション枯渇の予兆を検知できない | num_backends/max_connections比率にCloud Monitoringアラート |
確認コマンド
# 1. PgBouncerがtransaction poolingモードで起動していることを確認
kubectl exec -n mops deploy/coupon-service -c pgbouncer-primary -- cat /etc/pgbouncer/pgbouncer.ini | grep pool_mode
# Expected: pool_mode = transaction
# 2. Cloud SQL replicaへの接続確認(recovery mode=trueならレプリカ)
kubectl exec -n mops deploy/coupon-service -c app -- psql "$DB_READ_DSN" -c "SELECT pg_is_in_recovery();"
# Expected: t
# 3. アクティブコネクション数の監視値を確認
gcloud monitoring time-series list \
--filter='metric.type="cloudsql.googleapis.com/database/postgresql/num_backends"' \
--format="table(points[0].value.int64Value)"
# Expected: max_connections(200)を大きく下回る値で安定
# 4. 残数消費の競合安全性を確認(remaining_uses=1のクーポンに同時2リクエスト)
for i in 1 2; do
kubectl exec -n mops deploy/coupon-service -c app -- \
python -c "from coupon_repository import CouponRepository; \
repo = CouponRepository('$DB_WRITE_DSN', '$DB_READ_DSN'); \
print(repo.consume_one_use('LOWSTOCK1'))" &
done
wait
# Expected: 片方はTrue、もう片方はFalse(remaining_usesがマイナスにならない)
# 5. Secret ManagerからDB認証情報が取得できていることを確認(K8s Secretに平文パスワードがないこと)
kubectl get secret -n mops coupon-db-credentials 2>&1
# Expected: Error from server (NotFound) — Secret Manager + Workload Identityへ完全移行済み
ポイント解説
カテゴリ A
1
データクラス問題の本質は「データと、そのデータに関する判断ロジックが別々の場所にある」こと(Ch1-2)
CouponDataが単なる入れ物のままだと、「有効かどうか」を判定するコードを書くたびにapplyのような外部関数が全フィールドを覗き込む必要がある。Coupon.is_applicable()のようにクラス自身にロジックを持たせると、判定ルールの変更が1箇所(クラス定義)に閉じる。
2
省略形の命名は「読む人が定義元に戻る回数」を増やすコスト
tp/v/mn/mxは書く側には短くて楽だが、読む側は毎回__init__のコメントに戻る必要がある。discount_type/value/minimum_purchase_amountのような完全な名前は、コメントなしでも意味が通るようになる。
3
ガード節(早期continue/return)はネストの深さを「条件の数」ではなく「条件を除いた後の本体」だけに保つ
元のコードは4段ネストの奥に本体処理があったが、Good版では本体(割引加算・残数消費)が常にトップレベルの1段で読める。
元のコードは4段ネストの奥に本体処理があったが、Good版では本体(割引加算・残数消費)が常にトップレベルの1段で読める。
カテゴリ B
4
コネクションプーラーは「アプリの並行度」と「DBが許容できる同時接続数」の間のギャップを吸収する層
HPAでレプリカ数を増やすほど直接接続では線形にコネクション数が増えるが、PgBouncerのtransaction poolingは実際のトランザクション実行中だけDBコネクションを占有するため、少数のDBコネクションで多数のクライアント接続を捌ける。
HPAでレプリカ数を増やすほど直接接続では線形にコネクション数が増えるが、PgBouncerのtransaction poolingは実際のトランザクション実行中だけDBコネクションを占有するため、少数のDBコネクションで多数のクライアント接続を捌ける。
5
読み書き分離は「読み取りが大半のワークロード」に対してコスト効率よくスケールする定石
クーポン検索のような読み取りをreplicaに逃すことで、primaryのコネクション・CPUリソースを書き込み(残数消費)に集中させられる。
クーポン検索のような読み取りをreplicaに逃すことで、primaryのコネクション・CPUリソースを書き込み(残数消費)に集中させられる。
6
Lost Updateは「読んでから書く」を2つの独立した文に分けたときに発生する
SELECTでremaining_usesを読み、アプリ側で>0を判定し、別のUPDATE文で書き込む、という3段階の間に別リクエストが割り込むと、両方が同じ古い値を元に減算してしまう。UPDATE...WHERE...RETURNINGのように「条件チェックと更新を1つの原子的な文にする」ことで、DBのロック機構に競合解決を委ねられる。
実務への応用
- Couponのようにデータクラス問題を解消するリファクタリングは、MOpsの他のドメイン(キャンペーン設定・ポイント付与ルール・A/Bテストのバリアント設定等)にも同じ形で横展開できる: 「このクラスは自分の状態について何か判断できるか、それとも外部の関数が判断しているか」をコードレビューの観点に加えると発見しやすい
- ガード節によるネストのフラット化は、条件分岐が3段以上になったコードすべてに機械的に適用できる即効性の高いリファクタリングで、レビューコストが低い割に可読性の改善幅が大きい
- PgBouncer + リードレプリカの読み書き分離パターンは、coupon-serviceに限らずセール当日にトラフィックが跳ねる全MOpsサービス(クーポン・在庫・レコメンド)に横展開できる標準構成。読み取り比率が高いサービスほど効果が大きい
UPDATE...WHERE...RETURNINGによる原子的な減算パターンは、在庫数・クーポン残数・キャンペーン予算消費額など「複数リクエストから同時に減らされうる数値」全般に適用できる: SELECT FOR UPDATEより単純で、明示的なトランザクション制御なしに競合を解決できる
今日のまとめ
カテゴリAでは、8フィールドを持ちながらロジックを一切持たない
カテゴリBでは、その
CouponData(データクラス問題)と、省略形の命名・深いネストが同居していたクーポン適用ロジックを、Coupon に is_applicable/calculate_discount/consume_one_use を持たせてデータと振る舞いを一体化し、ガード節でネストをフラット化した(Ch1-2)。カテゴリBでは、その
Coupon.consume_one_use() を実運用で支える coupon-service のCloud SQL接続を、PgBouncerによるプールサイズ固定・読み書き分離・Workload Identity Federation・原子的なUPDATE...RETURNINGで堅牢化した。どちらも共通するのは「本来1箇所にまとまるべき責務(データの意味、DBコネクションの管理、更新の原子性)が、複数の場所に薄く広く漏れ出していたものを、専用の場所に集約する」という設計思想である。
次のステップ
- 発展問題:
apply_couponsに「クーポンの適用順序(割引額が大きい順)」というビジネスルールを追加し、Couponのデータクラス問題を再発させずに(ソートロジックをどこに置くべきか)実装する - 発展問題: PgBouncerのプールサイズをセール当日のトラフィック予測に応じて動的に変更する運用(HPAのレプリカ数と連動したtfvars切り替え)を設計する
- 参考: 「良いコード・悪いコードで学ぶ設計入門」Ch1-2(悪しき構造・設計の初歩)、PgBouncer
pool_modeドキュメント、Cloud SQL 読み取りレプリカ、PostgreSQLUPDATE...RETURNING