Azure Database for PostgreSQLフレキシブル サーバーでキャッシュを使用してパフォーマンスを向上させる

フレキシブル サーバー上Azure Database for PostgreSQLアプリケーションを構築する場合、キャッシュ 層を追加することは、応答時間の向上、データベースの負荷の軽減、回復性の向上に最も効果的な方法の 1 つです。 メモリ内キャッシュから頻繁に読み取られたデータを提供することで、アプリケーションが PostgreSQL に送信するクエリが少なくなります。 つまり、CPU と IOPS の消費量が少ないため、より小さなコンピューティング レベルで実行し、サーバーをスケールアップせずに読み取りをスケーリングし、トラフィックの急増を吸収できます。 キャッシュは回復性を追加することもできます。 PostgreSQL に短時間の中断がある場合、キャッシュ内のデータにヒットした要求は成功し続ける可能性があるため、データベースの復旧中も読み取りパスは引き続き使用できます。

この記事では、キャッシュが役立つタイミングと、アプリケーションに適したパターンを決定するのに役立ちます。 その後、Azure Managed Redisredis ライブラリとpsycopg ライブラリ、およびMicrosoft Entra ID認証を使用して、Pythonに 4 つのキャッシュ パターン (キャッシュアサイド、参照データプリフェッチ、ライトスルー、およびイベント ドリブン) を実装します。

キャッシュを追加するタイミング

PostgreSQL では、頻繁にアクセスされるデータ ページがバッファー キャッシュに既にキャッシュされており、オペレーティング システムのファイル キャッシュの利点もあります。 これらのキャッシュを使用すると、アクセスの繰り返しが高速になりますが、データベース サーバーに割り当てられたメモリはクエリの実行やその他のプロセスと共有されます。 また、そのコンテンツも、一部のメンテナンスやフェールオーバー操作の後に再びウォームアップが必要になります。

より多くのメモリを持つコンピューティング オプションにスケーリングすることで、より多くのデータベース キャッシュ容量を取得できます。 PostgreSQL のメモリ設定を調整することもできますが、バッファー キャッシュに割り当てるメモリを増やすと、クエリの実行やオペレーティング システムに対するメモリの割り当て量が少なくなります。 メモリ不足の状態を避けるために、メモリの変更を慎重にテストします。

Azure Managed Redis は、これらのネイティブ キャッシュを補完します。 選択したアプリケーション データとクエリ結果をデータベース サーバーの外部に格納し、時間の影響を受けやすい読み取りパスに対する待機時間の短いアクセスを提供し、PostgreSQL がキャッシュを復旧またはウォーム化している間は、キャッシュされた読み取りを使用可能な状態に保つことができます。 これらの利点が、追加されたアプリケーション ロジックと動作する別のサービスを正当化する場合に使用します。 PostgreSQL が真実のソースとして置き換えられるわけではありません。

Azure Managed Redis などの外部キャッシュを追加すると、ワークロードに次の特性がある場合に最も役立ちます。

  • 読み取り負荷の高いアクセス パターン。 製品カタログ、ユーザー プロファイル、構成データ、参照テーブルなど、同じ行が変更されるよりもはるかに頻繁に読み取られます。
  • 高コストまたは繰り返されるクエリ。 集計、結合、または計算された結果。生成にはコストがかかりますが、短い時間枠で安定しています。
  • 待機時間の影響を受けやすいエンドポイント。 データベースラウンドトリップよりメモリ内読み取り (ミリ秒未満) が望ましいユーザー向けの操作。
  • 予測可能な急増。 季節要因やイベントによってトラフィックが増加し、通常であればコンピューティングのスケールが必要になる負荷をキャッシュで吸収できる場合。
  • メンテナンスとフェールオーバーの感度。 メンテナンスまたはフェールオーバー操作後に PostgreSQL インスタンスがキャッシュを復旧またはウォームアップする間に、安定した応答時間を必要とするパスを読み取ります。

キャッシュは、書き込み負荷の高いワークロード、常にトランザクション整合性が必要なデータ、またはすでに高速で実行され、繰り返し実行されることがほとんどないクエリに対しては、あまり効果がありません。

キャッシュ パターン

この記事では、小売店を一貫した例として取り上げます。 アプリの各部分は、さまざまなキャッシュ パターンの恩恵を受けます。 次のセクションでは、Pythonの最初の 4 つのパターンを実装します。 この記事では、セッションと状態のオフロードと複数リージョンのキャッシュ パターンについて説明しますが、それらのコード実装は提供していません。

