DBFluteはシーケンスのキャッシュ機能を装備した

@DBFlute-0.9.6.4, Java
そもそも "シーケンスのキャッシュ機能" とは?

#
# 概念説明
#
DBのシーケンスにはキャッシュ機能が備わっていますが、
それとは似ていながら全く独立した別の機能です。
アプリ側(ここではDBFlute内)で行うキャッシュ機能です。

{追記: 2010/1/22}
シーケンス処理には大きく二つのコストがあります。
o DB内部でのシーケンス採番コスト
o アプリからのシーケンス取得SQLの通信コスト

例えば、100万件のデータを登録するとしましょう。
すると、100万回のシーケンス取得の処理が走り、これがなかなか
重たいようなのですね。(DBMSによって違いはあるかもしれません)
DBFluteでは、登録前にそれぞれのEntityのためのシーケンスを
取得するので、単純にSQLの発行回数が
"insert回数 + sequence回数" と、二倍になってしまいます。

そこで、例えば、"50回" に一度だけシーケンスを取得して、
後の "49回" は単にアプリ上でその値をインクリメントするだけにし、
その後、またシーケンスを取得して、またしばらくインクリメントだけ
の生活を行う。このようにすることで、DBに対してシーケンス取得の
処理を委譲する回数が減るため、パフォーマンスが改善します。
 1件目:シーケンスから "1" をもらう
 2件目:シーケンス処理せずに "1" に "1" を足す
 3件目:シーケンス処理せずに "2" に "1" を足す
 4件目:シーケンス処理せずに "3" に "1" を足す
 ...
 49件目:シーケンス処理せずに "49" に "1" を足す
 50件目:シーケンスから "51" をもらう
 51件目:シーケンス処理せずに "51" に "1" を足す
1から50までのシーケンス番号を1件目の処理時点で
"予約" (キャッシュ)するような感じです。

さて、これの実現には大きなポイントがあります。
1件目と51件目だけしかシーケンスを取得していないので、
例えば、51件目のシーケンス取得で "2" を取得しちゃったら
なんのこっちゃですね。51件目の(実際のシーケンスからの)
シーケンス取得は "51" (以上)でなければなりません。
逆に言うと、そのようになっていれば、サブアプリでもシーケンス取得を
する場合でも、重複したIDで登録してしまうようなことはありません。
(シーケンスというアプリ間で共通のオブジェクトを利用しているため)
ただ、登録順序が逆転することはありますが...それは後述します。

ということで、このような挙動を実現するための方式が大きく二つ:
(方式の名前はDBFlute独自用語です)
#
# インクリメント方式 (incrementWay)
#
シーケンスのインクリメントサイズ(増分値)を "50" に設定します。
要は、キャッシュサイズとインクリメントサイズを同じにします。
そうすることで、シーケンスは採番するたびに50ずつ飛んだ番号を
戻してきます。先ほど、説明したパターンにピッタリはまります。
シーケンスのキャッシュ機能の王道の方式です。
create sequence SEQ_PURCHASE increment by 50;
メリット:
o 本当に50回に一度だけのシーケンス処理なので効果が高い。
o 仕組みとしてはとてもシンプル。

デメリット:
o シーケンスのインクリメントを "50" などにする必要がある
 - 同じ機構を持たないサブアプリでシーケンス取得すると
 "50ずつ" ぶっ飛んでいく。
 - 組織次第で実現が難しい場合がある(DB側へ影響があるため)
 - パフォーマンス問題出てからの適用がしづらい(DB側へ影響が...)
 - PostgreSQLのserial型ってインクリメント変えられたっけ?
o キャッシュサイズを膨大にした場合(5000とか10000とか):
 - 抜け番、逆転登録の可能性が大きくなる

考察:
メリットも大きいながらデメリットも大きいこの方式。
アプリ側がDB設計に対して比較的要望を出しやすい環境や、
そもそも自分たちでDB設計をやっている場合ならOKですが、
そうでないような環境の場合(レガシーなども含む)、そもそも
実現ができない可能性もあります。
サブアプリ関連のデメリットは、特に大きな問題ではないもの
ではありますが(既にシーケンス使う時点で抜け番は仕方ない)、
あまりにぶっ飛んでいくのは嫌がられる可能性があります。
#
# バッチ方式 (batchWay)
#
シーケンスのインクリメントサイズは "1" のまま、
一回のSQLでキャッシュサイズ分(例えば "50")のシーケンスを
取得して、それ以降はその取得したシーケンス番号をEntityに
割り当て、割り当てる番号が無くなったらまたシーケンスを取得。
インクリメントをしないあたりが、概念説明の王道からはちょっと
それた実現方法となっています。物理的に1から50までの番号を
"確保" (キャッシュ)しちゃう感じです。

