2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

プリザンターを多人数で利用しているときに「なんだか遅いな」と感じたことはないでしょうか。ユーザー数が増えるにつれてレスポンスが悪くなる原因のひとつに、セッションアクセスの仕組みがあります。今回はプリザンターのセッション管理がどのように実装されているかをソースコードから読み解き、なぜ同時アクセスで遅くなるのかを探ってみます。

セッションの仕組みをおさらい

プリザンターは ASP.NET Core 上で動作しており、セッション管理には ASP.NET Core 標準のセッションミドルウェアに加えて、独自のセッション管理機構を持っています。

ASP.NET Core のセッションミドルウェア

Startup.cs でセッションが構成されています。

Implem.Pleasanter/Startup.cs
services.AddDistributedMemoryCache();
services.AddMvc().AddSessionStateTempDataProvider();
services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(Parameters.Session.RetentionPeriod);
});

ミドルウェアパイプラインでは、UseSession() の後にカスタムの SessionMiddleware が登録されています。

Implem.Pleasanter/Startup.cs
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();
app.UseSessionMiddleware();

プリザンター独自のセッション管理

プリザンターでは ASP.NET Core のセッション(HttpContext.Session)はセッション GUID の保持程度にしか使っておらず、実際のセッションデータは 独自の Sessions テーブル(RDBMS)に保存されています。

リクエストごとに Context オブジェクトが生成され、その初期化時に SessionUtilities.Get() が呼び出されてセッションデータが読み込まれます。

Implem.Pleasanter/Libraries/Requests/Context.cs
SessionData = SessionUtilities.Get(
    context: this,
    includeUserArea: Controller == "sessions");

つまりすべてのリクエストで、セッションデータの読み込みが発生しています。

遅くなる原因:同期的な DB アクセス

ここが本題です。SessionUtilities.Get() の実装を見てみましょう。

Implem.Pleasanter/Models/Sessions/SessionUtilities.cs
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(
                column: Rds.SessionsColumn().Key().Value(),
                where: Rds.SessionsWhere()
                    .SessionGuid(sessionGuid ?? context.SessionGuid)
                    /* ... */),
            Rds.PhysicalDeleteSessions(
                where: Rds.SessionsWhere()
                    .SessionGuid(sessionGuid ?? context.SessionGuid)
                    .ReadOnce(true),
                _using: context.ApiRequestBody == null)
        })
        .AsEnumerable()
        .ToDictionary(
            dataRow => dataRow.String("Key"),
            dataRow => dataRow.String("Value"));
}

注目すべきポイントは次の 2 点です。

1. すべてが同期メソッド

Repository.ExecuteTable()iDatabase.HashGetAll()同期メソッドです。非同期版(async/await)が使われていません。

ASP.NET Core のリクエスト処理はスレッドプールのスレッドで実行されます。同期的な I/O 呼び出しはスレッドをブロック(占有)するため、I/O の完了を待っている間もそのスレッドは他のリクエストに使えません。

少人数のうちは問題になりませんが、同時アクセス数が増えると、DB の応答待ちでスレッドが次々とブロックされ、スレッドプールが枯渇してリクエストの処理が遅延します。

2. リクエストのたびに DB にアクセスする

セッションデータはメモリにキャッシュされず、毎回 DB から読み込みされます。さらに Set() メソッドも同期的に DB へ書き込みを行います。

Implem.Pleasanter/Models/Sessions/SessionUtilities.cs
private static void SetRds(Context context, string key, string value,
    bool readOnce, bool page, bool userArea, string sessionGuid = null)
{
    if (value != null)
    {
        // ...
        if (Parameters.Session.UseKeyValueStore && !userArea)
        {
            // Redis への同期書き込み
            iDatabase.HashSet(sessionGuid, hashEntrys);
            iDatabase.KeyExpire(sessionGuid, TimeSpan.FromMinutes(Parameters.Session.RetentionPeriod));
        }
        else
        {
            // RDBMS への同期書き込み
            Repository.ExecuteNonQuery(
                context: context,
                statements: Rds.UpdateOrInsertSessions(/* ... */));
        }
    }
}

結果として、1 リクエストあたり最低でもセッションの Get と Set で 2 回以上の同期 DB アクセスが発生しています。

多人数同時アクセスで何が起きるか

この仕組みのもとで同時アクセスが増えると、以下のような連鎖が起きます。

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

KVS(Redis)を導入すると改善するか

プリザンターには KVS(Redis)をセッションストアとして使う機能が用意されています。Session.jsonKvs.json で設定します。

App_Data/Parameters/Session.json
{
    "RetentionPeriod": 1440,
    "UseKeyValueStore": true
}
App_Data/Parameters/Kvs.json
{
    "ConnectionStringForSession": "localhost:6379"
}

Redis を有効にすると、SessionUtilities.Get() の処理が RDBMS ではなく Redis に切り替わります。

Implem.Pleasanter/Libraries/Redis/CacheForRedisConnection.cs
public class CacheForRedisConnection
{
    private static readonly Lazy<ConnectionMultiplexer> LazyConnection =
        new Lazy<ConnectionMultiplexer>(() =>
        {
            return ConnectionMultiplexer.Connect(
                Parameters.Kvs.ConnectionStringForSession,
                x => x.AllowAdmin = true);
        });

    public static ConnectionMultiplexer Connection
    {
        get { return LazyConnection.Value; }
    }
}

KVS で改善する部分

