※本記事はプロモーションを含みます
目次
Googleフォームの回答送信トリガーやWeb APIとしての公開、あるいは短時間で連続稼働する時間主導型トリガーなど、GASが同時に複数走る状況では、スプレッドシートのデータ破損が頻繁に起きます。普段は1行ずつ綺麗に追記されているように見えても、複数の実行インスタンスがほぼ同じ瞬間に立ち上がると、お互いに同じ最終行番号を読み取ってしまいます。なので、後から書き込んだ方のデータが先に入力されたデータを消し去る現象が発生します。ただし、こうしたデータ競合を防ぎ、シートの整合性を保つための唯一の標準機能が排他ロックを行う仕組みです。
排他制御を行う基本構造と即座に組み込むコード
同時実行によるデータ破壊を防ぐための基本は、処理の前に鍵を取得し、すべてのデータ読み書きが終わった後に鍵を返却する流れを作ることです。というのは、これをGASで実現するために用意されているのがLockServiceだからです。まずは、実際に動作するスクリプト全体の骨組みから示します。
function processExclusiveData() { // 10秒間ロックの取得を待機する if (lock.tryLock(10000)) { try { const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); const lastRow = sheet.getLastRow(); // 排他制御が必要なクリティカル処理 // ここで採番処理やデータの末尾追加を行う sheet.getRange(lastRow + 1, 1).setValue(new Date()); sheet.getRange(lastRow + 1, 2).setValue("処理完了"); // キャッシュを強制的に即時反映させる SpreadsheetApp.flush(); } catch (e) { console.error("処理中にエラーが発生しました: " + e.toString()); } finally { // どのような終了の仕方をしても確実にロックを解放する lock.releaseLock(); } } else { // 10秒以内にロックを取得できなかった場合の迂回処理 console.warn("他のプロセスが実行中のため、ロックを取得できませんでした。"); // 必要に応じてリトライを予約するか、エラーログを記録する } }
このコードをいつ使うかというと、Googleフォームからの連投処理、スプレッドシートのカスタム関数以外のWeb Apps(doPost/doGet)経由の書き込み、連番の自動採番ロジック、在庫数や残席数といった数値の増減を計算してシートへ書き戻す場面です。ただし、1人のユーザーが手動で画面上のボタンを押す運用であっても、ダブルクリックによってミリ秒単位で2重起動することがあります。なので、更新処理全般で確認しましょう。
実務でこの構文を使う際にずれる点は、ロックを取得しただけで安心してしまい、スプレッドシート側の書き込み反映遅延を考慮から外してしまうことです。GASは裏側でセルの更新処理をバッファリングしており、スクリプトが終了するか、特定の間隔が空くまで実際のシートに書き込みを確定させません。なので、ロックを解除した瞬間に次のプロセスが動き出し、まだ古い状態のシートを読み取ってしまうというズレが発生します。ただし、この問題を回避するために、コード例にあるように必ずSpreadsheetApp.flush()を明示的に呼び出して変更を物理的に確定させます。というのは、その上でfinallyブロックでlock.releaseLock()を呼ぶ順序を守る必要があるからです。
フォーム送信やトリガー重複でスプレッドシートが破壊される現場
GASでスプレッドシートを操作している際、タイトルの状況で何が起きるかというと、典型的な競合状態(Race Condition)の発生です。オフィス内で複数のメンバーが一斉に共有フォームからアンケートや日報を送信した瞬間や、外部Webhookから短時間に連続してリクエストが飛んできた瞬間に事故は起こります。
具体的な挙動を追ってみましょう。インスタンスAとインスタンスBが、わずか数ミリ秒の差で起動したとします。インスタンスAがsheet.getLastRow()を実行して「現在の最終行は100行目である」と取得します。ただし、その直後、インスタンスAがシートに101行目のデータを書き込むよりも前に、インスタンスBもsheet.getLastRow()を実行します。なので、この時点でスプレッドシート側のデータはまだ書き換わっておらず、インスタンスBも「最終行は100行目である」と判断します。
この結果、インスタンスAが101行目に自分のデータを書き込み、その直後にインスタンスBも「100行目の次だから101行目だ」と信じ込んで同じ101行目にデータを上書きします。インスタンスAの書き込んだ内容は完全に消失し、エラーログすら残りません。というのは、ログ上は両方の実行が「正常終了」と表示されるからです。なので、現場の担当者は「確かに送信したはずなのに、シートからデータが消えた」と頭を抱えることになります。
さらに深刻なのが、管理番号や請求書番号などの連番生成です。シートの特定セルにある最新の番号を読み取り、それに1を足してセルを更新するような処理を行っている場合、同時実行されると両方のプロセスが「前回の番号は1000だったから、今回は1001を付与しよう」と判断します。なので、同じ番号を持った異なるレコードが2件生成されてしまいます。ただし、業務システムにおいて主キーが重複することはデータの整合性を完全に破壊するため、このような事故を防ぐ排他ロックの導入は省略できません。確認しましょう。
waitLockとtryLockの使い分けと待機時間の設計
LockServiceを利用する際、ツールとしてのメソッドにはwaitLock()とtryLock()の2つが存在します。どちらを採用するか、そして待機ミリ秒数をいくつに設定するかによって、システム全体の耐障害性が大きく変わります。
waitLock(timeoutInMillis)は、指定したミリ秒数のあいだロックの解放を待ち続け、制限時間を超えてもロックが取れなかった場合にはエラーをスローしてスクリプトを異常終了させます。ただし、一方のtryLock(timeoutInMillis)は、同じように指定時間待ちますが、タイムアウトした場合はエラーを投げるのではなく単にfalseを返します。なので、スクリプトを途中でクラッシュさせず、エラー通知メールの乱発を防ぎながら、失敗時の分岐を綺麗に書きたい場合はtryLockを使いましょう。
待機時間の設定は、業務の性質に応じて慎重に決める必要があります。クリティカルセクション(ロック内部の処理)が普段2秒で終わるとしても、待機時間を2000ミリ秒に設定するのは危険です。というのは、スプレッドシートのアクセス速度はGoogleのサーバー負荷によって波があり、普段は1秒の処理が一時的に4秒かかることも日常茶飯事だからです。なので、待機時間が短すぎると、後続の処理が鍵の解放を待ちきれずに次々と脱落してしまいます。
一般的には、5000ミリ秒から10000ミリ秒(5〜10秒)程度を待機時間として設定することが多いです。ただし、後続の処理が待たされる時間の上限はGASの実行時間制限(通常のアカウントで6分)に直結します。なので、あまりに長い待機時間を設定し、さらに同時実行が10件も連なると、キューの後ろに並んだプロセスはロックを待っているだけで実行時間制限を迎えて強制終了してしまうリスクがあります。というのは、ロック内部の処理そのものを可能な限り軽量化し、最小限の時間で鍵を手放す構造に設計することが求められるからです。
finallyブロックを徹底しないと発生するデッドロックの恐怖
排他制御を実装する上で、プログラミング言語を問わず最も致命的なバグがデッドロックです。誰かが部屋の鍵をかけたまま鍵を失くしてしまい、以降誰もその部屋に入れなくなる状態を指します。ただし、GASのLockServiceにおいてこれを引き起こす最大の原因は、処理途中の例外発生とfinallyブロックの書き忘れです。
ロックを取得したあとの処理で、計算エラーや存在しないシートへのアクセス、外部APIの通信切断など、予期せぬ例外が発生したとします。もしtry…finallyで囲んでいなかった場合、スクリプトはその場で実行を停止し、後続に書いてあったlock.releaseLock()まで到達しません。なので、Googleのインフラ側で内部的なタイムアウトが働いて自動的にロックが破棄されるまでの間、後続のあらゆる実行がすべてブロックされ続けます。
GASの仕様上、プロセスが異常終了した場合でも一定時間が経過すればロックは自動解放される救済措置が存在します。ただし、その数分間の間にやってきた他のトリガーやユーザーリクエストはすべてロック取得失敗のエラーとなって跳ね返されます。というのは、数分間の業務停止は、連続したデータ入力を前提とする現場では致命的だからです。
そのため、lock.tryLock()が成功した直後には、何があっても直ちにtryブロックを開始し、何が起きても最終的に通過するfinallyブロックの中でlock.releaseLock()を呼び出す記述を徹底しましょう。catch節でエラーを握りつぶすか再スローするかに関わらず、finallyは必ず動きます。なので、この記述を習慣化することが、スクリプトの停止事故を根本から排除するための必須条件です。
LockServiceのスコープ選択とスプレッドシート特有の落とし穴
LockServiceには3つのスコープが用意されています。用途を取り違えると、本来防ぎたかった同時実行を全く制御できていないという事態に陥ります。確認しましょう。
用意されているのは、getScriptLock()、getDocumentLock()、getUserLock()の3種類です。getScriptLock()は、そのスクリプトを実行しているすべてのユーザー・すべてのトリガーを対象に、プロジェクト全体で単一のロックを共有します。なので、Web APIやGoogleフォームの送信トリガーなど、誰が実行しても同じシートの同じ領域を触る可能性がある処理では、このスクリプトロックを選択するのが正解です。
一方のgetDocumentLock()は、コンテナバインドスクリプト(スプレッドシートやドキュメントに紐づいたGAS)でのみ機能し、そのファイル単位でロックをかけます。複数の異なるスクリプトが同一のコンテナファイル内で動いている場合に有効です。ただし、基本的にはgetScriptLock()を選んでおけば大半の競合制御はカバーできます。最も注意が必要なのがgetUserLock()で、これは「実行しているユーザー単位」でロックをかけます。というのは、ユーザーAとユーザーBが同時に実行した場合、ロックは分離されているため両者とも同時にロックを取得できてしまい、競合状態をまったく防ぐことができないからです。なので、ユーザーごとの個別設定を保存するような極めて限定的な用途以外では、同時実行の防止目的でgetUserLock()を使ってはいけません。
そしてもう一つ、実務で極めて見落とされやすい落とし穴が「排他ロックの範囲を広げすぎること」です。シート全体を保護したいからといって、データの取得から外部へのメール送信、Slack通知、重たい集計計算までをすべてロックの内部に入れ込んでしまうケースが見受けられます。なので、クリティカルセクションが長くなればなるほど、次の実行がロック待ちで滞留し、システム全体の処理能力が著しく低下します。
ロックをかける範囲は、競合が発生する「データの読み取り」から「計算結果の書き込み」、そしてSpreadsheetApp.flush()による反映完了までの最小限の区間に絞り込みましょう。事前に外部からフェッチできるデータはロック取得前に取得しておきます。なので、シートへの最終書き込みの直前で鍵を取り、書き終わってフラッシュしたら即座に鍵を返します。ただし、この意識を持つことで、GASの同時実行制御は驚くほど安定し、業務データが静かに消えていく恐怖から解放されます。

