本記事では、MyBatis 3を使用したアプリケーションの開発中に 一次レベルキャッシュの影響で発生した不具合とその原因についてコードを交え説明します。
MyBatisについて
MyBatisはJava等で利用可能な、データベースアクセスに特化したフレームワークです。
MyBatisではクエリ内でINPUTの値に対してfor文やif文などを使用することができ、
動的かつ柔軟なクエリを簡単に作成することが可能です。
また、JavaコードとSQLコードを分離することが可能となり、
コードの保守性や再利用性を向上させることができます。
MyBatisの一次レベルキャッシュ機能について
MyBatis 3はデフォルト設定でクエリのキャッシュ機能(一次レベルキャッシュ)が有効化されています。 キャッシュ機能により、同一トランザクション内でselectクエリの結果はすべてキャッシュされ、 2回目以降の結果はキャッシュから取得されるようになります。
insert、update、deleteクエリを発行するか
flushCache属性を要素に付与することでキャッシュはクリアされます。
キャッシュ機能を使用したくない場合は、
設定ファイルに"localCacheScope": "STATEMENT"を指定することで実現できます。
また、アプリケーション全体で共通のキャッシュ情報が作成される二次レベルキャッシュもありますが、 本記事では触れません。キャッシュに関する詳細な情報はこちらをご覧ください。
MyBatisの一次レベルキャッシュに纏わる失敗事例
ここから本題です。 一次レベルキャッシュの特性を深く理解せず使用したため開発中に不具合が発生しました。 特殊な使用方法かもしれませんが、不具合とその原因をご紹介します。
失敗事例1:deleteクエリが2回目以降実行されない?
MyBatisではデータベースへの操作をXMLで定義することで
Javaから操作可能なクラス(モデル)を生成します。
例えばデータを取得する操作に対しては< select >~< /select >タグ内で必要なSQLを記述します。
ただ、タグと中に記載するSQLの操作を必ずしも一致させる必要はありません。
削除した値(RETURNING)をJava側で受け取りたい場合は、
deleteクエリでもselectタグを使用する必要があります。
当時開発していた機能でも、
削除した値を取得したく以下のコードのようにdeleteクエリでselectタグを使用していました。
また、条件に一致するレコードが数百万個になり削除に時間がかかることが想定されたため、
クエリタイムアウトが発生しないようループで1000個ずつ削除する設計としていました。
以下は一例です(コードは若干割愛しております)
■PostgreSQL
<select id="deleteHoge" resultType="java.lang.String">
DELETE
FROM
hoge_table AS ht
WHERE
ht.fuga_id = #{fugaId,jdbcType=VARCHAR}
LIMIT
#{limit,jdbcType=INTEGER}
RETURNING
ht.hoge_id
</select>
■Java
List<String> deleteIds = new ArrayList<String>();
while(true) {
List<String> tmp = hogeRepository.deleteHoge("fugaId", 1000);
if (CollectionUtils.isEmpty(tmp)) {
break;
}
deleteIds.addAll(tmp);
}
上記処理で問題なく対象を全件削除できると考えていましたが、実際に実行してみると2回目以降の
deleteHoge()呼び出しでは削除操作が実行されず無限ループが発生してしまいました。
どうやらRETURNINGで取得した結果にもキャッシュが効いてしまうようで、
deleteHogeが初回実行時にデータを削除すると
2回目以降の実行時で必ずデータが取得されループから脱出できないようになっていました。
タグ内に記述したSQLの操作とは関係なく、タグがselectであったため、キャッシュされた値が利用され2回目以降はクエリの呼び出しが行われませんでした。
RETURNINGを使用してデータを取得する際は、キャッシュを意識する必要があるため、ご注意ください。
本件はタグにflushCache属性を付与してクエリを発行することで解決できました。
失敗事例2:キャッシュを破壊したつもりはなかったのに…
当時開発していた機能では、大きなデータを保持する可能性があったためデータベースから取得し、
操作が完了した後に初期化をするようにしていました。
その後、別モジュール内で同様のクエリを呼び出す場合がありました。
当時の処理を一列に並べるとおおよそ以下のような形となります。
■Java
List<String> result = hogeRepository.selectIds();
・・・上記で作成されたキャッシュがクリアされない処理・・・
result.clear();
result = null;
List<String> result2 = hogeRepository.selectIds();
特に問題なく動作すると考えていましたが、
上記処理が実行されると1回目のクエリは想定通りの結果が取得できましたが、
2回目のクエリはselectIdsで必ずnullが取得されるようになっていました。
調査した結果、キャッシュからの取得結果がキャッシュ本体と同一オブジェクトを参照しているため、
result.clear()を行うことでキャッシュを破壊してしまっていることがわかりました。
キャッシュから取得した値をディープコピーせず、同一オブジェクトを参照している他のオブジェクトに影響のある操作をするとキャッシュを破壊してしまいます。
例えば、result.add("3")とすれば、次のクエリ発行時には存在しない"3"という値がクエリから取得されるようになります。
キャッシュから取得されたデータと、キャッシュ本体は別空間にあるものと思っていたため、想定外の動作となりました。
メモリの解放(clear)は処理として残しておきたかったため、今回は不具合1と同様、flushCache属性を付与し キャッシュを使わず再取得させることで対処しました。
本記事は以上となります。
最後までご覧いただきありがとうございます。皆様良いMyBatisライフをお過ごしください。