Oisix Creator's Blog(オイシックスクリエイターズブログ)

オイシックス株式会社のエンジニア・デザイナーが執筆している公式ブログです。

RDS for Oracleの『バインド変数』問題をPythonで解決!リードレプリカの実行計画を固定化した小ワザ

はじめに

こんにちは、SREセクションの子安です。

Oisixのサービスでは、データベースに Amazon RDS for Oracle Enterprise Edition (EE) を採用し、可用性とスケーラビリティを確保するために Active Data Guard 構成を組んでいます。

サービスの特性上、商品の情報を閲覧するような「読み取り(参照系)」の処理がとても多いのが特徴です。

そのため、読み取り専用の リードレプリカ に処理を逃がすことで、書き込みを行う プライマリDB の負荷を減らし、サービス全体のパフォーマンスを安定させています。

今回は、この構成ならではの「リードレプリカでだけSQLのパフォーマンスが急に悪化する問題」と、それを解決するためにデータベースエンジニアとしての経験を活かして取り組んだ、ちょっとした工夫についてお話しします。

そもそも「実行計画」ってなんだっけ?

本題に入る前に、まず「実行計画」について簡単におさらいをします。

Oracleをはじめとする多くのデータベースは、私たちが書いたSQLを受け取ると、そのまま実行するわけではありません。

「どの順番でテーブルを結合しようかな?」「この条件なら、どのインデックスを使うのが一番速いかな?」といったように、SQLを最も効率的に実行するための手順を内部的に組み立てます。

この「データベースが考えた最適な処理手順書」こそが、実行計画(Execution Plan) です。 料理人がレシピ(SQL)を見て、最高の味付けや調理手順(実行計画)を考えるのに似ていますね。

なぜ実行計画を「固定」する必要があるのか?

実はこの実行計画、データベースが持つ統計情報(テーブルの行数など)や、その時の状況によって変化することがあります。昨日まで高速だったSQLが、今日は全く違う手順で実行されてしまい、パフォーマンスが急激に悪化する…なんてことも起こり得ます。

そこで重要になるのが「実行計画の固定化」です。 一度見つけた「最高のレシピ」を保存しておき、いつでも同じ手順で実行できるようにして、パフォーマンスを安定させるのです。Oracleでは、SQL Plan Management (SPM) という機能を使ってこれを実現します。

直面した「壁」

さて、ここからが本題です。

課題:リードレプリカのSQLに対する実行計画の固定化

私たちの構成では、前述の通り「参照系のSQLはすべてリードレプリカで処理する」というルールで運用しています。これにより、プライマリDBの負荷を軽減できるという大きなメリットがあります。 しかし、ここで問題が発生しました。リードレプリカでのみ実行される特定のSQLの実行計画が、ある日突然非効率なものに変わってしまい、パフォーマンスが劣化する事象がたびたび起きたのです。

「それなら、いつものように実行計画を固定すればいいじゃないか」と考えますよね。 ところが、リードレプリカでしか実行されないSQLは、実行計画を固定化できないという大きな壁にぶつかりました。

なぜリードレプリカでは固定化できないのか?

Active Data Guard構成では、プライマリDBが「親」、リードレプリカは「子」のような関係です。 実行計画のキャッシュや固定化といった重要な設定変更は、すべて親であるプライマリDBしか許可されていません。リードレプリカはあくまで読み取り専用なので、たとえリードレプリカ側で一時的に良い実行計画が生まれたとしても、それを保存・固定する権限がないのです。

つまり、実行計画を安定させるためには、対象のSQLを一度プライマリDBで実行してあげる必要があるという制約がありました。

さらなる課題:やっかいな「バインド変数」問題

「じゃあ、問題のSQLを手動でプライマリDBで実行すればいいんだ!」と考えたのですが、ここにもう一つ、やっかいな問題がありました。アプリケーションが発行するSQLには「バインド変数」が使われているのです。

アプリケーションが実行するSQLは、こんな形をしています。

-- アプリケーションが投げるSQL
SELECT * FROM test_table WHERE id = :1;

この :1 は「1番目の変数」という意味で、プログラム側で具体的な値(例えば 50)がセットされます。これを位置指定バインド変数と呼びます。

しかし、このSQLはそのままでは実行できず、私たちが手動で再現しようとすると少し形が変わってしまいます。

-- 人間が手動で実行するSQL
VAR var1 NUMBER
EXEC :var1 := 50;
SELECT * FROM test_table WHERE id = :var1;

このように :var1 という名前をつけた変数を使う必要があり、これを名前付きバインド変数と呼びます。

データベースにとって、:1:var1全くの別人です。たとえSQLの他の部分が同じでも、この変数の書き方が違うだけで「SQL_ID」というSQLの識別番号が変わってしまい、「違うSQL」として扱われてしまうのです。これでは、プライマリDBで実行しても意味がありません。