シーケンス取得のSQLは、主に "union all" を使ったもので
スカラ値のリストを取得するよう形になります。
select next value for SEQ_PURCHASE
 union all
select next value for SEQ_PURCHASE
 union all
select next value for SEQ_PURCHASE
...
ちなみに、インクリメントサイズは別に "1" じゃなくてもOKです。
"キャッシュサイズ / インクリメントサイズ" で割り切れるもので
あれば良く、一回のSQLで取得するシーケンスの数で調整します。
(ただ、まあこの方式の場合は基本 "1" をターゲットとするかなと)

{追記: 2010/1/21}
"2" 以上のときは厳密にはインクリメント方式との複合方式です。
(カテゴリ的にはバッチ方式の方にまとめています)
例えば、インクリメントサイズが "3" の場合:
"1" はシーケンス(union allで "1", "4", "7", "10", ...をキャッシュ)、
"2" はインクリメント、"3"もインクリメント、"4" はキャッシュ
"5" はインクリメント、"6"もインクリメント、"7" はキャッシュ
...という感じです。
create sequence SEQ_PURCHASE increment by 1;
メリット:
o インクリメントサイズ "1" のままでOKなので利用しやすい。
 - 組織やレガシーなどの問題を回避しやすい
 - パフォーマンス問題出てからの適用が比較的しやすい
 - (同じ機構を持たない)サブアプリで利用されても番号がつながる
 - PostgreSQLのserial型でも利用可能

デメリット:
o 仕組みはちょっと複雑(union利用とか順序保証とかもろもろ)。
o DB内部でのシーケンス取得処理自体は省略できてない
o DB2だと "恐らく" できない(シーケンスのunionができない!?)
o キャッシュサイズを膨大にした場合(5000とか10000とか):
 - 抜け番、逆転登録の可能性が大きくなる
 - キャッシュ取得処理するときだけ目立って遅くなる

考察:
ほぼ、インクリメント方式の真逆のメリット・デメリット。
とにかく、適用しやすいというのに特化しているかと思います。
やはり、"シーケンスのインクリメント値を50にして欲しい" って
なかなか言えない場合もたくさんあるかと思います。
(そもそも自分たちがちょっといやだなぁと思う場合もあるかもだし)

ただ、やはりインクリメント方式に比べるとパフォーマンスは劣ります。
当然のことですが、やってることが多いので。但し、やらないよりは
やった方が当然速いです。アプリからのシーケンス取得のSQLの発行
回数がグンと減るため、何度も通信するということがなくなります。
また、"DB内部でのシーケンス取得処理が省略できてない" に関して、
一回のSQLで一気にシーケンスを何度も取得する処理は、"実際に試して
みたところそんな遅くなかった" という報告も聞いていますし、
インクリメント方式に比べてキャッシュサイズを比較的増やしやすい
かとは思います。(実際のインクリメントサイズには影響しないため)
但し、大きすぎるキャッシュサイズ(5000とか10000とか)は禁物。

{追記: 2010/01/31}
DBFluteユーザの集いのMLにて、パフォーマンス検証が報告されました。
詳しい数値などについてはそちらのMLを参照して下さい。
まとめると、インクリメント方式が一番速いが、バッチ方式も
キャッシュを利用しないときに比べてしっかりとした改善がある、
という感じです。良い報告です。

{追記: 2010/02/02}
DB2でも利用できることがわかりました。
(コメント参照)

補足:
ちなみに、この方式を "確保" じゃなくて "予約" でやってしまうと
問題があります。つまり、"50" 回のシーケンス取得のSQLを発行して、
一番小さい値だけ取得して後はインクリメントという感じになりますが、
これだと、そのSQLの実行中にサブアプリのシーケンス取得が実行されたら、
実際には "予約" できなかった番号が発生してしまう可能性があるから
です。例えば、1から50を予約しようとして、サブアプリに "27" を取得
されてしまった場合に、(この "予約" のやり方だと)メインアプリでは
そのことがわからないので同じ番号で登録しにいってしまいます。
本当にレアケースではありますが、厳密でないのでこのやり方はNGです。
(DBFluteも最初それでやってて実装途中で修正しました)
何かしら、ロックオブジェクトを使って"50" 回のシーケンス取得を
アトミックにできればですが、フレームワークの機能としてはそれは
なかなかやりづらいものです。(アプリで独自に実現するのであれば、
アリだとは思います)
#
# 共通のデメリット
#
どちらの方式でも共通のデメリットがあります:

o サブアプリがあると登録順序が逆転する可能性が高い
o アプリが再起動すると抜け番がでる可能性が高い
o サイズの設定のズレでへんてこりんな動きしちゃう

これが、許容できない場合は利用できないのですが、
キャッシュしなくてもシーケンスを使ってる時点で、
登録順序の逆転や抜け番は発生する可能性はあります。
まあ、"どの程度なら許せる!?" というところです。

一番、心配なのは、設定のズレとかによる "ハマり" です。
当然、"50" のインクリメントを期待していて "1" だったら、
たちどころに登録エラーになります。
#
# DBFluteでは
#
DBFluteでは、この二つの方式を採用しています。
"どっちの方式を利用する?" という感じではなく、
キャッシュサイズとインクリメントサイズの関係によって、
どちらの方式を利用するべきかを自動判別します。

キャッシュサイズ:50
インクリメントサイズ:50
 --> インクリメント方式

キャッシュサイズ:50
インクリメントサイズ:1
 --> バッチ方式

キャッシュサイズ:50
インクリメントサイズ:25
 --> バッチ方式

キャッシュサイズ:50
インクリメントサイズ:3
 --> 設定エラーとして自動生成時に例外

要は、キャッシュサイズとインクリメントサイズが同じであれば、
"インクリメント方式" で、キャッシュサイズがインクリメントサイズで
割り切れれば "バッチ方式" で、割り切れない場合は明示的な設定エラー
として極力とてもいやぁな "ハマり" が無いようにしています。

DBFluteは、自動生成な人です。
なので、インクリメントサイズはDBMSのメタ情報から取得します。
アプリではキャッシュサイズをDBFluteプロパティで指定し、
後はDBFluteが "方式を自動判別" という流れです。
へんてこりんなキャッシュサイズなら明示的な例外。
#
# 仕様
#
o キャッシュ処理は明示的に指定した場合のみ有効
o キャッシュ利用の指定は "テーブルごとのシーケンス" に設定
o キャッシュサイズが "1" 以下は設定ミスとして例外
o PKに対するシーケンスのみ対象
o デクリメントのシーケンスはキャッシュ利用サポート対象外

"sequenceDefinitionMap.dfprop" にて、
テーブルごとのシーケンスに対して、"キャッシュの利用有無"、
そして、"キャッシュサイズ" を指定します。
map:{
    ; PURCHASE = SEQ_PURCHASE:dfcache(50)
}
この場合、PURCHASEテーブルに関連付いたSEQ_PURCHASEシーケンス
において、キャッシュを利用することが明言されます。そして、
キャッシュサイズを "50" としてキャッシュ処理をします。
インクリメント方式なのかバッチ方式なのかは、メタ情報から取得した
インクリメントサイズ次第で決定します。

o 両方同じ値:インクリメント方式
o 割り切れる:バッチ方式

但し、インクリメントサイズをメタ情報から取得できないDBMSの場合、
インクリメント方式となります。DBFluteがインクリメントサイズを
知ることができないため、ズレが生じていても検知できないため、
注意しての利用となります。ただ、DBFluteでサポートしているDBMS
の中でシーケンスをサポートしている全てのDBでインクリメントサイズ
の取得が可能なので、基本的にこの状況での利用はありません。
(PostgreSQL, Oracle, DB2, H2)
万が一、ユーザ権限やDBMSのバージョンの問題でメタ情報が取得
できないようなときのために、この状況での利用を想定しています。
(そういう状況でも一応利用出来るようにと)

PostgreSQLのserial型のシーケンスに関しては、このdfpropに
明示的に設定をすることでキャッシュ機能が利用できます。


DB2に関しては、バッチ方式は利用できません。
一回のSQLでの複数シーケンス取得のSQLがわからなかったからです。
(誰か知っていたら教えて頂ければと!)


{追記: 2010/02/02}
DBFlute-0.9.6.5よりDB2でも利用可能です。

例外系:
o 割り切れない:明示的な例外
o デクリメントのシーケンスに設定されている場合は明示的な例外
o キャッシュサイズが "1" 以下の場合は明示的な例外
o DB2でバッチ方式は "実行時に" 明示的な例外
 ※明記されていないものは自動生成時に例外は発生しますが、
 幾つかは実行時にも同じチェックをしています。

