楽観ロック

概要

本日はデザインパターンMySQLの話です。
なんか愉快な音楽の話ではありません。

問題が起きた

最近お仕事でこんなプログラム書きました。

package App::Web::Controller;

sub dispatch_buy {
	my $self = sfhit;
	
	...
	
	$db->txn_start();
	try{
		die unless $user->has_enough_money($gold);
		$user->get_item($hoge);
		$user->lose_money($gold);
	}
	catch {
		my $err = $_;
		die $err;
		$db->rollback;
		$self->redirect();
	}

	$db->commit;
}

内容はでたらめですが、早い話が同じトランザクション内でお金が足りるかをチェックして、足りてなければrollbackする、という処理です。

ロストアップデート

で、$user->lose_moneyの内部では次のようなクエリを発行するようにしています。

update user_status set gold = gold - 100;

あ、言い忘れてた。これはInnoDBの話です、モバイルの話です。分離レベルがREPEATABLE READで運用しています。で、ユーザーがダブルクリックとかでPOSTが連続で行われた場合なんですが、どうもここでhas_enough_moneyで参照(SELECT)した際に、ロックをかけてないので直後のlose_moneyで該当する行が同一であることが保障されてないのです。

Oracleトランザクション勉強した人、俺が何言ってるかわからないですよね。わかります。

OracleMySQLでは分離レベルが違うのです。

ロックしないSELECTにロックをさせる

こういう作業が必要です。

SLECT * FROM tbl where keyX = xxx FOR UPDATE;

そもそも何が問題なのか

RDBMSに慣れていない人にはちょっとまだわかりづらい話だと思いますのでちょっと抽象化して解説します。

簡単にするとこういうこと

あなたの会社には会議室がA,B,C,D,Eとあります。部長から次の指令が下されました。

「会議室Aにある椅子の数を数えてくれ、もし6個以上なら多すぎるから1個減らしといてくれ」

MySQLを擬人化します。SELECTを新入社員の西●君、UPDATEを新入社員の須●君にやってもらいます。

では、西●さん、お願いします。

西●「わかりました。ちょっくらA会議室見てきます。」「A会議室には椅子が6個あります」

次に須●さん、お願いします。

須●「わかりました。A会議室ですね。行ってきます。」「ここがA会議室か、今から作業するからドアに作業中という張り紙を貼っておこう」「もどりました。椅子を1個減らしてきました。」

と、言うことで無事任務完了です。

問題はやはり西●なのか

部長とほぼ同じタイミングで、社長が同じ事をいってきました。再び西●さんがA会議室に走ります。

西●「ガチャ、えーと椅子は1,2,3,4,5,6個だな、バタン」
須●「あれ、今だれか部屋に入ってきてたかな?」

実はこの時、まだ須●さんが椅子を減らしている最中です。
このシチュエーションが今回の問題の発端なのです。

MySQL,Innodbの分離レベルではSELECT(西●さん)はロックをかけません。

須●さんは張り紙を貼っていますが、椅子の数を減らしたので張り紙をはがして自分の席に戻りました。すると西●さんから次のように言われました。

西●「須●、今度は社長から椅子の数を減らすように言われたんだけど、(A会議室に6個あったから)また椅子を一個減らしといて」

結果会議室の椅子の数は4個になりました。

結局指示の出し方が悪いのです。

西●さんに対して「会議室の入り口に作業中という張り紙が張ってあったら、入室を待つように」という指示をしなくてはなりません。

実際は西●さんは優秀な人間なので張り紙には気づきますが、InnoDBのデフォルトの分離レベルにおいて、SELECT文はロックをかけません。ロック待ちは、ロックをかけようとした時に発動する現象なので、そもそもロックをかけません。

150万の将兵はなぜ死んだのか?
首脳部の作戦指揮がまずかったからさ

Number Version

で、やっと本題になるわけですが、RDBMSではなくアプリケーション側で(張り紙をみようとしない)西●さんに問題が起きないように指示をする方法を考えます。西●さんには次のような指示をだしてみます。

「会議室Aの椅子の数を数えてくれ、それから部屋の前にver.Xと書かれた張り紙があるから、そのXの数値も数えてくれ」

須●さんには次のように指令をだしてみましょう。

「会議室Aで、入口にver.Xと書かれた部屋の椅子を1個減らしておいてくれ、終わったら入口のver.そのXをver.X+1に変更しておいてくれ。」

さっきと同じことをやってみる

上述の作業を、部長と社長が二人に対して指示します。

西●「ガチャ、A会議室には椅子が6個、入口にはver1って書いてあるな」

で、西●さんは席に一旦もどり、須●さんがA会議室に向かい、直後に社長からもう一回同じ事を言われます。

須●「A会議室の椅子を減らさないと、、、ゴソゴソ。あれ、今後ろに誰かいたような?」
西●「はいはい、もう一回A会議室には椅子が6個、入口にはver1って書いてある、と」
須●「ただいま戻りました、A会議室の椅子を減らしました。入口の張り紙も+1にしました」

で、ここで須●さんには次の作業依頼が行われるところがミソです。

「会議室Aで、入口にver.1と書かれた部屋の椅子を1個減らしておいてくれ」

でも、この時点で会議室Aはver.2なので須●さんは目的の部屋を発見できませんでした。

かくしてロストアップデートは免れました。めでたし。

SELECT ... FOR UPDATE vs Number Version

どちらがよいかといえば、おそらくはSELECT ... FOR UPDATEを行うべきでしょう。
ただし、私が直面している問題は、「業務で使用しているそのアプリケーションがO/Rマッパーを多用している」のでO/Rマッパー側でNumberVesionを実装してこの問題を吸収する、つまり修正箇所を少なくする(なるのか?)べきかを考えないといけないのですが、どっちがいいんだろう。

もうアプリは稼働しているのであんまり作業をする余裕がないから放置してます。

ちなみにhibernateではこの実装がすでにあるっぽい。PerlのO/Rマッパーにはこんな実装ないのかしら。