トランザクション
データを失うことなく障害に対処するスケーラブルなシステムを構築する
Udi Dahan
この記事では、次の内容について説明します。
- 永続性のあるメッセージング
- トランザクションとシステムの一貫性
- エラー キューを使用した問題の処理
- メッセージのサイズとタイミング
|
この記事では、次のテクノロジを使用しています。
WCF、MSMQ
|

目次
分散システムの設計は、かつてないほど簡単になりました。バージョンを重ねるごとに高まった CLR の機能、Visual Studio
® の生産性、および Windows
® Communication Foundation (WCF) のようなフレームワークが持つ細かい制御能力を入手した開発者は、スケーラブルなシステムの構築に必要なあらゆるツールを手にしていると言えます。ただし、それだけでは十分ではありません。
大規模な分散システムに取り組んでいるとき、私のチームは、データを失うことなく障害シナリオに対処する堅牢なシステムの開発が決して簡単な作業ではないことを実感しました。ただし、それはツールセットに欠けているものがあるからではありません。拡張性と堅牢性を兼ね備えたシステムを構築するには、非常に特殊なパターンでこれらのツールを使用する必要があるのです。
HTTP とメッセージの損失
チームが最初に直面した問題は、メッセージの損失でした。初めてそのシステムを設計したとき、私たちは、WCF ベースのサービスのトランスポートとして HTTP を使用することに決めていました。メッセージ処理の一環として、私たちのサービスはデータを頻繁にデータベースに書き込みます。ごく簡単に言うと、多くのシステムはこのように設計されます。
システムをステージング環境に移す前に、厳しいストレス テストが行われました。システムを 1 週間、高い負荷を加えた状態で実行し続けたのです。ストレス テストの結果、システムでメッセージが失われることが判明しました。
この状況の分析中に、私たちは主に注文情報を含むメッセージが失われていることを突き止めました。システムに対する負荷のほとんどは注文情報に関連するものであったため、これは意外ではありませんでした。ただし、このメッセージの損失が特定の 10 分間に集中していることには驚きました。それ以降は、システムの動作に問題はありませんでした。
各種ログ ファイルを確認したところ、その週、Windows の "緊急" の修正プログラムが公開されており、ステージング ラボのサーバーがその修正プログラムを自動的にインストールした後、再起動していたことがわかりました。各種ログ ファイルを確認したところ、その週に Windows の "緊急" の修正プログラムが公開されており、ステージング ラボのサーバーが修正プログラムの自動インストール後に再起動されていたことがわかりました。サーバーがときどき自動的に再起動するという状況は、その影響の大きさを考えると、無視できる問題ではありませんでした。システムは、何年もの間動作し続ける必要があり、その間にサーバーで何度も行われるアップグレードおよび再起動に対応できなければなりません。
HTTP 経由のメッセージを処理するとき、サービスはデータベースに対してトランザクションを開始し、データの書き込みを試行し、トランザクションをコミットします。通常の状況であれば、この処理は成功するでしょう。メッセージを処理するサーバーがトランザクション中に再起動した場合、データベースはトランザクションのタイムアウトを検出し、その変更をロールバックすることで一貫した状態を保ちます。ところが、サーバーの再起動が行われると、サーバーのネットワーク処理スタックにもメモリにも元のメッセージのデータがありません。つまり、メッセージは失われてしまいました。
注文処理システムの場合、これはお金を失うことと同義です。このシナリオが航空管制システムで発生したときの悪夢のような事態、つまり "航空機 A と航空機 B が衝突進路上にある" というメッセージが失われるような事態は、だれも想像したくはないでしょう。
永続性のあるメッセージング
HTTP から永続性のあるトランスポートに変更することを決めた後、私たちは Microsoft® メッセージ キュー (MSMQ) と SQL Server® Service Broker のどちらかを選ばなければなりませんでした。MSMQ に比べて Service Broker が優れている点は、メッセージングをデータベースに保管でき、メッセージング層とデータベース間での分散トランザクションを回避できるため、パフォーマンスが高くなる可能性があるという点でした。Service Broker に比べて MSMQ が優れていた点としては、Windows 上で直接 (無料で) 使用できること、WCF との緊密な統合、既にオーバーロードしているデータベースを介さずにサーバー間でピアツーピア通信を実行できることなどが挙げられます。最終的に、私たちは各サーバーでプライベート MSMQ キューを使用することにしました。
MSMQ を使用しても、すべてのメッセージが自動的にディスクに書き込まれるわけではありません。書き込みにはパフォーマンス オーバーヘッドが伴うため、これは好ましい特性です。メッセージを永続的なものとして定義するには、MSMQ を使用する際に、Message クラスの Recoverable プロパティを直接 true に設定するか、NetMsmqBinding を使用してください (このクラスは、Durable プロパティが既定で true に設定されているため)。
ソリューション全体で NetMsmqBinding を使用する場合は、永続メッセージングを必要としないメッセージは別のエンドポイントに転送されるようにする必要があります。それらのエンドポイントを非トランザクション キューとして構成し、<netMsmqBinding> セクションの Durable プロパティを false に設定します。
障害が発生した際にキューでメッセージをロールバックするには、トランザクション内で Receive 操作が行われる必要があります。これは、OperationBehaviorAttribute の TransactionScopeRequired プロパティを使用するだけで実現できます。メッセージをキューから取り出すために使用されるトランザクションは、キューに登録されたトランスポートと共に、メッセージを処理するために使用されます。
システムの一貫性
メッセージの処理の結果、サービスは 1 つ以上のメッセージで応答したり、それ以外の任意の数のメッセージを他のシステムに送信したりできます。サービスの任意のポイントでメッセージングを通じたコードの通信を実現できれば、非常に有益な柔軟性を得ることができます。ただし、これを HTTP や TCP などのトランザクション非対応のテクノロジで行うのは危険です。
メッセージの処理に関連するトランザクションは、データベースのデッドロックの結果としてロールバックされることがあります。トランザクションがロールバックされると、他のシステムに送信した情報が適正な内容ではなくなる可能性があります。図 1 のシナリオについて考えてみましょう。複数のスレッドが同じテーブルの情報を処理するデータベースでは、デッドロックが発生するおそれがあります。あるスレッドが正常にテーブル 1 をロックし、テーブル 2 のロックを試みるとします。それと同時に、別のスレッドが正常にテーブル 2 をロックし、テーブル 1 のロックを試みるとします。データベースはこのとき発生するデッドロックを検出し、一方のスレッドを犠牲にしてそのトランザクションを中止します。
図 1 データベースのデッドロックが発生したトランザクション (クリックすると拡大画像が表示されます)
ここでの問題点は、ロールバックされたテーブル 1 のデータに対する変更をシステム A に通知したことです。これにより、システム A がその内部データを変更し、誤った情報をさらに伝達する可能性があるので、最終的にはグローバルな不整合が生じるおそれがあります。
注文処理シナリオでは、処理は次のような流れになりました。
- 発注書が到着する。
- 発注書のテーブルを更新する (ステータスを受信する)。
- 保留中の注文を倉庫システムに通知する。
- 顧客の情報を更新するためにカスタマ リレーションシップ マネジメント (CRM) データベースにアクセスする。
- CRM データベースのデッドロックが検出される。
- トランザクションがロールバックされる。
トランザクションが再試行されたとき、まだ支払いが行われていなかったため、顧客データにはその顧客に対して処理する予定の取引がないことが示されていました。そのため、CRM データベースと同様に、注文システムは一貫性のある状態に保たれました。
ところが、倉庫システムには他のデータベースのロールバックが通知されていませんでした。なぜこのようなことが起きたのでしょうか。結局、担当者が電話をかけて注文がどうなっているかを確認するまで、冷蔵設備から取り出された商品が 1 時間以上も発送センターに放置されていました。商品はその時点で販売できなくなり、在庫を補充するのに時間がかかったため、他のいくつかの注文品を届けるのが何時間も遅れました。
この 1 件のミスにかかったコストにより、その週の収益は 30% 低下しましたが、こうした目立たない技術的な問題がコア ビジネス並みに注目されるようになりました。次のセクションで説明するように、MSMQ に移行したことで、チームで開発作業を行うことなくこれらの問題を解決できました。
トランザクション メッセージング
MSMQ を使用してメッセージをキューに送信した場合、メソッド呼び出しが返されたときにはメッセージは送信されません。これは、HTTP や他の接続テクノロジの動作とは大きく異なります。MSMQ を使用すると、メッセージは送信される前に、同じコンピュータ上の発信キューにローカルに格納されます。メッセージがトランザクションのコンテキストで送信された場合、メッセージはトランザクションがコミットされて初めてキューから出され、MSMQ で実際に送信できるようになります。
前の例では、注文システムが新しい注文を知らせるメッセージを倉庫システムに送信しても、そのメッセージはすぐには送信されませんでした。メッセージは、メッセージ処理トランザクション全体がコミットされて初めて、MSMQ により倉庫システムに送信されました。データベース デッドロックの結果、またはその他の理由でトランザクションが中止された場合、倉庫システム宛てのメッセージは MSMQ 発信キューから削除されます。つまり、全体としてのシステムの一貫性が失われるのを防ぐためには、トランザクション メッセージングが不可欠なのです。
トランザクション メッセージングでは、メッセージの送信側とそのメッセージを処理するサービスがトランザクションを共有するわけではないことに注意してください。メッセージが処理を行うサービスに到着したという事実は、送信側で別のトランザクションが正常にコミットされたことを表しています。ただし、トランザクションがコミットされたという事実は、受信側のサービスがそのメッセージを処理する際に発生することについては、何も示していません。
メッセージを受信するサービスによっては、そのメッセージの処理により例外が発生することがあります。この例外については数多くの根本原因が考えられますが、さまざまな原因の相違点とその処理方法の把握することが重要です。例外がメッセージの処理過程で 1 回か 2 回だけ発生するケースもあれば、サービスが何度そのメッセージの処理を試みても必ず失敗に終わるようなケースもあります。
後者のグループに属していることが明らかになったメッセージは有害メッセージと呼ばれ、特別な処理が必要になります。さまざまな根本原因を見ていく過程で、単一のシンプルなソリューションでそれらすべての原因に効果的に対処できることがおわかりになるでしょう。
一時的な条件
普段は正常に処理される適切な形式のメッセージが、データベースのデッドロックが発生した場合や、他のトランザクションの処理のためにデータベース接続プールのサイズが限界に達している場合などに、例外を引き起こすことがあります。これらすべてのシナリオに共通しているのは、一時的な環境条件で例外が発生しているということです。
例外がトランザクション スコープでバブルアップしたときのトランザクション メッセージングの既定の動作は、すべての処理をロールバックするというものです。これが実行されると、元のメッセージはキューに戻ります。その時点で、スレッド (おそらく、例外の生じた同じスレッド) はもう一度メッセージの処理を行うことができます。トランザクションのロールバックには少し時間がかかるため、以前の環境条件が変化し、メッセージの処理をトランザクションで正常に完了できる可能性が高くなります。
メッセージの処理に失敗するケースのうち見過ごされがちなのは、単にサービスによって使用されるデータベースが使用不可能になっている状態です。すべてのデータベースが 100% の可用性を備えているわけではありません。データベースを使用するコードの観点からすると、この問題は一時的なものとは言えません。データベースの使用不可能な状態がどのくらい続くかがわからないためです。単純にメッセージをロールバックし、もう一度処理しても、おそらく同じ例外が発生します。
メッセージ自体は完全に有効で、非常に重要なデータ (逃すわけにはいかない 100 万ドルの注文など) が含まれている可能性もあります。メッセージを処理のロールバック ループに入れたままにしておくのもメッセージを保つ方法の 1 つですが、別の問題が発生します。
サービス ロジックが複数のデータベースを使用するケース、または複数のテーブルを使用するケースを考えてみましょう。たとえば、あるデータベースが使用不可能な状態で、別のデータベースが使用可能であるというような場合があります。また、あるテーブルをバックアップしたデータ ファイルが、ダウンしているリモート サーバーに格納されている一方で、別のテーブルをバックアップしたファイルが利用可能な場合もあります。ここで、タイプ A のメッセージの処理にはテーブル A だけが必要で、タイプ B のメッセージの処理にはテーブル B だけが必要であると考えてみましょう。テーブル B (またはデータベース B) が使用不可能になると、どのような事態が発生するでしょうか。
タイプ A のメッセージは正常に処理されますが、タイプ B のメッセージはロールバックされ続けます。メッセージを処理するスレッドよりもサービスの入力キュー内にあるタイプ B のメッセージの方が多くなるまで、この状態は続きます。その時点で、すべてのリソースがオンラインに戻るまで、サービスはタイプ A のメッセージに対しても実質的に使用不可能になります。つまり、すべてのリソースが使用可能な状態でなければ、サービスは機能しません。これはシステムの可用性という観点からすると、気がかりな (コストのかかる) 特性です。
メッセージの処理が同じように何度も失敗したとき、サービスで実行できる最善の処理は、そのメッセージを入力キューから別のキュー (ここではエラー キューと呼びます) に移すことです。ただし、エラー キューと他のキューの間には本質的な違いはありません。このシナリオでは、あるリソースがオフラインになると、タイプ B のメッセージはロールバックが n 回実行された後でエラー キューに移されます。一方、タイプ A のメッセージは引き続き正常に処理されます。このようにリソースが使用不可能になることでサービスの待ち時間が長くなり、スループットが低下する可能性はありますが、サービスは引き続き利用できます。
逆シリアル化のエラー
多数のバイトがエンドポイントに到着すると、サービスの基になっているテクノロジは、それらのバイトを標準のオブジェクト、つまりメッセージに変換しようとします。この逆シリアル化処理は、さまざまな理由で失敗することがあります。その理由としては、XML を通じてテクノロジに作成を指示するデータ型がそのエンドポイントで展開されていないことや、そのデータ型の不適切なバージョンが展開されていることなどが考えられます。より一般的なのは、既存のクライアントが最近アップグレードされたサービスのエンドポイントにメッセージを送信したが、新しいバージョンのデータ型に古いバージョンとの互換性がないというケースです。
問題なのは、サービスが受信メッセージを理解できていない場合も、そのメッセージには重要なデータが含まている可能性があるために破棄できないようなケースです。同時に、そのメッセージでサービスの入力キューをいっぱいにしてはならない場合もあります。
この場合のソリューションは、データベースが使用不可能な場合に使用するソリューションと非常によく似ています。単純に、そのメッセージを別のキュー (まったく同じエラー キューの場合もあります) に移してください。このケースで若干異なるのは、このメッセージの処理の結果がどうなるかが明確であるという点です。つまり、常に例外が発生します。そのため、メッセージをエラー キューに移す前に、n 回ロールバックされるのを待つ必要はありません。逆シリアル化できないメッセージは、エラー キューに直接移動する必要があります。
エラー キュー内のメッセージ
前に説明したシナリオからは、多くの状況でさまざまなメッセージを有害メッセージと判断できることがおわかりになるでしょう。これらのメッセージの中には、破棄するのが一番な本当に不要なものもありますが、ミスなくその判断を下すには、人間のオペレータが確認するしかありません。
逆シリアル化の例外が発生した場合、オペレータは、旧バージョンのソフトウェアを実行しているエンドポイントにメッセージをルーティングするか、メッセージの形式を新しいバージョンに変換してそれを入力キューに戻すことができます。
オペレータは、データベースがオフラインになったことを把握できればよいのですが、1 つのデータ ファイルが使用不能になり、わずか数個のテーブルがその影響を受けているようなケースもあります。このとき、エラー キュー内のメッセージと対応するログ ファイルに、こうした目立たない問題に関するフラグを設定することができます。これを基に、問題を迅速に特定して解決できます。
有害メッセージのトピックから学ぶことができるのは、受け取ったメッセージにサービスが応答するまでには、かなり時間がかかることがあるということです。実際に、時間に関する問題は、メッセージベースのシステムを設計するうえで主な懸案事項となっています。応答が遅い場合、返されない方がよいこともあります。
時間とメッセージの損失
永続性のあるトランザクション メッセージングを使用すれば、メッセージの損失を完全に抑えることができると思われるかもしれませんが、やはり現実はそれほど単純ではありません。
顧客への注文品の出荷を処理するため、注文システムが外部の運送会社と通信するような企業間のシナリオについて考えてみましょう。注文を受けるたびに、メッセージを出荷システムに送信する必要があります。一方のシステムがダウンしてもメッセージが失われないようにするため、メッセージのトランザクション処理と共に永続性のあるメッセージングを使用して、注文情報を転送するとします。
このシナリオの詳細を分析したところ、メッセージの損失は、回避不可能であるだけでなくコスト効率の高いものであることがわかりました。送信される注文ごとのメッセージの平均サイズが 1 MB であり、注文システムで 1 秒あたり 10 件の注文を処理しているとします (これは決して大きな数字ではありません)。このとき、出荷サーバーが使用不可能になると、これらのメッセージは発信キューに永続的に格納されます。I/O の使用量という点から見ると、1 秒あたり 10 MB、つまり 1 分あたり 600 MB のメッセージが蓄積されます。
この事態は、通信が 3 時間途絶えた後、初めて認識されました。その間、システムは 100 GB を超えるデータを、容量が部分的にいっぱいになった RAID 5 アレイ (3 つの 36 GB の SCSI ディスクで構成) に書き込もうとしていました。サーバーは、ハード ドライブへの書き込みができなくなると不安定になる傾向にあります。また、データのバックアップがほぼ不可能になります。
そのとき私たちは、3 時間分の未処理の注文については、問題の原因であるにもかかわらずあまり心配しませんでした。その一方、システムのバックアップにかかる 2 時間のために何十万ドルもの注文を逃しているという事実は、私たちに緊急の問題を解決し、再発を防がなければならないという管理上の大きなプレッシャーをかけることになりました。このことから得た教訓は、永続性のあるメッセージングは完全無欠な方法などではなく、また永続性に伴ってコストが発生する場合があるということです。
安定性を維持するために、サーバーでは宛先に正しく送信されなかったメッセージは破棄する必要があります。難しいのは、どのメッセージを保持する (長時間保持する) 必要があるのかを判断することでした。
TimetoBeReceived
注文システムは他の多数のシステムから要求を受信し、イベントをパブリッシュし、他のシステムからイベントを受信しました。これらの各処理の結果、メッセージは着信キューまたは発信キューに入れられます。一部のサービスでは、フィードに入れられるデータを使用していました。フィードでデータの有効性が保たれるのは、わずか数分間です。それらのメッセージをそれより長い期間保持しても、意味はありませんでした。あらゆるサーバー プロセス (IIS、SQL Server、その他の Windows サービス) の発生にかかる時間より長い場合も同じです。私たちのシステムで送出したデータ フィードについても、同じことが当てはまりました。メッセージのデータが有効な期間よりもサブスクライバがオフラインである時間の方が長い場合、そのメッセージを発信キューに入れても意味はありませんでした。
各サービスのコントラクトを構成するさまざまなメッセージを調べていくうちに、データが必ずしも有効期間を保持していないメッセージについては、サービスの応答時間がそのサービスレベル契約に準拠している必要があることに気が付きました。つまり、サービスが所定の時間 (メッセージがキューで待機している時間も含む) 内にメッセージの処理を終了できない場合、ビジネスの観点からすると、そのサービスは既に正常に機能していないことになります。
これが意味するのは、有効期間は各メッセージ タイプに対して定義できるということです。この有効期間には、送信側の発信キューに入れられている間に経過する時間、移動中の時間、受信側のキューに入れられている間に経過する時間を含める必要があります。MSMQ では、Message クラスの TimeToBeReceived プロパティを使用してこの値を定義できます (実装の詳細については、「TimeToBeReceived の内部」のリンクを参照してください)。
この時点で明らかだったのは、メッセージの損失は不可避の現実であるということです。クライアント側からは、メッセージの損失、応答の損失、単なるサーバー パフォーマンスの低下を区別することはできません。メッセージ処理の一部として、自分のサービスで他のサービスをクライアントとして使用している場合は、サービスレベル契約で指定されている適切な応答時間を実現するために、自分のサービスでそれ自体を保護する必要があります。そこで、私たちはタイムアウトを採用することにしました。応答が指定した時間内に到着しない場合に、最低限の対策として、要求が受信されており、応答は後ほど送信されることを示すメッセージをサービスで送信できるようにするためです。ユーザーと対話している場合、このメッセージには電子メールを使用してもかまいません。
コール スタックの問題
技術的にもう 1 つ問題だったのは、図 2 に示すように、単にプロセスを管理するスレッドのコール スタックという形で外部システムとの対話の状態を保っていたという事実です。
図 2 外部システムとの対話 (クリックすると拡大画像が表示されます)
この技術的な問題は、やはりサーバーの再起動とかかわりがありました。サーバーが再起動したときに、スレッドが内部の CRM システムまたは外部の出荷パートナーのシステムからの応答を待機していた場合、その状態が失われるのです。つまり、システムが応答を最大で 5 分間待機することになっているとき、経過時間を把握しているサーバーが再起動し、その時間枠内で要求を処理する状態に戻った場合、システムはビジネス アクティビティ監視 (BAM) システムに通知する必要があることを認識できません。
元の要求が失われたか、破棄されたために応答が到着しない場合、この事態により不定期の注文は処理されなくなります。システムは他のシステムからの応答がないと処理を進めることができませんが、再起動のために応答を待機していたことを忘れてしまったのです。
今にして思えば、この状況は頻繁に生じていたわけではありません。しかしその時点では、私たちはこうした状況が生じていることにまったく気が付いていませんでした。BAM システムには何の問題も示されていませんでした。ただし、IT 部門に姿を見せた COO から、戦略パートナーからの急ぎの注文品が予定どおりに納品されておらず、だれもそのことに気付いてさえいないと叱責を受けたため、その問題を解決するためにもう一度リファクタリングを行うことになりました。
こうした種類のシステム間の統合プロセスを堅牢にするには、状態管理、メッセージング、および "時間" を単一の統合モデルに組み込む必要があることがわかります。
サイズの大きいメッセージ
実行時間の長いプロセスをソリューションに結び付ける方法を説明する前に、サイズの大きいメッセージがシステムのパフォーマンスと可用性に与える影響について理解する必要があります。
私たちが直面した最も厄介なパフォーマンス上の問題の 1 つは、最も収益の多い市場セグメントに属する戦略パートナーから受信したメッセージにその原因がありました。ほとんどの顧客から受信する発注書のサイズはかなり小さかったのですが、戦略パートナーからは多数のアイテムの注文を受信していました。しかも、各アイテムの種類が多岐にわたっていたのです。戦略パートナーによって頻繁に行われるアイテムのカスタマイズに対処する必要があったため、問題はさらに厄介になりました。これらの要因が組み合わされた結果、戦略パートナーから送られてくる XML メッセージのサイズは、容易に 20 MB ~ 50 MB に達していました。
この程度の分量の XML を逆シリアル化するだけで、Xeon コア プロセッサは最大 1 分間占有されます。私たちは、スキーマの検証をハードウェアの XML アプライアンスに肩代わりさせようとしましたが、全体的な問題の解決には至りませんでした。こうしたサイズが非常に大きなメッセージの処理には、対応するサイズの小さいメッセージが線的に増加した場合の処理よりも、桁違いに時間がかかりました。
TimeToProcess(BigMessage) >> N x TimeToProcess(SmallMessage) where
Size(BigMessage) == N x Size(SmallMessage)
私たちは常々、1 秒あたりにより多くのメッセージを処理できるようにスケール アップする能力の観点から、スケーラビリティについて考えていました。また、この能力についてテストを行っていました。ところが、サイズの大きいメッセージに対処するためのスケール アップは、はるかに難しい課題であることが明らかになりました。
ネットワークの輻輳がサイズの小さいメッセージに影響を及ぼすことはほとんどありませんが、サイズの大きいメッセージの場合、その影響を受ける可能性ははるかに高くなります。これは、永続性のあるメッセージングを使用しているときにこうしたデータをディスクに書き込む場合にも当てはまります。サイズの小さいメッセージを書き込むためにディスクの連続した空き領域を探すのは簡単ですが、ディスクの最適化を行わずにシステムをしばらくの間実行した後では、サイズの大きいメッセージを書き込むのにかなりの時間がかかることがあります。ユーザーは、コンピュータ間でサイズの大きいファイルをコピーする際に "99% 完了現象" に直面することがよくあります。サーバーも同じ問題に見舞われます。
こうした状況が続いている間、よりサイズの小さいメッセージも同様に影響を受けます。全体としての収益性から考えた場合はそれほど大きな問題ではありませんが、スループットの問題のために扱う注文の量が 10% 減るような事態は、最終的な収益に大打撃を与えます。そこで、サイズの大きい 1 つのメッセージを、それよりもサイズの小さい多数のメッセージに分割する何らかの手段が必要でした。
サイズの大きいメッセージをサイズの小さいメッセージに分割する
問題なのは、それらのメッセージ中のデータにビジネスの観点から固有の要件があるという点でした。発注番号は重複してはいけません。銀行システムを設計するとき、取引番号は連続している必要があります。並列処理には、テクノロジのレベルだけで対処することはできません。テクノロジを中心に、ビジネス ルールも設計する必要があります。
こうしたサイズの大きい発注データを分析し、戦略パートナーの開発チームと話し合った結果、これらのサイズの大きい発注書は時間をかけて段階的に作成され、完成した時点で送信されていることが判明しました。私たちが聞いた内容の中でも特に興味深いのは、こうしたサイズの大きいメッセージは送信側でも同様に問題になっており、複数の小さいメッセージに分割する方法を探すことに同じく関心を抱いているという点です。
問題なのは、現在のインターフェイスと処理ロジックが 1 つの発注番号を 1 つのメッセージに結び付けていることでした。その結び付きを壊すことのできるソリューションが見つかれば、他の問題も解決できることは明らかでした。
ある単純なソリューションが考案されましたが、そのソリューションは、これまでのステートレスなアーキテクチャをステートフルなアーキテクチャに転換するものでした。そのため、簡単に決定を下すわけにはいきません。ステートレスなアーキテクチャでは、すべての状態はデータベースに格納されます。構築しやすく、ツールのサポートを十分得ることができますが、このアーキテクチャではデータベースに対する負荷が大きくなります。
プロトタイプを構築して一連のストレス テストを行った結果、待ち時間、スループット、および全体的なリソース使用量が大きく改善することがわかったため、そのアーキテクチャで進めることにしました。
新しいアーキテクチャでは、パートナーは同じ発注番号を持つ複数のメッセージを私たちに送信できるようになりました。それらのメッセージには、既に送信済みのデータに対する変更データか、追加のデータだけが含まれています。発注書のすべてのデータが送信されると、パートナーのシステムは、現在のメッセージが発注書の最後のメッセージであることを示す Boolean フィールドを設定します。メッセージ コントラクトの点から見ると、変更はほんのわずか (フィールドの追加だけ) です。ただし、注文処理ロジックは根本的に変更する必要がありました。
通常の要求/応答セマンティクスの代わりに、私たちのサービスは "複数の要求/単一の応答" セマンティクスをサポートするようになりました。ロジックは、特定の発注書についていくつの要求を受信することになるかは把握していません。さらに、それらの要求は、発注書完了フィールドが true に設定された要求が到着するまでは処理されません。ただし、それらの要求のデータは、最後の要求が到着するまで保管されている必要があります。
私たちは、データベースに対する負荷をこれ以上増やさないために、この段階でデータのストレージを分離しました。この状態のデータ アクセス パターンを分析してみたところ、その時点ですべての発注書のレポートを端から端まで確認する必要はないことがわかりました。つまり、データを格納するためにリレーショナル データベースを選んだことは、トランザクションの一貫性の保証という観点からはやりすぎであり、"書き込みから読み出し" の割合が高いこの種類のロジックを処理するには時間がかかりすぎるということです。
分散キャッシュ ソリューションに移行したところ、パフォーマンスは大幅に改善されました (分散キャッシュ製品では、フォールト トレランスと高い可用性を確保するため、データは複数のサーバーのメモリで保持されます。また、これらの製品は、従来のデータベース テクノロジに代わる高パフォーマンスなテクノロジを備えています)。
注文処理コントラクトの静的な構造は大きく変わりませんでしたが、そのメッセージ交換パターンの動的な振る舞いは大きく変化しました。サービスは 1 つの発注書を複数のメッセージで受信できるようになったため、インフラストラクチャを決定するうえで、その強化されたプロトコルを活かすことができるようになりました。
アイデムポテントなメッセージング
戦略パートナーからの発注書に永続性のあるメッセージングを使用するという当初の決定に大きな影響を与えたのは、メッセージの損失を防ぐことができるという点です。ところが、私たちは、ディスクに書き込まれるメッセージに必ずしも 100% の保証がないことを事実と考えていました。ただし、新しいメッセージ プロトコルを使用することで、パートナーが私たちに発注書メッセージを送信したにもかかわらず妥当な時間内に応答がないとき、パートナーはもう一度同じメッセージを送信できるようになりました。
私たちは、アイデムポテントな (何度処理しても結果が同じになる) メッセージ コントラクトの作成にきちんと取り掛かっていたわけではありませんでしたが、そのメリットはごく短期間で実感できました (アイデムポテントなメッセージとは、サービスに複数回送信できるメッセージのことです。その結果のサービスの状態は、メッセージが 1 回だけ処理された場合と同じです)。永続性のあるメッセージングを使用すると、各メッセージはディスクに書き込まれます。高価でパフォーマンスの高いディスクでも、メッセージをメモリに格納する場合に比べると、処理がはるかに遅くなります。送信および処理するメッセージのサイズが小さいうえに、メッセージをディスクに書き込む必要がまったくなかったため、パフォーマンスの向上は 2 倍になりました。メッセージ ストレージのコストの削減は非常に重要です。
アイデムポテントなメッセージングの導入に伴って、さらに別の問題が発生します。アイデムポテントなコントラクトの設計が難しいのは言うまでもありませんが、クライアントには、サービスへのメッセージの送信以外の処理も求められるようになります。クライアントは、サーバーから応答が時間内に返されなかった場合に再度同じメッセージを送信できるように、そのエンドでタイマを管理する必要があります。つまり、サーバーにメッセージをステートフルに処理する必要があるだけでなく、クライアントにもメッセージをステートフルに送信する必要があるのです。
実行時間の長いプロセス
サイズの大きいメッセージを複数の小さいメッセージに分割する場合、さらに別のアーキテクチャ上の変化が生じます。それは、これまではシンプルだった要求/応答セマンティクスも実行時間の長いプロセスになるということです。実行時間の長いプロセスとは、複数のメッセージを処理するプロセスのことです。多くの場合、あるメッセージの状態は、次のメッセージを処理するロジックで利用できる必要があります。
既に説明したように、単一のメッセージの処理は単一のトランザクション作業単位であり、他のメッセージの送信だけでなく、ローカル状態の変更も伴います。実行時間の長いプロセスでは複数のメッセージを処理しますが、その処理それぞれがトランザクション作業単位ということになります。前に送信した要求に対する応答が到着すると、その応答は、それ自体の作業単位内で処理されます。
ただし、メッセージの処理間で実行時間の長いプロセスの状態をメモリで保持しておく必要はないため、その状態は可用性の高い何らかのストア (通常はデータベースですが、分散キャッシュの場合もあります) に保存されます。プロセス ID を含むメッセージがエンドポイントに到着すると、可用性の高いストアからデータが取得され、実行時間の長いプロセスがそのメッセージにディスパッチされます。
開発者を実行時間の長いプロセスの設計に向かわせるもう 1 つの一般的な要件は、応答時間を順守することと外部のシステムからの応答を確実に受け取ることです。既に説明したように、外部のシステムと通信する場合や、別のコンピュータ上のプロセスと通信する場合でさえも、問題が発生する可能性のある要素は数多くあります。コンピュータのダウン、プロセスの停止、メッセージの損失や破棄などが起きるかもしれません。
前に説明した、注文品を顧客に出荷するプロセスについて考えてみましょう。会社は複数の出荷パートナーと提携していますが、通常はあるパートナーを優先的に利用しているとします。その優先的に利用するパートナーのシステムがダウンしたり、パフォーマンスが低下したりする事態や、単に応答しなくなるような事態も考えられます。そのような場合には、最大 5 分間応答を待ち、応答が到着しない場合は別の出荷パートナーを選択するようにロジックで指示します。特定の製品の在庫がない場合は、注文品の一部だけを出荷し、在庫が補充されてから出荷プロセスに処理を続行させます。
System.Threading.Timer などの通常のメモリ内コンストラクトを使用してこれらのタイマを実装する際の問題点については、既に説明しました。それは、サーバーが再起動した際に、経過時間に関するデータが失われるという問題です。私たちが直面した課題は、可用性が高く、フォールト トレラントな方法で時間に関する情報を格納することでした。それに関連して、プロセスにどのような方法でタイムアウト イベントを通知するかという問題もあります。
タイムアウト イベントはシステムにおける他のビジネス イベントとよく似ており、ビジネス イベントはプロセスに到着するメッセージとして表されていることが判明したため、時間の問題を同様に処理するためのメッセージングを採用することにしました。実行時間の長いプロセスは、タイムアウト メッセージを Timeout Manager Service に送信します。このメッセージには、実行時間の長いプロセスの ID と、通知を実行する日時が含まれています。
Timeout Manager Service は、特定の間隔でポーリングを行うデータベースに格納するか、単にメッセージを永続性のあるキューに登録しておくことで、このデータを保持します。メッセージに指定されている時間が経過すると、Timeout Manager Service は、そのメッセージの送信元のエンドポイント (実行時間の長いプロセスのエンドポイント) にまったく同じメッセージを送信します。実行時間の長いプロセスは、他のメッセージと同じように、そのメッセージをそれ自体の作業単位内で処理します。タイムアウト メッセージの処理中、プロセスは、以前のすべての対話における状態を利用できます。
この出荷プロセスを調べてみると、タイムアウト メッセージを処理するロジックは、優先的に利用する出荷パートナーから応答を受け取っているかどうかをチェックすることがわかります。こうしたチェックを行うのは、タイムアウトが発生する直前にパートナーのシステムから応答を受け取っている可能性があるためです。これで、プロセスは残りの在庫が補充されるのを待機している状態になりました。この方法でのタイムアウト メッセージは、必ずしも何かに問題が生じていることを示すものではありません。むしろ、プロセスのウェイクアップ コールであると言えます。単一のプロセスで、複数のタイムアウト (各タイムアウトは応答時間やサービスレベル契約に関連するビジネス要件に対応) を使用することができます。
これらのタイムアウトは、データベースへの接続を試行したときや、HTTP Web サービスの呼び出しがタイムアウトしたときに発生するような技術的な現象ではないことを理解しておくことが重要です。これらのタイムアウトは、数日から数週間にも及ぶ可能性のあるビジネス イベントです。
対話における最も重要度の高い課題として時間に取り組み始めると、これまで重要ではなかった同期のブロッキング要求/応答セマンティクス自体がプロセスに変わりました。各対話には、待機する必要のある最長時間に応じて、最終的に何らかの制限が生じました。私たちは、シンプルな要求/応答の対話メカニズムを手に入れたと考えるたびに、「応答が返されるのに 2 日かかる場合はどうなるだろうか。それが 2 週間の場合はどうだろう。このシステムで、そのサービスレベル契約を遵守し続けることができるだろうか」と自問自答しました。その答えはいつも同じで、「時間は無視できない」というものでした。
失敗から学ぶ
信頼性の高いサービスの開発は簡単ではありません。私たちのチームが直面した問題は、一見、そのテクノロジが抱える短所のように見えるかもしれませんが、問題領域を再調査し、運用環境に置かれたシステムに対する苛酷な現実を考慮に入れることから解決策が導き出されることはよくあります。私たちはプロジェクトに繰り返し取り組んで、永続性のあるメッセージング テクノロジのテスト、作業単位に収まるトランザクション メッセージ処理の追加、バージョン管理の問題と有害メッセージへの対処、サイズの大きいメッセージの分割、そして実行時間の長いプロセスを使用した、時間への確実な対処を行いました。明らかになったことが 1 つあります。それは、基本的なパターンは同じだということです。
- 要求を送信したからといって、必ず応答が返されるとは考えないでください。
- 時間はビジネス イベントであり、他のメッセージと同様に処理できる他のイベントに類似しています。
- あらゆるメッセージ (クライアントからの要求、別のシステムからの応答、パブリッシュされたメッセージ (以前サブスクライブしていたもの) の到着) の処理は、すべてのローカル アクティビティやメッセージの送信も伴う単一の作業単位です。
決定を行う各ポイントで役立ったのは、「これはどのように失敗するだろうか」と問いかけることでした。これにより、テクノロジや、ステートフルな対話に対応したサービス コントラクトの設計方法を選択する際に、適切な判断を下すことができるようになります。経験の積み重ねが適切な判断につながり、不適切な判断の積み重ねが経験を形作るのです。私たちは、以前の設計上の決定のために痛手を負ったからこそ、設計時の決定が実行時の動作に与える影響について、はっきり見通すことができるようになりました。
Udi Dahan は、ソフトウェア シンプリストです。また、MVP であり、WCF、WindowsWF、および "Oslo" を担当する接続型テクノロジのアドバイザでもあります。彼は、サービス指向でスケーラブルな、セキュリティ保護された .NET アーキテクチャ設計を専門としており、トレーニング、メンタリング、およびハイエンド アーキテクチャ コンサルティング サービスを提供しています。Udi の連絡先は
Udi@UdiDahan.com です。