Skip to content

同時アクセスが遅くなる理由(セッション・Redis) ​

第5版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

多人数で利用するとレスポンスが悪くなる原因のひとつが、セッションアクセスの仕組みです。

  • プリザンターは すべてのリクエストで、セッションストア(既定は RDBMS の Sessions テーブル)への同期的な読み書き を行います。同時アクセスが増えると、スレッドプールの枯渇と DB 負荷の増大が重なって遅くなります。
  • ユーザー側でできる最も効果的な対策は KVS(Redis)の導入 です。ただし同期アクセスという根本構造は変わりません。

セッション管理の仕組み ​

ASP.NET Core のセッション ​

Startup.cs で ASP.NET Core 標準のセッションが構成され、UseSession() の後に独自の SessionMiddleware が登録されています。

csharp
services.AddDistributedMemoryCache();
services.AddMvc().AddSessionStateTempDataProvider();
services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(Parameters.Session.RetentionPeriod);
});
csharp
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();
app.UseSessionMiddleware();

(Startup.cs L99-L104、L405-L408)

独自の Sessions テーブル ​

実際のセッションデータは、ASP.NET Core のセッション(HttpContext.Session)ではなく 独自の Sessions テーブル(RDBMS) に保存されています。1.5.8.1 ではセッション GUID も、暗号化した Cookie Pleasanter_SessionGuid に持ちます(Context.cs#L331-L340)。テーブルの構造や読み書きの細部は セッション管理の実装 にまとめています。

リクエストごとに生成される Context の初期化時に SessionUtilities.Get() が呼ばれ、セッションデータが読み込まれます。つまり すべてのリクエストでセッションデータの読み込みが発生 します。

csharp
SessionData = SessionUtilities.Get(
    context: this,
    includeUserArea: Controller == "sessions");

(Context.cs)

確認したソースでも同じ構造で、Context の初期化時に SessionUtilities.Get() を呼んでいます(Context.cs)。SessionUtilities.Get() も同期メソッドのままです(SessionUtilities.cs)。

遅くなる原因 ​

SessionUtilities.Get() は、UseKeyValueStore が有効なら Redis の HashGetAll()、そうでなければ RDBMS への Repository.ExecuteTable() でセッションを取得します(SessionUtilities.cs L33-L87)。書き込み側の SetRds() も同様に、Redis の HashSet() か RDBMS の UpdateOrInsertSessions を実行します(L140-L192)。

csharp
public static Dictionary<string, string> Get(Context context, bool includeUserArea = false, string sessionGuid = null)
{
    if (Parameters.Session.UseKeyValueStore)
    {
        // KVS(Redis)を使う場合
        var key = sessionGuid ?? context.SessionGuid;
        StackExchange.Redis.IDatabase iDatabase =
            CacheForRedisConnection.Connection.GetDatabase();
        var sessions = iDatabase.HashGetAll(key)
            .Where(dataRow => /* フィルタ処理 */)
            .ToDictionary(/* 変換処理 */);
        return sessions;
    }
    // RDBMS を使う場合(デフォルト)
    return Repository.ExecuteTable(
        context: context,
        statements: new SqlStatement[]
        {
            Rds.SelectSessions(/* ... */),
            Rds.PhysicalDeleteSessions(/* ... */)
        })
        .AsEnumerable()
        .ToDictionary(
            dataRow => dataRow.String("Key"),
            dataRow => dataRow.String("Value"));
}

ポイントは 2 つです。

  1. すべて同期メソッド: Repository.ExecuteTable() も iDatabase.HashGetAll() も同期メソッドで、async/await は使われていません。同期 I/O はスレッドプールのスレッドを I/O 完了までブロックするため、同時アクセスが増えると DB の応答待ちでスレッドが次々に占有され、スレッドプールが枯渇してリクエストが遅延します。
  2. リクエストごとに DB にアクセス: セッションデータはメモリにキャッシュされず、毎回読み込まれます。1 リクエストあたり最低でも Get と Set で 2 回以上の同期 DB アクセス が発生します。

図を読み込み中…

要因影響
同期 I/O によるスレッドブロックスレッドプール枯渇 → リクエストのキューイング
Sessions テーブルへの集中アクセスDB 負荷増大 → クエリ応答遅延
リクエストごとの DB アクセス1 ユーザー 1 操作で複数回の DB 往復

排他制御(SessionExclusive) ​

同時アクセスに関わる仕組みとして SessionExclusive クラスもあります。ロック情報をセッションストア(RDBMS または Redis)に保存するため、ロックの取得・更新・解放のたびにセッションストアへの同期アクセスが発生します。ロックは 120 秒経過で取得可能になり、更新は 30 秒以内なら省略されます(SessionExclusive.cs)。

KVS(Redis)を使う ​

Session.json と Kvs.json の 2 ファイルで Redis をセッションストアにできます。

json
{
    "RetentionPeriod": 1440,
    "UseKeyValueStore": true
}
json
{
    "ConnectionStringForSession": "localhost:6379"
}

図を読み込み中…

接続は CacheForRedisConnection で ConnectionMultiplexer を遅延生成して使い回しています(CacheForRedisConnection.cs)。

観点RDBMS(既定)KVS(Redis)
I/O 速度遅い(ディスク I/O)速い(メモリ I/O)
ロック競合あり(Sessions テーブルへの UPDATE OR INSERT によるテーブル/ページ/行ロック)ほぼなし
同期/非同期同期同期
スレッドブロックありあり(短時間)
DB 負荷Sessions テーブルが業務データと競合業務データとは別サーバー

Redis でブロック時間は数十ミリ秒から数ミリ秒程度に短縮され十分高速化しますが、同期 I/O でスレッドを占有する構造と、リクエストごとのアクセスは変わりません。超大規模環境では、なおボトルネックになり得ます。

Redis にしても Sessions テーブルは使われます

確認したソースでは、Redis を使う設定でも次のものは Sessions テーブルに残ります。詳しくは セッション管理の実装 を参照してください。

  • Sessions API の値(userArea の値)は Sessions テーブルに書かれる。読み込みは Redis から行うため、Sessions API の Get と Delete が値を見つけられない
  • 個別のキーの削除(SessionUtilities.Remove())は Sessions テーブルだけを消し、Redis の値は TTL が切れるまで残る
  • 古いセッションの削除(1 日 1 回)は Sessions テーブルに対して実行される
  • Redis ではキーを _ で分けて読むため、_ を含むキー(TempFile_… など)が正しく読めない

DB アクセスの設定 ​

セッション以外の DB アクセスについて、Rds.json と接続文字列で変えられるものです(1.5.8.1)。

設定既定内容
接続プールドライバーの既定Pleasanter はプールの大きさを設定しない。SQL を実行するたびに接続を開いて閉じ、プールは各ドライバーに任せる(SqlIo.cs#L222-L257)。変えるときは UserConnectionString などの接続文字列にドライバーのキーワード(SQL Server の Max Pool Size など)を書く
SqlCommandTimeOut0コマンドのタイムアウト(秒)。0 は無制限(SqlIo.cs#L95)
DeadlockRetryCount4デッドロックのときに再実行する回数
DeadlockRetryInterval1000再実行までの待ち時間(ミリ秒)

デッドロックの再実行は記録が残らず、最後の再実行でもデッドロックになると、例外にならずにその SQL は実行されないまま戻ります(SqlIo.cs#L284-L305)。本体を改修する場合の案は DB アクセスの性能改善案 にあります。

Sessions テーブルへの書き込みは「UPDATE して 0 行なら INSERT」なので、同じキーへの同時書き込みで「PRIMARY KEY 違反。オブジェクト 'dbo.Sessions' には重複するキーを挿入できません。」がログに出ることがあります。原因と影響は セッション管理の実装 の「同時書き込みと PRIMARY KEY 違反」を参照してください。

ユーザー側でできる対策 ​

同期 I/O の根本構造はアプリケーションコードの変更でしか解決できませんが、設定やインフラ構成で影響を大きく減らせます。

対策効果内容
KVS(Redis)の導入大セッション I/O のレイテンシ短縮、Sessions テーブルのロック競合解消、業務データの DB との負荷分離
セッション保持期間の見直し中Session.json の RetentionPeriod(既定 1440 分 = 24 時間)を業務に合わせて短縮し、蓄積データ量を減らす
DB サーバーのリソース最適化中RDBMS をセッションストアにする場合、メモリ増強でバッファキャッシュのヒット率を上げる、SSD/NVMe 化、接続プールサイズの適正化
Web サーバーのスケールアウト中ロードバランサーで複数サーバーに分散し、スレッドプールの圧迫を軽減。セッションデータと Cookie の復号に使うデータ保護キーは既定では DB(Sessions テーブル)にあるので、サーバー間で共有される。Redis にすればセッションの負荷を DB から分けられる(セッション管理の実装 の「複数台構成で共有が必要なもの」)
接続プールの見直し中同時アクセスが多い環境では、接続文字列でプールの大きさを指定する(上の「DB アクセスの設定」)
不要なセッションデータの削減小スクリプト等で context.SessionData に保存しているデータを必要最小限にする

関連ページ ​

変更履歴

第5版記事の確認版を繰り返す表現を整理する
第4版セッションの仕組みとデータベースのテーブル構成の解説を追加し、性能とスケールアウトの説明を修正
第3版「構築・運用」を 1.5.8.1 のソースで検証して修正
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「構築・運用」セクションの記事を追加