cronジョブの多重起動を MySQLの汎用ロック機能で回避する

MySQL 2013年3月19日

MySQLのロック機能を使って楽に cronジョブの多重実行回避をしようという話

たかがロック、されどロック

システムで非同期にキューの処理などを行う場合、cronを使って短い間隔でバッチジョブを起動してキューを処理するという方法がよく取られるが、キューの混み具合によってはバッチジョブにかかる時間が長くなってしまうため、前に実行されたジョブがまだ走っているかどうかチェックして、もし走っている場合は処理を実行せずにそのまま終了するといった制御が必要になる。 たまにこの制御をしていないバッチジョブが溜まりに溜まってシステムをハングさせているのを見かける。

適当な空のファイルを作成してこれを flockするとか、システムコールレベルでアトミックに作成できることになっているオブジェクト(シンボリックリンクなど)を駆使してロックの代わりにするとかといったテクニックが典型的に利用されるが、これらを真面目にやろうとすると意外と難しい。例えば前者の場合はロックファイルが存在しない場合自動的に作成する方が運用が容易なのだが、その作成処理自体をアトミックに行う方法はじゃあ何かという所まで考え出すと(本当は考えなくても多くの場合問題にならないけど)投げ出したくなるし、後者はプロセスが異常終了した時に自動でクリーンナップできないしそもそもマニアックすぎて誰にもわかってもらえない。また、効率を上げるためにロックタイムアウトを実装しようとしたらそれだけで書かなければいけないコードの量は倍増する。いや、倍で済めばいいけど?

しかし、もしバッチジョブが MySQLを対象に行われるものである場合、ついでにそのコネクションと MySQLのロック機能を使ってジョブの多重実行回避もやってしまうことが出来る。データベースのロック機能というとテーブルや行といったエンティティに対するロックを想像しがちだが、実は MySQLにはそれらと関係なく適当な名前でロックを行う get_lock/release_lock という汎用のロック機能があるのだ。

get_lock, release_lock 関数についてはこちらを参照
MySQL :: MySQL 5.1 リファレンスマニュアル :: 11.10.4 その他の関数(mysql.com)

Pythonによる例

サンプルとして Python + MySQLdbモジュールによる例を示す。MySQLへのコネクションを保持できるプラットフォームであれば何にでも適用できる。

import sys,MySQLdb

DB_HOST="localhost"
DB_NAME="dbname"
DB_USER="dbuser"
DB_PASSWORD="dbpassword"
LOCK_NAME="mycronjob.lock"
LOCK_TIMEOUT=30

# MySQLサーバへ接続
conn = MySQLdb.connect(host=DB_HOST,db=DB_NAME,user=DB_USER,passwd=DB_PASSWORD)
cur = conn.cursor()

# MySQLサーバロック LOCK_NAMEの獲得を試みる。
# LOCK_TIMEOUT秒にわたってロックの獲得ができなかった(=前回のcronスケジュールで起動済みの
# ジョブがまだ実行を終了していない)場合は諦めてプロセスを終了する。
cur.execute("select get_lock(%s,%s)", (LOCK_NAME,LOCK_TIMEOUT))
if cur.fetchone()[0] != 1: sys.exit(1) # 0=タイムアウト 1=ロック獲得成功 None=エラー

# ロックが獲得できたのでここで時間のかかる処理を実行する。
# この処理の実行中は、次のcronスケジュールで同じバッチ処理が多重起動されてしまっても
# 前記のロック獲得に失敗してプロセスが終了するため二重に処理が行われてしまうことがない

cur.close()
conn.close() # コネクションが切断されるのでロックもここで解放される
# 仮にここまで来ずにプロセスが異常終了したとしてもMySQLとの接続は必ず切れるため
# ロックが残留してしまうことはない

注意事項

LOCK_TIMEOUT(秒)は cronジョブの実行間隔より短く設定すること。

LOCK_NAMEは何でも良いが、get_lock関数で使用されるロックの名称は MySQLサーバ全体にわたって有効なので、バッチジョブ名を含めるなどして名前の衝突が起こらないようにだけ気を使うこと。

MySQLの get_lock関数で獲得したロックは、release_lockで明示的にアンロックするか MySQLへのコネクションが切断されるまで保持される。したがって、実処理が行われている間に渡って MySQLのコネクションを保持しておくことのできないプラットフォーム(シェルスクリプトなど)ではこの方法を使えないことに注意すること。

get_lock関数によるロックをデータインテグリティを担保する目的で(つまり、テーブルロックなどの代わりに)使うような濫用を決してしないこと。MySQLのドキュメントによれば get_lockによって獲得できるロックの有効な境界はトランザクションと無関係だからトランザクションと一緒に使うな、ということになっている。多分、混ぜて使うとあっさりデッドロックする。ということは、この機能はこういう用途にも使える機能というよりはむしろこういう用途にしか使えない機能だと思う。

同じカテゴリの記事

MySQLに対するJDBC経由の操作が著しく遅い場合 2009年5月14日
MySQLをWindowsにインストールする 2008年10月14日
MySQLを使ってるとディスクの空き容量が足りなくなってくる 2008年9月24日
MySQL5とPEAR::MDB2とUTF-8と文字化け 2008年8月13日

お勧めカテゴリ

英語でアニメ観ようず
なじみ深い日本製アニメの英語版DVDで、字幕と音声から英語を学びましょうという趣旨のシリーズ記事です。
ScalaのようでJavaだけど少しScalaなJSON API
Scalaと Spring Frameworkを使って REST的なJSON APIを実装してみましょう。
ドクジリアン柔術少女 すから☆ぱいそん
代表 嶋田大貴のブログです。写真は神仏に見せ金をはたらく罰当たりの図