ServiceNowは、企業の重要な業務プロセスを管理するためのアプリケーションを構築することができるプラットフォームです。
そのため、通常のWebアプリケーションと同様に、アプリケーションによって操作されるデータは常に整合性が保たれていると考えている方も多いのではないでしょうか。
しかし、実際の開発現場では、「なぜか計算が合わない」「更新したはずのデータが消えている」といった不可解な現象に遭遇することがあります。
その原因の多くは、ServiceNowのバックエンドデータベース(MariaDB/InnoDB)が持つACID特性と、ServiceNow独自のアプリケーション仕様の「ギャップ」にあります。本記事では、一見難解なデータベース理論を、実証スクリプトと図解を用いて分かりやすく解説します。
| 特性 (ACID) | 定義と役割 |
|---|---|
| Atomicity (原子性) | 「All or Nothing」。トランザクションに含まれる一連の処理が、すべて実行されるか、あるいは一つも実行されない(白紙に戻る)かのどちらかであることを保証します。 |
| Consistency (一貫性) | トランザクションの前後で、データベースが定義したルール(一貫性制約)を満たしていることを保証します。矛盾したデータが作られるのを防ぐ役割があります。 |
| Isolation (独立性) | 複数のトランザクションが同時に実行されても、互いに干渉せず、あたかも順番に一つずつ実行されたかのように振る舞うことを保証します。 |
| Durability (永続性) | 一度完了(コミット)したトランザクションの結果は、その後システム障害などが発生しても失われず、永続的に保存されることを保証します。 |
【実証】原子性(Atomicity)
データベースにおける「原子性(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. 意図的なエラーの発生(2件目で失敗させる)
gs.info("[Step 2] 意図的にエラーを発生させて、トランザクションを中断します...");
// setAbortAction(true) を使うことで、このトランザクション全体を「なかったこと」にします
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("======= 検証結果 =======");
gs.info("【判定】原子性(Atomicity)が担保されています!");
gs.info("理由: 2件目の失敗により、成功していたはずの1件目も自動的に削除(ロールバック)されました。");
gs.info("========================");
} else {
gs.error("======= 検証結果 =======");
gs.error("【異常】原子性が守られていません。1件目のデータが残ってしまっています。");
gs.error("========================");
}
})();
以下のログからServiceNowではトランザクション途中の処理が失敗しても、ロールバックは行われず、すでにInsertされたレコードはDBに残っていることが分かります。
*** Script: --- [Atomicity Verification] 開始 ---
*** Script: [Step 1] 1件目の挿入に成功しました。SysID: c717fb333bff3210c09570e0c5e45aa6
*** Script: [Step 1] 現時点ではDBに書き込まれたように見えますが、まだ確定(Commit)していません。
*** Script: [Step 2] 意図的にエラーを発生させて、トランザクションを中断します...
*** Script: [Step 3] 最終確認を行います...
*** Script: ======= 検証結果 =======: no thrown error
*** Script: 【異常】原子性が守られていません。1件目のデータが残ってしまっています。: no thrown error
*** Script: ========================: no thrown error
考えられる実務上のリスクとして、親レコードのステータスを更新したものの、子レコードが中途半端なステータスで残ってしまい、データの不整合が発生する可能性があります。
標準SQLトランザクション分離レベル
MySQLはデフォルトでリピータブルリードを採用しています。
また、Postgresはリードコミッティドです。
| 分離レベル | ダーティリード | 反復不能読み取り | ファントムリード |
|---|---|---|---|
| リードアンコミッティド | 可能性あり | 可能性あり | 可能性あり |
| リードコミッティド | 安全 | 可能性あり | 可能性あり |
| リピータブルリード | 安全 | 安全 | 可能性あり |
| シリアライザブル | 安全 | 安全 | 安全 |
ServiceNowではどのようになっているか検証してみました。
【実証】一貫性(Consistency)
データベースにおける「一貫性(Consistency)」とは、あらかじめ定義されたルール(制約)に違反するようなデータ更新を決して許さない性質のことです。
しかし、ServiceNow開発においては、「UI上では防げているはずの不正データが、スクリプト経由だと簡単に作れてしまう」という落とし穴があります。
ServiceNowのテーブル定義(Dictionary)で、あるフィールドを「必須(Mandatory)」に設定したとします。通常、フォーム画面から保存しようとするとエラーになり、一貫性が守られます。
しかし、スクリプトからバックグラウンドでデータを作成すると、必須項目が入っていないにも関わらずデータが作成されてしまいます。
(function() {
var tableName = 'u_record_test'; // 作成したテストテーブル名
var fieldName = 'u_mandatory_test'; // 必須設定にしたフィールド名
gs.info("--- [Consistency Verification] 検証開始 ---");
gs.info("ターゲットテーブル: " + tableName);
gs.info("必須設定フィールド: " + fieldName);
// 1. 敢えて必須項目を空にして初期化
var gr = new GlideRecord(tableName);
gr.initialize();
gr.u_description = "Consistency Violation Test";
// gr[u_mandatory_test] = ""; // ここを敢えてセットしない
// 2. 挿入実行
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
データポリシーを適切に設定することで、スクリプトから不正なデータが作成されることを防ぐことができます。
【実証】独立性(Isolation):ノンリピータブルリード
ノンリピータブルリードとは1つのトランザクションの中で、Aさんがレコードを読み取った後、Bさんがその値を書き換えてコミットし、再びAさんが同じレコードを読み取ると、「さっきと値が違う!」という現象が起きることです。

まず、Session Aがレコードを読み込みます。この時点での値は “hoge” です。ここでスクリプトは、あえて15秒間の「スリープ」に入り、データベースの挙動を確認します。
(function() {
var recordSysId = '9ec5a37f3b3f3210c09570e0c5e45aa7'; // メモしたSysIDに書き換えてください
var tableName = 'u_record_test';
gs.info("--- [Session A] 検証開始 ---");
// 1回目の読み取り
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);
// 2回目の読み取り(同じトランザクション内)
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] 結果: 値は変わっていません。ノンリピータブルリードは『発生していません』。");
gs.info("[Session A] これがServiceNowのRepeatable Read(スナップショット隔離)の力です。");
} else {
gs.warn("[Session A] 結果: 値が 'huga' に変わりました!ノンリピータブルリードが発生しています。");
}
}
gs.info("--- [Session A] 検証終了 ---");
})();
Session Aが眠っている隙に、別のセッション(Session B)が同じレコードに対して更新をかけます。値は “huga” に書き換えられ、即座にコミット(確定)されました。
(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' にコミットしました。");
gs.info("[Session B] Session Aの画面に戻って、結果を確認してください。");
}
} else {
gs.error("[Session B] レコードが見つかりません。");
}
})();
結果を確認すると、読み取った値が変わっておりノンリピータブルリードが発生していることが分かります。
*** 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つのトランザクション内で、特定の条件(例:ステータスがPendingのレコード)を2回検索した際、1回目には存在しなかった「新しいレコード」が2回目に出現する現象を指します。

