Skip to content

セッション管理の実装(Sessions テーブルと Redis) ​

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

プリザンターのセッションデータは、ASP.NET Core のセッションではなく独自の Sessions テーブル(既定)か Redis(Session.json の UseKeyValueStore: true)に保存されます。読み書きはどちらも SessionUtilities に集まっていますが、Redis 側には RDB と同じにならない処理がいくつかあります。

  • セッション GUID は、データ保護 API で暗号化した Cookie Pleasanter_SessionGuid に入ります。ログインのたびに振り直されます。
  • 古いセッションの削除は、Web リクエストの処理中に 1 日 1 回だけ走ります。@ で始まる SessionGuid(ユーザー単位の値など)は削除されません。
  • Redis を使う設定でも、userArea の値(Sessions API の値)は Sessions テーブルに書かれます。一方で読み込みは Redis から行うため、両者が食い違います。
  • Sessions テーブルへの書き込みは「UPDATE して 0 行なら INSERT」で、同じキーへの同時書き込みで PRIMARY KEY 違反になることがあります。

対象バージョン

調査は 1.5.0.0 系のソースで行い、このページの内容は 1.5.8.1 のソース(コミット fdcbb3f)で確かめ直しています。セッション GUID の保存先など、1.5.8.1 で変わっている点はソースに合わせて書いています。

全体像 ​

図を読み込み中…

クラス役割
SessionUtilities読み書き・削除・古いセッションの削除・Sessions API の本体(SessionUtilities.cs)
Contextセッション GUID の決定、リクエストごとのセッションデータの読み込み(Context.cs)
AuthenticationTicketStoreCookie 認証のチケットをセッションデータに置く ITicketStore(AuthenticationTicketStore.cs)
SessionExclusive / TableExclusiveセッションデータを使った排他ロック(SessionExclusive.cs)
CacheForRedisConnectionRedis の ConnectionMultiplexer を遅延生成して使い回す(CacheForRedisConnection.cs)

セッション GUID の決まり方 ​

