2011年11月20日日曜日

MongoDBにASP.NETのSessionを格納する - 完結編

前回のあらすじ

不思議な光る石のペンダントをつけた少女が空から降りてきた。天使が舞い降りたと思い走って近づく少年。その少女は伝説となった天空の城の王の末裔。少年もまた亡き父の言葉を信じ、天空の城の存在を信じていた。悪意を持った別の王の末裔が少女をさらい、城を悪用しようとすることから、少年は少女と城を守るため、海賊とともに空に旅立った。

ドキドキするストーリーですね!

...。

続きです。最終的に接続エラーが出て、処理が継続できない状態に陥るっていうところでした。問題解決する前に、現時点でのパフォーマンスを確認してみます。

前回同様Apache Benchで実行しますが、10000回だとエラーになってしまって計測に失敗してしまうので、桁を下げて1000回(-n 1000 –c 8)としてましょう。

計測方法はabを3回実行してRequest per secondの平均を取ります。

InProc

ms1

1回目:538.39
2回目:588.10
3回目:660.41

平均:595.63

StateServer

ms11

1回目:525.43
2回目:567.28
3回目:599.09

平均:563.93

SQLServer

ms2

1回目:282.39
2回目:370.53
3回目:352.21

平均:335.04

MongoDB

ms3

1回目:129.71
2回目:146.35
3回目:145.03

平均:140.36

うん。早くない。ちなみに無印UniversalProviderだと 30.15 とドンマイな感じ。

Universal Provider

ms4

ちなみに、5回で平均を取りたかったけど、MongoDB版が5回目で接続エラーになって取れなかったので3回にしました。これは良くない!まずはちゃんと10000回普通に動かせるようにしないと。

と、いうわけで、MongoDB C# Official Driverのドキュメントに目を通してみると、内部でConnection Poolingを利用していると書かれています。が、挙動からはPoolがちゃんと利用されてないんじゃないかと、疑ってみました。 だって、接続エラーって...。使いまわしてるならそんなことにならないはず、という根拠のない診断。うさんくさいドクターハウス。

CSharp Driver Tutorial – MongoDB

MongoDB C# Driver API Documentation - Table of Content

詳細はドキュメントから判断出来なかったので、ソースを追いかけてみました。オープンソースだといろいろ調べれていいですねー。

mongodb/mongo-csharp-driver – GitHub

チラチラ見た結果からいうと、MongoServerクラスに保持しているMongoServerInstanceがConnectionPoolを管理していて、MongoServer.Disconnectを呼ぶとすべてのPoolをクローズして回る仕組みのようです。なんでだろ。理由はいいとして、そういう作りなんだということは、finallyで必ずDisconnectを呼び出すのはよろしくないかなー。そんな事しなくても、Poolに保持しているものは10秒ごとにMaxConnectionLifeTime/MaxConnectionIdleTimeを確認して破棄されるようになってるみたいなので、ほっといてもいいんじゃね?みたいな。

と、いうわけで、ソース中のすべてのDisconnectを削除します。

その状態で、abで確認します。

3回実行した結果は525.43 / 599.09 / 572.34。平均:565.62。ワォ!一気に上がりました。せっかくなので10000回出来るかどうかもチェック。

ms5

行けるようになりましたー。ちゃんと接続を使い回してくれてるようです。パフォーマンスも接続を使いまわすことで一気に改善しました。んじゃ、なんで、そういう作りにしてなかったのか?っていうのが気になります。

理由はありました。どういうことかというと、Poolが保持しているMongoConnectionは生成された時点でReplicaSetのPrimaryを判定して保持しています。つまり、Failoverした直後、Poolが破棄されない限り接続エラーがおきて、正しくデータの書き込みができなくなるということでしょう。

abを実行し、その最中にstepDownでFailoverしてみます。

ms6ms7

Safemode detected an error 'not master'. (Response was { "err" : "not master", "code" : 10056, "n" : 0, "lastOp" : NumberLong("5676991341247070789"), "connectionId" : 13356, "ok" : 1.0 }).

