メルカリJPのサーバーサイドエンジニアの@Hirakuです。最近はメルカリNOWの立ち上げに関わっておりGoとPHPを行ったり来たりしています。今回はネタとしては地味ですが、2017年に遭遇した、MySQLのデッドロックの話をしようと思います。MySQLで構成されており、複雑なトランザクションを含む処理が各所に存在しています。そのため、意図せずしてデッドロックを作ってしまうことがあり、場合によっては重大な問題につながります。今年は本当にデッドロックに関するトラブルに多く遭遇し、すっかり「デッドロック絶対に許さないおじさん」みたいになっていました。事例1)出品者と購入者デッドロックと言われてもピンと来ない方もいらっしゃるでしょう。メルカリはC2Cのフリマアプリですので、お客さまは出品も購入もできます。処理は1トランザクションの中で行われます。$buyer->update();});はい、デッドロックが起きうるコードですね!Aさんが出品者、Bさんが購入者Bさんが出品者、Aさんが購入者という状況で、AさんとBさんがお互いに同じ行動を取ったとしたら、こんな風に更新が2本走ってしまいますよね。MySQLは、1トランザクションの中で複数の更新をすると、更新のタイミングでレコードに排他ロックを取っていきます。お互いにロック解放待ちをしてしまい、2つのプロセス両方が処理を進められなくなってしまいました。順番をあまり意識せずに更新を行うと、そこはデッドロックの起きうる場所になってしまうわけです。冒頭のコード、ぼんやり眺めても気付けないかもしれませんが、アプリケーションエンジニアならびしっと「デッドロックの恐れアリ」を指摘したいところです。対策デッドロックの解消にはidなどの絶対値でsortして、書き込み順を揃えることです。}});こんな風にすれば、同時アクセスがあっても必ずidが小さい方のレコードからロックを取っていきますよね。ロックを取れなかった方はロックが取れるまで他の行のロックを取ろうとすることはないため、デッドロックを解消することができました。elseで書いてもいいですね)この例は初歩的ですが、非常にやらかしがちで、「出品者と購入者」以外にも「コメントを送った人、送られた人」とか、「フォローとフォロワー」みたいにいくらでも例があります。基本は主キーなどを使い、文脈に左右されない順番で処理することが解決策です。動作確認で2つ端末を用意して、役割をひっくり返し、せーのでボタンを押したりすると、再現することがあります。デッドロックの原因究明と推理デッドロックは並列並行プログラミングの初歩で出てくる話題ですが、原因究明は困難を極めることもあります。デッドロックを検知した箇所のログは残っています。ログを見て「では何故そこでデッドロックが起きるのか?」ソースコードを眺めてもすぐには理解できなかったりします。同じAPIに異なるパラメータで複数アクセスが来たせいで起きることもありますし、全く別のAPIが複数個絡み合って起きるデッドロックもあります。同時アクセスが発生の条件のため、A/Bテストを利用しての段階リリースでは発生せず、本開放してリクエストのパターンが増えてやっと発生する、なんてこともあります。結局今でも、最も解決に役立つのはソースコードの読解力だったりします...。ソースコードを丹念に読み解いては推理し仮説を立て、コードに修正を加えてはログを洗い直し...を繰り返すことになります。格好良くもない、泥臭い作業です。必要なのはただただ、根気とアプリケーションコードの理解です。Dayなどを利用してデッドロックの原因究明と推理にいそしんでいます。事例1の話だけ見ると「それぐらいできて当たり前」という手厳しい意見を頂戴するかもしれません。私は最近、「デッドロックは本質的に避けては通れず、思っていたより強大かもしれない」と思うようになりました。1つ例を挙げましょう。その昔、メルカリの取引において、住所と言えばお届け先の住所が1つだけ保存されていました。単純なため、取引情報を保存するテーブルに、住所がそのまま含まれていました。...);その後、らくらくメルカリ便やゆうゆうメルカリ便といった配送連携が増えて、発送元住所やコンビニ受け取りのための拠点情報といった、複数の住所が取引に紐づくケースが出てきました。住所は種類が違っても同じバリデーションですし、よく似たロジックも多くあります。1つの住所テーブルを作って複数のレコードを1取引に関連付けた方がすっきりしそうです。そこで、テーブルを分割するリファクタリングを行ってみました。...);すると、リリースしてから数多くデータベースでエラーが発生するようになってしまいました。先程の事例にもあったような更新順序をソートしていないことによるデッドロックもありましたが、想定していなかったのはMySQLのギャップロックによる不具合です。存在しないレコードに対するロック複数のレコードをまとめて構造にするメリットとして、不要な部分を保存しなくてもいいことが挙げられます。特殊な配送方法を使わないのであれば、送り先住所さえスナップショットを取っておけば十分ですよね。住所情報は引っ越しや入力ミスなどを想定して、取引の最中に変更することができます。この時、実装的には関連しそうなレコードを全部更新するようにしていました。'cvs';存在しないレコードに対してはUPDATE文を実行しても何も起こらないはずですし、コードがシンプルならいいかなと思っていたのです...。リリースしてみると、やたらとロックのタイムアウトが頻発するようになってしまいました。本件、SREの@kazeburoさんの協力もあり、原因はMySQLのギャップロックだと言うことがわかりました。トランザクション中で存在しない行に対してUPDATEなどを実行すると、インデックスのスキマ全域に対してロックがかかります。105のレコードを想定して何らかの更新を行おうとしたとき、などですね。これをギャップロックと言い、MySQLの特徴的な挙動になります。ギャップロックがかけられたスキマは、INSERTがブロックされるようになります。存在しないレコードに対してのUPDATE文を安易に発行していたため、別取引のレコードのINSERTもブロックされており、ロックが過剰になっていたわけですね。レコードの実在確認をしてからUPDATEを走らせるように修正したことで、タイムアウト問題は解消しました。(正確に言えば、実在確認してから実際に更新するまでの期間にレコードができてしまうこともあるため、排他制御としてはまだ甘いのですが、処理的にそこまでの並列実行はされないので、十分解消できている、という状況です。)なぜ最初の素朴なデータ構造では問題が起きなかったのか問題に対処しながら、では何故今までのコードではこういった問題が起きていなかったのかを考えていました。デッドロックの発生条件の1つに、「更新が複数のテーブルや行に跨っていること」がありました。初期の素朴な設計では、取引テーブル内に住所情報も含まれていることで、くしくも1レコードの読み書きで処理が完結し、デッドロックが起きにくいデータ構造となっていたのです(!)ただ、だからといって1つのテーブルになんでも詰め込むと、確かにロックの配慮は楽になるかもしれませんが、データとしては無駄が多くなります。それに仕様変更で情報を追加したくなったとき、既に数百万レコードまで成長してしまっていたりします。TABLEを実行するのも難しくなります。そんなわけで、今後、アプリケーションの拡張としてテーブルを分割すると、想定していなかった箇所でのデッドロックは起きやすくなるでしょう。これはプロダクトの小さなフェーズでは頻発しないため、これから取り組まなければならない課題だと思っています。思いつくのは以下のあたりでしょうか。リソースを専有して負荷に弱くなるからログが荒れて気持ち悪い割れた窓を直さないとソースコードの治安が悪くなる重要な箇所で発生すると致命的になりうるわけですが、ただ、私はこれらは些細なことだと思っています。デッドロックに巻き込まれた処理は、最大タイムアウトまで待った挙句、巻き込まれた全ての処理が失敗します。結果、発生するのは「システムエラーが起きました」という無情なダイアログになります。巻き込まれたお客さまには何の罪もありません。関わらず、がっかり体験をしているはずです。これを救えるのは、オーナーシップを持ってデッドロックの解消に取り組むエンジニアだけです。戦いは終わらない今後はメルカリ内のシステムもマイクロサービス化が進む予定です。共有リソースの更新をめぐるトラブルはRDBの壁を超えて、デッドロック以外の形でも顕在化するでしょう。その場合「デッドロックおじさん」では無いかもしれませんが、コードの海に繰り返し潜り、問題に向き合えるエンジニアであり続けたいと思っています。Mic...2018-02-26pvpool〜メルカリの商品閲覧数カウントアップの裏側〜SREチームの@cubicdaiyaです。採用情報メルカリは常に優秀なソフトウェアエンジニアを募集しています。ご興味を持たれた方はぜひご応募ください。skills月別アーカイブスターをつけました引用をストックしましたストック一覧を見る閉じる引用するにはまずログインしてくださいログイン閉じる引用をストックできませんでした。再度お試しください閉じる限定公開記事のため引用できません。