メディア掲載: レバテックフリーランス様のサイトで当ブログが紹介されました

GAS: LockServiceで排他制御して同時更新を防ぐ

Google Apps Scriptで作成したツールやアプリを、同時に大人数で使ったり高頻度でスクリプトを実行したりしていると、処理が競合し、意図せずデータが上書きされてしまったり、不整合が起きてしまうことがあります。

Google Apps Scriptでは、LockServiceを使って排他制御を行うことで、この問題を回避することができます。

この記事では、その方法を説明します。
また、LockServiceは、直感的に理解するのがやや難しく、勘違いしやすいポイントもあるため、そういった観点でもまとめてみたいと思います。

排他制御しないと何が起きるのか

例えば、下記のようなコードを複数人で(ほぼ)同時に実行したケースを考えます。

/**
 * 商品が売れた際に合計売上高(A1セル)を更新する
 */
function updateTotalEarnings(earnings) {
  const sheet = SpreadsheetApp.getActiveSheet();    // シートを取得
  const total = sheet.getRange("A1").getValue();    // 現時点での合計売上高を取得
  sheet.getRange("A1").setValue(total + earnings);  // 合計売上高を更新
}

二人の販売員(太郎さん、花子さん)が、商品を販売するたび、お店の合計売上高を更新します。

更新の際は、getValue()でその時点での合計売上高A1セル)を取得し、setValue()取得した合計売上高 + 今回売上A1セルにセットします。

図にするとこんな感じです。

問題が発生していない処理フロー

このケースでは、太郎さんの処理が完了した後に、花子さんの処理が始まっているので、特に問題は起きていません。

次に、太郎さんの処理中に、花子さんの処理が微妙なタイミングで始まってしまったケース考えます。図にするとこんな感じです。

処理が競合し、問題が発生してしまうフロー。最終的なA1セルの値が ¥1,500 になってしまっている。

合計売上高(A1セル)の初期値が¥1,000で、太郎さんが¥300売り上げ、花子さんが¥500売り上げたのに、処理後のA1セルの値は¥1,800ではなく、¥1,500になってしまいました。

これは、太郎さんの売り上げが反映される前の売上合計高(¥1,000)を、花子さんが取得してしまったために発生してしまう不具合です。

今回の例は、同じセルを同時に更新しようとした場合の不具合でしたが、それ以外にも、さまざまなケースで似た問題が発生する可能性があります。(appendRow()での行追加を複数人同時に実行した場合など)

どのようにして防ぐのか

このような問題を回避するには、問題が起きうる処理を同時実行できないようにする必要があります。

先述の例で言えば、太郎さんが処理(getValue()setValue())を行なっている間は、花子さんはそのコードを実行できないようにします。

ロックによる排他制御を行なったフロー

(もちろん、花子さんの他のメンバーも同様ですし、太郎さん自身も、処理が終わるまでは再度処理をできてはいけません)

これを実現するのが、LockServiceです。

LockServiceの使用例

LockServiceを使用して排他制御を行う単純な具体例を、コードで説明します。

/**
 * 商品が売れた際に合計売上高(A1セル)を更新する
 */
function updateTotalEarnings(earnings) {
  const sheet = SpreadsheetApp.getActiveSheet();    // シートを取得
  const lock = LockService.getDocumentLock();       // Lockインスタンスを取得

  // ロックを試みる。既にロックされていたら10秒間トライする
  if (lock.tryLock(10000)) {
    // ロックできた時の処理
    const total = sheet.getRange("A1").getValue();    // 現時点での合計売上高を取得
    sheet.getRange("A1").setValue(total + earnings);  // 合計売上高を更新
    lock.releaseLock();  // ロックを解除
  } else {
    // 10秒間ロックできなかった時の処理
    Logger.log('ロックされていて処理できなかった');
  }
}

上記のコードでは、10-13行目の部分が排他制御(同時に複数実行されない制御)をする部分になります。

9行目lock.tryLock(ミリ秒)は、以下のような意味をもちます。

  • ロックをしようと試みて、成功したらtrueを返します
  • 既にロックされてしまっていた場合は、指定した時間(この例では10000ミリ秒=10秒)を上限として、ロックが解除されるのを待ちます
  • 指定時間中にロックが解除されたら、自分がロックを獲得しtrueを返します
  • ロックが解除されないまま指定時間が過ぎたら、falseを返します