Pattern どのように機能するのか 小売店の店頭で
キャッシュアサイド (遅延読み込み) アプリケーションは最初にキャッシュをチェックします。 ミスが発生すると、PostgreSQL から読み取り、キャッシュが設定されます。 製品カタログと詳細ページ。人気のあるいくつかの項目が最も読み取りを促進します。
参照データのプリフェッチ 安定したデータは、キャッシュミス時ではなく、あらかじめキャッシュに読み込まれ、ソースが変更されたときに更新されます。 カテゴリ、ブランド、出荷構成。
ライトスルー アプリケーションは同じ操作でキャッシュと PostgreSQL に書き込み、一貫性を保ちます。 すぐに表示する必要がある価格と在庫の更新。
イベント駆動型の無効化 キャッシュ エントリは、タイマーではなく、データ変更イベントに応答して更新または無効化されます。 注文のフルフィルメントの進行に伴う注文ステータス。
セッションとステートのオフロード 一時的な状態は、データベースではなくキャッシュに存在します。 ショッピング カートとユーザー セッション。
複数リージョンのキャッシュ 各リージョンのキャッシュはローカル読み取りを処理し、アクティブ geo レプリケーションとの同期が保たれます。 複数の地域で買い物客にサービスを提供するグローバルなネットショップ。

Prerequisites

Tip

コードとしてのインフラストラクチャと 4 つのパターンをすべて含む、このサンプルのデプロイ可能な完全なバージョンについては、GitHubの amr-caching-pattern-samples リポジトリを参照してください。

手順 1: クライアント ライブラリをインストールする

Redis および PostgreSQL クライアント ライブラリを、Microsoft Entra認証用の Azure Identity ライブラリと共にインストールします。

pip install "redis>=5.0,<6.0" "psycopg[binary]>=3.1,<4.0" "azure-identity>=1.17,<2.0"

手順 2: Microsoft Entra IDに接続する

アクセス キーの代わりにMicrosoft Entra ID認証を使用します。 Microsoft Entra IDでは、アプリケーションにシークレットを格納する必要が不要になり、アクセスを一元的に管理できます。

次のコードでは、Redis クライアントと PostgreSQL 接続を作成します。どちらも、マネージド ID または開発者資格情報を使用して DefaultAzureCredentialによって認証されます。 この例では、クラスター対応の RedisCluster クライアントを使用します。これは、このサンプルで使用する OSS クラスタリング ポリシーに一致します。 キャッシュでエンタープライズ クラスタリング ポリシーを使用する場合は、代わりに標準の redis.Redis クライアントを使用します。

import os
import json
import redis
from redis.cluster import RedisCluster
import psycopg
from azure.identity import DefaultAzureCredential

REDIS_HOST = os.environ["REDIS_HOST"]  # for example, mycache.eastus.redis.azure.net
REDIS_PORT = 10000
PG_HOST = os.environ["PG_HOST"]        # for example, myserver.postgres.database.azure.com
PG_DATABASE = os.environ["PG_DATABASE"]

credential = DefaultAzureCredential()

# Acquire a token for Azure Managed Redis and use it as the password.
redis_token = credential.get_token("https://redis.azure.com/.default")

# The username is the object ID of the Microsoft Entra identity.
redis_client = RedisCluster(
    host=REDIS_HOST,
    port=REDIS_PORT,
    ssl=True,
    ssl_check_hostname=False,  # cluster nodes are reached by IP; the certificate chain is still validated
    username=os.environ["REDIS_USER_OBJECT_ID"],
    password=redis_token.token,
    decode_responses=True,
)

# Acquire a token for Azure Database for PostgreSQL and use it as the password.
pg_token = credential.get_token("https://ossrdbms-aad.database.windows.net/.default")

pg_conn = psycopg.connect(
    host=PG_HOST,
    dbname=PG_DATABASE,
    user=os.environ["PG_USER"],
    password=pg_token.token,
    sslmode="require",
)

Note

Microsoft Entraアクセス トークンは、通常、約 1 時間後に期限切れになります。 実行時間の長いアプリケーションの場合は、有効期限が切れる前にトークンを更新し、Azure Managed Redis と Azure Database for PostgreSQL の両方に再接続するか、トークンを透過的に再取得するヘルパーを使用します。 詳細については、「Azure Managed Redis での認証にMicrosoft Entra IDを使用する」を参照してください。