masterじゃないダニ!と言われ、書き込みできないエラーが発生しました。SafeModeじゃなくても同じようにエラーになることは確認済み。

と、得意げにドヤ顔したところで、それはつまりDisconnectしなくなったからでしょ?と、おっしゃるとおりな理由でのエラーなんですけどね。つまり、Failoverした後もちゃんと接続が継続できるようにするために毎回Disconnectしてたわけですね。

あちゃー、これはどうしたものか。なんて、あたかも今、解決策を思いつくような振りになってますけど、事前に調査してわかってることを書いてるだけなんで...。てへ!

つまり今回のエラーの原因はPoolから取り出したMongoConnectionがPrimary(Master)を指してないから例外が起きるわけです。そして、MongoServer.Disconnectを呼ぶことでPoolのすべてのコネクションが破棄されるというのはソース見て確認済み。つまりMongoDBに対する処理で例外が発生したらMongoServer.Disconnectを呼び出し、処理をリトライすることで、Failover後に決定したNodeに接続するようにしてあげればよい、という方法が取れましょう。MaxConnectionLifeTimeを短くしてみては?っていうのもあるでしょうが短すぎると接続エラーになりそうだし。

で、用意したのが以下のようなリトライ実行するヘルパー。

public class MongoDbInvokeSetting
{
  public string ServerName { get; set; }
  public string DatabaseName { get; set; }
  public int RetryCount { get; set; }
  public int RetrySeconds { get; set; }
}

public class MongoDbHelper
{
  /// <summary>
  /// リトライを繰り返すInvoker
  /// </summary>
  /// <param name="setting"></param>
  /// <param name="functor"></param>
  public static void RetryInvoker(MongoDbInvokeSetting setting, Action<MongoServer> functor)
  {
    var retry = setting.RetryCount;
    Exception exception = null;
    while (retry > 0)
    {
      var server = MongoServer.Create(setting.ServerName);
      try
      {
        functor(server);
        break;
      }
      catch (MongoException ex)
      {
        exception = ex;
        // ↓こいつでPool内の全コネクションクリアが走るはず。
        server.Disconnect();
      }
      retry--;

      // とりあえずn秒待ってみる。
      Thread.Sleep(setting.RetrySeconds * 1000);
    }

    // ここでイベントログに書き出すか、Exceptionを出力する必要がある。
    // 後続の処理に進んだら困るならException。
    // ログなら進んでもいいかもねー。
  }
}

ダサくない!ダサくないよ!これでいいんだもん!

このコードを書いてる途中、さすがに心配になったんですけど(これでいいのかっていうね)、そんなおり以下のページを見つけました。

File: REPLICA_SETS — MongoRuby-1.4.1

似たようなリトライさせてますね。だもんで、いいってことで。

これを使って実行する書き方は↓こう。

MongoDbHelper.RetryInvoker(GetSetting(), server =>
{
  // serverを使ってCollection検索したり、
  // データ追加したり...
});

GetSettingは設定を入れたものを返しましょう。サーバー名とかリトライ回数とか。

これつかって丸っとしたもので、実行しつつFailoverさせてみます(abでエラーが出ないことを確認しました)。

ms8

大成功!エラーも起きずFailover成功しました。データも欠落なくいけてます。

で、この状態でのパフォーマスンスを測定してみます。

3回実行したのが485.62 / 496.92 / 552.61 平均:511.72

遅くなったじゃん!っていう感じしますけど、あったまって無かったみたい。実質Disconnect削除のパフォーマンスと同じ数値でした。

でも~、これだと~、なんていうかパンチが弱い。十分いいとは思うんだけど~。どこか早くできそうなところないかなー、とMongoSessionStateStoreのソースを見ているとありましたねー。

private string Serialize(SessionStateItemCollection items)
{
  using (MemoryStream ms = new MemoryStream())
  using (BinaryWriter writer = new BinaryWriter(ms))
  {
    ...
    return Convert.ToBase64String(ms.ToArray());
  }
}