今回の実験では、u_name が Phantom で始まるレコードが 0件 の状態からスタートします。Session Aが「0件であること」を確認してスリープしている間に、Session Bが「幽霊」を1件挿入します。
(function() {
var tableName = 'u_record_test';
var queryString = 'u_nameSTARTSWITHPhantom';
gs.info("--- [Session A] ファントムリード検証開始 ---");
// 1回目のクエリ:この瞬間の「検索結果セット」がスナップショットされる
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);
// 2回目のクエリ:同じトランザクション内で再検索
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] 判定: 件数に変化なし。ファントムリードは阻止されました。");
gs.info("[Session A] 原因: REPEATABLE READレベルの『ネクストキーロック』または『MVCC』が機能しています。");
} else {
gs.warn("[Session A] 判定: 件数が増加しました!ファントムリードが発生しています。");
}
gs.info("--- [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] 終了 ---");
})();
待機している間にBトランザクションがInsertしたレコードが取得されてしまっていることが分かります。
*** 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] 検証終了 ---
【実証】独立性(Isolation):ダーティーリード
ダーティーリードとはあるトランザクションが更新中の、まだ確定(コミット)されていない「書き換えられたばかりの生データ」を、別のトランザクションが読み取ってしまう現象です。

ダーティーリードが発生すると、コミット前の未確定データを参照して処理を行うため、データの不整合が発生する危険性があります。
実際にスクリプトで、Session Bが update() を完了する前の値を 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] 検証終了 ---");
})();
Bのトランザクションではupdate();を実行せずに、メモリ上で値のセットのみを行っています。
(function() {
var recordSysId = '9ec5a37f3b3f3210c09570e0c5e45aa7';
var gr = new GlideRecord('u_record_test');
if (gr.get(recordSysId)) {
gr.u_name = "DIRTY_DATA";
// ここで update() を呼ぶ直前、または処理中にわざと重い処理をさせる
gs.info("[Session B] 値をメモリ上で 'DIRTY_DATA' に変更しました。まだコミットしていません。");
gs.sleep(20000); // この間、DB上では「書き換え中」だが未確定
// gr.update(); // ここをコメントアウトするか、スリープ後に実行
gs.info("[Session B] 終了(コミットせずに終了、または遅延コミット)");
}
})();
ログからは更新前の値である「hoge」を参照できていることが分かります。
*** Script: --- [Session A] ダーティーリード検証開始 ---
*** Script: [Session A] 初期値: hoge
*** Script: [Session A] 15秒待機。この間にSession Bで『update()せずに』値を書き換えます...
*** Script: [Session A] 待機後の値: hoge
*** Script: --- [Session A] 検証終了 ---
ServiceNowにおけるACID特性の検証結果まとめ
まず、今回の検証で明らかになったServiceNow(標準のGlideRecord操作)におけるACID特性のサポート状況をまとめました。
| 項目 | 検証結果 | 実務上の挙動・注意点 | 対応策 (ベストプラクティス) |
|---|---|---|---|
| Atomicity (原子性) |
担保されない | 複数レコードの更新中にエラーが発生しても、既に更新済みのレコードはロールバックされません。 | ロールバックされない前提で、エラーハンドリングを含めた設計を行う。 |
| Consistency (一貫性) |
担保されない | スクリプト経由のインサートでは、Dictionaryの必須チェック(Mandatory)を容易に突破可能です。 | Business RuleやData Policyを重層的に配置し、論理的な一貫性を守る。 |
| Isolation (独立性) |
不完全 | ファントムリードやロストアップデートが実際に発生することを確認済み。 | 楽観的ロックや、イベントキューによる処理の直列化を実装する。 |
| Durability (永続性) |
PFで担保 | コミットされたデータは冗長化ストレージとバックアップにより、障害時も保護されます。 | プラットフォーム標準機能のため、開発者が意識する必要はありません。 |
ServiceNowは非常に自由度の高いプラットフォームです。
ただし、簡単にアプリケーションを構築できる分、考慮しなければいけないことや許容せざるを得ない制約が存在することを忘れてはいけません。
私たち、ServiceNow開発者は常にServiceNowの制約と向き合い、業務特性やデータの重要度など様々なことを考慮した上で設計をしていく必要があります。
この記事が、ServiceNowによる思わぬ被害を防ぐ助けになれば幸いです。

コメント