これまでの一時対応と、その限界

これまでは、以下のような方法でなんとか対応していました。

  1. コードを修正する
    問題のSQLを、一時的にプライマリDBに向けるようにアプリケーションの向き先を変更する
  2. 実行計画を固定する
    プライマリDBでSQLが実行されたのを確認し、SPMで実行計画を固定する。
  3. コードを元に戻す
    再度、リードレプリカに向くように修正する


しかし、この方法にはいくつかの課題がありました。

  • スピードが出ない:コード修正や有識者によるレビューで、問題解決までに時間がかかってしまう。
  • 再現が難しい:アプリケーション経由で実行しないと、正しい実行計画が再現できないことがある。

パフォーマンス問題は時間との勝負です。もっと素早く、より簡易的な方法で完結できないだろうか…?

解決策:Pythonで "なりすまし" 実行

私たちがたどり着いたのが、Pythonの cx_Oracle ドライバ を使う方法です。

cx_Oracle は、まさにアプリケーションと同じように、:1:2 という形式のバインド変数を扱えるため、SQLの文字列を一切変えることなく実行できます。

これを使って、SREの作業環境から直接プライマリDBにSQLを実行し、実行計画を覚えさせます。

実践!Pythonを使った実行計画の固定手順

実際にどのように対応したか、テストテーブルを例にご紹介します。

1. 対象SQLの確認

まず、リードレプリカでパフォーマンスが悪化しているSQLの SQL_ID とSQL本文を正確に確認します。

-- Replica側で実行
SQL> select sql_id, sql_text from v$sql where sql_text = 'select * from test_table where id = :1';

SQL_ID        SQL_TEXT
------------- ------------------------------------
93aqagsa8gp3h select * from test_table where id = :1

また、プライマリDBでSQLが存在しないことを確認します。

-- Primary側で実行
SQL > select sql_id,sql_text from v$sql where sql_text = 'select * from test_table where id = :1';

no rows selected

2. Pythonスクリプトの準備

次に、確認したSQL文をそのまま実行するPythonスクリプトを作成します。

sql_bind_example.py

import cx_Oracle

# 接続先はプライマリDBを指定!
USERID = "*****"
PASSWORD = "*****"
DESTINATION = "*****"

# リードレプリカ側で確認したSQL文を、一字一句変えずに設定
sql = "select * from test_table where id = :1"

# データベースに接続してSQLを実行
connection = cx_Oracle.connect(USERID, PASSWORD, DESTINATION)
cursor = connection.cursor()

# 第2引数でバインド変数に値を渡す(値は適当でOK)
cursor.execute(sql, ('1'))

# 結果は使わないが、念のためループを回しておく
for row in cursor:
    print(row[0])

cursor.close()
connection.close()

print("Execution finished!")

3. スクリプトの実行

このPythonスクリプトを実行します。

$ python ./sql_bind_example.py

4. 結果確認

最後に、プライマリDB側で、意図した SQL_ID でSQLが実行されたかを確認します。

-- Primary側で実行
SQL> select sql_id, sql_text from v$sql where sql_text = 'select * from test_table where id = :1';

SQL_ID        SQL_TEXT
------------- ------------------------------------
93aqagsa8gp3h select * from test_table where id = :1

リードレプリカ側で確認した SQL_ID (93aqagsa8gp3h)同じIDで、プライマリDB側にSQLをキャッシュさせることに成功しました。 あとは、この SQL_ID を使って、SPMで適切な実行計画を固定すれば対応完了です。

得られた成果と、これからのこと

この仕組みを利用することで今後の改善が見込めました。

  • コード修正が不要:PythonからプライマリDB側に実行できることでアプリケーションのコード修正が不要になった
  • スピード向上:パフォーマンス問題発生時の初動対応速度を向上できる
  • 安定運用:リードレプリカでしか実行されないSQLでも、実行計画を安定化させることができる

おわりに

今回は、Amazon RDS for OracleとActive Data Guard構成という特定の環境下で、参照系SQLの実行計画を安定させるための改善活動についてご紹介しました。

今回紹介した方法は、私たちの環境やチーム体制においてはベストな選択でしたが、これが唯一の正解というわけではありません。システムの特性やチーム間の役割分担によって、最適な解決策は変わってくるはずです。「銀の弾丸はない。だからこそ、現場に合わせた柔軟な工夫が大切だ」と改めて感じました。

実行計画の世界は本当に奥が深く、安定性と柔軟性のバランスを取るのは常に悩ましい問題です。しかし、こうした制約の中でどう立ち向かうか、そこにデータベースエンジニアとしての面白さや、腕の見せ所があるのかもしれません。

この記事が、同じような課題に直面している誰かのヒントになれば幸いです。

Oisix Creator's Blogはオイシックス株式会社のエンジニア・デザイナーが執筆している公式ブログです。

オイシックス株式会社では一緒に働く仲間を募集しています