Mercari Advent Calendar 2017 の18日目です。
こんにちは。メルカリJPのサーバーサイドエンジニアの@Hirakuです。最近はメルカリNOWの立ち上げに関わっておりGoとPHPを行ったり来たりしています。
今回はネタとしては地味ですが、2017年に遭遇した、MySQLのデッドロックの話をしようと思います。
これまでも何度か話されている通り、メルカリのコア部分は今でもPHP + MySQLで構成されており、複雑なトランザクションを含む処理が各所に存在しています。そのため、意図せずしてデッドロックを作ってしまうことがあり、場合によっては重大な問題につながります。
今年は本当にデッドロックに関するトラブルに多く遭遇し、すっかり「デッドロック絶対に許さないおじさん」みたいになっていました。
事例1)出品者と購入者
デッドロックと言われてもピンと来ない方もいらっしゃるでしょう。
まずは小手調べです。
メルカリはC2Cのフリマアプリですので、お客さまは出品も購入もできます。
ここで、あるAPIの中に、
「(1)出品者の情報を更新し、(2)購入者の情報を更新する」
という処理が書かれていたとしたらどうでしょうか? もちろん、処理は1トランザクションの中で行われます。以下のコードに何か問題はあるでしょうか?(制限時間5秒)
<?php // 擬似コード doTransaction(function (User $seller, User $buyer) { $seller->update(); $buyer->update(); });
はい、デッドロックが起きうるコードですね!
- Aさんが出品者、Bさんが購入者
- Bさんが出品者、Aさんが購入者
という状況で、AさんとBさんがお互いに同じ行動を取ったとしたら、こんな風に更新が2本走ってしまいますよね。MySQLは、1トランザクションの中で複数の更新をすると、更新のタイミングでレコードに排他ロックを取っていきます。
お互いにロック解放待ちをしてしまい、2つのプロセス両方が処理を進められなくなってしまいました。
順番をあまり意識せずに更新を行うと、そこはデッドロックの起きうる場所になってしまうわけです。
冒頭のコード、ぼんやり眺めても気付けないかもしれませんが、アプリケーションエンジニアならびしっと「デッドロックの恐れアリ」を指摘したいところです。
対策
デッドロックの解消にはidなどの絶対値でsortして、書き込み順を揃えることです。
<?php // 擬似コード doTransaction(function(User $seller, User $buyer){ $users = [$seller, $buyer]; usort($users, function ($a, $b) { return $a->getId() <=> $b->getId(); }); foreach ($users as $user) { $user->update(); } });
こんな風にすれば、同時アクセスがあっても必ずidが小さい方のレコードからロックを取っていきますよね。ロックを取れなかった方はロックが取れるまで他の行のロックを取ろうとすることはないため、デッドロックを解消することができました。(この場合はsort関数まで使わなくても、単純にif elseで書いてもいいですね)
この例は初歩的ですが、非常にやらかしがちで、「出品者と購入者」以外にも「コメントを送った人、送られた人」とか、「フォローとフォロワー」みたいにいくらでも例があります。基本は主キーなどを使い、文脈に左右されない順番で処理することが解決策です。
動作確認で2つ端末を用意して、役割をひっくり返し、せーのでボタンを押したりすると、再現することがあります。
デッドロックの原因究明と推理
デッドロックは並列並行プログラミングの初歩で出てくる話題ですが、原因究明は困難を極めることもあります。MySQL(InnoDB)だと、SHOW ENGINE INNODB STATUS
の出力にある LATEST DETECTED DEADLOCK
など、デッドロックを検知した箇所のログは残っています。しかしログを見て「では何故そこでデッドロックが起きるのか?」ソースコードを眺めてもすぐには理解できなかったりします。
同じAPIに異なるパラメータで複数アクセスが来たせいで起きることもありますし、全く別のAPIが複数個絡み合って起きるデッドロックもあります。
また同時アクセスが発生の条件のため、A/Bテストを利用しての段階リリースでは発生せず、本開放してリクエストのパターンが増えてやっと発生する、なんてこともあります。
結局今でも、最も解決に役立つのはソースコードの読解力だったりします…。ソースコードを丹念に読み解いては推理し仮説を立て、コードに修正を加えてはログを洗い直し…を繰り返すことになります。特に格好良くもない、泥臭い作業です。必要なのはただただ、根気とアプリケーションコードの理解です。
こういった性質から、デッドロックの解消はシニアなアプリケーションエンジニアの得意分野になることも多く、私もBe Professional Dayなどを利用してデッドロックの原因究明と推理にいそしんでいます。
(まあ、自分でも作ってしまうことがあるのですけどね :P)
事例2)正規化に伴う複雑化
事例1の話だけ見ると「それぐらいできて当たり前」という手厳しい意見を頂戴するかもしれません。しかし私は最近、「デッドロックは本質的に避けては通れず、思っていたより強大かもしれない」と思うようになりました。1つ例を挙げましょう。
その昔、メルカリの取引において、住所と言えばお届け先の住所が1つだけ保存されていました。単純なため、取引情報を保存するテーブルに、住所がそのまま含まれていました。
-- だいたいこういうイメージ CREATE TABLE transactions ( id INT UNSIGNED , status ENUM(...) , ... , dst_zipcode VARCHAR(7) , dst_first_name VARCHAR(255) , dst_last_name VARCHAR(255) , dst_prefecture VARCHAR(4) , ... );
その後、らくらくメルカリ便やゆうゆうメルカリ便といった配送連携が増えて、発送元住所やコンビニ受け取りのための拠点情報といった、複数の住所が取引に紐づくケースが出てきました。住所は種類が違っても同じバリデーションですし、よく似たロジックも多くあります。1つの住所テーブルを作って複数のレコードを1取引に関連付けた方がすっきりしそうです。
そこで、テーブルを分割するリファクタリングを行ってみました。
-- こんなイメージ CREATE TABLE transactions ( id INT , status ENUM(...) , ... ); CREATE TABLE transaction_addresses ( transaction_id INT , type ENUM(...) , first_name , family_name , ... );
すると、リリースしてから数多くデータベースでエラーが発生するようになってしまいました。先程の事例にもあったような更新順序をソートしていないことによるデッドロックもありましたが、想定していなかったのはMySQLのギャップロックによる不具合です。
存在しないレコードに対するロック
複数のレコードをまとめて構造にするメリットとして、不要な部分を保存しなくてもいいことが挙げられます。特殊な配送方法を使わないのであれば、送り先住所さえスナップショットを取っておけば十分ですよね。
住所情報は引っ越しや入力ミスなどを想定して、取引の最中に変更することができます。この時、実装的には関連しそうなレコードを全部更新するようにしていました。
-- 送り先住所を更新 UPDATE ... WHERE transaction_id = xxx AND type = 'src'; -- 発送元住所を更新 UPDATE ... WHERE transaction_id = xxx AND type = 'dst'; -- 拠点受け取りの際の住所を更新 UPDATE ... WHERE transaction_id = xxx AND type = 'cvs';
存在しないレコードに対してはUPDATE文を実行しても何も起こらないはずですし、コードがシンプルならいいかなと思っていたのです…。
しかしリリースしてみると、やたらとロックのタイムアウトが頻発するようになってしまいました。
本件、SREの@kazeburoさんの協力もあり、原因はMySQLのギャップロックだと言うことがわかりました。
トランザクション中で存在しない行に対してUPDATEなどを実行すると、インデックスのスキマ全域に対してロックがかかります。
... transaction_id = 100 (データにスキマがある) transaction_id = 110 ...
この状態でtransaction_id = 105のレコードを想定して何らかの更新を行おうとしたとき、などですね。これをギャップロックと言い、MySQLの特徴的な挙動になります。そしてギャップロックがかけられたスキマは、INSERTがブロックされるようになります。
存在しないレコードに対してのUPDATE文を安易に発行していたため、別取引のレコードのINSERTもブロックされており、ロックが過剰になっていたわけですね。
レコードの実在確認をしてからUPDATEを走らせるように修正したことで、タイムアウト問題は解消しました。(正確に言えば、実在確認してから実際に更新するまでの期間にレコードができてしまうこともあるため、排他制御としてはまだ甘いのですが、処理的にそこまでの並列実行はされないので、十分解消できている、という状況です。)
なぜ最初の素朴なデータ構造では問題が起きなかったのか
問題に対処しながら、では何故今までのコードではこういった問題が起きていなかったのかを考えていました。
思い出して下さい。デッドロックの発生条件の1つに、「更新が複数のテーブルや行に跨っていること」がありました。
初期の素朴な設計では、取引テーブル内に住所情報も含まれていることで、くしくも1レコードの読み書きで処理が完結し、デッドロックが起きにくいデータ構造となっていたのです(!)
ただ、だからといって1つのテーブルになんでも詰め込むと、確かにロックの配慮は楽になるかもしれませんが、データとしては無駄が多くなります。
それに仕様変更で情報を追加したくなったとき、既に数百万レコードまで成長してしまっていたりします。巨大に育ったテーブルは、ALTER TABLEを実行するのも難しくなります。
そんなわけで、今後、アプリケーションの拡張としてテーブルを分割すると、想定していなかった箇所でのデッドロックは起きやすくなるでしょう。これはプロダクトの小さなフェーズでは頻発しないため、これから取り組まなければならない課題だと思っています。
デッドロックを憎む理由
ところで、デッドロックは何が問題なのでしょうか? ざっと思いつくのは以下のあたりでしょうか。
- リソースを専有して負荷に弱くなるから
- ログが荒れて気持ち悪い
- 割れた窓を直さないとソースコードの治安が悪くなる
重要な箇所で発生すると致命的になりうるわけですが、ただ、私はこれらは些細なことだと思っています。
デッドロックに巻き込まれた処理は、最大タイムアウトまで待った挙句、巻き込まれた全ての処理が失敗します。
結果、発生するのは「システムエラーが起きました」という無情なダイアログになります。
巻き込まれたお客さまには何の罪もありません。にも関わらず、がっかり体験をしているはずです。これを救えるのは、オーナーシップを持ってデッドロックの解消に取り組むエンジニアだけです。
戦いは終わらない
今後はメルカリ内のシステムもマイクロサービス化が進む予定です。おそらく、共有リソースの更新をめぐるトラブルはRDBの壁を超えて、デッドロック以外の形でも顕在化するでしょう。その場合「デッドロックおじさん」では無いかもしれませんが、コードの海に繰り返し潜り、問題に向き合えるエンジニアであり続けたいと思っています。
明日の19日の担当は@_hitimaさんです! お楽しみに!