Skip to content

複数インスタンス間のマスターデータ同期の設計 ​

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

本社と拠点で別々にプリザンターを動かしていて、従業員マスターや商品マスターのようなサイトだけを揃えたい、という要件に対する設計メモです。本体の標準機能ではありません。 プリザンター本体は改修せず、同期用の外部アプリケーションが各環境の DB を直接読み書きする前提です。

DB を直接書き換える前提の設計です

DB への直接書き込みは、プリザンターの入力チェック・権限チェック・通知・サーバースクリプト・履歴の自動作成をすべて通りません。整合性は同期アプリ側で担保する必要があり、プリザンターのバージョンアップでテーブル構造が変わったら同期アプリも追従が要ります。

方針 ​

項目方針
形態プリザンターとは独立した外部アプリケーション
データアクセスDB への直接 SQL(API は使わない)
対応 DBSQL 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)。

  1. Items に INSERT して採番された ID を取る(Title には表示用のタイトル)
  2. その ID を ResultId にして Results に INSERT
  3. リンク項目があれば Links に INSERT
  4. 添付ファイルを更新
  5. サイトの「作成時の権限」設定があれば 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 に共通です。

テーブルインデックス列
ResultsPkSiteId, UpdatedTime DESC, ResultId
ResultsIx1ResultId, SiteId, UpdatedTime DESC
ResultsIx2ResultId
ResultsIx3SiteId, Locked, ResultId
ItemsPkReferenceId
ItemsIx1ReferenceType, ReferenceId
ItemsIx2SiteId, ReferenceId
ItemsIx4SiteId, ReferenceType, UpdatedTime
ItemsIx5SiteId, ReferenceType
BinariesPkBinaryId
BinariesIx1ReferenceId, BinaryId
BinariesIx2Guid, BinaryId

根拠: _BaseItems_SiteId.json、_BaseItems_UpdatedTime.json、Results_ResultId.json、Items_ReferenceId.json、Binaries_Guid.json。

同期の処理条件使えるインデックス
変更の検出SiteId = @s AND UpdatedTime > @tResults の Pk(先頭が SiteId, UpdatedTime)
同期キーでの照合SiteId = @s AND ClassA = @kSiteId だけ。分類項目のインデックスは無い
Items の更新ReferenceId = @idItems の Pk
添付の取得ReferenceId = @idBinaries の Ix1
添付の照合Guid = @gBinaries の Ix2

同期キーの分類項目で照合する件数が多いなら、同期アプリ用に (SiteId, ClassA) のインデックスを追加します。CodeDefiner はインデックスの変化を検出すると作り直すため、再実行時に追加分が消える可能性があります。パラメータ Rds.DisableIndexChangeDetection が有効なら、この検出は行われません(Indexes.cs)。

sql
-- 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 ファイルで持ちます。

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 ServerPostgreSQLMySQL
ポーリング(UpdatedTime の比較)○○○
変更データキャプチャCDCLogical ReplicationBinlog
トリガーDML トリガーNOTIFY / LISTENDML トリガー

ポーリングを基本にし、CDC などが使える環境では併用するのを推奨します。ポーリングはどの DB でも同じロジックで書け、DB 側の設定も要りません(間隔ぶんの遅れと、短い間隔での負荷が欠点)。CDC などはほぼ即時に削除も含めて取れますが、DB ごとに実装が違い、DBA による設定と容量・保持期間の管理が要ります。

