DatadogでAPI毎のSQL発行数を可視化してN+1を改善!

こんにちはSREチームの宮後(@miya10kei)です。最近、スマートホーム化にハマっていていろいろと買い漁っています🛒

N+1問題を検知するのって結構大変ですよね?
今回はDatadogを使ってN+1問題を可視化し、パフォーマンス改善につなげた話を紹介したいと思います。

※ 2024/05/29に開催されたJapan Datadog User Group Meetup#4 でもLTしてきました🎤

speakerdeck.com

N+1問題とは?

N+1問題は主にデータベースへのクエリーを扱う際に出くわすパフォーマンス上の問題です。1つのクエリーでN件のデータを取得した後にそれぞれのデータに対して追加でクエリーが発行されることでパフォーマンスを大幅に低下させます。(1 + N問題と呼んだ方がしっくりくる気がしますが...🤫)

背景

この取り組みを始めた経緯は、システムへのリクエスト数に対してデータベースの負荷が高いことに気付いたことからです。Datadogを導入してAPMを有効化していたため、データベースに対する全体のSQL発行数が異常に多いことはすぐに特定できました。また、N + 1問題が発生している可能性も容易に予測できました。しかし、具体的にどのAPIで問題が発生しているのかを特定するのが難しかったため、まずは計測してみることにしました。

メトリクスの計測

アプリケーション上でAPI毎のSQLの発行数を計測し、Datadogに転送し可視化することで対象のAPIを特定することにしました。

全体構成

Park DirectのアプリケーションはAWS ECS(Fargate)上で稼働しており、もともとdatadog-agentがサイドカーとして存在していました。

アプリケーション側でOpenTelemetryで計測し、datadog-agent経由でDatadogにメトリクスを蓄積する構成としました。DatadogのSDKの利用も検討しましたが、今後LogsなどもOpenTelemetryの仕様に準拠していきたいという思いもあったので採用しませんでした。

全体構成

アプリケーション側の実装

Park DirectのアプリケーションはPython x Djangoという技術スタックとなっています。
他言語の主要なフレームワークであれば似た機能が存在していると思うので参考にしてみてください。

アプリケーション実装の全体構成を載せ、詳細を紹介していきます。

アプリケーション実装の全体構成

計測するメトリクス

アプリケーション側では次の2つのメトリクスを計測しています。
この2つのメトリクスから次の計算式でAPI毎の平均SQL発行数を算出します。
APIの平均SQL発行数 = database.sql.issued_count / http.server.requests

メトリクス名 属性 説明
http.server.requests http.method, http.path API毎のリクエスト数を記録するメトリクス
database.sql.issued_count http.method, http.path API毎のSQL発行数を記録するメトリクス

上記メトリクスの計測にはOpenTelemetry SDKを利用しています。SDKの使い方は公式サイトに譲るとして、特徴的な部分を2点紹介します。

1点目はインスタンスをシングルトンにしている点です。シングルトンにすることでOpenTelemeryのメトリクスが複数できるのを防いでいます。
2点目はメトリクス毎に関数を定義している点です。関数化することでメトリクスの属性名がゆれることを防いでいます。以下は実際のコードです。

import os

from django.conf import settings

from opentelemetry import metrics as otel_metrics
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import (
    HOST_NAME,
    PROCESS_PID,
    Resource,
    SERVICE_NAME,
)


