実環境におけるデータベース更新の競合に対処するはじめにデータベース同時実行時の競合を検出したり、それに対処したりする方法については、数多くの記事が書かれています。 残念ながら、こうした記事と、そこに記されているソリューションのほとんどは、大きな欠陥を抱えています。いずれも、実際のデータとそのデータの使い方ではなく、技術的な問題とデータベースの実装に焦点が当てられているのです。本稿では、データベースの実装に焦点を当てることと、実環境のデータに焦点を当てることの違いを説明し、これらの同時実行時の問題を解決する方法の有効なアプローチをいくつか紹介します。 データベース同時実行時の競合とは何か初めに、データベース同時実行時の競合とは何か、なぜそれを解決する必要があるかを簡単に説明しましょう。 データベースアプリケーションの大半はマルチユーザーアプリケーションです。つまり、任意の時点で、複数のユーザー/プロセスが同じデータベースとの間で、読み取り/書き込みを行うことが予想されます。複数のユーザー/プロセスが同じデータベース内のデータを更新しているとすれば、2人/2つの異なるユーザー/プロセスが、同時に同一のデータの更新を試みることは時間の問題です。通常の更新サイクルは次の一連のアクションから成り立ちます。
従って、2人のユーザーがメモリに同じデータを読み込むこともあるわけです。そのデータをユーザー2が更新して変更をデータベースに書き込む前に、ユーザー1が同じことをするかもしれません。ここで同時実行制御の競合が発生します。なぜなら、ユーザー2が持っているデータは、ユーザー1がデータベースにデータを書き込む前に読み取ったものだからです。ユーザー2がデータをデータベースに書き込めば、ユーザー1が加えた変更を上書きすることになり、結果的にユーザー1の変更が失われてしまいます。基本的には、最後に変更を保存した人の勝ちになります。先に保存した人の変更は、その後に保存された変更によって上書きされてしまいます。 この種のデータベース同時実行の問題は、ユーザー間でも、自動化されたプロセス間でも、またその両者の間でも発生する可能性があります。ユーザー間の方が、読み取り/更新/書き込みサイクルの時間が長いので同時実行の問題が発生する確率が高くなります。しかし、自動化されたプロセス間でも同じ問題が発生する可能性はあり、通常はこちらの方が解決が困難です。ユーザーによる更新の場合は、ユーザーに何をしたいか(他のユーザーが加えた変更を上書きしたいか)を質問してその答えを得ることができますが、プロセスの場合は、すべてのアクションを完全に自動化する必要があるからです。 現在のアプローチまず、データベースの同時実行問題の解決策として一般的に言われていることと行われていることを説明しましょう。通常、この問題の解決は、2つの基本的なアプローチに分けることができます。
問題を処理するために、この2つの異なる選択肢について簡単に説明します。ここでは、問題を明確にすることだけに焦点を当て、問題処理の適用範囲とその性質についての説明は一切省略します。 ペシミスティックな同時実行制御ペシミスティックな同時実行制御では、アプリケーションがデータを変更する前にユーザー/プロセスにあるアクションを要求することで競合を防止します。この「アクション」は複数の要素から構成することができますが、通常は、データベース内のデータをロックし、それによって別のユーザーが同じロックを保持できないようにします。 長所と短所長所:
短所:
ペシミスティックなロックには、実際のデータベースロックではなくソフトロックを使うこともできます。この方法は、あるフィールドを更新することによって、ユーザーがデータを使用中でありデータが「ロック中」であることを示します。接続を確立する必要がないため、スケーラビリティの問題はなくなります。 しかし、このアプローチには欠点があります。データベース側でロックを実施するのではないため、別のコードによってロックが無視される可能性があります。また、ロックを手動で解除しなければならないので、解除しないとデータは永遠にロックされたままになります。 ペシミスティックなロックはそれなりに意味があるものの、欠点も多くあり、.NETアプリケーションではデータが分離されているため、あまりうまく活用できません。 オプティミスティックな同時実行制御オプティミスティックな同時実行制御では、ユーザーがデータをロックすることで同時実行問題の発生を防ぐのではなく、データベースに書き込むときにその問題を検出して解決します。通常、開発者はさまざまなアプローチからオプティミスティックな同時実行制御を行っています。例えば次のようなアプローチがあります。
1.チェックを行わず、最後のものを優先する これは、サーバーが単に問題を無視するだけなので、本当のことを言えば同時実行制御のメカニズムではありません。最後にデータを更新するユーザーが、それまでのユーザーが加えた変更を上書きします。この場合のSQL 2.ユーザーが変更したフィールドを比較する この場合は、更新処理の中で、ユーザーが変更したいデータとデータベース内のデータを比較し、ユーザーがデータを読み取ったときと同じデータであることが確認できた場合は変更をコミットします。最初に読み取ったデータとデータベース内のデータが異なる場合、サーバーはユーザーの変更をコミットせずに、ユーザーに警告を発行します。この場合のSQL 3.すべてのフィールドを比較する この種のオプティミスティックな同時実行制御のアプローチでは、更新をコミットする前に、変更されたフィールドだけでなく、すべてのフィールドをチェックします。これは手間のかけすぎのように思えますが、標準の 4.行バージョンを比較する このアプローチでは、データに行バージョンフィールドという追加フィールドを用意します。このフィールドは、タイムスタンプフィールドとも言い、サーバーはデータを更新するたびにこのフィールドを変更します。この方法だと、サーバーはSQL あるユーザーが現在の値ですべてのデータを更新するという更新処理を送信した場合も、データの行バージョンは更新されます。つまり、同じ行に何か変更を加えたい2番目のユーザーがいる場合、サーバーが行バージョンをチェックするアプローチでは競合が検出されますが、すべてのフィールドを比較するアプローチの場合は競合が検出されることはありません。 長所と短所長所:
短所:
実環境での問題データベースの同時実行問題を詳しく説明している記事は他にもたくさんありますが、これらの記事には1つの共通する欠陥があります。それは、問題全体を、ユーザーの観点からではなく、データベースの観点から見て説明している点です。 私が何を言いたいかお分かりでしょうか。これまで私が目にしてきたデータベースの同時実行の説明とソリューション案は、いつも1つのテーブル内の1行全体の同時実行時の競合と検出を焦点にしていました。実環境での更新は、複数の行、複数のテーブル、場合によっては複数のデータベースにまたがることもよくあります。ユーザーに対するデータの表示方法は、通常は更新対象の1つの行にマッピングされていません。 以降では会計パッケージの例を取り上げて解説を続けます。ここで紹介する原則は、すべてではないものの、中規模から大規模のほとんどのアプリケーションに当てはまります。 ユーザーが表示するデータは次のとおりです。
用語解説
注1:VATコード -- Value Added Taxの略称。付加価値税。世界80カ国に普及している間接税制度。ヨーロッパや海外諸国で生産の全過程において課せられる税金のこと。
注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内部のツールと、 欠点を見るために簡単な例を取り上げましょう。データベースに債務者情報が格納されているとします。この情報には、住所、現在の残高、および債務者の与信限度が含まれます。債務者からの支払いがあるたび(または新しい発注が発生するたび)に、アプリケーションが自動的に残高を更新します。ユーザーが残高を直接更新することはできません。基本的に、残高はパフォーマンス上の理由から、計算フィールドとして実際に用意します。 顧客窓口部のユーザーが顧客の住所を変更しているときに、財務部の別のユーザーがその債務者の与信限度を変更するとします。厳密には、2人のユーザーが同時に同じ行を更新したいので、同時実行時の競合が発生します。しかし、顧客窓口部は住所データを所有しているのに対し、財務部は与信限度を所有しているので、手続き的に言えば競合はありません。しかし、それぞれのユーザーにデータのサブセットだけを提供してもこの問題を解決することはできません。財務部は弁済能力の査定を行うために債務者の住所を知らなければならないからです。財務部は債務者の住所を更新する必要はないので、必要なのは住所フィールドへの読み取りアクセス権だけです。同じことは、顧客窓口部にも当てはまります。顧客窓口部は債務者の与信限度を知る必要があるかもしれませんが、その情報の更新は行いません。 この例の両方の変更を、すべての債務者情報を更新する権限を持つユーザーが行うとしても、これを同時実行時の競合と呼ぶことはまずありません。言うまでもなく、SQL 問題を解決する 本当に必要なのは、読み取り専用フィールドの概念と、読み取り専用以外のフィールドを個別のSQL このコードではコレクションを反復処理し、ユーザーが更新しようとする個々のフィールドについて、古い値と新しい値を含んでいるパラメータを各コマンドに設定します。特定のコマンドで古い値と新しい値がすべて一致した場合は、言うまでもなくデータのその部分に変更はないので、コマンドを実行する必要はありません。すべてのSQL 例 次の ’’’ <summary> ’’’ Test application to check database updates. ’’’ </summary> ’’’ <remarks></remarks> Sub Main() Dim dta As New pubsDataSetTableAdapters.titlesTableAdapter Dim table As pubsDataSet.titlesDataTable Dim cb As New CommandBuilder ’ Load the data table = dta.GetData() ’ Configure the table cb.ConfigureDataTable(table) ’ Make a change to the data table.Item(0).price *= 1.1 ’table.Item(0).title = "New title" ’ Update the database cb.UpdateTable(table) Console.WriteLine( _ "Press any key to terminate the application.") Console.ReadKey() End Sub 型指定されたデータセットとSQL UPDATEコマンド
Visual Studio 2005で型指定されたデータセット(typed dataset)を生成する場合、既定ではオプティミスティックな同時実行制御が使用されます。問題のテーブルにタイムスタンプ列がある場合は、このタイムスタンプを使って行バージョンを比較します。問題のテーブルにタイムスタンプ列がない場合は、データセットデザイナはすべてのフィールドのチェックを追加して同時実行時のエラーを検出します。
同時実行チェックのないSQL UPDATEコマンドが必要な場合は、このチェックをオフにすることができます。これは新しいSQL UPDATEコマンドを生成するときの設計時アクションです。オプティミスティックな同時実行制御チェックのオン/オフを切り替えるにはデータセットデザイナを使用します。
[Advanced Option]ダイアログを見ると同時実行時の不一致を防ぐことができるように思えますが、実際には検出しかできません。同時実行時の不一致が検出されると、 System.Data.DBConcurrencyExceptionがスローされます。不一致をキャッチして処理することは、開発者の役目であることに変わりありません。型指定されたpubsDataSetを作成する
サンプルコードでは、型指定されたデータセットを使ってSQL Server Pubsデータベースにアクセスしています。型指定されたデータセットを作成すれば、データベースから迅速、かつ簡単にデータを取得できます。次のステップに従ってpubsDataSetを作成します。
メインプログラムは、型指定されたテーブルアダプタを使ってPubsデータベースからtitlesテーブルを読み込みます。他の関数はすべて、メイン関数で作成された 次の ’’’ <summary> ’’’ Configure the columns into update groups. ’’’ </summary> ’’’ <param name="table">The table with columns.</param> ’’’ <remarks></remarks> Public Sub ConfigureDataTable( _ ByVal table As pubsDataSet.titlesDataTable) ’ Basic data about the book table.title_idColumn.ExtendedProperties("UpdateGroup") = "Book" table.titleColumn.ExtendedProperties("UpdateGroup") = "Book" table.typeColumn.ExtendedProperties("UpdateGroup") = "Book" table.notesColumn.ExtendedProperties("UpdateGroup") = "Book" table.pubdateColumn.ExtendedProperties("UpdateGroup") = "Book" ’ Financial data about the book table.pub_idColumn.ExtendedProperties("UpdateGroup") = _ "Financial" table.priceColumn.ExtendedProperties("UpdateGroup") = _ "Financial" table.advanceColumn.ExtendedProperties("UpdateGroup") = _ "Financial" table.royaltyColumn.ExtendedProperties("UpdateGroup") = _ "Financial" ’ Sales information about the book table.ytd_salesColumn.ExtendedProperties("UpdateGroup") = _ "Sales" End Sub リスト1 UpdateTable()関数
’’’ <summary> ’’’ Sends all updates to the database ’’’ </summary> ’’’ <param name="table">The table with changes,</param> ’’’ <returns></returns> ’’’ <remarks>Just demo code. ’’’ Cannot execute as there is no connection and Insert/Delete ’’’ is not implemented. ’’’ </remarks> Public Function UpdateTable(ByVal table As DataTable) As Boolean Dim updateCommands As List(Of SqlCommand) ’ Get a list of update commands to execute updateCommands = GetUpdateCommands(table) For Each row As DataRow In table.GetChanges(DataRowState.Modified).Rows() Select Case row.RowState Case DataRowState.Added ’ New row, do an database insert Case DataRowState.Deleted ’ Deleted row, do a database delete Case DataRowState.Modified ’ Changed row, do the required database updates For Each cmd As SqlCommand In updateCommands Dim hasChanges As Boolean = False For Each param As SqlParameter In _ cmd.Parameters() ’ Populate all parameters Dim fieldName As String fieldName = param.ParameterName.Substring(3) If param.ParameterName. StartsWith("old") Then param.Value = row(fieldName, DataRowVersion.Original) Else param.Value = row(fieldName, DataRowVersion.Current) End If ’ Check if this field is changed hasChanges = hasChanges OrElse Not row(fieldName, _ DataRowVersion.Original). Equals(row(fieldName, _ DataRowVersion.Current)) Next If hasChanges Then Console.ForegroundColor = _ ConsoleColor.Yellow Console.WriteLine( "Executing command:") ’cmd.ExecuteScalar() Else Console.ForegroundColor = ConsoleColor.Red Console.WriteLine("Skipping command:") End If Console.WriteLine(cmd.CommandText) Console.WriteLine() Console.ResetColor() Next End Select Next End Function 次のコードは、 ’’’ <summary> ’’’ Build a collection of update commands for the table. ’’’ </summary> ’’’ <param name="table"> ’’’ The table that needs to be updated.</param> ’’’ <returns> ’’’ A collection of SQLCommands for the update.</returns> ’’’ <remarks></remarks> Private Function GetUpdateCommands(ByVal table As DataTable) _ As List(Of SqlClient.SqlCommand) Dim groups As IDictionary(Of String, List(Of DataColumn)) Dim cmds As List(Of SqlClient.SqlCommand) cmds = New List(Of SqlClient.SqlCommand) Console.WriteLine("Building update commands.") Console.WriteLine() ’ Split all columns into groups based upon the ’ UpdateGroup extended property. groups = SplitColumnIntoGroups(table) For Each group As List(Of DataColumn) In groups.Values Dim cmd As SqlCommand cmd = CreateUpdateCommand(table, group) cmds.Add(cmd) Console.WriteLine("Update command {0}:", cmds.Count) Console.WriteLine(cmd.CommandText) Console.WriteLine() Next Return cmds End Function リスト2 SplitColumnIntoGroups()関数
’’’ <summary> ’’’ Split all columns into groups based upon the UpdateGroup ’’’ extended property. ’’’ </summary> ’’’ <param name="table"> ’’’ The table with columns to split.</param> ’’’ <returns>A dictionary with the groups of columns.</returns> ’’’ <remarks></remarks> Private Function SplitColumnIntoGroups(ByVal table As DataTable) _ As Dictionary(Of String, List(Of DataColumn)) Dim groups As New Dictionary(Of String, List(Of DataColumn)) For Each col As Data.DataColumn In table.Columns Dim updateGroup As String If col.ExtendedProperties.Contains("UpdateGroup") Then updateGroup = col.ExtendedProperties("UpdateGroup").ToString() Else updateGroup = "" End If If Not groups.ContainsKey(updateGroup) Then groups.Add(updateGroup, New List(Of DataColumn)) End If groups(updateGroup).Add(col) Next Return groups End Function リスト3の リスト3 CreateUpdateCommand()関数
’’’ <summary> ’’’ Create a SqlCommand to update the field group. ’’’ </summary> ’’’ <param name="table">The table being updated.</param> ’’’ <param name="group">The field group.</param> ’’’ <returns>The SqlCommand to update the table.</returns> ’’’ <remarks></remarks> Private Function CreateUpdateCommand(ByVal table As DataTable, _ ByVal group As IEnumerable(Of DataColumn)) As SqlCommand ’ Build an update command for the group of columns Dim cmd As New Data.SqlClient.SqlCommand Dim sqlSet As New System.Text.StringBuilder() Dim sqlWhere As New System.Text.StringBuilder() For Each col As DataColumn In table.PrimaryKey If sqlWhere.Length > 0 Then sqlWhere.Append(" and ") End If sqlWhere.Append("([") sqlWhere.Append(col.ColumnName) sqlWhere.Append("] = @org") sqlWhere.Append(col.ColumnName) sqlWhere.Append(" or [") sqlWhere.Append(col.ColumnName) sqlWhere.Append("] = @new") sqlWhere.Append(col.ColumnName) sqlWhere.Append(")") cmd.Parameters.AddWithValue("old" + col.ColumnName, col.DataType) cmd.Parameters.AddWithValue("new" + col.ColumnName, col.DataType) Next For Each col As DataColumn In group If sqlSet.Length > 0 Then sqlSet.Append(", ") End If sqlSet.Append("[") sqlSet.Append(col.ColumnName) sqlSet.Append("] = @new") sqlSet.Append(col.ColumnName) If sqlWhere.Length > 0 Then sqlWhere.Append(" and ") End If sqlWhere.Append("([") sqlWhere.Append(col.ColumnName) sqlWhere.Append("] = @org") sqlWhere.Append(col.ColumnName) sqlWhere.Append(" or [") sqlWhere.Append(col.ColumnName) sqlWhere.Append("] = @new") sqlWhere.Append(col.ColumnName) sqlWhere.Append(")") If Not cmd.Parameters.Contains("old" + col.ColumnName) Then cmd.Parameters.AddWithValue("old" + col.ColumnName, _ col.DataType) End If If Not cmd.Parameters.Contains("new" + col.ColumnName) Then cmd.Parameters.AddWithValue("new" + col.ColumnName, _ col.DataType) End If Dim commandText As String commandText = "Update [{0}] Set {1} Where ({2})" cmd.CommandText = String.Format(commandText, _ table.TableName, sqlSet.ToString(), sqlWhere.ToString()) Next Return cmd End Function おわりにここで紹介した手法は、すべての更新の同時実行問題に対応する完全なソリューションではありませんが、正しい方向に向かう一歩だと思います。この取り組みは現在進行形なので、この先、個々のケースに対処する最適な方法を見つける人も出てくるでしょう。 このソリューションが、使いやすく、あまりテクノロジ指向ではない性質のアプリケーションの作成に役立つことを願います。 著者紹介Maurice de Beijer(Maurice de Beijer)
フリーのソフトウェア開発者、ベータテスタ、そしてMicrosoftのMVP受賞者である。.NET、オブジェクト指向、Visual FoxProのほか、技術的に難しい問題の解決を専門とする。「The Problem Solver」というサイト(www.TheProblemSolver.nl)を運営している。
|