さらに、キャッシュサイズの指定を省略しての利用が可能です。
インクリメント方式の利用を確定させたい場合はこれを利用します。
map:{
    ; PURCHASE = SEQ_PURCHASE:dfcache()
}
この場合、インクリメントサイズがそのままキャッシュサイズとなります。
よって、必ず "インクリメント方式" でのキャッシュ処理となります。

例外系:
o インクリメントサイズが取得できないDBMSは明示的な例外
o デクリメントのシーケンスに設定されている場合は明示的な例外
o インクリメントサイズが "1" 以下の場合は明示的な例外
 ※基本的に、自動生成時に例外は発生しますが、
 実行時にも幾つかは同じチェックをしています。
#
# 拡張
#
{シーケンスキャッシュの単位}
シーケンスキャッシュの単位のデフォルトは、
"テーブルごとのシーケンス" です。
(実質、テーブル単位と言えます)

 キー:[table-name].[sequence-name]

そのキーの単位を変更したい場合は、"DBFluteConfig"
にて、キーを生成するコールバックオブジェクトを登録する
ことで可能です。主に "SelectableDataSource" を
利用しているような場合に有効です。(動的なデータソース)
"SequenceCacheKeyGenerator" インターフェースを
実装したアプリ独自のクラスを登録するようにして下さい。

{思いっきり拡張}
ImplementedInvokerAssistant経由で拡張できます。
SequenceCacheHandlerというクラスが拡張ポイントです。
また、BehaviorのExクラス経由でも拡張できます。
createSelectNextValCommand()メソッドの拡張で、
この "Behaviorだけ" という拡張ができます。
無論、これらをやるときは "厳重に、そして、慎重に"!
#
# 補足
#
{BehaviorのselectNextVal()}
このキャッシュ機能を利用した場合、BehaviorのselectNextVal()
メソッド自体がその機能を利用しますので、このメソッドを明示的に
実行しても実際にはDBアクセスせずにキャッシュである可能性があります。

{違うテーブルで同じシーケンス}
違うテーブルで同じシーケンスを利用していても問題ありません。
通常は、それぞれのテーブルで同じキャッシュ設定をすることを
想定していますが、(無意味ですが)別の設定をしたとしても動作に
問題はありません。違うテーブルからの同じシーケンスへの
アクセスは、サブアプリからの同じシーケンスにアクセスする
のと(シーケンス側から見れば)同じことです。

{Example}
シーケンスをサポートする4つのDBMSのExampleにおいて、
がっつりテストがされています。(マルチスレッドもがっつり)
H2は、"dbflute-guice-example" にて試しています。
なので、利用(実行)されている様を気軽に覗いてみたい人は、
このExampleを利用すると良いでしょう。(DBが組み込みなので)

{びっくり}
バッチ方式でのシーケンス取得のSQLは見るとなかなかびっくりします。
#
# 実装後記 
#
最初、ここまでやるつもりなかったのですが、やってしまいました。
やはり基本的に、安全を基調とし実現をしたかったため、どうせやるなら
徹底してやってしまおうと思いました。メタ情報もそれぞれのDBMSで
取得の仕方が全然違うので、かなり大変だったのですが、アドバイスも
もらったりと、おかげさまでなんとかやりきれました。

メタ情報を取得できないDBMSの場合に、例外にしようかどうしようか
迷ったのですが、"安全を基調としながらもいざってときは柔軟に"
というのも大事なことなので、利用できるようにしました。

DBFlute-0.9.6.4はまだこの記事を書いた時点で、"RC2" です。
なので、最後にまた最終調整はあるかもしれません。
変更あれば、追記しますね。(何かフィードバックあれば大歓迎)

{追記: 2010/02/02}
DBFlute-0.9.6.4が無事リリースされました。
DB2でのバッチ方式の制限に関しては、0.9.6.5より解除されます。

実は、自分自身この概念をよく知らなくて、要望をもらってから、
勉強して分析して話し合いをして、色々な情報を総合的にまとめて
このように実装になりました。一緒に考えてくれた方本当にありがとう。
#
# 追記履歴
#
2010/01/21: バッチ方式で、インクリメントサイズ "2" 以上のときの補足
2010/01/22: 概念説明で、シーケンス処理には大きく二つのコスト
2010/01/22: DBFlute-0.9.6.4-RC2 を公開
2010/01/25: キャッシュサイズを膨大にした場合のデメリット
2010/01/31: バッチ方式のパフォーマンス検証
2010/02/01: DBFlute-0.9.6.4 をリリース
2010/02/02: DB2でのバッチ方式の制限解除(0.9.6.5よりサポート)