class Metrics(object):
    __instance = None

    def __new__(cls):
        if cls.__instance is None:
            cls.__instance = super(Metrics, cls).__new__(cls)
        return cls.__instance

    def __init__(self):
        if not hasattr(self, '__initialized'):
            setattr(self, "initialized", True)
            self.__initialize_opentelemetry()
            self.__initialize_metrics()

    def __initialize_opentelemetry(self):
        resource = Resource(
            attributes={
                HOST_NAME: str(os.getenv('HOSTNAME')),
                SERVICE_NAME: settings.APPLICATION_NAME,
                # preforkモデルで稼働しているためPIDを属性に付与しています
                PROCESS_PID: os.getpid(),
            }
        )
        reader = [PeriodicExportingMetricReader(OTLPMetricExporter())]
        provider = MeterProvider(resource=resource, metric_readers=reader)
        otel_metrics.set_meter_provider(provider)

    def __initialize_metrics(self):
        meter = otel_metrics.get_meter('opentelemetry.instrumentation.xxxx')
        self.http_server_requests = meter.create_counter(
            'http.server.requests',
            description='The number of requests',
        )
        self.database_sql_issued_counts = meter.create_counter(
            'database.sql.issued_counts',
            description='The number of SQL issued',
        )

    def increment_http_server_requests(self, method: str, path: str, status: int):
        self.http_server_requests.add(1, {'http.method': method, 'http.path': path, 'http.status': status})

    def increment_database_sql_issued_counts(self, method: str, path: str):
        self.database_sql_issued_counts.add(1, {'http.method': method, 'http.path': path})


metrics = Metrics()

リクエスト数(http.server.requests)の計測

DjangoにはMiddlewareと呼ばれるAPI処理の前後で任意の処理にフックさせる機能が存在します。こちらを利用しAPIのリクエスト数を計測しています。また、後続処理の中でリクエストの情報(http.method、http.path)を参照したいのでThreadLocal領域に保存することで同スレッド内から参照できるようにしています。以下は実際のコードです。

import threading

from django.urls import resolve

from apps.core.opentelemetry import metrics

REQUEST_CONTEXT = threading.local()

HTTP_METHOD_KEY = 'http_method_key'
HTTP_PATH_KEY = 'http_path_key'


class RequestContextMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        self.__pre_process(request)
        response = self.get_response(request)
        self.__post_process(response)
        return response

    def __pre_process(self, request):
        try:
            method = request.method if request.method else 'unknown'
            resolved_path = resolve(request.path).route
            setattr(REQUEST_CONTEXT, HTTP_METHOD_KEY, method)
            setattr(REQUEST_CONTEXT, HTTP_PATH_KEY, resolved_path)
        except Exception:
            setattr(REQUEST_CONTEXT, HTTP_METHOD_KEY, 'unknown')
            setattr(REQUEST_CONTEXT, HTTP_PATH_KEY, 'unknown')

    def __post_process(self, response):
        try:
            method = get_context_str_value(HTTP_METHOD_KEY, 'unknown')
            path = get_context_str_value(HTTP_PATH_KEY, 'unknown')
            status = response.status_code
            metrics.increment_http_server_requests(method, path, status)
            delattr(REQUEST_CONTEXT, HTTP_METHOD_KEY)
            delattr(REQUEST_CONTEXT, HTTP_PATH_KEY)
        except Exception:
            pass


def get_context_str_value(key: str, default=None) -> str:
    return str(getattr(REQUEST_CONTEXT, key, default))

SQL発行数(database.sql.issued_count)の計測

こちらは少しトリッキーな方法になっています。
SQL発行の前後で処理をフックさせメトリクスの計測をおこないたかったのですが、Djangoには適切な機能がありませんでした(探したけど見つからなかった...)
他の言語であれば例えばJavaのMySQLのJDBC DriverにあたるConnector/JなどはInterceptorという機能が存在しSQLの実行前後で処理をフックさせることができます。
そこで今回はDabaseEngine部分を拡張することで実現しました。 もともと使用していたPostGISのDatabaseEngineをラップし、SQLの発行を担うメソッドをオーバーライドすることで計測しています。以下は実際のコードです。

from django.conf import settings
from django.contrib.gis.db.backends.postgis import base
from psycopg2.extensions import cursor

from apps.core.middleware.request_context import (
    HTTP_METHOD_KEY,
    HTTP_PATH_KEY,
    get_context_str_value,
)
from apps.core.opentelemetry import metrics


class DatabaseWrapper(base.DatabaseWrapper):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def create_cursor(self, name=None):
        if name:
            cursor = self.connection.cursor(
                name,
                cursor_factory=CustomeCursor,
                scrollable=False,
                withhold=self.connection.autocommit
            )
        else:
            cursor = self.connection.cursor(cursor_factory=CustomeCursor)
        cursor.tzinfo_factory = self.tzinfo_factory if settings.USE_TZ else None
        return cursor