ロックに成功するか、指定時間が経過するまでは待機状態となり、次行以降のコードは実行されません。つまり、この例では、9行目の処理で最大10秒間の待ち時間が発生する可能性があります。

13行目lock.releaseLock()は、ロックの解除です。
これを実行しないと、スクリプトが終了するまでの間、ロックし続けてしまいますので注意が必要です。

ロックの種類(Document, Script, User)

公式ドキュメントにあるとおり、ロックの種類はDocumentLock, ScriptLock, UserLockの3種類があります。

まず大前提として、ロックをかける対象はスクリプトのtryLock・waitLockからreleaseLockまでの間のコードです。
詳細は後で説明しますが、この大前提を知らないと勘違いにつながります。(と思います)

ロックの種類説明
DocumentLockロックをかけた場合、同じドキュメントのスクリプトからはロック部分のコードを同時実行できません
なお、スクリプトをライブラリとして共有し、他のスクリプトから呼び出した場合、ロック部分のコードも同時実行されます

なお、スタンドアロンスクリプトやWebアプリからはDocumentLockは利用できません。
ScriptLockロックをかけた場合、ロック部分のコードを同時実行できません
スクリプトをライブラリとして共有し、他のスクリプトから呼び出した場合でも、ロック部分のコードを同時実行できません
UserLockロックをかけた場合、同じユーザーはロック部分のコードを同時実行できません
ユーザーが異なれば、ロック部分のコードを同時実行できます。

なお、Webアプリとしてデプロイする際、次のユーザーとして実行:を「自分」にしていても、違うユーザーが実行した場合はロック部分のコードを同時実行できました。

ケース別のパターン表を作ってみましたので、よければ参考にしてみてください。

ケースDocument
Lock
Script
Lock
User
Lock
AさんファイルAでボタンを連打してしまい、
同時に処理を実行した
ロック有効ロック有効ロック有効
AさんファイルAファイルBでボタンを同時に押し、
同時に処理を実行した
同時実行ロック有効ロック有効
AさんBさんファイルAに紐づくスクリプトで
同時に処理を実行した
ロック有効ロック有効同時実行
AさんファイルABさんファイルBに紐づくスクリプトで
同時に処理を実行した
同時実行ロック有効同時実行
ウェブアプリAさんがボタンを連打してしまい、
同時に処理を実行した
エラーロック有効ロック有効
ウェブアプリAさんBさんがボタンを同時に押し、
同時に処理を実行した
* ウェブアプリの実行者の設定は結果に影響しない。
どちらの設定でもGoogleにログインしているユーザーで判別。
エラーロック有効同時実行

ロックの方法(tryLockとwaitLock)詳細

公式ドキュメントにあるとおり、ロック取得にはtryLockwaitLockの2つのメソッドがあります。

できることはどちらでも同じなのですが、
指定時間中にロックができなかった場合、tryLockは例外は発生させずfalseを返します。
一方、waitLock例外を発生させます

waitLockの基本的なコードは下記のような感じになります。
このコードでは、10秒間ロックの取得にトライして、それでもロックを取得できなかったらスクリプトは異常終了します

/**
 * 商品が売れた際に合計売上高(A1セル)を更新する
 */
function updateTotalEarnings(earnings) {
  const sheet = SpreadsheetApp.getActiveSheet();    // シートを取得
  const lock = LockService.getDocumentLock();       // Lockインスタンスを取得

  // ロックを試みる。既にロックされていたら10秒間トライする。(ロックできなかったらエラーで終了)
  lock.waitLock(10000);
  // ロックできた時の処理
  const total = sheet.getRange("A1").getValue();    // 現時点での合計売上高を取得
  sheet.getRange("A1").setValue(total + earnings);  // 合計売上高を更新
  lock.releaseLock();  // ロックを解除
}

waitLockを使用した場合で、ロックできなかった時にスクリプトを異常終了させたくない場合、try~catchを使う必要があります。先述したtryLockの例と同じ動きにするなら、下記のようなコードになります。

/**
 * 商品が売れた際に合計売上高(A1セル)を更新する
 */
function updateTotalEarnings(earnings) {
  const sheet = SpreadsheetApp.getActiveSheet();    // シートを取得
  const lock = LockService.getDocumentLock();       // Lockインスタンスを取得

  try {
    // ロックを試みる。既にロックされていたら10秒間トライする
    lock.waitLock(10000);
    // ロックできた時の処理
    const total = sheet.getRange("A1").getValue();    // 現時点での合計売上高を取得
    sheet.getRange("A1").setValue(total + earnings);  // 合計売上高を更新
    lock.releaseLock();  // ロックを解除
  } catch(error) {
    // 10秒間ロックできなかった時の処理
    Logger.log('ロックされていて処理できなかった');
  }
}

