同時アクセスが遅くなる理由(セッション・Redis)
多人数で利用するとレスポンスが悪くなる原因のひとつが、セッションアクセスの仕組みです。
- プリザンターは すべてのリクエストで、セッションストア(既定は RDBMS の
Sessionsテーブル)への同期的な読み書き を行います。同時アクセスが増えると、スレッドプールの枯渇と DB 負荷の増大が重なって遅くなります。 - ユーザー側でできる最も効果的な対策は KVS(Redis)の導入 です。ただし同期アクセスという根本構造は変わりません。
セッション管理の仕組み
ASP.NET Core のセッション
Startup.cs で ASP.NET Core 標準のセッションが構成され、UseSession() の後に独自の SessionMiddleware が登録されています。
services.AddDistributedMemoryCache();
services.AddMvc().AddSessionStateTempDataProvider();
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(Parameters.Session.RetentionPeriod);
});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() が呼ばれ、セッションデータが読み込まれます。つまり すべてのリクエストでセッションデータの読み込みが発生 します。
SessionData = SessionUtilities.Get(
context: this,
includeUserArea: Controller == "sessions");確認したソースでも同じ構造で、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)。
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 つです。
- すべて同期メソッド:
Repository.ExecuteTable()もiDatabase.HashGetAll()も同期メソッドで、async/awaitは使われていません。同期 I/O はスレッドプールのスレッドを I/O 完了までブロックするため、同時アクセスが増えると DB の応答待ちでスレッドが次々に占有され、スレッドプールが枯渇してリクエストが遅延します。 - リクエストごとに 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 をセッションストアにできます。
{
"RetentionPeriod": 1440,
"UseKeyValueStore": true
}{
"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 など)を書く |
SqlCommandTimeOut | 0 | コマンドのタイムアウト(秒)。0 は無制限(SqlIo.cs#L95) |
DeadlockRetryCount | 4 | デッドロックのときに再実行する回数 |
DeadlockRetryInterval | 1000 | 再実行までの待ち時間(ミリ秒) |
デッドロックの再実行は記録が残らず、最後の再実行でもデッドロックになると、例外にならずにその 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 に保存しているデータを必要最小限にする |