class CustomeCursor(cursor):

    def execute(self, sql, params=None):
        super().execute(sql, params)
        method = get_context_str_value(HTTP_METHOD_KEY, 'unknown')
        path = get_context_str_value(HTTP_PATH_KEY, 'unknown')
        metrics.increment_database_sql_issued_counts(method, path)

    def executemany(self, sql, param_list):
        super().executemany(sql, param_list)
        method = get_context_str_value(HTTP_METHOD_KEY, 'unknown')
        path = get_context_str_value(HTTP_PATH_KEY, 'unknown')
        metrics.increment_database_sql_issued_counts(method, path)

OpenTelemtry -> datadog-agent間のメトリクス転送設定

続いてアプリケーションで計測したメトリクスをdatadot-agentに転送するための設定を紹介します。

datadog-agentコンテナの設定

datadog-agnetはOpenTelemetryをサポートしているので環境変数を設定するだけでリクエストを受け付けることができます。

環境変数 説明
DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT 0.0.0.0:4318 OpenTelemeryからのメトリクス転送を受け付けるエンドポイント
DD_OTLP_CONFIG_METRICS_RESOURCE_ATTRIBUTES_AS_TAGS true ECSのリソース情報をメトリクスの属性に追加する設定

Park Directではdatadog-agentはサイドカーとして動作しているので、アプリケーションからの通信を許可するために以下のようにPortMappingを設定します。

hostPort containerPort protocol
4318 4318 tcp

アプリケーションコンテナの設定

OpenTelemetryのExporterは環境変数でメトリクスの転送先を指定することができます。以下のように設定することでdatadog-agentのサイドカーに転送することができます。

環境変数 説明
OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4318 OpenTelemetry Exporterのメトリクス転送先

計測結果を可視化

前述の方法で計測したメトリクスを元にAPI毎の平均SQL発行数をランキング化してみると1回のリクエストで数百回SQLを発行しているAPIを特定することができました!(すこし多いですね...😅)

API毎の平均SQL発行数のランキング

あとは個別にAPMのTrace情報を確認することで実際にN+1問題が発生していることを確認できたので個別に改善を進めていきました!

APMのTrace情報(黄・紫:SQLを発行している箇所)

改善効果

個別に改善を進めていったことでリクエストあたりの平均SQL発行数を50%程削減することができました。

リクエストあたりの平均SQL発行数

また、システム全体のAPIレイテンシーについても90パーセンタイル値で約200ms程改善されパフォーマンスを大きく改善することができました🎉

全体のAPIレイテンシー(90パーセンタイル値)

注意点

紹介した方法で計測したメトリクスは、Datadog上ではカスタムメトリクスとして扱われます。カスタムメトリクスは同じメトリクスでも属性値が異なる場合は別ものとしてカウントされます。そのため、リクエスト毎に異なる値を属性値に設定してしまうと気づいたら多額の請求が発生しているということにもなりかねないので十分に気を付けてご利用ください。公式サイトでカスタムメトリクスの課金形態が説明されているので利用の際は必ずお読みください。

今後やりたいこと

今回、API毎のSQL発行数を可視化しN+1を改善することでパフォーマンスを大きく向上させることができました!

ですが、まだいくつかやりたいことも残っているので最後に紹介して終わりたいと思います。

1つ目はコスト削減です。Park Directのアプリケーションはpreforkモデルで動作しており、属性値にPIDを埋め込んでいるため余計なコストが発生しています。datadog-agentに転送する前に各プロセスのメトリクスを集約することでコストを削減していきたいと考えています。

2つ目は継続的な監視です。SQLの発行数はAPI毎に大きく異なっているため従来の閾値を設定した監視では難しいと考えています。(API数も多く個別の監視設定を運用するのもコストが高いです)一方で同じAPIであれば1リクエスト中のSQL発行数はそれほど大きな差は発生していません。このようなケースではDatadogの異常検知モニターが効果を発揮するのではないかと考えているので今後導入を検討していきます。

それでは、N+1を撲滅して安定したシステム稼働を目指しましょう!!!