Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

INT 32 障害とその BOLD な対策

この記事は、 Mercari Bold Challenge Monthの11日目の記事です。

こんにちは。Mercariで、通知に関連するサービスの開発をしているNotificationチームへ所属している @sters です。
通知という広く大きい舞台でのマイクロサービス化を主に進めているチーム、という文脈でも語れることがあるのですが、それはまた別のどこかの機会でお届けします。

今回の記事では、とある日に起きたNotificationチームにも関連するインシデント=障害について、何が起きたのか、何が原因だったのか、実際にどのように対応をしたか、今後どのようにしていくかをご紹介します。

なお、インシデント対応については、少し前の記事でも紹介していますが、現在もここでの通り振り返りなどを行っています。

tech.mercari.com

インシデント発生、一次対応まで

とある日の夕方、あるお知らせが見えたり見えなかったりする、といった旨の内容が社内Slackのインシデント対応専用のチャンネルにて報告されました。 さまざまな人の協力も得ながら徐々に情報を集めていき、問題を切り分けていくとこのような状態であることがわかりました。

  • Push通知は問題ない
  • Androidの端末でのみ発生している
  • エラーが微増していたタイミングはお昼近辺
  • 同じお知らせでも社内においてエラーになる人とエラーにならない人がいる
  • エラーがでるタイミングはお知らせ一覧から個別のお知らせを開こうとしたとき

なかなか原因が特定できない中、エラーになるタイプのデータを注意深く見てみると、DBに保存されているレコードのうち、プライマリキーに当たるオートインクリメントなIDの値が、

符号付き32ビット整数型(INTと記載します)の上限値(2 ** 31 - 1 = 2147483647INT MAXと記載します)

を超える値になっていることに目が付きました。

商品に対するコメントやいいね!、キャンペーン情報といったさまざまな種類のものを、多数のユーザに対して、お知らせとして送っています。まだまだメルカリが小さかったときならともかく、今ではおかげさまでとても大きなデータ量になっています。

そのため INT をIDの処理として採用しているようなことはないような…と思っていましたが、意外とあるかもという口頭での話もあり、対応チャンネルへと共有しました。すると即座にAndroidアプリの実装が確認され、お知らせIDの型として INT を使っていることがわかりました。このオーバーフローした値はAndroidアプリ上で、上限を超えた値が負数になることはなく、0として扱われていることも、実際の挙動を確認してわかりました。

つまり、お知らせ一覧として取得したレスポンスをパースする際に、Androidアプリ上で INT MAX を超える値をうまく取り扱っておらずオーバーフローしてしまう。そしてそのオーバーフローした数値は0として利用される。 個別のお知らせを開こうとしたとき、ID=0としてAPIにリクエストされることで、DBに検索する際にもそんなレコードは存在しません。その結果、APIのレスポンスも、要求したお知らせは見つからない、というエラーとなってしまっていました。

f:id:sters9:20190902220042p:plain

さて、原因が分かったところですが、正しく対応をすると、Androidアプリの実装の修正とリリースをし、新しいバージョンのアプリが審査され、お客さまにアップデートをしてもらう、ところまでになり解決までの時間が掛かります。また、ブログやツイッターといったメディアでのアナウンスをしたり、お問い合わせに対するご案内など、CustomerServiceチームの手間も大きく掛かります。

そこで、開発陣の中で話に上がった案は、そのお知らせのIDにゲタをつけて辻褄をあわせる、という作戦です。

f:id:sters9:20190902220145p:plain

古くは2014年ごろのものからお知らせDBにデータが残っており、そのIDを再利用するような形で、アプリケーションの実装をすることが出来るはずです。 あくまでイメージではありますが、このような実装ができます。

function convert($id) {
    if ($id > INT_MAX) {
        return $id - INT_MAX;
    }
    return $id;
}

function deconvert($id) {
    if ($id < INT_MAX) {
        return $id + INT_MAX;
    }
    return $id;
}

function makeResponse($notifications) {
    $response = [];
    foreach ($notifications as $notification) {
        $response[] = [
            'id' => convert( $notification->id );
            // other fields
        ];
    }
    return $response;
}

function findById($id) {
    $row = SELECT( deconvert($id) );
    if ( !is_null($row) ) {
        return $row;
    }

    return SELECT($id);
}

そしてこのようなIDへと変換されることになります。

ID Converted ID
1000 1000
INT_MAX - 1 INT_MAX - 1
INT_MAX INT_MAX - INT_MAX = 0
INT_MAX + 1000 (INT_MAX + 1000) - INT_MAX = 1000

ただしこのやりかたでは問題があります。発生する可能性は高くはないですが、古いお知らせと新しいお知らせとで、同じお客さまに対してのものだった場合に、表示されるべき内容がおかしなことになってしまいます。 他にもいくらINT MAXで余裕があるとはいえ、お知らせをたくさん送る = IDの消費をする、ことが、根本的な対応が完了するまで続くようであれば同じ問題に遭遇する可能性が高まります。