ロック取得後の処理におけるエラーもハンドリング

基本的な使い方は先述したとおりですが、ロックに成功した後の処理(この例ではgetValuesetValue)が異常終了した場合の考慮がされていません。

そのようなケースも想定したコードサンプルは下記のとおりです。

/**
 * 商品が売れた際に合計売上高(A1セル)を更新する
 */
function updateTotalEarnings(earnings) {
  const sheet = SpreadsheetApp.getActiveSheet();    // シートを取得
  const lock = LockService.getDocumentLock();       // Lockインスタンスを取得

  // ロックを試みる。既にロックされていたら10秒間トライする
  if (lock.tryLock(10000)) {
    // ロックできた時の処理
    try {
      const total = sheet.getRange("A1").getValue();    // 現時点での合計売上高を取得
      sheet.getRange("A1").setValue(total + earnings);  // 合計売上高を更新
    } catch(error) {
      // ロック成功後の処理で例外が発生した時の処理
      Logger.log('ロック成功後の処理でエラーが発生した');
    } finally {
      lock.releaseLock();  // ロックを解除
    }
  } else {
    // 10秒間ロックできなかった時の処理
    Logger.log('ロックされていて処理できなかった');
  }
}
/**
 * 商品が売れた際に合計売上高(A1セル)を更新する
 */
function updateTotalEarnings(earnings) {
  const sheet = SpreadsheetApp.getActiveSheet();    // シートを取得
  const lock = LockService.getDocumentLock();       // Lockインスタンスを取得

  try {
    // ロックを試みる。既にロックされていたら10秒間トライする
    lock.waitLock(10000);
    try {
      // ロックできた時の処理
      const total = sheet.getRange("A1").getValue();    // 現時点での合計売上高を取得
      sheet.getRange("A1").setValue(total + earnings);  // 合計売上高を更新
    } catch(error) {
      // ロック成功後の処理で例外が発生した時の処理
      Logger.log('ロック成功後の処理でエラーが発生した');
    } finally {
      lock.releaseLock();  // ロックを解除
    }
  } catch(error) {
    // 10秒間ロックできなかった時の処理
    Logger.log('ロックされていて処理できなかった');    
  }
}

LockServiceのありがちな間違いを紹介

ありがちな間違い(というか、私が勘違いしたり、悩んだりしたもの)をいくつか紹介したいと思います。


誤1: A1セルを安全に更新するためにドキュメントロックを掛けたから、ロック中はそのドキュメントは他の人に更新されない。

正1: ロックを記述した部分のコードは他者に実行されませんが、それ以外の方法でドキュメントを更新できます。たとえば、スプレッドシートの画面でA1セルへの書き込みは普通にできます。
また、スクリプトにおいても、別の関数や、同じ関数内の別の箇所A1セルを更新する処理があった場合、その処理は同時に実行できるので、A1セルが更新される可能性があります。


2: コード.gs内の関数functionAでスクリプトロックを掛けたら、コード.gsがロックされるから、functionAの処理が完了するまでは、コードgs内のスクリプトは実行できない。

正2: ロックを記述した部分のコードは実行されませんが、それ以外のコードは実行できます


誤3: tryLockwaitLockの引数に10秒を指定したので、 当該処理は10秒間ロックされる。

正3: 引数で指定する時間は、処理をロックする時間ではなく、既にロックされていた場合に、ロック解除を待つ最長時間です。この時間を過ぎても既存のロックが解除されなかった場合は、tryLockfalseを返し、waitLockはエラーを発生させます。


誤4: releaseLockをコーディングし忘れた、または、releaseLockより前の処理で異常終了してしまった。この場合、ロックしっぱなしの状態になってしまうので大変だ。

正4: releaseLockが実行されなかった場合でも、スクリプトの正常終了・異常終了時にロックは解除されます


誤5: ウェブアプリのデプロイ時に、実行者を「自分」にしたので、誰がコードを実行したとしても、ユーザーロックの効果がある。

正5: ウェブアプリの実行者を「自分」にしていても、ロックは実際のログインユーザーが取得します。よって、ユーザーロックを掛けている最中でも、別の人がウェブアプリから処理を実行することができます。

コメント

タイトルとURLをコピーしました