手順 3: キャッシュアサイド

キャッシュアサイドは最も一般的なパターンであり、このサンプルの製品の読み取りに使用されます。 アプリケーションは最初に Redis をチェックし、見逃した場合は PostgreSQL に対してクエリを実行し、キャッシュに Time to Live (TTL) を設定します。 少数の人気製品が閲覧の大半を占めているため、ヒット率は高いです。

ほとんどの読み取りはメモリから処理されるため、キャッシュアサイドは PostgreSQL から持続的な読み取り負荷を受け取ります。 この削減により、接続の数が減り、バッファー キャッシュのチャーンが少なくなり、CPU と IOPS が低下します。 サーバーをスケールアップしたり、読み取りレプリカを追加したりすることなく、読み取りスパイクを吸収できます。 PostgreSQL に対してクエリを実行するのは、ミス時 (最初のアクセス、または TTL の有効期限が切れた後) だけです。 キャッシュする必要があるクエリを特定するには、「 キャッシュする対象を見つける 」を参照してください。

CACHE_TTL_SECONDS = 300  # 5 minutes

def get_product(product_id: int) -> dict | None:
    cache_key = f"product:{product_id}"

    # 1. Try the cache first.
    cached = redis_client.get(cache_key)
    if cached is not None:
        return json.loads(cached)

    # 2. On a miss, read from PostgreSQL.
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name, price FROM products WHERE id = %s", (product_id,))
        row = cursor.fetchone()
    if row is None:
        return None

    product = {"id": row[0], "name": row[1], "price": float(row[2])}

    # 3. Populate the cache with a TTL, then return.
    redis_client.set(cache_key, json.dumps(product), ex=CACHE_TTL_SECONDS)
    return product

手順 4: 参照データのプリフェッチ

常に読み取られますが、ほとんど変更されない安定したデータ (カテゴリ、ブランド、出荷構成など) は、キャッシュ ミスを待つ必要はありません。 これをキャッシュに先行して読み込み、ソースが変更されたときに更新します。 PostgreSQL スキーマでは、通常、これらは多数のクエリに結合される小さな参照テーブルとディメンション テーブルです。 メモリからサービスを提供すると、データベースから大量の繰り返し結合と参照が削除されます。 キャッシュアサイドとは異なり、要求ごとのミスや TTL 競合はありません。 変更が発生するたびに更新するため、読み取り時には常にキャッシュが準備された状態になります。

def prefetch_categories() -> None:
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name FROM categories ORDER BY name")
        categories = [{"id": r[0], "name": r[1]} for r in cursor.fetchall()]
    redis_client.set("ref:categories", json.dumps(categories))  # no TTL; refreshed on change

def get_categories() -> list[dict]:
    cached = redis_client.get("ref:categories")
    return json.loads(cached) if cached else []

手順 5: ライトスルー

変更をすぐに表示する必要がある場合は、TTL の有効期限が切れるのを待ったり、キーを無効にしたりするのではなく、PostgreSQL とキャッシュを同じ操作で書き込みます。 たとえば、このパターンは、サンプルの価格情報を更新するために使用されます。 PostgreSQL は真実の源のままです。 変更は最初にそこでコミットされ、次にキャッシュが更新されるため、書き込み後の読み取りによって新しい値が返されます。

トランザクションを共有しない 2 つのシステムに書き込む場合、キャッシュの更新が失敗し、簡単な修正は行われませんが、データベースのコミットは成功します。 次のスニペットは、幸せなパスを示し、エラー処理を残します。運用環境では、失敗した更新を調整する方法を決定します。たとえば、障害が一時的に見えたときに再試行する方法や、次の読み取りが PostgreSQL から再読み込みされるようにキーを無効にする方法などです。 いずれの場合も、PostgreSQL は正しい値を保持するため、古いキャッシュ エントリまたは不足しているキャッシュ エントリは常に回復可能です。 キャッシュの更新を確実に行う必要がある場合は、代わりにデータベースの変更ストリームからキャッシュ更新を実行します ( イベントドリブンの無効化を参照)。

