GASのLockServiceで
処理の二重実行・競合を防ぐ方法
フォームの連続送信やトリガーの重なりで、同じ行を二重に処理したり採番が飛んだりする「競合」を、LockService(同時実行を防ぐ鍵をかけるGASの標準サービス)で防ぐ方法を、動くコードで解説します。
Table of Contents
そもそも競合とは何か
競合(レースコンディション)とは、複数の処理がほぼ同時に同じデータを読み書きして、想定外の結果になる現象です。 GASでも、次のような場面で起こります。
採番が飛ぶ・重複する
2人が同時に伝票番号を採番し、同じ番号を取得したり番号が欠番になる
在庫がマイナスになる
残り1個の商品を2件が同時に引き当て、在庫が-1になってしまう
行の二重追加
フォームの連打やWebhookの再送で、同じ内容が複数行書き込まれる
トリガーの重なり
前回の実行が終わる前に次の時間主導トリガーが発火し、処理が二重に走る
対策の基本は「同時に走らせない」こと。ある処理が資源(シートやプロパティ)を触っている間、ほかの実行を待たせるのが排他ロックです。 GASではLockServiceが標準で用意されています。
LockServiceの基本形(waitLockとfinally)
最小構成はこの形です。waitLock(ミリ秒)で鍵を取り、tryの中で本処理をして、finallyで必ず解放します。解放を忘れると後続が待たされるため、finallyに置くのが鉄則です。
function appendRowSafely(values) {
const lock = LockService.getScriptLock();
// 最大30秒までロック取得を待つ。取れなければ例外になる
lock.waitLock(30000);
try {
const sheet = SpreadsheetApp.getActive().getSheetByName("受付");
sheet.appendRow(values);
SpreadsheetApp.flush(); // 変更を確実に書き込んでから解放する
} finally {
lock.releaseLock(); // 成功・失敗にかかわらず必ず解放
}
}waitLockは指定時間内に鍵が取れなければ例外を投げます。呼び出し側でtry/catchして「混雑しているので後でやり直し」と扱うのが安全です。SpreadsheetApp.flush()は、書き込みを確定させてからロックを解放するために入れています。
tryLockで多重起動を握りつぶす
「同じ処理が動いているなら、今回は何もしなくていい」というケースではtryLock(ミリ秒)が便利です。取れなければ例外ではなくfalseを返すので、そのままreturnしてスキップできます。時間主導トリガーの重なり対策に向いています。
function processOnce() {
const lock = LockService.getScriptLock();
// 5秒だけ待って取れなければ、今回はスキップ(多重起動の握りつぶし)
if (!lock.tryLock(5000)) {
Logger.log("別の実行が処理中のためスキップしました");
return;
}
try {
doHeavyJob();
} finally {
lock.releaseLock();
}
}実践例1:連番を競合なく採番する
伝票番号や会員番号のような連番は、競合が起きやすい典型です。「今の値を読む→+1する→書き戻す」の間に別の実行が割り込むと、同じ番号が2回発行されます。 値の保存にはPropertiesService(設定値を保存するGASのキー・バリュー保管庫)を使い、読み書き全体をロックで囲みます。
// 連番(伝票番号)を競合なく採番する
function issueOrderNumber() {
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const props = PropertiesService.getScriptProperties();
const current = Number(props.getProperty("orderSeq") || "0");
const next = current + 1;
props.setProperty("orderSeq", String(next));
// 例: 2026-000123 のように整形して返す
return "2026-" + String(next).padStart(6, "0");
} finally {
lock.releaseLock();
}
}ロックで囲むことで「読む→書く」がひとかたまりになり、番号の重複や欠番を防げます。
実践例2:在庫をマイナスにしない引き当て
在庫の引き当ても、ロックが無いと危険です。残り1個を2件が同時にチェックすると、両方が「在庫あり」と判断して在庫が-1になります。 「在庫を読む→足りるか判定→引く」までを排他にすれば、この矛盾を防げます。
// 在庫を引き当てる。ロックが無いと在庫がマイナスになりうる
function reserveStock(productId, qty) {
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const sheet = SpreadsheetApp.getActive().getSheetByName("在庫");
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0] === productId) {
const stock = Number(data[i][1]);
if (stock < qty) {
return { ok: false, reason: "在庫不足", stock };
}
sheet.getRange(i + 1, 2).setValue(stock - qty);
SpreadsheetApp.flush();
return { ok: true, remaining: stock - qty };
}
}
return { ok: false, reason: "商品が見つかりません" };
} finally {
lock.releaseLock();
}
}getScriptLockとgetUserLockの使い分け
LockServiceには取得方法が3つあります。目的に合わせて選びます。
getScriptLock:全員で1つの鍵を共有
誰が実行しても同じ1つのロックになります。在庫更新や採番など、全員が同じ資源を奪い合う処理はこれ。共有シートの整合性を守るなら基本はこちらです。
getUserLock:ユーザーごとに別の鍵
実行ユーザー単位でロックが分かれます。「同じ人の二重クリックだけ防ぎたい」ときに使います。別ユーザー同士は並行して動けます。
getDocumentLock:ドキュメント単位の鍵
コンテナ(そのスプレッドシートやドキュメント)に紐づくロックです。バインドされたスクリプトで、対象ファイル単位に排他したいときに使います。
// 同じユーザーの二重送信だけを防ぐ(ユーザーごとに別のロック)
function submitForm(payload) {
const lock = LockService.getUserLock();
if (!lock.tryLock(3000)) {
return { ok: false, message: "送信処理中です。少しお待ちください" };
}
try {
saveSubmission(payload);
return { ok: true };
} finally {
lock.releaseLock();
}
}ロック処理を1か所にまとめる汎用ラッパー
毎回try/finallyを書くのは冗長です。ロック取得〜解放を1つの関数にまとめ、本処理だけを渡す形にすると、解放漏れも防げて読みやすくなります。
// ロック取得〜解放を1か所にまとめる汎用ラッパー
function withScriptLock(timeoutMs, fn) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(timeoutMs)) {
throw new Error("ロックを取得できませんでした(他の処理が実行中)");
}
try {
return fn();
} finally {
lock.releaseLock();
}
}
// 使い方
function example() {
const result = withScriptLock(30000, () => {
return issueOrderNumber();
});
Logger.log(result);
}実務での注意点
1. ロックの中の処理は短く保つ
ロック中はほかの実行が待ちます。API呼び出しや大量の書き込みを丸ごと囲むと待ち行列が伸びます。競合が起きる「読んで書く」部分だけを囲み、重い処理はロックの外に出しましょう。
2. 待ち時間は用途に合わせて調整する
onEditのように高頻度で発火する処理では、長い待ち時間は詰まりの原因です。短めのtryLockで、取れなければスキップする設計にします。逆にバッチ処理では長めに待たせても問題ありません。
3. flush()で書き込みを確定してから解放する
GASの書き込みは遅延することがあります。ロックを離す前にSpreadsheetApp.flush()を呼び、変更を確定させてから解放すると、次の実行が古い値を読む事故を防げます。
4. 実行時間制限とは別物
LockServiceは競合対策であり、6分の実行時間制限は解決しません。長時間処理は分割実行やトリガーで対処し、必要なら両方を組み合わせます。
まとめ
GASの競合は、採番の飛びや在庫のマイナス、行の二重追加といった形で静かに起こります。 対策の基本はLockServiceで「読んで書く」部分を排他にすること。確実に取りたいならwaitLock、混雑時はスキップしたいならtryLockを使い分け、finallyで必ず解放する——この型を押さえれば、共有シートを使った業務システムでも安心して自動化を進められます。
よくある質問
LockServiceは、同じスクリプトの処理が同時に走らないように「鍵(ロック)」をかけるGASの標準サービスです。誰かがロックを取っている間、ほかの実行は待つか諦めます。フォームの連続送信やトリガーの重なりで、同じ行を二重に処理したり採番が飛んだりする競合を防げます。
getScriptLock()はスクリプト全体で1つのロックを共有し、誰が実行しても排他になります。在庫更新や連番の採番など「全員で1つの資源を奪い合う」処理に使います。getUserLock()は実行ユーザーごとに別々のロックになり、同じ人の二重クリックだけ防ぎたいときに向きます。共有シートの整合性を守るなら基本はgetScriptLockです。
tryLock(ミリ秒)は指定時間だけロック取得を試み、取れなければfalseを返して処理を続けられます。waitLock(ミリ秒)は取れなければ例外を投げます。「取れなければスキップしたい」ならtryLockでfalseを分岐、「必ず取れる前提で、取れなければ失敗として扱いたい」ならwaitLockをtry/catchで囲む、という使い分けになります。
処理の途中でエラーが出てreleaseLock()に到達しないと、ロックが残ったように見え、後続の実行が待ち時間いっぱい待たされることがあります。これを防ぐため、ロック取得後の処理はtryで囲み、finallyでreleaseLock()を必ず呼ぶ書き方にします。なお実行が完全に終了すればロックは自動で解放されます。
いいえ。LockServiceは同時実行の競合を防ぐ仕組みで、実行時間の制限とは別物です。6分制限は処理の分割や時間主導トリガーで対処します。両者は目的が違うため、大量データを分割実行しつつ競合も防ぎたい場合は、LockServiceと分割実行を組み合わせて使います。
使えますが注意が必要です。編集のたびに短時間で何度も発火するonEditで長めのwaitLockを使うと、待ち行列が伸びて処理が詰まりやすくなります。onEditではtryLockで短めの待ち時間にし、取れなければ今回はスキップする設計にすると安定します。
GAS開発・業務Webシステムを
相談する。
在庫管理・受発注・予約システムなど、競合対策まで踏まえた堅牢なGAS開発をご相談いただけます。フォーム連携や帳票出力の自動化も対応します。