Apps Scriptの同時実行でシート更新が競合するとき|LockServiceの範囲と解放を確認

Apps Scriptのread-modify-write競合を、LockServiceのscope・取得結果・flush・解放から診断し、対象外の更新経路も確認します。

AIものさし編集部

Apps Scriptで同時実行すると連番が重なったり、シートの更新が一部消えたりする場合、LockServiceを呼んだかだけでなく、何をどの範囲で保護したかを確認します。古い値を読んだ後でlockを取っても、同じ古い値から上書きする競合は残ります。

この記事は、同じApps Script projectの処理が共有シートをread-modify-writeする場合の設計です。LockServiceはsheet全体を人の編集や別APIから保護するDB transactionではありません。以下の表とコードは検証用の架空例で、Apps ScriptやSheetsで同時実行・書込を試した結果ではありません。

読み取りから書き戻しまでを一つにする

架空のカウンターが40のとき、AとBがともに40を読み、それぞれ41を書けば、二回の更新が一回分になります。必要な保護単位は、lock取得、現在値の読取、検算、変更、書込、反映、解放という範囲です。

表は横にスクロールできます

順序の架空例AB結果の意味
lockなし40を読む40を読む同じ旧値を保持
それぞれ書く41を書く41を書く一回分を上書き
読取前に排他40→41を反映して解放その後41→42を反映二回分を順に計算する設計

これは論理上の例で、実際に42になった実測報告ではありません。GoogleのLock公式資料(新しいタブで開く)も、同時の採番で同じ値を読み重複する例を示しています。すべての更新経路が同じ保護規約を守るかを確認します。

Script・User・Documentのscopeを選ぶ

LockServiceの公式資料(新しいタブで開く)では、Script Lockはscript単位、User Lockはscriptとuser単位、Document Lockはscriptと現在のdocument単位の排他です。複数ユーザーが同じカウンターを更新するのにUser Lockだけを使うと、ユーザーをまたぐ排他にはなりません。

表は横にスクロールできます

種類保護の単位判断する条件
Script Lock同じscriptの実行全userで共有する更新か
User Lock同じscript・同じuseruserごとに独立した状態か
Document Lock同じscript・現在documentdocumentの実行contextがあるか

standalone scriptやWebアプリなど、包含documentのcontextがない場合、getDocumentLockはnullを返します。いつもbound scriptで動く前提を、別の実行経路へ持ち込みません。Script Lockも別script projectや手作業のシート編集まで止めるものではありません。

取得できた場合だけ更新する

lock objectを取得しただけでは排他は始まりません。tryLockは指定時間内の取得成否をbooleanで返し、waitLockは取得できなければ例外になります。待機時間はミリ秒です。取得失敗を無視して処理を続けないようにします。

次は検証用のbound scriptで、「検証」シートのB2に非負の整数を置いた場合の説明例です。実運用の採番・会計・在庫へそのまま使う完成実装ではありません。5000は例の待機上限で、推奨値や成功保証ではありません。

function incrementTestCounter() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(5000)) {
    return { status: 'busy', changed: false };
  }
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    if (!ss) throw new Error('Bound spreadsheet required');
    const sheet = ss.getSheetByName('検証');
    if (!sheet) throw new Error('Test sheet required');
    const cell = sheet.getRange('B2');
    const value = cell.getValue();
    if (!Number.isSafeInteger(value) || value < 0 ||
        !Number.isSafeInteger(value + 1)) {
      throw new Error('Safe nonnegative integer required');
    }
    cell.setValue(value + 1);
    SpreadsheetApp.flush();
    return { status: 'updated' };
  } finally {
    lock.releaseLock();
  }
}

取得に失敗したbusyは更新成功とは別の結果です。例外になった場合も、書込前に止まったのか、書込後の反映で失敗したのかを区別します。flushに失敗したから必ず元に戻ったとは言えず、結果不明のまま同じ更新を無条件に再実行しません。

flushと解放を更新の境界に置く

Googleの公式資料は、spreadsheet操作では保留中の変更を反映するため、lock解放前のSpreadsheetApp.flushを案内しています。finallyで解放すれば、例外経路でも不要な保持を避けられます。ただしflushとlockを組み合わせても、複数cellの更新をDB transactionのように一括rollbackできるわけではありません。

外部APIや長い計算は可能ならlock取得前に済ませ、共有状態に関係する読取と更新だけを短く保ちます。ただしlock前に読んだ共有状態から更新値を決めると競合が戻るため、取得後に必要な現在値を取り直します。

表は横にスクロールできます

症状確認する箇所修正の方向
lockがあるのに更新が欠ける読取がlockより前ではないかread-modify-write全体を保護
userが違うと重複User Lockを使っていないか共有状態に合うscope
timeoutが増える保持時間・外部呼出・多重起動保護範囲と流量を整理
外部API更新と競合別writerの経路共通の更新責務や保存方式を設計

検証は同時実行と失敗経路も含める

検証用シートで、逐次二回、同時二回、lock取得失敗、入力不正、書込結果が不明なケースを分けます。期待値、返った状態、更新後の値、未処理の要求を照合し、実行ログが成功でも一回分が失われていないか確認します。

再試行が必要な業務では、要求IDと処理済み状態を使う重複対策を別に設計します。LockServiceは同時実行を直列にしても、同じ要求が後から再送されたことを自動では識別しません。失敗時は原因を修正して検証用の初期状態へ戻し、実データを一括で巻き戻す手順にはしません。

確認した公式資料