セッションストアの改修案(Redis の不整合・UPSERT・クリーンアップ・抽象化)
本体の標準機能ではありません
このページは、プリザンター本体のセッション管理を改修する場合の設計メモです。現行の動きは セッション管理の実装 にまとめています。
前提にした現行実装
1.5.8.1 のソースで確かめた、改修に関係する点です。詳しい根拠は セッション管理の実装 にリンクしています。
| 現状 | 影響 |
|---|---|
SessionUtilities.Remove() は Sessions テーブルだけを消し、Redis のフィールドを消さない | Redis を使う設定で、ロック情報・一時ファイルの許可などが TTL まで残る |
userArea: true の値は Redis を使う設定でも Sessions テーブルに書くが、読み込みは Redis から | Redis を使う設定で、Sessions API の Get / Delete が値を見つけられない |
Redis のフィールド名 {キー}_{ページ} を Split('_') で分ける | _ を含むキー(View_…、TableExclusive_…、TempFile_…)が正しく読めない |
| Sessions テーブルへの書き込みは「UPDATE して 0 行なら INSERT」で、トランザクションも指定しない | 同じキーへの同時書き込みで PRIMARY KEY 違反 |
DeleteOldSessions() は Web リクエストの中で 1 日 1 回、プロセスごとに走る | アクセスの無い時間帯は削除されない。削除のコストが利用者のリクエストに載る |
リクエストごとに SessionData と UserSessionData を全件読む | 使わない値も毎回読む |
Redis への接続は ConnectionMultiplexer.Connect() を遅延生成するだけ(CacheForRedisConnection.cs) | Redis が落ちると、セッションの読み書きが例外になる。RDB への切り替えは無い |
Redis の操作は SessionUtilities・Context・AuthenticationTicketStore・UserUtilities などに直接書かれ、インターフェースが無い | Redis 以外の KVS を足すには各所を直す必要がある |
Redis との不整合を直す
Remove() で Redis のフィールドも消す
Remove() の中で、Redis を使う設定ならフィールドも消します。HDEL はキーの TTL を変えないので、残ったフィールドは今までどおり TTL で消えます。
public static void Remove(Context context, string key, bool page, string sessionGuid = null)
{
var targetSessionGuid = sessionGuid ?? context.SessionGuid;
Repository.ExecuteNonQuery(
context: context,
statements: Rds.PhysicalDeleteSessions(
where: Rds.SessionsWhere()
.SessionGuid(targetSessionGuid)
.Key(key)
.Page(context.Page, _using: page)));
if (Parameters.Session.UseKeyValueStore)
{
var iDatabase = CacheForRedisConnection.Connection.GetDatabase();
var fieldName = page && context.Page != null
? $"{key}_{context.Page}"
: key;
iDatabase.HashDelete(targetSessionGuid, fieldName);
iDatabase.HashDelete($"{targetSessionGuid}_readOnce", fieldName);
}
}| 方式 | 内容 | 考えること |
|---|---|---|
HDEL だけ(上の案) | フィールドを消す | 空のハッシュが TTL まで残る |
HDEL + EXPIRE | 消したあと TTL を付け直す | 削除でセッションが延命される |
HDEL + 空なら DEL | 最後のフィールドならキーも消す | HLEN の呼び出しが 1 回増える |
削除はセッションの利用とは言えないので、TTL は付け直さない方が自然です。今ある ReturnOriginalUser() の HashDelete のような個別の対応は、この改修で不要になります。
userArea の値を読むときは RDB から読む
Get() で includeUserArea が true のとき、または SessionGuid が @ で始まるときは RDB から読むように分けます。書き込み先と読み込み先がそろい、Redis を使う設定でも Sessions API が動くようになります。
フィールド名の区切りを変える
| 方法 | 内容 |
|---|---|
| 区切り文字を変える | キーやページに入らない文字で区切る |
最後の _ で分ける | LastIndexOf('_') で分ける。ページに _ が入らない前提 |
| 構造化する | フィールド名を JSON({"key":"View","page":"items/1"})にする |
区切りを変えると、切り替え前に Redis に入っていた値は読めなくなります。切り替えは TTL が切れる程度の期間を見込むか、切り替え時に Redis を空にします。
PRIMARY KEY 違反を防ぐ
| 対策 | 内容 | 効果 |
|---|---|---|
| トランザクション + 再試行 | SetRds() を transactional: true にし、主キー違反なら UPDATE をやり直す | READ COMMITTED ではトランザクションだけでは競合は防げない。再試行が必要 |
| DB の UPSERT 構文 | UpdateOrInsert を DB ごとの 1 文の UPSERT にする | 競合そのものが起きなくなる |
| Redis | UseKeyValueStore: true | userArea の値は RDB に残るので、その分は残る |
| DB | UPSERT 構文 |
|---|---|
| SQL Server | merge ... when matched then update when not matched then insert |
| PostgreSQL | insert ... on conflict ("SessionGuid", "Key", "Page") do update set ... |
| MySQL | insert ... on duplicate key update ... |
insert into "Sessions"
("Creator", "Updator", "SessionGuid", "Key", "Page", "Value", "ReadOnce", "UserArea")
values
(@U, @U, @SessionGuid, @Key, @Page, @Value, @ReadOnce, @UserArea)
on conflict ("SessionGuid", "Key", "Page")
do update set
"Updator" = @U,
"UpdatedTime" = now() at time zone 'UTC',
"Value" = @Value,
"ReadOnce" = @ReadOnce,
"UserArea" = @UserArea;UpdateOrInsert は Sessions 以外でも使われているので、ISqlCommandText.CreateUpdateOrInsert() 自体を変えるか、Sessions 専用の SQL を足すかを選びます。
古いセッションの削除をバックグラウンドに移す
DeleteOldSessions() を Context.SetData() から外し、Quartz.NET のタイマーで決まった時刻に実行します。既存の DeleteUnusedRecordTimer と同じく ClusterExecutionTimerBase([DisallowConcurrentExecution] 付き。ClusterExecutionTimerBase.cs)を継承し、BackgroundService.json に有効・無効と実行時刻の設定を足します。
public class DeleteOldSessionsTimer : ClusterExecutionTimerBase
{
public override async Task Execute(IJobExecutionContext context)
{
await Task.Run(() =>
{
var ctx = CreateContext();
Rds.ExecuteNonQuery(
context: ctx,
statements: Rds.PhysicalDeleteSessions(
where: Rds.SessionsWhere()
.UpdatedTime(
DateTime.Now.AddMinutes(Parameters.Session.RetentionPeriod * -1),
_operator: "<")
.Add(raw: "( \"SessionGuid\" not like '@%' )")));
}, context.CancellationToken);
}
}利用者のリクエストから削除のコストが無くなり、アクセスの無い時間帯でも削除されます。削除件数が多い環境では、DeleteUnusedRecordTimer と同じように件数を区切って繰り返します。
読み込みを減らす
| 案 | 内容 | 考えること |
|---|---|---|
| 必要になったときに読む | SessionData を、キーを初めて参照したときに読むクラスにする | 参照箇所が多いので、SessionData.Get() の呼び出し方は変えずに中身だけ差し替える |
| 必要なキーだけまとめて読む | Key in (...) / HMGET で指定したキーだけ読む | 呼び出し元がキーを知っている必要がある |
RDB の場合、今の Get() は SELECT と ReadOnce の DELETE を 1 回の呼び出しで送っているので、往復の回数ではなく読み込む行の量を減らすのが目的になります。
Redis の障害に備える
Redis の操作を、失敗が続いたら一定時間 RDB に切り替える仕組み(サーキットブレーカー)で包みます。
図を読み込み中…
切り替えている間に RDB に書いた値は、Redis に戻ったあとは読まれません。ログインが切れる、表示中のビューが戻るといった影響が出るので、許容できるかを決めてから入れます。切り替えと復帰は SysLogs に残します。
Redis 以外の KVS を使えるようにする
今の実装は StackExchange.Redis に直接依存しているので、ほかの KVS を足すには抽象化が先に必要です。
図を読み込み中…
public interface ISessionStore
{
Dictionary<string, string> Get(string sessionGuid, string page, bool includeUserArea);
void Set(string sessionGuid, string key, string value, string page, bool readOnce, TimeSpan expiry);
void Remove(string sessionGuid, string key, string page);
void Clear(string sessionGuid);
void Rename(string oldSessionGuid, string newSessionGuid);
}| 変える箇所 | 内容 |
|---|---|
Models/Sessions/SessionUtilities.cs | 分岐を ISessionStore の呼び出しにする |
Libraries/Redis/CacheForRedisConnection.cs | RedisSessionStore の中に移す |
Libraries/Requests/Context.cs | ログアウト・SessionAbandon()・RotateSessionGuid() の Redis 操作 |
Libraries/Security/AuthenticationTicketStore.cs | RemoveAsync() の Redis 操作 |
Models/Users/UserUtilities.cs | ReturnOriginalUser() の HashDelete |
App_Data/Parameters/Kvs.json | プロバイダーの種類を足す(例: "Provider": "Redis") |
ハッシュ型・キー単位の TTL・キーの付け替えなど、Redis の機能を前提にした処理が多いので、他の KVS ではこれらの代わりをどう作るかが課題になります。