改善点 理由
I/O レイテンシの低減 Redis はインメモリなので RDBMS より桁違いに速い
RDBMS の負荷軽減 Sessions テーブルへのアクセスがなくなり、業務データのクエリにリソースを集中できる
ロック競合の緩和 RDBMS のテーブルロック・行ロックが発生しなくなる

特に RDBMS をセッションストアとして使っている場合、Sessions テーブルへの UPDATE OR INSERT が大量に発生し、テーブルレベルやページレベルのロック競合が発生します。Redis であればこの問題は大幅に軽減されます。

KVS でも解決しない部分

しかし、Redis を導入しても根本的な問題は残ります

残る課題 理由
同期 I/O のまま iDatabase.HashGetAll() は同期メソッド。スレッドブロックは変わらない
リクエストごとのアクセス セッションデータのキャッシュがないため、毎回 Redis にアクセスする
スレッドプール枯渇のリスク Redis が速くてもスレッドをブロックする以上、大量同時接続では枯渇する可能性がある

Redis によってレイテンシは大幅に短縮されるため、ブロック時間は短くなります。それでも「スレッドを占有する」という根本構造は変わりません。RDBMS 時代の数十ミリ秒のブロックが数ミリ秒になるだけで、十分高速化はされますが、超大規模環境ではなおボトルネックになり得ます。

排他制御の仕組み

もうひとつ、同時アクセスに関連する仕組みとして SessionExclusive クラスがあります。

Implem.Pleasanter/Models/Sessions/SessionExclusive.cs
public class SessionExclusive : IDisposable
{
    public class Lock
    {
        public long SiteId { get; set; }
        public int UserId { get; set; }
        public string Comment { get; set; }
        public DateTime UpdatedTime { get; set; }
    }

    public bool TryLock()
    {
        // ...
        if (value == null || value.UpdatedTime.AddSeconds(120) < DateTime.UtcNow)
        {
            Refresh();
            return true;
        }
        return false;
    }

    public void Refresh()
    {
        if (LockObj == null) return;
        if (LockObj.UpdatedTime != DateTime.MinValue
            && LockObj.UpdatedTime.AddSeconds(30) > DateTime.UtcNow) return;
        LockObj.UpdatedTime = DateTime.UtcNow;
        SessionUtilities.Set(
            context: Context,
            key: Key,
            value: LockObj.ToJson(),
            sessionGuid: SessionExclusiveGuid);
    }
}

この排他制御はセッションストア(RDBMS or Redis)を通じてロック情報を保存します。つまりロックの取得・更新・解放のたびにセッションストアへの同期アクセスが発生します。KVS を導入すればロック操作自体は高速化しますが、同期アクセスである点は変わりません。

全体像を整理する

観点 RDBMS(デフォルト) KVS(Redis)
I/O 速度 遅い(ディスク I/O) 速い(メモリ I/O)
ロック競合 あり(テーブル/行ロック) ほぼなし
同期/非同期 同期 同期
スレッドブロック あり あり(短時間)
DB 負荷分散 Sessions テーブルが業務データと競合 業務データとは別サーバ

ユーザサイドでできる対策

ここまで見てきた通り、同期 I/O の根本構造はアプリケーションコードの変更でしか解決できません。しかし、ユーザサイドの設定変更やインフラ構成で影響を大幅に軽減できます。

1. KVS(Redis)の導入【効果:大】

最も効果的な対策です。Session.jsonKvs.json の 2 ファイルを設定するだけで有効化できます。

  • セッション I/O のレイテンシが RDBMS の数十ミリ秒からインメモリの数ミリ秒に短縮される
  • RDBMS の Sessions テーブルへのロック競合がなくなる
  • 業務データの DB と負荷が分離される

2. セッション保持期間の見直し【効果:中】

Session.jsonRetentionPeriod(デフォルト: 1440 分 = 24 時間)を業務に合わせて短縮することで、セッションストアに蓄積されるデータ量を減らせます。

3. DB サーバのリソース最適化【効果:中】

RDBMS をセッションストアとして使う場合(デフォルト構成)は、Sessions テーブルへのアクセスが業務データと競合します。以下が有効です。

  • DB サーバのメモリを増やしてバッファキャッシュのヒット率を上げる
  • ディスクを高速な SSD/NVMe に変更して I/O レイテンシを下げる
  • DB の接続プールサイズを適切に設定する

4. Web サーバのスケールアウト【効果:中】

スレッドプール枯渇はサーバ単位で発生するため、ロードバランサーで複数の Web サーバに負荷分散することでスレッドプールの圧迫を軽減できます。この場合、セッション共有のために KVS(Redis)の導入が事実上必須となります。

5. 不要なセッションデータの削減【効果:小】

スクリプト等で context.SessionData にデータを保存している場合は、本当に必要なデータだけを保持するよう見直すことで、1 回あたりの I/O 量を減らせます。

まとめ

  • プリザンターのセッション管理は、リクエストごとにセッションストア(RDBMS)への同期的な読み書きが発生する
  • 同時アクセスが増えると、同期 I/O によるスレッドプール枯渇と DB 負荷増大が重なってレスポンスが悪化する
  • KVS(Redis)を導入すると、I/O レイテンシの低減RDBMS のロック競合解消で大幅に改善する
  • ただし、セッションアクセスが同期メソッドのままという根本構造は変わらないため、完全な解決にはならない
  • ユーザサイドでは KVS 導入が最も効果的で、セッション保持期間の見直しや DB リソース最適化、Web サーバのスケールアウトも有効な対策となる
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?