private SessionStateStoreData Deserialize(HttpContext context,
string serializedItems, int timeout)
{
  using (MemoryStream ms =
    new MemoryStream(Convert.FromBase64String(serializedItems)))
  {
    ...
  }
}

ほら、ここ。Base64に変換してstringにしてるじゃないですか。コレいらなくない?BsonBinaryArray使えばbyte[]をそのまま入れることが出来るんじゃないのー?と、気になったので試した結果が↓こちら。

3回実行したのが 542.95 / 538.68 / 534.19、 平均:538.61。微妙...。まぁ、いいか。

ms9

ちなみにこのままだと、Expireしたセッション削除が残り続けるので、SetAndReleaseItemExclusiveの中で2000回に一回くらいクリーンアップするように仕込んでみました。その状態で10000回実行。

ms10

10000回中5回クリーンアップしてるけど、まぁまぁ。外部タスクにしてしまえば影響はでないものなので、ここはいいでしょう。

ち・な・み・に、SQLServerだと平均335.04ですからね。いいじゃないですか。ねぇ。Shardingしてみても同じマシンだとあまり変化でなかったです。と、いうのもCPUを最も使ってるのがIISExpressのプロセスだったので、アプリケーションが遅いってことですから。あとは、マシン分けて計測しかないですが、少なくとも同一環境内ではMongoDBでのSessionStateStoreがStateServerに負けないくらいの速度を実現しました(何度か試したらStateServerはもっと早かったけどー、そこは情報操作!)。

2011年11月19日土曜日

MongoDBにASP.NETのSessionを格納する

ASP.NETのSessionといえば

  • InProc(アプリけーションプロセス内のインメモリステート管理)
    シリアライズコストも発生せず、同一プロセス内で言わばstatic Dictionary<string,object>の実装で最速。ただし、アプリけーションのリサイクルと同時にセッションが破棄されるのと複数プロセス間で共有できないです。
  • StateServer(専用プロセスによるインメモリステート管理)
    InProcの弱点である、アプリけーションのリサイクルによる破棄とプロセス間共有を実現出来るようにしたもの。プロセスをまたぐのでシリアライズコスト発生するけど、インメモリなのでInProcにつぐ速度を実現。ただし、シングルプロセスでの実装になるので、耐障害性という意味では弱い。ステートをホストしているマシンの障害や再起動などでセッションが破棄されてしまいます。
  • SQLServer(ストーレジに保持するステート管理)
    StateServerの弱点であり、耐障害性をクリアしたストレージ保存型でのセッション管理。シリアライズコスト+DBサーバーのパフォーマンス次第で遅くもなるし早くもなる。
  • Custom(SessionStateStoreProviderBaseを派生したカスタム実装)
    キャッシュクラスタ(Velocity)を利用したり、EntityFrameworkを利用した実装(UniversalProvider)を使ったりSessionStateStoreに対してイロイロです。DBをSQLServer以外にする場合にも利用されてます。

選択肢多くていいですね。標準のものもそれぞれ優秀で素晴らしいです。SessionIDの生成や、クライアントとのやり取りは別の仕組みで実装されてるので、SessionStateStoreProviderのカスタムとは分離されてて安心です。

MongoDB ASP.NET Session State Store Provider | AdaTheDev

最近、すっかりMongoDBに心奪われてる身としては、SessionをMongoDBに入れてしまいたいという衝動にかられてます。あれやこれやの問題もあり、SQLServerだけにたよるソリューションだとよろしくないなというのもありまして。

MongoDB、大変優秀ですね。ReplicaSet(レプリケーション)による可用性の確保(自動フェールオーバー有り)と、Sharding(データのパーティショニング)によるパフォーマンスの確保、素晴らしいです。

いろいろ試して部分的に利用を始めてる段階で、まだまだ自信をもって使いまくるって言う状況ではないですが、ちょっとずつです。ちょっとずつ使っていってSQLServer+MongoDBで行けるところまで行ってみようと企んでます。

まずはTraceListnerのMongoDB化。前回のエントリでそれっぽい感じのものを提示しましたが、これでサーバー群のトレース情報を低コストなストレージに保持出来るようになりますね。トレース情報くらいなら万が一情報欠落しても致命的になることも無いでしょう。しかし!セッションはそういうわけにはいきませんね。システムとして提供している表の機能に影響がでちゃいますからね。

