くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログ。積極採用中です。

Django ORMとAldjemyの共存環境でRead Replicaを利用する

f:id:curama-tech:20211101141106p:plain

こんにちは、バックエンドエンジニアのキダです。 最近DjangoのアプリケーションにてRead Replica(リードレプリカ)構成を検証してみたので、その内容を共有しようと思います。

この記事では、DjangoのアプリケーションからDB操作にはDjango ORMと、Aldjemyを使う前提の構成になります。

Django ORMやAldjemyそのものについては以下のWebサイトをご参照ください。

DjangoでSQLAlchemyを使ってみよう

Read Replica構成について

まず前提として一般的なアプリケーションでのRead Replica構成について説明します。 databaseをWriter(Main)とReader(Replica)に分割して、負荷の高い書き込み処理と読み取り処理を、接続するdatabase単位で分けることにより、パフォーマンスを高める事ができる構成です。 Readerは2台以上構成する事も可能で、高負荷がかかった時にスケールアウトすることもできるので、スケーラビリティの高い構成と言えます。

Amazon Auroraなどのサービスでは、コンソールから簡単にRead Replicaインスタンスを追加・削除する事が可能です。 AuroraのRead Replicaは高い同期性能を持っていて、15ミリ秒程度のラグでWriterの更新がReaderに同期されます。

アプリケーション側では接続するホストの設定によって、WriterとReaderどちらを使用するかを切り替えています。 例えば、くらしのマーケットの環境では、データダッシュボードとしてRedashを利用していますが、こちらは読み取り操作のみ実行できればいいのでReaderのDBにつながるような設定をしています。

今回はRead(読み取り)とWrite(書き込み)が両方発生する、Djangoのアプリケーションにてこちらの構成を考えてみます。

公式で紹介されているDjangoでのRead Replica構成・設定方法について

公式ドキュメントでは、以下の方法が紹介されています。

デフォルトdatabaseの他にReaderの接続情報を定義して、ルーティングを定義することができます。 ルーティングは細かい設定は不要で、実際に実行されるSQLに応じて、Read系操作はdb_for_readに定義した接続先、Write系操作はdb_for_writeに定義した接続先につながるようになっていました。

しかしこのDBルーティングの機能は完全では無く、以下の問題点がありました。

  • トランザクションがコミットされる前には、WriterとReaderで不整合が発生する
  • WriterとReaderの同期ラグによって、不整合が発生する可能性がある
  • AldjemyによるDBアクセスはReaderに繋がってしまう

トランザクションやラグについては公式ドキュメントでも以下の通り言及されています。

The primary/replica (referred to as master/slave by some databases) configuration described is also flawed – it doesn’t provide any solution for handling replication lag (i.e., query inconsistencies introduced because of the time taken for a write to propagate to the replicas). It also doesn’t consider the interaction of transactions with the database utilization strategy.

トランザクションがコミットされる前には、WriterとReaderで不整合が発生する

以下のようなユーザー作成のAPIを考えてみます。

  1. [API] リクエストを受け取り、トランザクションの開始
  2. [Writer] Userテーブルにレコードを追加
  3. [Reader] Userテーブルから実際のデータをクエリして結果を取得
  4. [API] トランザクションを終了し、結果のレスポンスを返す

ルーティングにより、2.のユーザー作成のWrite操作はWriterに繋がり、3.のRead操作はReaderに繋がります。 Readerに作成されたユーザーデータが同期されるのはトランザクションがコミットされた後なので、3.のタイミングでは結果が取得できずエラーになってしまいます。

また、トランザクションを使っていない場合でも同期ラグによって、結果が取得できない事も起こり得ます。

AldjemyによるDBアクセスはReaderに繋がってしまう

クエリの接続先を見ていると、正常にルーティングできているものが多かったのですが、一部のWrite操作がReaderに繋がっている物も確認できました。

詳細を調べてみると、Aldjemyを使用したオブジェクトアクセスによるものはdb_for_readに定義した接続先に繋がっており、DBルーティングの機能では対応できない事がわかりました。

解決法

上記の結果を踏まえて、Django ORM・Aldjemyの両方でルーティング可能かつ、トランザクションの問題も発生しない方法を検討しました。

結論から言うと、DjangoのMiddleware実装によって、リクエスト単位で接続先のDBを切り替える事が可能でした。

Django Middleware

https://docs.djangoproject.com/en/3.2/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware

Djangoではリクエスト単位で、処理の前後にMiddlewareとして共通処理を定義することができます。 今回はこのMiddlewareを利用して、APIリクエストの情報に応じて接続をルーティングする機能を実装しました。

# config.py

MIDDLEWARE_CLASSES = (
    # middlewareの設定以外は省略
    'app.request_session.RequestMiddleware',
)

