実環境での問題(1/2)
データベースの同時実行問題を詳しく説明している記事は他にもたくさんありますが、これらの記事には1つの共通する欠陥があります。それは、問題全体を、ユーザーの観点からではなく、データベースの観点から見て説明している点です。
私が何を言いたいかお分かりでしょうか。これまで私が目にしてきたデータベースの同時実行の説明とソリューション案は、いつも1つのテーブル内の1行全体の同時実行時の競合と検出を焦点にしていました。実環境での更新は、複数の行、複数のテーブル、場合によっては複数のデータベースにまたがることもよくあります。ユーザーに対するデータの表示方法は、通常は更新対象の1つの行にマッピングされていません。
以降では会計パッケージの例を取り上げて解説を続けます。ここで紹介する原則は、すべてではないものの、中規模から大規模のほとんどのアプリケーションに当てはまります。
ユーザーが表示するデータは次のとおりです。
- 単純な参照データ -- この種のデータは通常は1つの行にマッピングされます。例えば、適切なデータが入っているVATコード(注1)のリストがこれに相当します。
- 複合的なアプリケーションデータ -- 通常、これは複数の行、またはテーブルにまたがるデータから成り立ちます。例えば、請求書や発注書などがこれに相当します。
- 標準のアプリケーションデータ -- この種のデータは、通常はデータベース内の1つの行に格納されますが、ユーザーはこれを「関連性はあるが半分は異種であり、所有者が異なるデータの集合」と見なします。例えば、品目データがこれに相当します。アプリケーションは品目に関するすべてのデータを1つの行に保管するのに対し、ユーザーはデータの各部分を誰が「所有」しているかに基づいてデータを分割します。
- 基本的な品目データ -- これは、すべてのユーザーが使うSKU(注2)と説明から成り立ちます。
- 販売情報 -- この行には、販売価格、請求処理時に使うVAT情報、販売されたが出荷されていないユニットの数などが入ります。このデータはすべて営業部が所有および更新し、他のユーザーは読み取り専用となります。
- 購買情報 -- この行には、優先供給業者、最高購入価格、発注したが入庫していない品目の数などが入ります。このデータは購買部が所有し更新します。
- 在庫 -- この行には、在庫の数が入ります。物流部が在庫データを所有し、注文の処理に応じて出荷または入庫した品目の数を更新します。
注2:SKU -- Stock Keeping Unitの略称。在庫管理を行う場合の管理単位のことで、SCMなどで在庫管理する際には、このSKUの単位で商品/製品を管理する。
単純な参照データ
単純な参照データを処理するほとんどのアプリケーションでは、ユーザーが表示するデータセットはデータベース内の1行に正確にマッピングされるため、データの同時実行問題は標準的な方法でうまく対応できます。つまり、基本的なことはうまくカバーされるので、特に注意する必要はほとんどありません。ただし残念ながら、これはあまり変更されない種類のデータでもあるので、同時実行問題が発生する確率は非常に低くなります。
複合データ
多くのアプリケーションでは、複合データをあまりうまく処理していません。2人のユーザーが同じ発注書を開き、各ユーザーがその発注書の別々の行を変更し、保存しようとしたとしましょう。技術的に言えば、各ユーザーはデータベース内の異なる行を変更しているので、競合は発生しません。しかし、ユーザーが表示しているのは1つの発注書であり、データベース内の別々の行ではないので、心配するとなれば、これは2人のユーザーが同じデータを変更するマルチユーザーによる競合です。
ある程度までは、この問題は組織内の手続きに従って解決することができます。また、このようなシナリオが発生する確率は非常に小さいと主張する人もいるでしょう。そうかもしれませんが、遅かれ早かれ、発注書は、承認または出荷のために、営業部から別の部署に移動します。この時点で、組織の2つのまったく異なる部署が関わったことになります。一方の部署は明細を更新するのに対し、もう一方の部署はヘッダレコードを更新することでその発注の所有者になります。再び、厳密に言えば、2人のユーザーが更新するテーブルは異なるので競合はないのですが、実環境では、これはまさにマルチユーザーによる競合です。
このようなシナリオを解決するには、個別の品目ではなく、データベース内の個々の行を全体として考える必要があります。アプリケーションがデータベースに発注を格納する方法を変更し、データを1つの行に格納することを考えます。SQL Server 2005のXMLフィールドを使えば、すべての明細をXML要素として格納することができます。ただし、このソリューションで同時実行問題は解決されるものの、このアプローチには欠点もたくさんあります。中でも特に目立つのが、XMLでの明細と、データベースのその他の部分(データベース自身では処理できずコードで処理しなければならない部分)との相関的な整合性チェックがないことです。
複合データの同時実行問題を解決するためのもう1つのアプローチは、明細行を更新するときに発注書のヘッダの行バージョンをチェックすることです。このチェックの基本的な仕組みは次のとおりです。
- データベーストランザクションを開始します。
- 発注書のヘッダ行の行バージョンをチェックします。
- 発注書のヘッダ行を更新します。発注書のヘッダに実際には変更を加えていない場合でも、この更新によって行バージョンを更新します。
- 明細行をすべて更新、削除、または挿入します。
- トランザクションをコミットします。
ここでの問題は、データベースが実際にはこのチェックの実施を支援できないことと、発注書のヘッダ行のバージョンを更新せずに発注明細だけを更新するプロセスがないことです。この問題を解決するには、発注明細テーブルで定義されたトリガを使って発注書のヘッダ行のバージョンを更新することです。
このように、ヘッダ行のバージョンが確実に更新されるようにすることはできますが、これは、明細行のいずれかが更新された場合に、メモリ内の発注のヘッダ行を更新しなければならないことを意味します。繰り返しますが、これは理想的な状況ではありませんが、少なくとも非常に整合性があり、しかもテスト可能です。これをテストするには、同じ発注を2回変更します。2回目の変更は、1回目の変更を保存した直後に行います。2回目の変更を保存できた場合、メモリ内の行バージョンはデータベース内の行バージョンと同じになります。
標準のアプリケーションデータ
ほとんどのソリューションは、どちらにしても、この標準のアプリケーションデータをあまり適切に処理していません。主な問題は、データベースと、データを処理するための.NETクラスが、両方とも1行全体を中心にしている点です。Visual Studio内部のツールと、SqlCommandBuilderなどのクラスは両方とも、SQL SELECTステートメントに基づき、SQL SELECTのすべてのフィールドに対してSQL UPDATEステートメントを生成します。これは、単純な参照データであれば、通常、ユーザーは行全体を更新することになるので問題ないのですが、標準のアプリケーションデータの場合はあまり意味がありません。
欠点を見るために簡単な例を取り上げましょう。データベースに債務者情報が格納されているとします。この情報には、住所、現在の残高、および債務者の与信限度が含まれます。債務者からの支払いがあるたび(または新しい発注が発生するたび)に、アプリケーションが自動的に残高を更新します。ユーザーが残高を直接更新することはできません。基本的に、残高はパフォーマンス上の理由から、計算フィールドとして実際に用意します。
顧客窓口部のユーザーが顧客の住所を変更しているときに、財務部の別のユーザーがその債務者の与信限度を変更するとします。厳密には、2人のユーザーが同時に同じ行を更新したいので、同時実行時の競合が発生します。しかし、顧客窓口部は住所データを所有しているのに対し、財務部は与信限度を所有しているので、手続き的に言えば競合はありません。しかし、それぞれのユーザーにデータのサブセットだけを提供してもこの問題を解決することはできません。財務部は弁済能力の査定を行うために債務者の住所を知らなければならないからです。財務部は債務者の住所を更新する必要はないので、必要なのは住所フィールドへの読み取りアクセス権だけです。同じことは、顧客窓口部にも当てはまります。顧客窓口部は債務者の与信限度を知る必要があるかもしれませんが、その情報の更新は行いません。
この例の両方の変更を、すべての債務者情報を更新する権限を持つユーザーが行うとしても、これを同時実行時の競合と呼ぶことはまずありません。言うまでもなく、SQL SELECTステートメントから得たすべてのフィールドを単純に含むSQL UPDATEステートメントも、ここでは活躍しません。この問題は、各フィールドを別々のエンティティとして更新することで解決できます。これが、前述の問題を回避し解決する方法ですが、住所などの特定のフィールドを論理的にグループにまとめているなど、問題をあまりにも単純化しすぎています。
問題を解決する
本当に必要なのは、読み取り専用フィールドの概念と、読み取り専用以外のフィールドを個別のSQL UPDATEにグループ化することです。このためには、CommandBuilderクラスのように、データ定義を入力として受け取り、SQL UPDATEコマンドオブジェクトのコレクションを生成してデータを更新するクラスが必要です。SQL UPDATEコマンドのコレクションでは、住所用のコマンドと、与信情報用のコマンドを分けることができます(現在のユーザーが両方の情報を更新する権限を持っていることを前提とします)。
このコードではコレクションを反復処理し、ユーザーが更新しようとする個々のフィールドについて、古い値と新しい値を含んでいるパラメータを各コマンドに設定します。特定のコマンドで古い値と新しい値がすべて一致した場合は、言うまでもなくデータのその部分に変更はないので、コマンドを実行する必要はありません。すべてのSQL UPDATEコマンドの読み込みが終了すると、コードはこれらのコマンドを実行することができます。この場合、これらの更新を単一のトランザクションの一部としてラップする必要はありません。すべてのSQL UPDATEコマンドの実行が正常終了すれば、競合は発生せず、ユーザーは仕事を進めることができます。競合が発生しても、それはデータの特定のサブセットに限定されるので、アプリケーションは遅延することなくデータのその他の部分を更新できます。しかし、更新をグループ化するために、SQLの行バージョンを使うことはできません。代わりに、各コマンドのSQL WHERE句の主キーによって、一緒に更新するフィールドの完全なリストを取り込むことになります。
