DB アクセスの性能改善案(接続プール・N+1・キャッシュ・トランザクション・デッドロック)
本体の標準機能ではありません
このページは、プリザンター本体の DB アクセスを改修する場合の設計メモです。設定だけでできる対策は 同時アクセスが遅くなる理由(セッション・Redis) にあります。セッションまわりの改修案は セッションストアの改修案 に分けています。
前提にした現行実装
| 項目 | 1.5.8.1 の実装 |
|---|---|
| 接続 | SQL を実行するたびに factory.CreateSqlConnection() で接続を作って開き、終わったら閉じる(SqlIo.cs#L222-L257)。プールは各ドライバーの機能に任せ、Pleasanter 側ではプールの大きさを設定しない |
| DBMS の実装 | RdsFactory.Create() の結果をアプリケーション全体で 1 つ使い回す(Context.cs#L1393-L1401) |
| 複数文の実行 | 1 回の呼び出しに複数の SqlStatement を渡すと、1 つのコマンドにまとめて送る |
| トランザクション | transactional: true のときだけ BeginTransaction()。分離レベルは指定しない(既定の READ COMMITTED)。トランザクション自体のタイムアウトは無い(Rds.cs#L219-L254) |
| コマンドのタイムアウト | Rds.json の SqlCommandTimeOut(既定 0)を CommandTimeout に入れる(SqlIo.cs#L95)。ADO.NET では 0 は無制限 |
| デッドロック | DeadlockRetryCount(既定 4)回まで、DeadlockRetryInterval(既定 1000 ミリ秒)待って再実行する。ログは残さない(SqlIo.cs#L284-L305、Rds.json) |
| キャッシュ | テナント単位のキャッシュ(サイト・組織・グループ・ユーザー)はあるが、クエリ結果のキャッシュは無い |
デッドロックの再試行が尽きると例外にならない
SqlIo.Try() は、最後の試行でもデッドロックになった場合、例外を投げずにループを抜けます。合計 5 回(既定)ともデッドロックになると、その SQL は実行されないまま、呼び出し元にはエラーとして伝わりません。
接続プールの設定を明示する
プールの大きさは接続文字列のキーワードで指定できます(SQL Server の Max Pool Size、PostgreSQL(Npgsql)の Maximum Pool Size、MySQL(MySqlConnector)の Maximum Pool Size など)。本体を改修しなくても、Rds.json の UserConnectionString に書けば効きます。
改修するなら、Rds.json にプールの設定を足し、接続文字列を組み立てるときに各ドライバーの ConnectionStringBuilder で上書きします。
{
"MaxPoolSize": 200,
"MinPoolSize": 10,
"ConnectionLifetime": 300,
"ConnectionTimeout": 30
}Sa・Owner・User の 3 つの接続文字列はそれぞれ別のプールになるので、画面の処理で使う UserConnectionString を中心に決めます。
ループの中の個別クエリをまとめる
一覧の各行について関連データを 1 件ずつ取るような処理は、行数 + 1 回のクエリになります。
図を読み込み中…
| 改修 | 内容 |
|---|---|
| まとめて送る | ループで作った SqlStatement を配列にして 1 回の呼び出しで送る(ExecuteDataSet 系) |
| 1 つのクエリにする | IssueId_In(...) のような In 条件や JOIN で、まとめて 1 回で取る |
CodeDefiner が生成するコードにも同じ形があるので、テンプレートの側で直す必要があります。
クエリ結果をキャッシュする
ユーザーやサイトの情報のように、読まれる回数が多く、変わる回数が少ないデータを MemoryCache に置きます。
public static class QueryCache
{
private static readonly MemoryCache Cache = new MemoryCache(
new MemoryCacheOptions { SizeLimit = 10000 });
public static T GetOrAdd<T>(Context context, string cacheKey, Func<T> factory, TimeSpan? expiration = null)
{
var key = $"{context.TenantId}:{cacheKey}";
if (Cache.TryGetValue(key, out T cached)) return cached;
var value = factory();
Cache.Set(key, value, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromMinutes(5),
Size = 1
});
return value;
}
public static void Invalidate(Context context, string cacheKey)
{
Cache.Remove($"{context.TenantId}:{cacheKey}");
}
}- キーにテナント ID を入れ、テナントをまたいで値が混ざらないようにします。
- 更新したときに消す処理が必要です。複数台構成では、1 台で消しても他の台のキャッシュは残るので、既存のテナント単位のキャッシュと同じように更新日時で確かめるか、Redis などの共有キャッシュを使います。
トランザクションにタイムアウトを付ける
Rds.json にトランザクションのタイムアウト(例: TransactionTimeout、秒)を足し、Rds.ExecuteScalar<T>() で処理が時間内に終わらなければロールバックして例外にします。SqlCommandTimeOut を 0 以外にすると、コマンド単位で待ち時間の上限が付きます。こちらは本体を改修せずに設定できますが、CodeDefiner は実行時にこの値を 0 に戻して使います(Starter.cs#L65)。
デッドロックの再試行を記録する
SqlIo.Try() で、デッドロックを検出したとき・再試行で成功したとき・再試行が尽きたときに SysLogs へ記録し、尽きたときは例外を投げ直します。
for (int i = 0; i <= Environments.DeadlockRetryCount; i++)
{
try
{
action();
// i > 0 なら「再試行で成功」を記録
break;
}
catch (DbException e) when (factory.SqlErrors.ErrorCode(e) == factory.SqlErrors.ErrorCodeDeadLocked)
{
// 「デッドロックを検出(i + 1 回目)」を記録
if (i >= Environments.DeadlockRetryCount)
{
// 「再試行が尽きた」を記録
throw;
}
System.Threading.Thread.Sleep(Environments.DeadlockRetryInterval);
}
}SqlIo は Implem.Libraries にあり、SysLogs を書く SysLogModel は Implem.Pleasanter にあるので、記録は Implem.Libraries 側にコールバックを渡す形にします。記録が増えると、どの処理でデッドロックが起きているか、再試行の回数と間隔が合っているかを確かめられます。
優先度の目安
| 改修 | 効果 | 手間 |
|---|---|---|
| 接続プールの設定 | 高い(同時アクセスが多い環境) | 小さい。接続文字列だけならすぐできる |
| デッドロックの記録と例外 | 問題の発見 | 小さい |
| ループの中の個別クエリ | 高い(該当する画面) | 大きい。生成コードのテンプレートにも及ぶ |
| クエリ結果のキャッシュ | 高い | 大きい。消す処理と複数台での整合が要る |
| トランザクションのタイムアウト | 長いロックの防止 | 中くらい |
効果の大きさは環境で変わるので、入れる前後で実測して確かめます。