def update_price(product_id: int, new_price: float) -> None:
    # 1. Write to PostgreSQL, the source of truth.
    with pg_conn.cursor() as cursor:
        cursor.execute("UPDATE products SET price = %s WHERE id = %s", (new_price, product_id))
    pg_conn.commit()

    # 2. Refresh the cached entry so reads see the new price right away.
    with pg_conn.cursor() as cursor:
        cursor.execute("SELECT id, name, price FROM products WHERE id = %s", (product_id,))
        row = cursor.fetchone()
    if row is not None:
        product = {"id": row[0], "name": row[1], "price": float(row[2])}
        redis_client.set(f"product:{product_id}", json.dumps(product), ex=CACHE_TTL_SECONDS)

手順 6: イベントドリブンの無効化

イベント ドリブンの無効化は、データ変更イベントに対応することで、キャッシュとデータベースの整合性を維持します。 タイマーで期限切れになるのではなく、変更されたエントリを更新または無効化します。 ライターは、Durable Redis ストリーム、追加専用ログにイベントを追加し、1 つ以上のコンシューマーがそれらのイベントを読み取ってキャッシュを更新します。 ストリームは保持されるため、イベントはコンシューマーの再起動後も存続します。 コンシューマー グループは、各イベントを 1 つのワーカーに配信し、受信確認を追跡して、何も失われないようにしたり、2 回処理したりしないため、ワーカー間で処理をスケーリングできます。

このパターンは、キャッシュされた値が他の場所 (状態、プロジェクション、または集計) を変更するデータから派生する場合に使用します。TTL は古いデータを処理するか、定数の再計算を強制します。 ストアフロントでは、このパターンによって、フルフィルメントの進行に合わせて注文ステータスを更新します。 注文を行う場合、注文は PostgreSQL に書き込み、その初期状態がキャッシュされ、 placed イベントがストリームに追加されます。

ORDER_STREAM = "orders:events"

def place_order(product_id: int, quantity: int) -> int:
    with pg_conn.cursor() as cursor:
        cursor.execute(
            "INSERT INTO orders (product_id, quantity, status) VALUES (%s, %s, 'placed') RETURNING id",
            (product_id, quantity),
        )
        order_id = cursor.fetchone()[0]
    pg_conn.commit()

    redis_client.set(f"order:{order_id}:status", "placed", ex=86400)
    redis_client.xadd(ORDER_STREAM, {"order_id": order_id, "status": "placed"}, maxlen=10000, approximate=True)
    return order_id

フルフィルメント ワーカーはコンシューマー グループを実行します。新しいイベントの読み取り、PostgreSQL での各注文の進め、キャッシュされた order:{id}:status プロジェクションの更新、イベントの受信確認を行います。 注文ページはそのプロジェクションを読み取るため、状態チェックは高速に維持され、データベースに触れることはありません。 イベントによって最新の状態が維持されるため、値は正しいままです。

GROUP = "fulfillment"

def process_orders() -> None:
    try:
        redis_client.xgroup_create(ORDER_STREAM, GROUP, id="0", mkstream=True)
    except redis.exceptions.ResponseError:
        pass  # group already exists

    while True:
        events = redis_client.xreadgroup(GROUP, "worker-1", {ORDER_STREAM: ">"}, count=10, block=5000)
        for _stream, entries in events or []:
            for event_id, fields in entries:
                order_id = int(fields["order_id"])
                with pg_conn.cursor() as cursor:
                    cursor.execute("UPDATE orders SET status = 'shipped' WHERE id = %s", (order_id,))
                pg_conn.commit()
                redis_client.set(f"order:{order_id}:status", "shipped", ex=86400)
                redis_client.xack(ORDER_STREAM, GROUP, event_id)

このサンプルのイベント ソースは、PostgreSQL を書き込み、同じパスにイベントを追加するアプリケーションです。 PostgreSQL は、軽量通知用の LISTEN/NOTIFY や、持続的な行レベルの変更ストリームの論理デコード (変更データ キャプチャ) など、変更自体を出力することもできます。 PostgreSQL の独自の変更ストリームからキャッシュを駆動することは、コミットされたすべての変更 (アプリケーションをバイパスする書き込みも含む) に対応することを意味します。

ベスト プラクティス