sql
-- 変更の取得(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;
sql
-- 同期先の更新(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 ServerPostgreSQLMySQL
識別子の囲み[Results]"Results"`Results`
採番された ID の取得OUTPUT INSERTED.ReferenceIdRETURNING "ReferenceId"LAST_INSERT_ID()
現在時刻GETDATE()CURRENT_TIMESTAMPNOW()

各 DB の UPSERT 構文(MERGE、ON CONFLICT、ON DUPLICATE KEY UPDATE)は、Items との整合を取る必要があるため使わず、SELECT してから INSERT / UPDATE に分けます。

削除の同期 ​

同期元の Results_deleted などから削除されたレコードを取り、同期先で対応するレコードを削除します。同期先でも「本体の行を _deleted にコピーしてから本体と Items から消す」というプリザンターの削除の形に合わせます。DELETE だけを実行すると _deleted との整合が崩れます。

競合 ​

戦略内容向く場面
Source Wins同期元(親)を常に優先親子型
Last Write WinsUpdatedTime が新しいほう更新の少ない対等型
手動解決検出して管理者に知らせる正確さが最優先
項目単位のマージ項目ごとに新しいほう別の項目を同時に編集する運用

図を読み込み中…

同期ループの防止 ​

双方向では、同期で書いた更新が変更として再検出されて往復し続けるおそれがあります。同期専用のユーザーを作り、Updator がそのユーザーの行は変更の検出から外す方法を推奨します(上の SQL の "Updator" <> @SyncUserId)。ほかに、書き込んだレコードを同期アプリ側で覚えておく、同期元の UpdatedTime をそのまま書く、という方法もあります。

Wiki の同期 ​

項目Results / IssuesWikis
主キーResultId / IssueIdWikiId
分類・数値・日付の項目ありなし(タイトルと内容)
同期キー分類項目タイトル、または 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
CDCSQL Server は SqlClient のクエリ、PostgreSQL は Npgsql.Replication、MySQL は MySqlCdc
ホスト.NET Generic Host(Windows サービス、systemd、コンテナ)
実行環境形態
Azure(新規)Azure Container Apps でコンテナとして常駐
Azure(既存の App Service)常駐型の WebJob
オンプレミス WindowsWindows サービス
オンプレミス Linuxsystemd サービス
環境を問わないDocker コンテナ

Azure Functions のタイマートリガーは実行時間の制限があり、常駐型の同期には向きません。ヘルスチェック用の口を用意し、停止要求(SIGTERM・サービス停止)で処理中のバッチを終えてから止まるようにします。

初回同期 ​

新しい環境を加えたときは全件を送ります。1 つのトランザクションで全件を扱うとロック・メモリ・タイムアウトの問題が出るので、分割します。

項目目安
1 バッチの件数レコード 100〜1,000 件、添付ファイル 10〜50 件
トランザクションバッチごとに COMMIT。失敗したらそのバッチだけやり直す
バッチの間隔100 ミリ秒〜1 秒
並列度1(ロックの競合を避ける)

OFFSET は後ろほど遅くなるので、前のバッチの最後の主キーを条件にするキーセット方式で取ります。

sql
SELECT * FROM "Results"
WHERE "SiteId" = @SiteId AND "ResultId" > @LastResultId
ORDER BY "ResultId" ASC
LIMIT @BatchSize;

中断しても続きから再開できるよう、同期アプリ用の DB に進み具合を記録します。

図を読み込み中…

列内容
SyncId・TargetInstanceId・TableName主キー
Phaserecord_sync / items_sync / binaries_sync / verification
LastProcessedId最後に処理した主キー
ProcessedCount・TotalCount進み具合
Statusrunning / paused / completed / failed

同じレコードが 2 回処理されても壊れないよう、同期キーで存在を確かめてから INSERT / UPDATE し、添付ファイルは Guid で照合します(2 回目は Ver が 1 つ進むだけ)。

運用の注意 ​

  • サイトの定義(項目やビューの設定)は同期しない。各環境のサイト設定は事前に手で揃えておく
  • 同期アプリの DB ユーザーには、同期対象のテーブルへの必要な権限だけを付ける。DB への接続は TLS にする
  • ログに接続文字列やレコードの内容を出さない

関連ページ ​

変更履歴

第1版外部連携の改修・設計メモ(iCal・RSS/Atom・Webhook 送受信・iPaaS・短縮 URL・POP 受信・マスターデータ同期)を追加