Node.jsはイベントループで動作していて、それはシングルスレッドで動作する、async/awaitやpromise使って並列で動いているように見える・・・というのは、「node シングルスレッド」でググったらいろいろな先人達がブログを残してくれています。
そこで今回は、そういう動きをするということはわかるのだけど、実際そこを意識しないプログラムを作ってしまうと実運用システムにおいてどんな問題が起こるのか?について書きたいと思います。
今回は、24時間運用していて普通に動いていたNode.jsを使って作られたWebサーバー(ここではexpressサーバー)が突然応答なしになる、しかも時々・・・でもしばらく(数分~30分くらい)したら何事もなかったように正常に動作するようになる、みたいな事象が起きていた時のお話をします。
そもそも通常動作は?
基本的な通常動作は以下のようになります。(かなり簡略化しています)
左の図は1リクエストが1レスポンス返すまでに他のリクエストがない場合の図で、右の図は1リクエストが完了する前に2リクエストが要求されて、2リクエストが動けるまで(イベントループ回ってくるまで)waitしてから、スタートする図です。
基本的にはこれが3リクエストだろうがXリクエストだろうが、awaitなどでイベントループが回ってきたときに動作し、処理の途中で各リクエストがレスポンスを返すまで実行できるタイミングで実行します。
リクエストが1000リクエスト/分あったとしてもこれが動き、大体10ms~長くても500ms程度でイベントループで実行する処理が切り替わりながら動作するので、同時処理しているようにユーザーには見えるわけです。(そういえば、Windows95はマルチタスク風でしたね)
余談ですが、1リクエストに対して応答に1秒もかかったらユーザーは遅い、と感じるそうなので、これをどれだけ縮められるかが、軽いサービス(と思わせられる)かどうかになると考えています。(どうしても遅くなる場合にはwait状態の表示をUIなどで表示させるように心がけたい)
問題動作って?
そして、今回問題になった時は以下の挙動をしました。
なぜかDBから30分レスポンスがない?
この30分レスポンスがなくなるリクエストがすべてのリクエストであれば、DBの単純負荷(スペック不足)などが考えられるのですが、レスポンスが30分かかるリクエストは特定のデータが格納されたデータだけでした。
さらに、DB接続はスレッドプールを利用して接続していたため、レスポンスが30分待ちのものがあってもスレッドプールに接続プールが残っていたらそちらを使うので他のリクエストは問題なく処理できていました。
ただ、この30分かかる処理がスレッドプールの上限(当時は10)を超えた場合・・・
すべてのDBリクエストがrequestの時点で「スレッドプール解放待ち」になります。(awaitから返ってこなくなります)
そうなると基本的にほぼすべてのリクエストはDBにデータを参照したり書き込んだりするわけで・・・
サーバーが応答なしになります!
最終的には30分かかる処理が終わってスレッドプールが他のリクエストでも使えるようになると、突然サーバーは復帰し、何事もなかったかのように溜まった処理をすべて処理して、通常状態に戻ります。
結局は実装の問題
ちなみに今回の問題ではDBに1回のリクエストでAWSの制限クォータを超える大量のレコード数のWrite命令をしていて、それでもAWSががんばってくれた結果、30分で処理を終わらせてくれてた、というのが事の真相です。(AWSすごい!)
対処方法はシンプルで1回のリクエストでWriteするレコード数を減らして複数回のWrite命令でリクエストをするでした。
そのため「Node.jsのシングルスレッド関係なくてDBの使い方の問題じゃん!」と思うかもしれません。
しかし、今回はDBでロックしてしまっただけで、コードの書き方や他のリソースへのアクセスでイベントループに戻らないような長い処理(for文ループやBlock I/OのファイルRead/Writeなど)でも同じことが起こります。むしろDBのようにスレッドプールがなければ、即止まります。(というかそういう事象も起きました・・・)
回避方法はいくつか(WorkerThreadを使う、ロックするような処理は別サーバーや別プロセスで実行するなど)ありますが、それは結構大変だったりするので最終手段として、以下を気をつけてコードを書くようにするのがよいと思います。
- 可能ならawait/asyncやpromiseにして細かくイベントループが動けるようにする
- for文などのループ内処理が同期処理のみでループが抜けるのに時間がかかりすぎないかを確認して、かかりすぎるようであれば、await/asyncやpromiseで処理を分割する(必要であればログを仕込んで実行時間を確認する)
- どこでイベントループに戻るかを意識する
マルチプロセスやマルチスレッドの場合には、同時処理(ディスパッチやクリティカルセクション、ミューテックスやセマフォなど)に関するリソース管理や実行順序などの注意が必要ですが、Node.jsでは他は動かないということに注意する必要があります。
システムが止まるというのは胃がキリキリする話ですが、そういったことが少しでも減って安定したシステムを提供できるようにしたいですね!
以上