Context.SessionGuid の初期値は Strings.NewGuid()(ハイフンなしの大文字 32 文字)です(Context.cs#L55)。SetSessionGuid() は Cookie Pleasanter_SessionGuid をデータ保護 API で復号し、読めればその値を使い、無ければ初期値を暗号化して Cookie に書きます(Context.cs#L331-L380)。復号に失敗した Cookie は削除されます。Cookie は HttpOnly、SameSite=Lax で、Security.json の SecureCookies が true か HTTPS のときに Secure が付きます(Context.cs#L382-L394)。

  • Cookie が無いリクエストでは、SessionMiddleware が StartTime と LastAccessTime を書きます(Startup.cs#L897-L927)。
  • ログイン(FormsAuthenticationSignIn)と Windows 認証のときは RotateSessionGuid() で新しい GUID に振り直し、古い GUID の行を新しい GUID に付け替えます。Redis を使う設定では Redis のキーも KeyRename で付け替え、TTL を付け直します(Context.cs#L1445-L1521)。
  • ログアウトと SessionAbandon() では Cookie を削除し、Redis を使う設定なら Redis のキーを削除します(Context.cs#L1339-L1364)。SessionUtilities.Abandon() はさらに Sessions テーブルの行を消します(SessionUtilities.cs#L262-L278)。

Startup.cs では ASP.NET Core のセッション(AddDistributedMemoryCache() と AddSession())も構成されていますが(Startup.cs#L128-L135)、1.5.8.1 では HttpContext.Session に値を書く Context.SetSessionData() の呼び出し元がありません。セッション GUID もセッションデータも、ASP.NET Core のセッションには置かれていません。

特別な SessionGuid ​

Sessions テーブルには、ブラウザのセッション以外の SessionGuid の行もあります。

SessionGuid使う処理内容
Cookie の GUID画面・API のリクエスト通常のセッションデータ
@{ユーザー ID}Sessions API の SavePerUser: true(SessionUtilities.cs#L365)、ビューの保存方法がユーザー単位のサイト(Views.cs#L203-L222)ユーザー単位の値
認証チケットごとの GUIDAuthenticationTicketStore.StoreAsync()キー AuthenticationTicket に Base64 のチケット
SessionExclusiveSessionExclusive(SessionExclusive.cs#L31)サイト単位のロック情報
@AspNetCoreDataProtectionKeys既定のデータ保護キーの保存先(AspNetCoreKeyManagementXmlRepository.cs#L14)ASP.NET Core のデータ保護キー(XML)

Sessions テーブルの構造 ​

列型主キー内容
SessionGuidnvarchar(32)1セッション識別子
Keynvarchar(256)2キー
Pagenvarchar(32)3ページ単位の値のときの context.Page。それ以外は空文字
Valuenvarchar(max)値(文字列。オブジェクトは JSON にして保存)
ReadOncebit1 回読んだら消す値
UserAreabitSessions API などユーザー領域の値

このほかに全テーブル共通の Ver、Comments、Creator、Updator、CreatedTime、UpdatedTime があります(Sessions_SessionGuid.json、Sessions_Key.json、Sessions_Page.json)。テーブル定義上は Sessions_deleted と Sessions_history も作られますが、使われていません(データベースのテーブル構成)。

Page に入る context.Page は、items なら items/{サイト ID}(ゴミ箱では末尾に /trashbox)、users なら users/{ID} などです。多くの値は page: false で書かれ、Page は空文字になります。

主なキーは次のとおりです。

キー書く処理
StartTime / LastAccessTimeセッション開始時と、ログを取るリクエストのたび(SysLogModel が SessionRequestInterval() を呼ぶ。SysLogModel.cs#L3432、Context.cs#L716-L720)
AuthenticationTicketログイン時・チケット更新時
Message次の画面に出すメッセージ(readOnce: true。SessionUtilities.cs#L236-L243)
View、View_{テーブル名}一覧のビュー(page: true)
TableExclusive_SiteId={サイト ID}サイト単位の排他ロック(SessionExclusive.cs#L90-L102)
TempFile_{GUID}一時ファイルのダウンロード許可(BinaryUtilities.cs#L700-L739)
SwitchLoginId特権ユーザーのユーザー切り替え
User_{任意のキー}Sessions API(SetUserArea() が User_ を付ける。SessionUtilities.cs#L283-L292)

読み込み ​

リクエストごとに Context が作られ、次の 2 つを読み込みます。

プロパティ読む SessionGuid呼び出し箇所
SessionDataCookie の GUIDSetData()(Context.cs#L629-L640)
UserSessionData@{ユーザー ID}SetUserProperties()(Context.cs#L514-L517)

どちらも includeUserArea: Controller == "sessions" で読むため、UserArea の値が入るのは Sessions API のリクエストのときだけです。

SessionUtilities.Get() の RDB 側は、SELECT と、ReadOnce の行を消す DELETE を 1 回の呼び出しでまとめて送ります(SessionUtilities.cs#L57-L86)。

  • Page が空文字の行と、Page が今の context.Page と一致する行を読む
  • ReadOnce の削除は context.ApiRequestBody == null のときだけ。API のリクエストでは消さない

Redis 側は HashGetAll() でハッシュ全体を取り、{GUID}_readOnce のキーがあればそれも読んでキーごと削除します(SessionUtilities.cs#L35-L56)。Redis 側には UserArea の絞り込みがありません。

書き込み ​

SessionUtilities.Set() は context.SessionData を書き換えてから SetRds() で保存先に書きます(SessionUtilities.cs#L92-L192)。値が null なら Remove() になります。

図を読み込み中…

RDB と Redis の違い ​

項目RDB(Sessions テーブル)Redis
1 セッションの格納行(主キー SessionGuid + Key + Page)キー {GUID} のハッシュ
ページ単位の値Page 列フィールド名 {キー}_{ページ}
ReadOnce の値ReadOnce 列。読み込み時に行単位で削除別キー {GUID}_readOnce。読み込み時にキーごと削除
userArea の値保存する保存しない(RDB に書く)
個別キーの削除(Remove())行を削除何もしない(RDB だけ消す)
期限切れ1 日 1 回の DeleteOldSessions()書き込みのたびに付ける TTL

Redis ではキーの _ で分割される ​

Redis 側はフィールド名を {キー} または {キー}_{ページ} で書き(SessionUtilities.cs#L159)、読み込みでは Split('_') の 0 番目をキー、1 番目をページとして扱います(SessionUtilities.cs#L39-L44)。そのため、キー自体に _ を含む値は次のようになります。

  • 2 番目の要素が今のページと一致しない値は読み込まれない(context.Page と引数の sessionGuid がどちらも null のときは全部読まれる)
  • 読み込まれた値のキーは最初の _ の前だけになる(TempFile_ABC… は TempFile になる)
  • 前半が同じキーが複数読まれると、ToDictionary() のキーが重複する

1.5.8.1 には View_{テーブル名}、TableExclusive_SiteId={サイト ID}、TempFile_{GUID} のように _ を含むキーがあります。たとえば一時ファイルのダウンロード許可は、SessionUtilities.Get() の結果に TempFile_{GUID} と完全一致するキーがあるかで判定しているため(BinaryUtilities.cs#L708-L716)、Redis を使う設定ではこの判定が一致しません。Sessions API の User_ の値は RDB に書かれるので、この分割の影響は受けません。

Remove() は Sessions テーブルだけを消す ​

SessionUtilities.Remove() は Sessions テーブルへの DELETE だけで、Redis のフィールドは消しません(SessionUtilities.cs#L248-L257)。呼び出し元ごとの扱いは次のとおりです。

呼び出し元消すものRedis 側
AuthenticationTicketStore.RemoveAsync()認証チケットCacheForRedisConnection.Clear() でキーごと削除(AuthenticationTicketStore.cs#L65-L77)
ユーザー切り替えの解除SwitchLoginIdHashDelete で個別に削除(UserUtilities.cs#L5278-L5290)
ユーザー切り替えの失敗時SwitchLoginId消さない(UserUtilities.cs#L5252-L5258)
SessionExclusive.Clear()ロック情報消さない(SessionExclusive.cs#L73-L82)
一時ファイルTempFile_{GUID}消さない
BinariesController のアップロードアップロード中の情報消さない

Redis に残った値は TTL が切れるまで残ります。SessionExclusive のロックは取得時に更新時刻から 120 秒経っていれば取り直せるので(SessionExclusive.cs#L43-L59)、残ったロックは解放されたことにならず、120 秒経つまで他の処理が取得できません。

Redis を使うと Sessions API の値が読めない ​

userArea: true の値は、UseKeyValueStore が true でも Sessions テーブルに書かれます(SessionUtilities.cs#L155)。Sessions API の Set はこの userArea: true で書きます(SessionUtilities.cs#L354-L388)。

一方、Sessions API の Get と Delete が見るのは、Context が読み込んだ SessionData / UserSessionData です(SessionUtilities.cs#L297-L303、L320-L349)。これは SessionUtilities.Get() の結果で、UseKeyValueStore が true なら Redis から読まれます。Redis には Sessions API の値が無いため、Get と Delete は値を見つけられず 404 を返します。

データ保護キーの保存先 ​

Security.json の AspNetCoreDataProtection で Blob も Redis も指定しない既定の構成では、データ保護キーは AspNetCoreKeyManagementXmlRepository が SessionUtilities 経由で SessionGuid @AspNetCoreDataProtectionKeys に保存します(Startup.cs#L273-L311、AspNetCoreKeyManagementXmlRepository.cs#L16-L67)。userArea ではないので、UseKeyValueStore が true なら Redis に書かれ、RetentionPeriod 分の TTL が付きます。Sessions テーブルに書かれる場合、この行は @ で始まるため DeleteOldSessions() の対象外で、キーの expirationDate から 90 日を過ぎたものが新しいキーを保存するときに消されます。

有効期限と古いセッションの削除 ​

Session.json の RetentionPeriod(分、既定 1440)は次の 3 か所で使われます。

使われ方箇所
ASP.NET Core のセッションの IdleTimeoutStartup.cs#L130-L132
認証 Cookie の ExpireTimeSpan(SAML の有無どちらも)Startup.cs#L157、L174
Redis のキーの TTL(書き込みのたび)SessionUtilities.cs#L165
Sessions テーブルの古い行の削除SessionUtilities.cs#L434-L450

Redis の TTL は読み込みでは延びず、書き込みのたびに付け直されます。ログを取るリクエストでは LastAccessTime を書くので、そのたびに TTL も付け直されます。

DeleteOldSessions() ​

csharp
if (前回実行日 != 今日)   // SiteInfo.SessionCleanedUpDate(static)
{
    SiteInfo.SessionCleanedUpDate = DateTime.Now;
    DELETE FROM Sessions
    WHERE UpdatedTime < 現在 - RetentionPeriod 分
      AND SessionGuid NOT LIKE '@%';
}
  • 前回実行日は static 変数なので、プロセス(Web サーバー)ごとに 1 日 1 回です。日付はリクエストしたユーザーのタイムゾーンで比べます。プロセスの起動直後の最初の呼び出しでは必ず実行されます。
  • 削除は行単位で、行ごとの UpdatedTime を見ます。セッション全体ではなく、長く書かれていないキーの行から消えます。
  • @ で始まる SessionGuid(Sessions API の SavePerUser: true、ユーザー単位のビュー、データ保護キー)は消しません。これらは明示的に消すまで残ります。
  • UseKeyValueStore が true でも、この削除は Sessions テーブルに対して実行されます。

呼び出し元は Context.SetData() だけです(Context.cs#L640)。SetData() は、sessionData(既定 true)と user(既定 true)が有効な Context で、ルート情報があるリクエストのときだけ呼ばれます(Context.cs#L227-L243、L481-L485)。

図を読み込み中…

バックグラウンド処理や認証チケットの読み書きは sessionData: false や Context(tenantId: ...) で Context を作るので、削除は走りません。利用者のアクセスが無い時間帯には、古い行はそのまま残ります。

同時書き込みと PRIMARY KEY 違反 ​

Sessions テーブルへの書き込み(UpdateOrInsertSessions)は、DB ごとに次の SQL になります。

DB生成される SQL箇所
SQL Serverupdate ... の後に if @@rowcount = 0 insert ...SqlServerCommandText.cs#L60-L79
PostgreSQLwith CTE1 as (update ... returning 0) insert ... where not exists(select * from CTE1)PostgreSqlCommandText.cs#L64-L91
MySQLupdate ...; と insert ... where not exists (select 1 from ...) の 2 文MySqlCommandText.cs#L71-L90

SetRds() はトランザクションを指定せずに実行します(SessionUtilities.cs#L169-L182)。どの DB でも、行がまだ無いときの UPDATE は行ロックを取らないので、同じ SessionGuid + Key + Page に 2 つのリクエストが同時に書くと、両方が INSERT に進み、後の方が主キー違反になります。

図を読み込み中…

SQL Server では「PRIMARY KEY 違反。オブジェクト 'dbo.Sessions' には重複するキーを挿入できません。」というエラーになります。起きやすいのは、同じセッションの行がまだ無い(またはクリーンアップで消えた)状態で、画面表示と非同期のリクエストがほぼ同時に来たときです。失敗した書き込みの値は保存されませんが、LastAccessTime のように次のリクエストでまた書かれる値は、次の書き込みで保存されます。

Redis の HashSet は 1 コマンドで上書きするため、Redis に書く値ではこの違反は起きません。ただし userArea の値は Redis を使う設定でも Sessions テーブルに書かれます。

同じ「UPDATE → INSERT」の形は Upsert API にもあります(Upsert 処理)。

複数台構成で共有が必要なもの ​

1.5.8.1 ではセッション GUID を暗号化した Cookie に持つので、どの Web サーバーがリクエストを受けても、次の 2 つが共有されていれば同じセッションとして扱われます。

共有するもの既定の置き場所別の置き場所
セッションデータSessions テーブル(DB なので共有される)UseKeyValueStore: true で Redis
データ保護キー(Cookie の復号に使う)Sessions テーブルの @AspNetCoreDataProtectionKeysSecurity.json の AspNetCoreDataProtection で Azure Blob Storage か Redis

ASP.NET Core のセッション(AddDistributedMemoryCache())はプロセス内のメモリですが、1.5.8.1 ではセッション GUID もセッションデータもそこに置かれていないため、サーバー間で共有されなくても影響しません。

関連ページ ​

変更履歴

第2版Sessionに保存される情報と共有範囲の解説を追加
第1版セッションの仕組みとデータベースのテーブル構成の解説を追加し、性能とスケールアウトの説明を修正