GASのLockServiceで
処理の二重実行・競合を防ぐ方法

フォームの連続送信やトリガーの重なりで、同じ行を二重に処理したり採番が飛んだりする「競合」を、LockService(同時実行を防ぐ鍵をかけるGASの標準サービス)で防ぐ方法を、動くコードで解説します。

|対象: GAS / LockService / 排他制御

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開発をご相談いただけます。フォーム連携や帳票出力の自動化も対応します。

GAS Webシステム開発を見る