複数インスタンス間のマスターデータ同期の設計
本社と拠点で別々にプリザンターを動かしていて、従業員マスターや商品マスターのようなサイトだけを揃えたい、という要件に対する設計メモです。本体の標準機能ではありません。 プリザンター本体は改修せず、同期用の外部アプリケーションが各環境の DB を直接読み書きする前提です。
DB を直接書き換える前提の設計です
DB への直接書き込みは、プリザンターの入力チェック・権限チェック・通知・サーバースクリプト・履歴の自動作成をすべて通りません。整合性は同期アプリ側で担保する必要があり、プリザンターのバージョンアップでテーブル構造が変わったら同期アプリも追従が要ります。
方針
| 項目 | 方針 |
|---|---|
| 形態 | プリザンターとは独立した外部アプリケーション |
| データアクセス | DB への直接 SQL(API は使わない) |
| 対応 DB | SQL Server・PostgreSQL・MySQL |
| タイミング | 変更検出によるほぼリアルタイムの同期を基本に、定期的な突き合わせで補う |
| 本体の改修 | 不要(バージョンアップが難しい既存環境でも使える) |
API を使わない理由は次のとおりです。
| 観点 | API の課題 | DB 直接の利点 |
|---|---|---|
| 性能 | HTTP と認証のオーバーヘッド | 直接操作するので速い |
| 一括処理 | 一括 Upsert のタイムアウトなど | SQL でまとめて処理できる |
| 競合制御 | Upsert に DB レベルのロックが無い | トランザクションと行ロックで守れる |
| 削除の検出 | API では削除済みレコードを取れない | _deleted テーブルを読める |
| 運用 | 環境ごとに API キーの管理が要る | DB の接続情報だけ |
テーブル構造(1.5.8.1 で確認)
Items と Results / Issues / Wikis
レコードは Items と、サイトの種類に応じた Results / Issues / Wikis の 2 か所に行を持ちます。種類は Sites.ReferenceType で分かります。
図を読み込み中…
自動採番されるのは Items.ReferenceId で、Results.ResultId などは採番されません(Items と Results のうち、定義に Identity があるのは Items_ReferenceId だけ)。プリザンターがレコードを作るときは、次の順で SQL を組み立てます(ResultModel.cs)。
Itemsに INSERT して採番された ID を取る(Titleには表示用のタイトル)- その ID を
ResultIdにしてResultsに INSERT - リンク項目があれば
Linksに INSERT - 添付ファイルを更新
- サイトの「作成時の権限」設定があれば
Permissionsに INSERT
同期先にレコードを作るときもこの順序に合わせます。Items には全文検索用の FullText・SearchIndexCreatedTime 列もあり、SQL で直接書くとアプリ側の更新処理は動きません。
_deleted と _history
CodeDefiner は、履歴の対象列(History 属性)を持つテーブルについて、本体と同じ列の _deleted テーブルと、履歴対象の列だけの _history テーブルを作ります(TablesConfigurator.cs)。Results・Issues・Wikis だけでなく Binaries にも Binaries_deleted / Binaries_history があります。
| テーブル | 用途 | 同期での扱い |
|---|---|---|
Results など | 現在のレコード | 同期の主対象 |
Results_deleted など | 削除されたレコード | 削除の検出に使う |
Results_history など | 更新履歴 | 同期しない(書くなら更新前の行をコピー) |
インデックス
CodeDefiner は Definition_Column の Pk・Ix1〜Ix5 の数字の順に列を並べてインデックスを作ります。_BaseItems の列(SiteId・UpdatedTime など)は Results・Issues・Wikis に共通です。
| テーブル | インデックス | 列 |
|---|---|---|
| Results | Pk | SiteId, UpdatedTime DESC, ResultId |
| Results | Ix1 | ResultId, SiteId, UpdatedTime DESC |
| Results | Ix2 | ResultId |
| Results | Ix3 | SiteId, Locked, ResultId |
| Items | Pk | ReferenceId |
| Items | Ix1 | ReferenceType, ReferenceId |
| Items | Ix2 | SiteId, ReferenceId |
| Items | Ix4 | SiteId, ReferenceType, UpdatedTime |
| Items | Ix5 | SiteId, ReferenceType |
| Binaries | Pk | BinaryId |
| Binaries | Ix1 | ReferenceId, BinaryId |
| Binaries | Ix2 | Guid, BinaryId |
根拠: _BaseItems_SiteId.json、_BaseItems_UpdatedTime.json、Results_ResultId.json、Items_ReferenceId.json、Binaries_Guid.json。
| 同期の処理 | 条件 | 使えるインデックス |
|---|---|---|
| 変更の検出 | SiteId = @s AND UpdatedTime > @t | Results の Pk(先頭が SiteId, UpdatedTime) |
| 同期キーでの照合 | SiteId = @s AND ClassA = @k | SiteId だけ。分類項目のインデックスは無い |
| Items の更新 | ReferenceId = @id | Items の Pk |
| 添付の取得 | ReferenceId = @id | Binaries の Ix1 |
| 添付の照合 | Guid = @g | Binaries の Ix2 |
同期キーの分類項目で照合する件数が多いなら、同期アプリ用に (SiteId, ClassA) のインデックスを追加します。CodeDefiner はインデックスの変化を検出すると作り直すため、再実行時に追加分が消える可能性があります。パラメータ Rds.DisableIndexChangeDetection が有効なら、この検出は行われません(Indexes.cs)。
-- PostgreSQL
CREATE INDEX CONCURRENTLY IF NOT EXISTS "ix_Results_SiteId_ClassA" ON "Results" ("SiteId", "ClassA");
-- SQL Server
CREATE NONCLUSTERED INDEX [ix_Results_SiteId_ClassA] ON [Results] ([SiteId], [ClassA]);
-- MySQL
CREATE INDEX `ix_Results_SiteId_ClassA` ON `Results` (`SiteId`, `ClassA`);同期キー
ResultId などは環境ごとに採番されるので、同期キーには使えません。従業員番号や商品コードのような業務上の一意な値を分類項目に持たせ、それで照合します(複数の分類項目の組み合わせでもよい)。Wiki は分類項目が無いので、タイトルか ID の対応表で照合します。
トポロジ
| 親子型(Hub-Spoke) | 対等型(Peer-to-Peer) | |
|---|---|---|
| 正本 | 親が唯一の正本 | 正本なし |
| 方向 | 親 → 子が基本。子 → 親は制限付き | 双方向 |
| 競合 | 起きにくい | 起きやすい |
| 実装 | 簡単 | 複雑 |
| 例 | 本社 → 支社 | 拠点が対等に運用 |
図を読み込み中…
特別な理由が無ければ親子型を推奨します。
同期の制御
| 単位 | 例 |
|---|---|
| サイト | サイト A は同期、サイト B はしない |
| レコード | 承認済みだけ、機密フラグ付きは除く、支社 A には東日本だけ |
| 項目 | 分類 A は同期、分類 B はしない |
| 方向 | 子 A → 親では分類 C を除く、子 B には分類 D を送らない |
レコードのフィルタは変更検出の SQL の WHERE に足します。フィルタから外れたレコードが同期先に残っているときに消すか残すかは、運用で決めておきます。
同期の定義は JSON ファイルで持ちます。
{
"syncId": "master-employee",
"topology": "hub-spoke",
"source": { "instanceId": "headquarters", "dbms": "PostgreSQL", "connectionString": "Host=hq-db;...", "siteId": 12345 },
"targets": [
{ "instanceId": "branch-a", "dbms": "SQLServer", "connectionString": "Server=branch-a-db;...", "siteId": 23456 },
{ "instanceId": "branch-b", "dbms": "MySQL", "connectionString": "Server=branch-b-db;...", "siteId": 34567 }
],
"syncKeys": ["ClassA"],
"columns": {
"default": { "include": ["Title", "ClassA", "ClassB", "ClassC", "NumA"] },
"overrides": [{ "targetInstanceId": "branch-b", "exclude": ["ClassC"] }]
},
"recordFilter": {
"include": { "ClassB": "approved" },
"exclude": { "ClassC": "confidential" },
"targetOverrides": [{ "targetInstanceId": "branch-a", "include": { "ClassD": "east" } }]
},
"direction": {
"sourceToTarget": true,
"targetToSource": { "enabled": true, "excludeColumns": ["ClassB", "NumA"] },
"overrides": [{ "targetInstanceId": "branch-a", "targetToSource": false }]
},
"attachments": { "enabled": true, "storageType": "Rds" },
"changeDetection": { "method": "polling", "intervalSeconds": 5 },
"conflictResolution": "source-wins",
"syncUser": { "userId": 1 },
"idMapping": {
"users": [{ "sourceInstanceId": "headquarters", "sourceUserId": 42, "targetInstanceId": "branch-a", "targetUserId": 108 }],
"depts": [],
"groups": [],
"records": []
}
}接続文字列は設定ファイルに平文で置かず、Key Vault・DPAPI・環境変数などから読みます。定義は JSON Schema と、Schema では書けない規則(instanceId の重複、syncKeys が同期対象の項目に含まれるか、親子型なのに親 → 子が無効になっていないか、添付の保管先が Rds か、など)で検証するコマンドを用意します。
ID の対応付け
環境ごとに DB が別なので、同じ実体でも ID が違います。
| ID | 対応のしかた |
|---|---|
SiteId | 定義の source.siteId / targets[].siteId で対応させる |
ResultId / IssueId | 同期キーで照合する。結果を idMapping.records に記録して次回から使う |
WikiId | タイトルか idMapping.records |
BinaryId | 親レコードと Guid で照合する |
UserId・DeptId・GroupId | 管理者が idMapping の users / depts / groups に書く |
Items.ReferenceId・Binaries.ReferenceId | 同期先のレコード ID に置き換える |
図を読み込み中…
Updatorは同期専用ユーザーの ID に固定し、CreatorはidMapping.usersで変換する(対応が無ければ同期専用ユーザー)。こうすると後述の同期ループも防げる- 分類項目にユーザー・組織・グループの ID や、リンク先レコードの ID が入っている項目は、どの対応表で変換するかを定義に書く。対応が無いときに元の値のまま書くかエラーにするかも設定で選べるようにする
idMapping.recordsは件数に比例して増えるので、起動時にメモリに読み、まとめてファイルに書き戻す。大きくなったら別ファイルに分ける
変更の検出
| 方式 | SQL Server | PostgreSQL | MySQL |
|---|---|---|---|
ポーリング(UpdatedTime の比較) | ○ | ○ | ○ |
| 変更データキャプチャ | CDC | Logical Replication | Binlog |
| トリガー | DML トリガー | NOTIFY / LISTEN | DML トリガー |
ポーリングを基本にし、CDC などが使える環境では併用するのを推奨します。ポーリングはどの DB でも同じロジックで書け、DB 側の設定も要りません(間隔ぶんの遅れと、短い間隔での負荷が欠点)。CDC などはほぼ即時に削除も含めて取れますが、DB ごとに実装が違い、DBA による設定と容量・保持期間の管理が要ります。
-- 変更の取得(PostgreSQL)。同期専用ユーザーの更新は除く
SELECT "ResultId", "SiteId", "Title", "Body", "ClassA", "ClassB", "ClassC", "NumA",
"Ver", "Creator", "Updator", "CreatedTime", "UpdatedTime"
FROM "Results"
WHERE "SiteId" = @SiteId
AND "UpdatedTime" > @LastSyncTime
AND "Updator" <> @SyncUserId
AND "ClassB" = 'approved' -- レコードフィルタ(include)
AND "ClassC" <> 'confidential' -- レコードフィルタ(exclude)
ORDER BY "UpdatedTime" ASC;-- 同期先の更新(PostgreSQL)
UPDATE "Results" SET
"Title" = @Title, "ClassB" = @ClassB, "ClassC" = @ClassC, "NumA" = @NumA, "Body" = @Body,
"Ver" = "Ver" + 1, "Updator" = @SyncUserId, "UpdatedTime" = CURRENT_TIMESTAMP
WHERE "SiteId" = @TargetSiteId AND "ResultId" = @TargetResultId;
UPDATE "Items" SET
"Title" = @Title, "Updator" = @SyncUserId, "UpdatedTime" = CURRENT_TIMESTAMP
WHERE "ReferenceId" = @TargetResultId;更新前の行を _history にコピーしておくと、プリザンターの履歴表示と整合します(必須ではありません)。DB ごとの違いは次のとおりです。
| 項目 | SQL Server | PostgreSQL | MySQL |
|---|---|---|---|
| 識別子の囲み | [Results] | "Results" | `Results` |
| 採番された ID の取得 | OUTPUT INSERTED.ReferenceId | RETURNING "ReferenceId" | LAST_INSERT_ID() |
| 現在時刻 | GETDATE() | CURRENT_TIMESTAMP | NOW() |
各 DB の UPSERT 構文(MERGE、ON CONFLICT、ON DUPLICATE KEY UPDATE)は、Items との整合を取る必要があるため使わず、SELECT してから INSERT / UPDATE に分けます。
削除の同期
同期元の Results_deleted などから削除されたレコードを取り、同期先で対応するレコードを削除します。同期先でも「本体の行を _deleted にコピーしてから本体と Items から消す」というプリザンターの削除の形に合わせます。DELETE だけを実行すると _deleted との整合が崩れます。
競合
| 戦略 | 内容 | 向く場面 |
|---|---|---|
| Source Wins | 同期元(親)を常に優先 | 親子型 |
| Last Write Wins | UpdatedTime が新しいほう | 更新の少ない対等型 |
| 手動解決 | 検出して管理者に知らせる | 正確さが最優先 |
| 項目単位のマージ | 項目ごとに新しいほう | 別の項目を同時に編集する運用 |
図を読み込み中…
同期ループの防止
双方向では、同期で書いた更新が変更として再検出されて往復し続けるおそれがあります。同期専用のユーザーを作り、Updator がそのユーザーの行は変更の検出から外す方法を推奨します(上の SQL の "Updator" <> @SyncUserId)。ほかに、書き込んだレコードを同期アプリ側で覚えておく、同期元の UpdatedTime をそのまま書く、という方法もあります。
Wiki の同期
| 項目 | Results / Issues | Wikis |
|---|---|---|
| 主キー | ResultId / IssueId | WikiId |
| 分類・数値・日付の項目 | あり | なし(タイトルと内容) |
| 同期キー | 分類項目 | タイトル、または ID の対応表 |
| 件数 | 複数 | 通常 1 サイト 1 ページ |
内容の Markdown に /items/12345 のようなリンクがあれば、その ID も変換が要ります。
添付ファイルの同期
添付ファイルを DB に保管している(BinaryStorage.json の Provider が Rds。既定値。BinaryStorage.json)環境どうしに限って同期します。
| 列 | 扱い |
|---|---|
BinaryId | 環境ごとに採番。ID の対応表で管理 |
TenantId | 同期先のテナント ID に置き換え |
ReferenceId | 同期先のレコード ID に置き換え |
Guid | そのまま(照合キーにも使う) |
BinaryType・Title・Bin・Thumbnail・Icon・FileName・Extension・Size・ContentType | そのままコピー |
図を読み込み中…
Guid を保つので、内容や添付ファイル項目の JSON に入っている Guid の参照は変換しなくて済みます。大きなファイルは DB と回線の負荷になるので、件数を小さく区切って送ります。
同期アプリの構成
| 部品 | 役割 |
|---|---|
| 設定の読み込み | JSON の同期定義 |
| 変更の検出 | 各環境の DB をポーリング(または CDC) |
| 同期ルール | 項目・レコード・方向のフィルタ、競合の解決 |
| ID の変換 | 環境間の ID の対応 |
| DB ライター | DB ごとの SQL を作って実行 |
| ログ・監視 | 成功・失敗・競合の記録、接続状態と遅れの監視 |
| 要素 | 候補 |
|---|---|
| 言語 | C#(.NET 8 以降)。本体と同じで型の扱いが近い |
| DB 接続 | Microsoft.Data.SqlClient、Npgsql、MySqlConnector |
| CDC | SQL Server は SqlClient のクエリ、PostgreSQL は Npgsql.Replication、MySQL は MySqlCdc |
| ホスト | .NET Generic Host(Windows サービス、systemd、コンテナ) |
| 実行環境 | 形態 |
|---|---|
| Azure(新規) | Azure Container Apps でコンテナとして常駐 |
| Azure(既存の App Service) | 常駐型の WebJob |
| オンプレミス Windows | Windows サービス |
| オンプレミス Linux | systemd サービス |
| 環境を問わない | Docker コンテナ |
Azure Functions のタイマートリガーは実行時間の制限があり、常駐型の同期には向きません。ヘルスチェック用の口を用意し、停止要求(SIGTERM・サービス停止)で処理中のバッチを終えてから止まるようにします。
初回同期
新しい環境を加えたときは全件を送ります。1 つのトランザクションで全件を扱うとロック・メモリ・タイムアウトの問題が出るので、分割します。
| 項目 | 目安 |
|---|---|
| 1 バッチの件数 | レコード 100〜1,000 件、添付ファイル 10〜50 件 |
| トランザクション | バッチごとに COMMIT。失敗したらそのバッチだけやり直す |
| バッチの間隔 | 100 ミリ秒〜1 秒 |
| 並列度 | 1(ロックの競合を避ける) |
OFFSET は後ろほど遅くなるので、前のバッチの最後の主キーを条件にするキーセット方式で取ります。
SELECT * FROM "Results"
WHERE "SiteId" = @SiteId AND "ResultId" > @LastResultId
ORDER BY "ResultId" ASC
LIMIT @BatchSize;中断しても続きから再開できるよう、同期アプリ用の DB に進み具合を記録します。
図を読み込み中…
| 列 | 内容 |
|---|---|
SyncId・TargetInstanceId・TableName | 主キー |
Phase | record_sync / items_sync / binaries_sync / verification |
LastProcessedId | 最後に処理した主キー |
ProcessedCount・TotalCount | 進み具合 |
Status | running / paused / completed / failed |
同じレコードが 2 回処理されても壊れないよう、同期キーで存在を確かめてから INSERT / UPDATE し、添付ファイルは Guid で照合します(2 回目は Ver が 1 つ進むだけ)。
運用の注意
- サイトの定義(項目やビューの設定)は同期しない。各環境のサイト設定は事前に手で揃えておく
- 同期アプリの DB ユーザーには、同期対象のテーブルへの必要な権限だけを付ける。DB への接続は TLS にする
- ログに接続文字列やレコードの内容を出さない