そうなると、ReplicaSetの機能を利用することは必須となりましょう。そうするとFailoverの時の挙動を確認したりする必要も出てきますよね。そういうテストを繰り返してこそですよね。楽しい時間ですね。

先ずはReplicaSetでMongoDBを2つ起動。ArbitarっていうSQLServerでいうところのウィットネスサーバーを1台追加してないと投票結果が偶数とかになって次のPrimary決定時に困っちゃうことがあるので、3ノード起動します(詳しくはオフィシャルサイトでどーぞ)。

rem arbitar
start "arbitar" c:\mongo\bin\mongod.exe --port 27020 --replSet mongo --dbpath c:\mongo\dba

rem replicaset
start "mongo1" c:\mongo\bin\mongod.exe --rest --noauth --port 27031 --replSet mongo --oplogSize 20 --dbpath c:\mongo\dbr1
start "mongo2" c:\mongo\bin\mongod.exe --rest --noauth --port 27032 --replSet mongo --oplogSize 20 --dbpath c:\mongo\dbr2

これで起動してるので(c:\mongoに一式ある前提です)、まずは初期化。

27031のノードにMongoDB Shellで接続。

mongo localhost:27031

初期化コマンド順番に実行すればOKです。

rs.initiate()
rs.add("hostname:27031")
rs.add("hostname:27032")
rs.addArb("hostname:27020")

ms

まずはこの状態で公開されてるソースを利用してSessionを使ってMongoDBにデータを入れてみましょう。

MVC4DPでアプリケーションを作り、Home/IndexアクションでSessionに値をいれて、Home/Indexビューで表示するだけのものです。

public ActionResult Index()
{
  ViewBag.Message = "Modify this template to kick-start your ASP.NET MVC application.";
  Session["message"] = ViewBag.Message;

  return View();
}
<h2>from session:@Session["message"]</h2>

MongoSessionStateStore/MongoSessionStateStore.cs at master from AdaTheDev/MongoDB-ASP.NET-Session-State-Store - GitHub

↑ここからソースを取得して、プロジェクトに追加。後はNuGetでMongoDB Official Driverを入れておきましょう。

ms2

コメントに従い、接続文字列を指定するとレプリカセットにならないので、今回起動したMongoDBを指すように接続文字列を変更。

connectionString="mongodb://localhost:27031,localhost:27032/"

ms3

ちゃんと出来てますね。

MongoVUEで27031(primary)データを確認。

ms4

入ってるねー。

続いて27032(Secondary)のデータを確認。

ms5

もちろん入ってますね。

ここで、ReplicaSetを入れ替えましょう!

MongoDB ShellでPrimaryにつないで rs.stepDown() で強制フェールオーバーを実行。

ms6

ちゃんと切り替わりました。

この状態で再度先ほどの起動したブラウザでF5でリロード。ちゃんと動くなら、これでFailoverしたほうを参照して、表示されるはず!

ms7

ms11

タターン!うまくいきました。内部ではSafeModeっていう書き込み確認モードで動作するようになってるので、手堅いです。

27032(SecondaryからPrimaryに変更したノード)でSessionsコレクション(テーブルですね)を確認して見つつブラウザのF5リロードを繰り返すと、ちゃんとLockIdがカウントアップしていくので、読み込みも書き込みもFailover後にちゃんとできてるのが確認できます。

だがしかし!ここで大問題が!!

パフォーマンスを測定しようとApache Benchでリクエストを投げまくってみると、最後まで完了せずにエラーで途中終了してしまいます。

ms9

ms8

ms10

Unable to connect to the primary member of the replica set: システムのバッファー領域が不足しているか、またはキューがいっぱいなため、ソケット操作を実行できませんでした。

なかなかの男気あふれる強気のエラーメッセージ。
どーしたんだMongoSessionStateStore!
これが精一杯なのかOfficial Driver!

長くなったのでつづく...。