DATABASES = {
    # writerのDB接続情報
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'name',
        'USER': 'user',
        'PASSWORD': 'password',
        'HOST': 'writer_host_name',
        'PORT': 5432
    }
}
# request_session.py

from django.db import connections

class RequestMiddleware(object):
    # リクエストを受け取った際の処理(DBセッションが作られる前)
    def process_request(self, request):
        for conn in connections.all():
            conn.settings_dict["HOST"] = "writer_host_name"
            # GETリクエストの場合はReplicaを参照させる
            if request.method == "GET":
                conn.settings_dict["HOST"] = "reader_host_name"
        return

    # レスポンスを返す際の処理(DBセッションが作られた後)
    def process_response(self, request, response):
        for conn in connections.all():
            if hasattr(conn, 'sa_session'):
                conn.sa_session.close_all()
        return response

こちらは一番シンプルな例ですが、全てのGETリクエストをReaderに接続させて、それ以外のリクエストはWriterに接続するMiddlewareの記載例になります。

connections.all()によって取得されるdb connectionはループで書いていますが、config.pyに記載されたデフォルトdatabase(Writer)のみです。

WriterとReaderはHOST以外は同じ接続情報でつながるようになっていて、HOST情報のみを切り替える実装になります。 こちらのHOSTを書き換えることによって、実際にDjango ORMやAldjemyによって実行されるdatabaseアクセスを切り替える事が可能でした。

ちなみにconnectionのsettings_dictは以下のようなオブジェクトになっていました。

{
    'CONN_MAX_AGE': 0, 
    'TIME_ZONE': 'UTC', 
    'USER': 'user', 
    'NAME': 'name', 
    'ENGINE': 'django.db.backends.postgresql_psycopg2', 
    'HOST': 'writer_host_name', 
    'PASSWORD': 'password', 
    'PORT': 5432, 
    'OPTIONS': {}, 
    'ATOMIC_REQUESTS': False, 
    'AUTOCOMMIT': True
}

複数プロセスでの接続状況の確認

先程の実装ですが、複数のプロセスが立ち上がっている場合に、意図しない接続にならないかを確認しました。

conn.settings_dictの内容はプロセス単位でメモリが共有されているようでしたので、同一リクエスト内ではMiddlewareを通ってからレスポンスを返すまで、同じ接続情報を保持している事が確認できました。

# 確認用のコード
from django.db import connections
import os

class RequestMiddleware(object):
    def process_request(self, request):
        for conn in connections.all():
            print("request : [pid:" + str(os.getpid()) + "] [Method:" + request.method + "] [HOST:" + str(conn.settings_dict["HOST"]) + "]")
            conn.settings_dict["HOST"] = "writer_host_name"
            # GETリクエストの場合はReplicaを参照させる
            if request.method == "GET":
                conn.settings_dict["HOST"] = "reader_host_name"
        return
    
    def process_response(self, request, response):
        for conn in connections.all():
            print("response: [pid:" + str(os.getpid()) + "] [Method:" + request.method + "] [HOST:" + str(conn.settings_dict["HOST"]) + "]")
            if hasattr(conn, 'sa_session'):
                conn.sa_session.close_all()
        return response
# 実行時ログ
request : [pid:25] [Method:GET]  [HOST:writer_host_name] ## 初回のリクエストはwriterを参照している
response: [pid:25] [Method:GET]  [HOST:reader_host_name]
request : [pid:21] [Method:GET]  [HOST:writer_host_name] ## 一つ前のリクエストとはプロセスが違うので、writerを参照している
response: [pid:21] [Method:GET]  [HOST:reader_host_name]
request : [pid:25] [Method:POST] [HOST:reader_host_name] ## 前回のリクエスト時に変更された参照を持っている
response: [pid:25] [Method:POST] [HOST:writer_host_name]
request : [pid:25] [Method:POST] [HOST:writer_host_name]
response: [pid:25] [Method:POST] [HOST:writer_host_name]
request : [pid:21] [Method:GET]  [HOST:reader_host_name]
response: [pid:21] [Method:GET]  [HOST:reader_host_name]

まとめ

今回DjangoのアプリケーションのMiddlewareを使ったRead Replica構成について紹介しました。

Django ORMやAldjemyのDBセッション単位では無く、APIリクエスト単位でルーティング設定ができるので、使い勝手が良い処理だと思います。

例では全てのGETリクエストをシンプルにルーティングしていますが、WriterとReaderに50%ずつ接続したり、リクエストのURL毎にルーティングルールを定義したりできそうです。

みんなのマーケットでは、くらしのマーケットの開発を手伝ってくれるエンジニアを募集しています!
詳細はこちらをご覧ください!