ServiceNowは、企業の重要な業務プロセスを管理するためのアプリケーションを構築できるプラットフォームです。
通常のWebアプリケーションと同様に、アプリケーションによって操作されるデータは常に整合性が保たれていると考えている方も多いのではないでしょうか。
しかし実際の開発現場では、「なぜか計算が合わない」「更新したはずのデータが消えている」 といった不可解な現象に遭遇することがあります。
その原因の多くは、ServiceNowのバックエンドデータベース(MariaDB/InnoDB)が持つ ACID特性 と、ServiceNow独自のアプリケーション仕様の「ギャップ」にあります。本記事では、一見難解なデータベース理論を、実証スクリプトと図解を用いて分かりやすく解説します。
ACID特性とは
| 特性 | 定義と役割 |
|---|---|
| Atomicity(原子性) | 「All or Nothing」。トランザクション内の処理がすべて成功するか、一つも実行されないかを保証する |
| Consistency(一貫性) | トランザクション前後でデータベースが定義したルール(制約)を満たすことを保証する |
| Isolation(独立性) | 複数のトランザクションが同時実行されても互いに干渉せず、順番に実行されたように振る舞うことを保証する |
| Durability(永続性) | 一度コミットしたトランザクションの結果は、障害が発生しても失われないことを保証する |
【実証】原子性(Atomicity)
データベースにおける「原子性」とは、一連の処理が 「すべて実行されるか(All)、一つも実行されないか(Nothing)」 のどちらかであることを保証する性質です。
しかし、ServiceNowの標準的な GlideRecord 操作においては、この「All or Nothing」が直感に反する挙動を示すことがあります。
以下のスクリプトでは、1件目のレコード挿入には成功させ、その直後の2件目で意図的にエラーを発生させています。
(function() {
var tableName = 'u_record_test';
var testTag = "ATOMIC_TEST_" + new Date().getTime();
gs.info("--- [Atomicity Verification] 開始 ---");
// 1. トランザクション内での1件目の処理
var gr1 = new GlideRecord(tableName);
gr1.initialize();
gr1.u_name = "Record 1 (Should be rolled back)";
gr1.u_test_tag = testTag;
var id1 = gr1.insert();
if (id1) {
gs.info("[Step 1] 1件目の挿入に成功しました。SysID: " + id1);
gs.info("[Step 1] 現時点ではDBに書き込まれたように見えますが、まだ確定(Commit)していません。");
}
// 2. 意図的なエラーの発生
gs.info("[Step 2] 意図的にエラーを発生させて、トランザクションを中断します...");
var gr2 = new GlideRecord(tableName);
gr2.initialize();
gr2.u_name = "";
gr2.setAbortAction(true); // 「Nothing」を引き起こすトリガー
var id2 = gr2.insert();
// 3. 最終判定:1件目が消えているか確認
gs.info("[Step 3] 最終確認を行います...");
var checkGr = new GlideRecord(tableName);
checkGr.addQuery('u_test_tag', testTag);
checkGr.query();
if (!checkGr.next()) {
gs.info("【判定】原子性(Atomicity)が担保されています!");
} else {
gs.error("【異常】原子性が守られていません。1件目のデータが残ってしまっています。");
}
})();
実行ログ:
*** Script: --- [Atomicity Verification] 開始 ---
*** Script: [Step 1] 1件目の挿入に成功しました。SysID: c717fb333bff3210c09570e0c5e45aa6
*** Script: [Step 1] 現時点ではDBに書き込まれたように見えますが、まだ確定(Commit)していません。
*** Script: [Step 2] 意図的にエラーを発生させて、トランザクションを中断します...
*** Script: [Step 3] 最終確認を行います...
*** Script: 【異常】原子性が守られていません。1件目のデータが残ってしまっています。
ServiceNowではトランザクション途中でエラーが発生してもロールバックは行われず、すでにInsertされたレコードはDBに残り続けます。
実務上のリスク:親レコードのステータスを更新したものの、子レコードが中途半端なステータスで残ってしまい、データの不整合が発生する可能性があります。
標準SQLトランザクション分離レベル
MySQLはデフォルトで リピータブルリード を採用しています。PostgreSQLは リードコミッティド です。
| 分離レベル | ダーティリード | 反復不能読み取り | ファントムリード |
|---|---|---|---|
| リードアンコミッティド | ❌ 発生 | ❌ 発生 | ❌ 発生 |
| リードコミッティド | ✅ 安全 | ❌ 発生 | ❌ 発生 |
| リピータブルリード | ✅ 安全 | ✅ 安全 | ❌ 発生 |
| シリアライザブル | ✅ 安全 | ✅ 安全 | ✅ 安全 |
ServiceNowではどのようになっているか検証してみました。
【実証】一貫性(Consistency)
データベースにおける「一貫性」とは、あらかじめ定義されたルール(制約)に違反するようなデータ更新を決して許さない性質のことです。
しかし、ServiceNow開発においては、「UI上では防げているはずの不正データが、スクリプト経由だと簡単に作れてしまう」 という落とし穴があります。
ServiceNowのテーブル定義(Dictionary)で、あるフィールドを「必須(Mandatory)」に設定したとします。通常、フォーム画面から保存しようとするとエラーになり、一貫性が守られます。
しかし、スクリプトからバックグラウンドでデータを作成すると、必須項目が入っていないにも関わらずデータが作成されてしまいます。
(function() {
var tableName = 'u_record_test';
var fieldName = 'u_mandatory_test'; // 必須設定にしたフィールド名
gs.info("--- [Consistency Verification] 検証開始 ---");
// 敢えて必須項目を空にして初期化
var gr = new GlideRecord(tableName);
gr.initialize();
gr.u_description = "Consistency Violation Test";
// gr[u_mandatory_test] = ""; // ここを敢えてセットしない
var newSysId = gr.insert();
})();
テーブル定義として必須設定した項目が空のレコードが作成されてしまいます。
*** Script: --- [Consistency Verification] 検証開始 ---
*** Script: ターゲットテーブル: u_record_test
*** Script: 必須設定フィールド: u_mandatory_test
Background message, type:error, message: Data Policy Exception:
The following fields are read only: description
対策: データポリシーを適切に設定することで、スクリプトから不正なデータが作成されることを防ぐことができます。Data PolicyとBusiness Ruleの重層的な配置が不可欠です。
【実証】独立性(Isolation):ノンリピータブルリード
ノンリピータブルリードとは、1つのトランザクションの中で、Aさんがレコードを読み取った後、Bさんがその値を書き換えてコミットし、再びAさんが同じレコードを読み取ると、「さっきと値が違う!」 という現象が起きることです。
まず、Session Aがレコードを読み込みます。この時点での値は "hoge" です。スクリプトはあえて15秒間の「スリープ」に入り、データベースの挙動を確認します。
Session A:
(function() {
var recordSysId = '9ec5a37f3b3f3210c09570e0c5e45aa7'; // メモしたSysIDに書き換えてください
var tableName = 'u_record_test';
gs.info("--- [Session A] 検証開始 ---");
var gr1 = new GlideRecord(tableName);
if (gr1.get(recordSysId)) {
gs.info("[Session A] 1回目のRead結果 (Name): " + gr1.u_name); // 期待値: hoge
}
gs.info("[Session A] 15秒間スリープします。この間にSession Bで値を 'huga' に更新してください...");
gs.sleep(15000);
var gr2 = new GlideRecord(tableName);
if (gr2.get(recordSysId)) {
var secondReadName = gr2.u_name;
gs.info("[Session A] 2回目のRead結果 (Name): " + secondReadName);
if (secondReadName == 'hoge') {
gs.info("[Session A] 結果: 値は変わっていません。ノンリピータブルリードは『発生していません』。");
} else {
gs.warn("[Session A] 結果: 値が 'huga' に変わりました!ノンリピータブルリードが発生しています。");
}
}
gs.info("--- [Session A] 検証終了 ---");
})();
Session B(Session Aのスリープ中に実行):
(function() {
var recordSysId = '9ec5a37f3b3f3210c09570e0c5e45aa7'; // Aと同じSysID
var tableName = 'u_record_test';
gs.info("--- [Session B] 値を 'huga' に更新します ---");
var gr = new GlideRecord(tableName);
if (gr.get(recordSysId)) {
gr.u_name = "huga";
var success = gr.update();
if (success) {
gs.info("[Session B] 更新成功!値を 'huga' にコミットしました。");
}
}
})();
実行ログ:
*** Script: --- [Session A] 検証開始 ---
*** Script: [Session A] 1回目のRead結果 (Name): hoge
*** Script: [Session A] 15秒間スリープします。この間にSession Bで値を 'huga' に更新してください...
*** Script: [Session A] 2回目のRead結果 (Name): huga
*** Script: [Session A] 結果: 値が 'huga' に変わりました!ノンリピータブルリードが発生しています。
*** Script: --- [Session A] 検証終了 ---
読み取った値が変わっており、ノンリピータブルリードが発生していることが確認できました。
【実証】独立性(Isolation):ファントムリード
ファントムリードとは、1つのトランザクション内で、特定の条件を2回検索した際、1回目には存在しなかった「新しいレコード」が2回目に出現する 現象です。
今回の実験では、u_name が Phantom で始まるレコードが 0件 の状態からスタートします。Session Aが「0件であること」を確認してスリープしている間に、Session Bが「幽霊」を1件挿入します。
Session A:
(function() {
var tableName = 'u_record_test';
var queryString = 'u_nameSTARTSWITHPhantom';
gs.info("--- [Session A] ファントムリード検証開始 ---");
var gr1 = new GlideRecord(tableName);
gr1.addEncodedQuery(queryString);
gr1.query();
var count1 = gr1.getRowCount();
gs.info("[Session A] 1回目の検索結果: " + count1 + " 件");
gs.info("[Session A] 15秒間待機... この間にSession Bで新しいレコードをInsertしてください。");
gs.sleep(15000);
var gr2 = new GlideRecord(tableName);
gr2.addEncodedQuery(queryString);
gr2.query();
var count2 = gr2.getRowCount();
gs.info("[Session A] 2回目の検索結果: " + count2 + " 件");
if (count1 === count2) {
gs.info("[Session A] 判定: 件数に変化なし。ファントムリードは阻止されました。");
} else {
gs.warn("[Session A] 判定: 件数が増加しました!ファントムリードが発生しています。");
}
gs.info("--- [Session A] 検証終了 ---");
})();
Session B(Session Aのスリープ中に実行):
(function() {
var tableName = 'u_record_test';
gs.info("--- [Session B] 幽霊レコードの挿入 ---");
var gr = new GlideRecord(tableName);
gr.initialize();
gr.u_name = "Phantom_New_Record_" + new Date().getTime();
var newSysId = gr.insert();
if (newSysId) {
gs.info("[Session B] 挿入完了。DBにコミットされました。SysID: " + newSysId);
}
gs.info("--- [Session B] 終了 ---");
})();
実行ログ:
*** Script: --- [Session A] ファントムリード検証開始 ---
*** Script: [Session A] 1回目の検索結果: 0 件
*** Script: [Session A] 15秒間待機... この間にSession Bで新しいレコードをInsertしてください。
*** Script: [Session A] 2回目の検索結果: 1 件
*** Script: [Session A] 判定: 件数が増加しました!ファントムリードが発生しています。
*** Script: --- [Session A] 検証終了 ---
待機中にSession BがInsertしたレコードが取得されており、ファントムリードが発生していることが確認できました。
【実証】独立性(Isolation):ダーティーリード
ダーティーリードとは、あるトランザクションが更新中の、まだ確定(コミット)されていない「書き換えられたばかりの生データ」 を、別のトランザクションが読み取ってしまう現象です。
ダーティーリードが発生すると、コミット前の未確定データを参照して処理を行うため、データの不整合が発生する危険性があります。
実際にスクリプトで検証しましたが、このようなダーティーリードは発生しませんでした。
Session A:
(function() {
var recordSysId = '9ec5a37f3b3f3210c09570e0c5e45aa7';
gs.info("--- [Session A] ダーティーリード検証開始 ---");
var gr1 = new GlideRecord('u_record_test');
if (gr1.get(recordSysId)) {
gs.info("[Session A] 初期値: " + gr1.u_name); // 期待値: hoge
}
gs.info("[Session A] 15秒待機。この間にSession Bで『update()せずに』値を書き換えます...");
gs.sleep(15000);
var gr2 = new GlideRecord('u_record_test');
if (gr2.get(recordSysId)) {
gs.info("[Session A] 待機後の値: " + gr2.u_name);
}
gs.info("--- [Session A] 検証終了 ---");
})();
Session B(update()を呼ばずにメモリ上でのみ値を変更):
(function() {
var recordSysId = '9ec5a37f3b3f3210c09570e0c5e45aa7';
var gr = new GlideRecord('u_record_test');
if (gr.get(recordSysId)) {
gr.u_name = "DIRTY_DATA";
gs.info("[Session B] 値をメモリ上で 'DIRTY_DATA' に変更しました。まだコミットしていません。");
gs.sleep(20000); // この間、DB上では未確定
// gr.update(); // コメントアウト — 意図的にコミットしない
gs.info("[Session B] 終了(コミットせずに終了)");
}
})();
実行ログ:
*** Script: --- [Session A] ダーティーリード検証開始 ---
*** Script: [Session A] 初期値: hoge
*** Script: [Session A] 15秒待機。この間にSession Bで『update()せずに』値を書き換えます...
*** Script: [Session A] 待機後の値: hoge
*** Script: --- [Session A] 検証終了 ---
ログからは更新前の値である「hoge」を参照できており、ダーティーリードは発生していません。この点についてはServiceNowのプラットフォームが適切に保護していることが確認できました。
検証結果まとめ
| 特性 | 検証結果 | 実務上の挙動・注意点 | 対応策(ベストプラクティス) |
|---|---|---|---|
| Atomicity(原子性) | ❌ 担保されない | 複数レコードの更新中にエラーが発生しても、既に更新済みのレコードはロールバックされない | ロールバックされない前提で、エラーハンドリングを含めた設計を行う |
| Consistency(一貫性) | ❌ 担保されない | スクリプト経由のInsertでは、DictionaryのMandatoryチェックを容易に突破可能 | Business RuleやData Policyを重層的に配置し、論理的な一貫性を守る |
| Isolation(独立性) | ⚠️ 不完全 | ファントムリードやノンリピータブルリードが発生することを確認済み | 楽観的ロックや、イベントキューによる処理の直列化を実装する |
| Durability(永続性) | ✅ PFで担保 | コミットされたデータは冗長化ストレージとバックアップにより、障害時も保護される | プラットフォーム標準機能のため、開発者が意識する必要はない |
おわりに
ServiceNowは非常に自由度の高いプラットフォームです。
ただし、簡単にアプリケーションを構築できる分、考慮しなければいけないことや許容せざるを得ない制約が存在することを忘れてはいけません。
私たちServiceNow開発者は常にこれらの制約と向き合い、業務特性やデータの重要度など様々なことを考慮した上で設計をしていく必要があります。
この記事が、ServiceNowによる思わぬデータ不整合を防ぐ助けになれば幸いです。
この記事はNowmon Blogにも掲載しています。
https://now-mon.com/servicenow-database-acid/