そこで、別案として同じくゲタをつけるものですが、その値として負数が使えないか、という話があがりました。かなり怪しい方法ではありますが、Androidアプリでの実装はINTを使っているということで、そのまま処理できる可能性がありました。もしこちらの方法が取れるなら、古いお知らせと新しいお知らせが混在する可能性がなくなるほか、再びこの問題に衝突するまでの余裕が生まれます。

こちらも実装のイメージを掲載するとこのようになります。

function convert($id) {
    if ($id > INT_MAX) {
        return $id - (INT_MAX * 2);
    }
    return $id;
}

function deconvert($id) {
    if ($id < 0) {
        return $id + (INT_MAX * 2);
    }
    return $id;
}

// makeResponse, findById は上記のものと同様です

そしてこのようなIDへと変換されます。

ID Converted ID
1000 1000
INT_MAX - 1 INT_MAX - 1
INT_MAX INT_MAX - (INT_MAX * 2) = -INT_MAX
INT_MAX + 1000 (INT_MAX + 1000) - (INT_MAX * 2) = -INT_MAX + 1000

ID=0からを再利用する古いIDを上書きするような形でのやり方は別の方がすでに取り掛かっていたので、負数を使う案を私の方で並行して、検証用の環境を利用し試していました。リクエストやレスポンスの内容を何パターンか見ている限りでは、正しくできているように見えました。その問題ない旨を共有し、方向性を話した結果、混乱も少なくいけそうなので、と、負数を使う案ですすめることとなりました。

そして、この修正による二次障害が起こらないよう慎重なコードレビュー、動作確認、QAチェックと行い、問題が無く動作することを確認した上でリリースを行いました。

ココまでで一次対応の完了です。

二次対応

このインシデントに対してのやることが残っています。

  • Androidアプリの修正、アップデートのリリース
  • APIの修正のリバート

一次対応のつぎはぎ状態なので、この状態のまま走り続けるのは非常に危険です。ふとした瞬間に意図しない更なるバグを作り込んでしまったり、今が動いているからとやるべき対応を後ろ倒しにしてそのまま忘れてしまうなど、何かしら次の問題へとつながってもおかしくありません。

早めに本来あるべき正しい状態へと戻していく必要があります。

どちらの対応も動作確認や二次障害などに気をつけつつ慎重に進めていましたが、このブログの公開時点ではどちらも対応が完了しています。

再発防止策、恒久対応

さて、原因究明と対応が行われましたが、さらに再発防止策や恒久対応についても考えていきます。そこでこのような案が挙げられました。

  • 既存のコード、DBに対して、INT型を使っている箇所の確認
  • INTや更に大きな整数型を使う理由があるかの確認
  • 文字列型へと切り替えられないかの検討
  • APIのレスポンス型について各Backendチームと確認
  • テストケース、コードレビューガイドライン、Lint Ruleとして整数型の確認
  • 内容に応じて自在にスケールされるID専用型の導入
  • APIエンドポイントごとに監視するなどモニタリング強化

今回起きたインシデントは、Android上で表現される型がINTであった、が直接の原因でありますが、API側のモニタリングをしっかりとしていればもっと早い段階で気づくことができ、異なる対応が出来た可能性もあります。

また、エンジニアロール別のチームの垣根を超えたコミュニケーションや、機能やデータの見直し、大きなプロジェクトの振り返りといった機会が足りていない、というのもあるなと個人的には感じています。なにかのタイミングで「お知らせIDがだいぶ大きいんですが大丈夫ですか」という一言が出来ていたら、今回のインシデントを回避できていたかもしれません。それはもしかしたら、これから出来ていく全員Software Engineer、Cross Functional Teamsといった世界観においてより現実味があると思っています。主にお客さまから見えるような表の部分はもちろん、主に私たちSoftware Engineerが見る裏の部分も、今よりもっと素敵な状態へと進化、成長させていきたいですね。

おわりに

とある日に起きたインシデントについて、何が起きたのか、何が原因だったのか、実際にどのように対応をしたか、今後どのようにしていくかをご紹介しました。

クライアントのみで発生したINTのオーバーフローというなかなか見られない事象で、かつお知らせという影響も大きいところでしたが、それを負数のゲタをつけるというアイデアで、一時的ではありますが、十分な時間を突破することができました。

この記事をご覧の皆さんも、ぜひ一度、利用しているデータ型、その大きさ、そして実際の値を確認してみてはどうでしょうか? ぜひ数値だけではなく、文字列なデータも確認してみてください。例えばそのフィールドは本当にxx文字を上限として扱って大丈夫ですか?

サービス、プロダクトのリリース直後は大丈夫であったはずの値が、ふとしたときに突如として問題となるかもしれません。


さて、明日の記事は @robert による「something bank related」です。
somethingとは果たしてどんな内容の記事になるのでしょうか。お楽しみに!

この記事は Mercari Bold Challenge Month の11日目として @sters からお送りしました。