キャッシュを正しく、効率的に、コスト効率の高い状態に保つには、次のプラクティスに従ってください。

  • 古くなるリスクがある箇所では、TTL を設定します。 無効化に失敗した場合でも、TTL によってデータの古さを一定範囲に抑えられます。 TTL を、アプリケーションが許容できるデータの最大古さに合わせます。 信頼できる無効化と更新のプロセスが存在する場合にのみ、参照データには長い TTL または TTL なしを使用します。
  • 一貫性のあるキーの名前付けスキームを使用します。 サービス、エンティティ、識別子別の名前空間キー ( product:42user:1001:profileなど)。 キーの形式または値のスキーマが変更される可能性がある場合は、バージョンを追加します。
  • 適切な粒度をキャッシュします。 個々のエンティティまたは小さな結果セットが頻繁に再利用され、無効にしやすいときにキャッシュします。 再利用が少ないデータはキャッシュしないでください。 過剰なキャッシュによりメモリが無駄になり、ヒット 率が低下する可能性があります。
  • キャッシュ ミスと停止を適切に処理します。 キャッシュは、信頼のソースではなく、最適化として扱います。 Redis が使用できない場合は、PostgreSQL への境界付きフォールバックを使用します。 PostgreSQL を保護するために、タイムアウト、サーキット ブレーカー、バックオフ、および要求の制限を追加します。 PostgreSQL に短時間の中断がある場合は、書き込みがデータベースの復旧を待機している間、キャッシュに既に存在するデータの読み取りを提供し続けることができます。
  • キャッシュスタンピードを防止します。 一般的なキーの有効期限が切れると、多くの要求が一度にデータベースにヒットする可能性があります。 TTL ジッター、リクエスト集約、stale-while-revalidate、または短時間の分散ロックを使用して、1 件のリクエストだけでエントリを再生成できるようにします。
  • キャッシュのサイズを適切に設定します。 ヒット率、メモリ使用量、削除率、有効期限、待機時間、ホット キー、キーカーディナリティを監視します。 ヒット 率が低い場合は、キャッシュが小さすぎる、過剰な削除、キーの選択が不十分、またはアクセス パターンが不適切であることを示す可能性があります。 サイズ選定のガイダンスについては、Azure Managed Redis レベルの選択ガイダンスを参照してください。
  • キーに合った削除ポリシーを選択します。 キャッシュ専用データベースの場合は、allkeys-lru または allkeys-lfu から始めます。 volatile-* ポリシーは、同じデータベースに期限切れのキャッシュ キーと保護された期限切れでないキーが含まれている場合にのみ使用します。 volatile ポリシーでは、TTL が設定されたキーがなくなると、エビクションが停止することがあります。 可能な場合は、キャッシュ データを保護されたデータから分離します。
  • 効率的にシリアル化します。 JSON は読み取り可能で移植可能です。 高スループットパスの場合は、コンパクトなバイナリ形式をテストして、メモリとネットワークのオーバーヘッドを削減します。 形式を変更する前に、メモリ、CPU、待機時間、スキーマの進化、デバッグの影響をベンチマークします。

キャッシュする内容を見つける

最も効果的なキャッシュ ターゲットは、変更が最も少ないデータに対してアプリケーションが最も頻繁に実行するクエリです。 この説明に当てはまるクエリを推測するのではなく、関連機能を有効にすると Azure Database for PostgreSQL で収集できるクエリ テレメトリの過去と現在のビューを比較してください。

  • クエリ ストアは、履歴分析のためにクエリ実行統計を保持します。 呼び出し数と合計実行時間と平均実行時間を使用して、長期間にわたって一貫してデータベースの負荷を支配するクエリを見つけます。 クエリ ストアを使用したパフォーマンスの監視を参照してください。
  • Query Performance Insight では、Azure ポータルでクエリ ストアデータが視覚化されるため、頻繁にリソースを消費するクエリを見つけ出し、時間の経過と同時にそれらの動作を比較できます。 クエリ パフォーマンス分析情報を参照してください。
  • pg_stat_statements は、現在の監視ウィンドウを直接表示するために、データベース内のステートメントごとの累積統計を公開します。 統計はリセットできるため、監視ウィンドウ間で履歴を保持する必要がある場合は、クエリ ストアを使用します。

キャッシュ候補を選択する前に、両方のビューを使用します。 短期的なスパイクはワークロードの通常の動作を表さない場合もありますが、履歴平均では現在の回帰を非表示にすることができます。 頻度が高く、コストが高く、安定しているクエリに優先順位を付けます。つまり、呼び出し回数が多く、実行時間が長く、すべての要求で変更されない結果になります。 これらのクエリは、キャッシュ ヒット率が最も高く、データベースの負荷が最も大きく低下します。 常に実行されるが、製品一覧、カテゴリ ツリー、価格表など、分単位で同じ行を返すクエリは理想的な候補です。