くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログ。積極採用中です。

レポート作成機能の開発とS3・ECSに関する小話

こんにちは、バックエンドエンジニアの富永です!
今年も早いもので、もう11月ですね〜。
最近東京では過ごしやすい気候が続いています。寒さが苦手な自分にとってはずっとこのままでいてほしいな〜、と願って止まない今日この頃です。

さて私事ですが、今年6月にみんなのマーケットにジョインし、レポート作成機能という新規機能の開発を担当させていただきました。
その際に、Amazon S3(以下、S3)・Amazon Elastic Container Service(以下、ECS)を活用し開発しました。前職で少しAWSの経験はあったのですが、AWS SDKを使っての開発はほぼ初めての経験で、機能要件を満たすためにそれぞれのサービスを調べたり、操作する中で勉強になったことがありました。
そこで、本記事では、開発したレポート作成機能の説明とS3・ECSに関する小話をお伝えしたいと思います。

レポート作成機能の開発に至った背景

現在、コンサルティング本部の担当者が業務の一環で利用している、レポートがあります。 従来、このレポートは下記フローチャートの流れで作成していました。レポート作成は、月2〜3回の頻度で発生しており、その度にコンサルティング本部の担当者と、SREエンジニアの担当者の間でコミュニケーションが発生し、お互いに煩わしさを感じていました。 そこで、このコミュニケーションコストを削減することを目的に、今回のレポート作成機能の開発を行いました。

図1.レポート作成のフローチャート(レポート作成機能開発前)

   

レポート作成機能について

開発したレポート作成機能の画面イメージ1は、以下の通りです。
コンサルティング本部の担当者は、レポート作成画面で必要な情報を入力し、「レポートを作成」ボタンをクリックします。
入力した情報を元に、システム内部でECSタスクを実行するコマンドを生成し、ECSタスクを実行します。
実行したECSタスクの中では、複数種類のレポートを作成しており、レポートの作成が完了したら、そのファイルをS3にアップロードするようになっています。
なおレポートはhtmlファイルになっており、レポート確認画面の「開く」リンクをクリックすると、レポートを別タブで開くことができるようになっています。

図2.レポート作成画面の画面イメージ
図3.レポート確認画面の画面イメージ

S3・ECSに関する小話

本章では、S3・ECSについて調べたり操作していく中で分かったことについて、5点共有させていただきます。

1.【S3】AWS マネジメントコンソールからオブジェクトを作成する際の注意点

一度のレポート作成で、複数種類のレポートファイルが作成されるため、それらを一つのフォルダの中にまとめ、そこからファイルを取得するように実装しています。
これを実装する際に、AWS マネジメントコンソールを使って、一つのフォルダの中に、複数種類のレポートファイルをアップロードした後の状況を再現し、ファイル取得以降の処理を実装しようとしていました。
この際に、レポートファイルのみをオブジェクトとして取得したかったのですが、設定方法によっては期待通りに動かないことがあったため、そのときに学んだことを共有させていただきます。
例えば、「hoge」バケットの中にfuga/piyo.txtのファイルをアップロード(保存)する場合を考えてみましょう。

 hoge/(バケット)
  └ fuga/
    └ piyo.txt

AWS マネジメントコンソールからこの設定を行う方法として、以下の2パターンがあります。

  • パターン1. 「hoge」バケット直下から、「piyo.txt」が入った「fuga」フォルダをアップロードする方法
  • パターン2. 「hoge」バケット直下に、「fuga」フォルダを作成し「piyo.txt」をアップロードする方法

パターン1、パターン2の操作手順は、それぞれ以下の通りです。2

パターン1の操作手順

1.「hoge」バケットの「アップロード」ボタンをクリックします。


図4.手順1の画面例(パターン1)

2.開いた画面で「フォルダの追加」ボタンをクリックし「fuga」フォルダを選択した後、「アップロード」ボタンをクリックします。


図5.手順2の画面例(パターン1)

パターン2の操作手順

1.「hoge」バケットの「フォルダの作成」ボタンをクリックします。


図6.手順1の画面例(パターン2)

2.開いた画面で「フォルダ名(fuga)」を入力し「フォルダの作成」ボタンをクリックします。


図7.手順2の画面例(パターン2)

3.「fuga」フォルダに移動し、「ファイルを追加」ボタンをクリックし「piyo.txt」ファイルを選択後、「アップロード」ボタンをクリックします。


図8.手順3の画面例(パターン2)

以上のパターン1,2の手順を実施した後、どちらも画面上には、以下のような形で「piyo.txt」を確認することができます。


図9.「piyo.txt」の画面

次に、それぞれの操作完了後、「hoge」バケットの中身を取得します。

■パターン1の結果

$ aws s3api list-objects --bucket hoge
{
    "Contents": [
        {
            "Key": "fuga/piyo.txt", 
            "LastModified": "2021-11-10T02:48:47.000Z", 
            "ETag": "\"f290d73a3d0ba856b5d82eff7a1e8ece\"", 
            "StorageClass": "STANDARD", 
            "Size": 3,
            "Owner": {
                "DisplayName": "(Onwerの表示名)", 
                "ID": "(OnwerのID)"
            }
        }
    ]
}

■パターン2の結果

$ aws s3api list-objects --bucket hoge
{
    "Contents": [
        {
            "Key": "fuga/", 
            "LastModified": "2021-11-10T02:48:46.000Z", 
            "ETag": "\"af572fda0c25b593deaecb6663a081df\"", 
            "Size": 0,
            "StorageClass": "STANDARD", 
            "Owner": {
                "DisplayName": "(Onwerの表示名)", 
                "ID": "(OnwerのID)"
            }
        }, 
        {
            "Key": "fuga/piyo.txt", 
            "LastModified": "2021-11-10T02:48:47.000Z", 
            "ETag": "\"f290d73a3d0ba856b5d82eff7a1e8ece\"", 
            "StorageClass": "STANDARD", 
            "Size": 3,
            "Owner": {
                "DisplayName": "(Onwerの表示名)", 
                "ID": "(OnwerのID)"
            }
        }
    ]
}

パターン1だと「piyo.txt」しか存在しません。一方、パターン2だと「piyo.txt」と「fuga」フォルダがオブジェクトとして存在します。
これは、S3のデータモデルがフラットな構造になっていることが起因しています。
S3では、バケットを作成し、そのバケットにオブジェクトが直接格納されます。そのため、バケットの中には、そもそもフォルダという概念はなく「フォルダの作成」で、見た目としての「フォルダ」を作成した場合も、内部的にはオブジェクトとして管理さています。

今回のように、何も意識せずに、AWSマネジメントコンソールで「フォルダの作成」の操作を行ってしまうと、本来想定していなかった不要なオブジェクトを生成してしうまうことになってしまうため、意外と注意が必要だなと思いました。
なおAWS CLIやAWS SDKを使って、S3にファイルをアップロードする場合も同様の注意が必要です。

以上の話を踏まえ、今回の開発ではフォルダはオブジェクトに含めたくなかったため、パターン1の方法でアップロードし、開発を進めていくようにしました。

オブジェクトキー名の作成

Amazon S3 のデータモデルはフラットな構造をしています。バケットを作成し、バケットにオブジェクトを保存します。サブバケットやサブフォルダの階層はありません。ただし、Amazon S3 コンソールで使用されているようなキー名のプレフィックスや区切り記号を使用して論理的な階層を暗示できます。

2.【S3】署名付きURLの有効期限

レポートのhtmlファイルは、画面を描画するタイミングで署名付きURLを発行し、それをアンカータグにセットすることで、別タブで開くことができるようにしています。
署名付きURLを使うことで、本来はアクセス権限がないユーザでも、対象のファイルにアクセスすることが可能になります。
この署名付きURLには、有効期限があり、デフォルトでは15分に設定されています。
開発当初、有効期限を考慮できておらず、15分経過した後にページをリロードすると以下のようなエラー画面が表示されていました。
ちなみに有効期限は最大7日間、設定することができます。

図10.有効期限切れの画面例

有効期限 デフォルト15分(「Options Hash」部分を参照)

Options Hash (params): Expires (Integer) — default: 900 — the number of seconds to expire the pre-signed URL operation in. Defaults to 15 minutes.

有効期限 最大7日間

IAM ユーザー: 最大 7 日間有効 (AWS Signature Version 4 を使用した場合)

3.【S3】ライフサイクルの設定時間と物理削除の遅延

コンサルティング本部の担当者から「古いレポートは見れなくしてほしい」という要望があり、対応する必要がありました。
そこで活用したのが、S3のオブジェクトのライフサイクルになります。

オブジェクトのライフサイクルを設定することで、バックアップの取得、削除を自動で行ってくれます。

ここで注意しなければいけないのが、ライフサイクルの設定時間です。

ライフサイクル設定の要素

Amazon S3 は、ルールに指定された日数をオブジェクトの次の新しいバージョンが作成された時間に加算し、得られた日時を翌日の午前 00:00 (UTC) に丸めることで、時間を算出します。たとえば、バケット内に 2014 年 1 月 1 日の午前 10 時半 (UTC) に作成されたオブジェクトの現行バージョンがあるとします。現行バージョンを置き換えるオブジェクトの新しいバージョンが 2014 年 1 月 15 日の午前 10 時半 (UTC) に作成され、3 日間の移行ルールを指定すると、オブジェクトの移行日は 2014 年 1 月 19 日の午前 0 時 (UTC) となります。

上記から、日数計算の開始は 0時0分(UTC) とありますので、日本時間では 午前9時00分 から計算を開始するようです。 また、作成日時は丸めて算出されるということですので、削除日時は以下のようになります。

例:削除期間を1日に設定した場合(以下、全て日本時間) * 作成日時:2021年11月10日 午前8時59分→ 削除日時:2022年11月11日 午前9時00分 * 作成日時:2021年11月10日 午前9時01分→ 削除日時:2022年11月12日 午前9時00分

これを考慮し、日本時間の30日後に削除をしたかったため、ライフサイクルは以下のように設定をしました。 しかし、テストや運用で削除結果の様子を見ていたところ、午前9時00分ピッタリに削除されず、削除指定日のその日中には削除されるといった具合でした。
これに関しては公式からも、このような回答が出ているので、物理的なファイルの削除にはタイムラグがあるのかなと思っております。

図11.ライフサイクルの設定画面

4. 【ECS】タスクの実行結果は永久保存されない/【S3】ユーザ定義のメタデータの付与ができる

最後に、内部設計の際に調べていて気づいたことを共有させていただきます。

レポート確認画面では、レポート作成を実行したタイミングごとに、各種レポートのリンクと合わせて以下の情報を出力しています。

  • ECSタスクを実行した日時(レポートの作成日時)
  • ECSタスクを実行したユーザ(レポートの作成者)
  • ECSタスクの実行ステータス

この情報をどこに保存して、参照するのかについて、検討する必要がありました。 そこで、色々調べた結果、以下の4つの案について検討しました。

  • 案1.データベースに保存する案
  • 案2.ECSタスクの実行結果を取得する案
  • 案3.S3のオブジェクトに対し、ユーザ定義のメタデータを付与する案
  • 案4.S3にjsonファイルをアップロードする案

案1.データベースに保存する案

これは一瞬で却下となりました。 わざわざテーブルの新規作成や、カラム追加を行って管理するほど重要な情報ではないためです。

案2.ECSタスクの実行履歴を取得し、参照する案

次にECSタスクの実行結果を取得する案を考えました。 調べたところ、ListTasksというコマンドがあり、実行結果を一覧として取得することができそうでした。

しかし、よく見てみてると以下のような記述があり、タスクが停止したものは1時間しか保存されず、永久的に参照することは難しいようでした。そのためこの案は、却下となりました。

Recently stopped tasks might appear in the returned results. Currently, stopped tasks appear in the returned results for at least one hour.

案3.S3のオブジェクトに対し、ユーザ定義のメタデータの付与する案

他にも情報収集を行っていたところ、S3にアップロードするオブジェクトに対して、ユーザ定義のメタデータ情報を付与できることを知りました。

ユーザー定義のオブジェクトメタデータ

オブジェクトをアップロードするときに、そのオブジェクトにメタデータを割り当てることもできます。このオプション情報は、オブジェクトを作成するための PUT リクエストまたは POST リクエストを送信するときに、名前と値 (キーと値) のペアとして指定します。REST API を使用してオブジェクトをアップロードするときは、オプションのユーザー定義メタデータ名を「x-amz-meta-」で始め、他の HTTP ヘッダーと区別する必要があります。

例えばS3にレポートのhtmlファイルをアップロードする際に、今回画面上に表示したい情報をメタデータとして付与し、そこを参照することで、今回の要件を満たすことができると考えました。
しかし、この案は採用しませんでした。確かに要件は満たせそうだったのですが、今回はレポート作成を実行したタイミングごとの情報があれば十分だったので、この案は却下となりました。
レポートファイルごとに実行状況のステータスなどが必要な場合は、こちらを採用しても良かったのかなと思っています。

案4.S3にjsonファイルをアップロードする案

今回の開発では案4を採用し、次のように実装しました。
ECSタスクの実行ステータスを「作成中」として、下記情報を含んだjsonファイルを生成し、レポート作成前にS3にアップロードします。

  • ECSタスクを実行した日時(レポートの作成日時)
  • ECSタスクを実行したユーザ(レポートの作成者)
  • ECSタスクの実行ステータス

全てのレポートの作成が完了したら、ECSタスクの実行ステータスを「完了」にしたjsonファイルを、S3にアップロードします。
なおレポート作成中にエラーが発生した場合は、ECSタスクの実行ステータスを「エラー」にしたjsonファイルを、S3にアップロードします。

ECSタスクの実行開始までに若干のタイムラグはありますが、シビアな同期は求められず、やりたかったことも実現できたため、今回の開発では案4を採用しました。

おわりに

本記事では、レポート作成機能の説明とS3・ECSに関する小話をまとめました。
改めて、クラウドサービスについて知っていることで選択肢の幅が広がるな、と実感しました。それと同時に、全てをクラウドサービスで解決するのではなく要件に合わせてベストな選択をしていくことが大切、ということも実感しました。
また、くらしのマーケットでは他のAWSサービスも活用しているため、それらについても学んでいきたいなと思いました!

最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています!
詳しくは、こちらをご覧ください。


  1. 今回機能開発したシステムは、弊社の社員が利用するシステムで外部公開していません。そのため、画面イメージを用いて説明しています。

  2. パターン1でオブジェクトの作成と確認を行った後、一度オブジェクトを全て削除し、その後パターン2で新しくオブジェクトの作成し、確認を行っています。その情報をまとめていますので、ご了承ください。

Django ORMとAldjemyの共存環境でRead Replicaを利用する

f:id:curama-tech:20211101141106p:plain

こんにちは、バックエンドエンジニアのキダです。 最近DjangoのアプリケーションにてRead Replica(リードレプリカ)構成を検証してみたので、その内容を共有しようと思います。

この記事では、DjangoのアプリケーションからDB操作にはDjango ORMと、Aldjemyを使う前提の構成になります。

Django ORMやAldjemyそのものについては以下のWebサイトをご参照ください。

DjangoでSQLAlchemyを使ってみよう

Read Replica構成について

まず前提として一般的なアプリケーションでのRead Replica構成について説明します。 databaseをWriter(Main)とReader(Replica)に分割して、負荷の高い書き込み処理と読み取り処理を、接続するdatabase単位で分けることにより、パフォーマンスを高める事ができる構成です。 Readerは2台以上構成する事も可能で、高負荷がかかった時にスケールアウトすることもできるので、スケーラビリティの高い構成と言えます。

Amazon Auroraなどのサービスでは、コンソールから簡単にRead Replicaインスタンスを追加・削除する事が可能です。 AuroraのRead Replicaは高い同期性能を持っていて、15ミリ秒程度のラグでWriterの更新がReaderに同期されます。

アプリケーション側では接続するホストの設定によって、WriterとReaderどちらを使用するかを切り替えています。 例えば、くらしのマーケットの環境では、データダッシュボードとしてRedashを利用していますが、こちらは読み取り操作のみ実行できればいいのでReaderのDBにつながるような設定をしています。

今回はRead(読み取り)とWrite(書き込み)が両方発生する、Djangoのアプリケーションにてこちらの構成を考えてみます。

公式で紹介されているDjangoでのRead Replica構成・設定方法について

公式ドキュメントでは、以下の方法が紹介されています。

デフォルトdatabaseの他にReaderの接続情報を定義して、ルーティングを定義することができます。 ルーティングは細かい設定は不要で、実際に実行されるSQLに応じて、Read系操作はdb_for_readに定義した接続先、Write系操作はdb_for_writeに定義した接続先につながるようになっていました。

しかしこのDBルーティングの機能は完全では無く、以下の問題点がありました。

  • トランザクションがコミットされる前には、WriterとReaderで不整合が発生する
  • WriterとReaderの同期ラグによって、不整合が発生する可能性がある
  • AldjemyによるDBアクセスはReaderに繋がってしまう

トランザクションやラグについては公式ドキュメントでも以下の通り言及されています。

The primary/replica (referred to as master/slave by some databases) configuration described is also flawed – it doesn’t provide any solution for handling replication lag (i.e., query inconsistencies introduced because of the time taken for a write to propagate to the replicas). It also doesn’t consider the interaction of transactions with the database utilization strategy.

トランザクションがコミットされる前には、WriterとReaderで不整合が発生する

以下のようなユーザー作成のAPIを考えてみます。

  1. [API] リクエストを受け取り、トランザクションの開始
  2. [Writer] Userテーブルにレコードを追加
  3. [Reader] Userテーブルから実際のデータをクエリして結果を取得
  4. [API] トランザクションを終了し、結果のレスポンスを返す

ルーティングにより、2.のユーザー作成のWrite操作はWriterに繋がり、3.のRead操作はReaderに繋がります。 Readerに作成されたユーザーデータが同期されるのはトランザクションがコミットされた後なので、3.のタイミングでは結果が取得できずエラーになってしまいます。

また、トランザクションを使っていない場合でも同期ラグによって、結果が取得できない事も起こり得ます。

AldjemyによるDBアクセスはReaderに繋がってしまう

クエリの接続先を見ていると、正常にルーティングできているものが多かったのですが、一部のWrite操作がReaderに繋がっている物も確認できました。

詳細を調べてみると、Aldjemyを使用したオブジェクトアクセスによるものはdb_for_readに定義した接続先に繋がっており、DBルーティングの機能では対応できない事がわかりました。

解決法

上記の結果を踏まえて、Django ORM・Aldjemyの両方でルーティング可能かつ、トランザクションの問題も発生しない方法を検討しました。

結論から言うと、DjangoのMiddleware実装によって、リクエスト単位で接続先のDBを切り替える事が可能でした。

Django Middleware

https://docs.djangoproject.com/en/3.2/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware

Djangoではリクエスト単位で、処理の前後にMiddlewareとして共通処理を定義することができます。 今回はこのMiddlewareを利用して、APIリクエストの情報に応じて接続をルーティングする機能を実装しました。

# config.py

MIDDLEWARE_CLASSES = (
    # middlewareの設定以外は省略
    'app.request_session.RequestMiddleware',
)

DATABASES = {
    # writerのDB接続情報
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'name',
        'USER': 'user',
        'PASSWORD': 'password',
        'HOST': 'writer_host_name',
        'PORT': 5432
    }
}
# request_session.py

from django.db import connections

class RequestMiddleware(object):
    # リクエストを受け取った際の処理(DBセッションが作られる前)
    def process_request(self, request):
        for conn in connections.all():
            conn.settings_dict["HOST"] = "writer_host_name"
            # GETリクエストの場合はReplicaを参照させる
            if request.method == "GET":
                conn.settings_dict["HOST"] = "reader_host_name"
        return

    # レスポンスを返す際の処理(DBセッションが作られた後)
    def process_response(self, request, response):
        for conn in connections.all():
            if hasattr(conn, 'sa_session'):
                conn.sa_session.close_all()
        return response

こちらは一番シンプルな例ですが、全てのGETリクエストをReaderに接続させて、それ以外のリクエストはWriterに接続するMiddlewareの記載例になります。

connections.all()によって取得されるdb connectionはループで書いていますが、config.pyに記載されたデフォルトdatabase(Writer)のみです。

WriterとReaderはHOST以外は同じ接続情報でつながるようになっていて、HOST情報のみを切り替える実装になります。 こちらのHOSTを書き換えることによって、実際にDjango ORMやAldjemyによって実行されるdatabaseアクセスを切り替える事が可能でした。

ちなみにconnectionのsettings_dictは以下のようなオブジェクトになっていました。

{
    'CONN_MAX_AGE': 0, 
    'TIME_ZONE': 'UTC', 
    'USER': 'user', 
    'NAME': 'name', 
    'ENGINE': 'django.db.backends.postgresql_psycopg2', 
    'HOST': 'writer_host_name', 
    'PASSWORD': 'password', 
    'PORT': 5432, 
    'OPTIONS': {}, 
    'ATOMIC_REQUESTS': False, 
    'AUTOCOMMIT': True
}

複数プロセスでの接続状況の確認

先程の実装ですが、複数のプロセスが立ち上がっている場合に、意図しない接続にならないかを確認しました。

conn.settings_dictの内容はプロセス単位でメモリが共有されているようでしたので、同一リクエスト内ではMiddlewareを通ってからレスポンスを返すまで、同じ接続情報を保持している事が確認できました。

# 確認用のコード
from django.db import connections
import os

class RequestMiddleware(object):
    def process_request(self, request):
        for conn in connections.all():
            print("request : [pid:" + str(os.getpid()) + "] [Method:" + request.method + "] [HOST:" + str(conn.settings_dict["HOST"]) + "]")
            conn.settings_dict["HOST"] = "writer_host_name"
            # GETリクエストの場合はReplicaを参照させる
            if request.method == "GET":
                conn.settings_dict["HOST"] = "reader_host_name"
        return
    
    def process_response(self, request, response):
        for conn in connections.all():
            print("response: [pid:" + str(os.getpid()) + "] [Method:" + request.method + "] [HOST:" + str(conn.settings_dict["HOST"]) + "]")
            if hasattr(conn, 'sa_session'):
                conn.sa_session.close_all()
        return response
# 実行時ログ
request : [pid:25] [Method:GET]  [HOST:writer_host_name] ## 初回のリクエストはwriterを参照している
response: [pid:25] [Method:GET]  [HOST:reader_host_name]
request : [pid:21] [Method:GET]  [HOST:writer_host_name] ## 一つ前のリクエストとはプロセスが違うので、writerを参照している
response: [pid:21] [Method:GET]  [HOST:reader_host_name]
request : [pid:25] [Method:POST] [HOST:reader_host_name] ## 前回のリクエスト時に変更された参照を持っている
response: [pid:25] [Method:POST] [HOST:writer_host_name]
request : [pid:25] [Method:POST] [HOST:writer_host_name]
response: [pid:25] [Method:POST] [HOST:writer_host_name]
request : [pid:21] [Method:GET]  [HOST:reader_host_name]
response: [pid:21] [Method:GET]  [HOST:reader_host_name]

まとめ

今回DjangoのアプリケーションのMiddlewareを使ったRead Replica構成について紹介しました。

Django ORMやAldjemyのDBセッション単位では無く、APIリクエスト単位でルーティング設定ができるので、使い勝手が良い処理だと思います。

例では全てのGETリクエストをシンプルにルーティングしていますが、WriterとReaderに50%ずつ接続したり、リクエストのURL毎にルーティングルールを定義したりできそうです。

みんなのマーケットでは、くらしのマーケットの開発を手伝ってくれるエンジニアを募集しています!
詳細はこちらをご覧ください!

ISUCONの過去問にチャレンジしてみた: Part-2

こんにちは、バックエンドエンジニアの片山です。
ISUCON 11の予選に参加したのですが、まだまだできることがあると思い、同じ問題に再挑戦しました!

問題のソースコード: https://github.com/isucon/isucon11-qualify
マニュアル: https://github.com/isucon/isucon11-qualify/blob/main/docs/manual.md
アプリケーションについて: https://github.com/isucon/isucon11-qualify/blob/main/docs/isucondition.md

Part-1 はこちら。 part と書いていますが前後関係は特にありません。
https://tech.curama.jp/entry/2021/09/22/182212

改善前の状態(1124点)

まずは何もしてない状態でベンチマークを走らせ、そのスコアと、part1 で紹介していました Elastic APM で各エンドポイントのパフォーマンスを測定します。
初期スコアは 1124点でした。平均 1,000ms 超えのエンドポイントもいくつかありますね。この状態からどこまで行けるかに挑戦します!

改善した項目に括弧書きでベンチマークのスコアを書いていきます。

なお、今回は以下のスペックのPCで実行しています。実行環境は Node.js(TypeScript)です。

OS:  Ubuntu 20.04.1 LTS
RAM: DDR4 64GB
CPU: AMD® Ryzen 5 3600 6-core processor × 12

f:id:curama-tech:20211019163054p:plain

やったこと

1. Index を貼る(1347点)

椅子を管理するテーブルの isu と、椅子の状態を管理している isu_condition では頻繁に絞り込みや集計、ソートの含まれるクエリが発行されるのですが、以下のカラムにインデックスがなかったので貼りました。スコア的には小さな改善ですが、全体的に速度が上がりました。

  • jia_isu_uuid
  • timestamp
  • jia_user_id
  • character

2. POST /api/condition/:jia_isu_uuid の改善(3155点)

Elastic APM で見たとき、impact(レイテンシが大きく、高い頻度でリクエストされるほど大きくなります)の値が一番大きい POST /api/condition/:jia_isu_uuid を改善します。この API では受け取った複数のコンディション情報を以下のようにループで1つずつ INSERT していました。これを、まとめて一度 INSERT が実行されるようにしました。

また、約40,000tpm(1分間のトランザクション数)とこの API はリクエスト頻度が非常に高いのですが、ベンチマーカーの仕様としてレスポンスまでに 100ms 以上かかるとタイムアウトします。INSERT をまとめて実行するだけでは、未だに多くのリクエストが失敗してしまっていました。そこで、bull という非同期にジョブを実行できるライブラリを使用し、データの書き込みを非同期的に実行することで API のレイテンシを改善しました。

Avg. duration 95th percentile
改善前 851 ms 10,813 ms
改善後 54 ms 84 ms

改善前

// INSERT 処理の一部
for (const cond of request) {
    const timestamp = new Date(cond.timestamp * 1000);

    if (!isValidConditionFormat(cond.condition)) {
        await db.rollback();
        return res.status(400).type("text").send("bad request body");
    }

    await db.query(
        "INSERT INTO `isu_condition`" +
        "    (`jia_isu_uuid`, `timestamp`, `is_sitting`, `condition`, `message`)" +
        "    VALUES (?, ?, ?, ?, ?)",
        [jiaIsuUUID, timestamp, cond.is_sitting, cond.condition, cond.message]
    );
}

改善後

// 非同期で SQL を実行する queue, worker を用意
const queue = new Queue("worker_execute_eql", redisUrl);
queue.process(WORKERS, __dirname + "/worker_execute_eql.ts");
let values = [];
for (const cond of request) {
    const timestamp = new Date(cond.timestamp * 1000);

    if (!isValidConditionFormat(cond.condition)) {
        return res.status(400).type("text").send("bad request body");
    }

    values.push([jiaIsuUUID, timestamp, cond.is_sitting, cond.condition, cond.message])
}

// enqueue するだけで、リクエスト中は DB への書き込みは行わない
await queue.add({ 
    sql: "INSERT INTO `isu_condition` (`jia_isu_uuid`, `timestamp`, `is_sitting`, `condition`, `message`) VALUES ?",
    values: [values]
});  

3. isu_condition を MongoDB にリプレイスする +α (8916点)

2の改善ではリクエスト頻度の高い API を改善するために insert の実行を非同期にしましたが、実はできるだけ同期的に insert を行いたいです。(理由は5で後述します) 大量に送られるデータを同期的に insert できるような、書き込みの性能の高い DB を調べていると、どうやら MongoDB が良さそうでした。

同じような条件で MySQL と MongoDB でベンチマークを回してみました。確かに MySQL よりも MongoDB のほうが insert が高速でした。MongoDB では insert が同時に4つ走っていますが(全てのトランザクションではありません)、MySQL ではすべて単一だったことが高速化に寄与しているのかもしれません。いくつかトランザクションを見てみると、MongoDB は 数ms ~ 100ms 程度、MySQL は 数ms ~ 6,300ms 程度でした。同様に、読み込みも比較してみると、全体的には少し MySQL の方が速いようでした。

isu_condition テーブルを MongoDB にリプレイスする以外には、以下のような事を行いスコアを改善しました。

  • GET /api/trend の N+1 問題の解消
  • 最新のコンディション情報を別の collection(MongoDB のテーブルのようなもの)に入れる
  • クエリの発行回数を減らすために、グローバルで宣言した Map を用いてキャッシュをする

Write

API DB Avg. duration 95th percentile
POST /api/condition/:jia_isu_uuid MySQL 161 ms 672 ms
POST /api/condition/:jia_isu_uuid MongoDB 19 ms 48 ms

MySQL f:id:curama-tech:20211019163252p:plain

MongoDB f:id:curama-tech:20211019163303p:plain

Read

API DB Avg. duration 95th percentile
GET /api/condition/:jia_isu_uuid MySQL 45 ms 100 ms
GET /api/condition/:jia_isu_uuid MongoDB 68 ms 147 ms

MySQL f:id:curama-tech:20211019163313p:plain

MongoDB f:id:curama-tech:20211019163325p:plain

4. Node.js のクラスタ化(19,207点)

Node.js は シングルスレッドで動作するため、マルチコアCPUを十分使えていません。 3 ノードで動作させるようにしたことで、スコアが大きく伸びました。以下を参考に簡単にクラスタ化を行うことができました。
https://nodejs.org/api/cluster.html#cluster_cluster

5. POST /api/condition/:jia_isu_uuid のチューニング(最大 24,619点)

ベンチマーカーのログを見ていると「ユーザーが増えませんでした」と表示されていました。ユーザーが増えるようになればスコアも改善されそうですね。色々試していると、GET /api/trend の API が最新のコンディションを返すようにしないとユーザーが増えない事がわかりました。また、ベンチマーカーの仕様から POST /api/condition/:jia_isu_uuid の API のレスポンスに 100ms 以上時間がかかるとタイムアウトし、100回以上タイムアウトするとベンチマークが強制停止する事がわかっています。

API は非同期にデータ投入するように修正しましたが、なるべく同期的に行うとスコアが伸ばせそうです。また、100ms を超えないようにする必要もあります。

そこで、はじめは insert を同期的に行い、タイムアウトが増えると徐々に非同期的に行う確率を上げていくようにしてみます。 これによりベンチマークの序盤〜中盤まではユーザーが増える様になり、スコアが改善しました。

ちなみに確率的な処理にしたためか、実行ごとに 16,000~24,000点位のブレがあります。

// リクエスト数と、タイムアウトせず失敗した数を持っている
const okRate = okCount / reqCount;
let asyncInsertProbability = 1 - okRate * 0.7;

// タイムアウト100回を超えそうになったら完全に非同期にする(ノード全体で超えないように MAX_THREADS で割ってます)
if (reqCount - okCount >= 98 / MAX_THREADS) {
    asyncInsertProbability = 1;
}

if (Math.random() <= asyncInsertProbability) {
    // 非同期的に insert する
    await queue.add({ insert_data: data, latest_data: latestData });
} else {
    // 同期的に insert する
    const [collection1, collection2] = await Promise.all([
        getIsuconditionCollection(),
        getLatestIsuconditionCollection(),
    ]);

    await Promise.all([
        collection1.insertMany(data),
        collection2.updateOne(
        { jia_isu_uuid: latestData.jia_isu_uuid },
        {
            $set: {
            jia_isu_uuid: jiaIsuUUID,
            timestamp: latestData.timestamp,
            is_sitting: latestData.is_sitting,
            condition: latestData.condition,
            message: latestData.message,
            },
        },
        { upsert: true }
        ),
    ]);
}

6 ログを消す(43,013点)

最後に、Node.js のログや Elastic APM を無効にしたところ、かなり大きくスコアが改善しました。 Elastic APM やログを使用するために追加した以下のコードをコメントアウトしました。

アプリケーションと APM は同じ PC 上で動いているため、効果が大きくなったと考えられます。

// Elastic APM の設定
// const apm = require("elastic-apm-node").start({
//   // Override the service name from package.json
//   // Allowed characters: a-z, A-Z, 0-9, -, _, and space
//   serviceName: "",

//   // Use if APM Server requires a secret token
//   secretToken: "",

//   // Set the custom APM Server URL (default: http://localhost:8200)
//   serverUrl: "http://apm-server:8200",
// });

// Node.js のログ表示用
// app.use(morgan("short"));

最終的なパフォーマンス

f:id:curama-tech:20211019163405p:plain

まとめ

思いつく範囲のことを試し、スコアを伸ばすことができましたが、まだまだできることはありそうです。今回初めて触ったのですが、エンドポイントごとのサマリーや、サンプルのトランザクションのタイムラインを簡単に眺めることができる Elastic APM がすごく便利でした。また、MongoDB も十分理解できていないまま使用したので、ほとんど本領発揮できていない状態だと思います。MongoDB (NoSQL)に興味が出てきたのでもう少し勉強してみたいと思いました!

とにかく、ISUCON はいい刺激になったので、次回も参加したいです!

AngularJS製のページをReactでリプレースした話

AngularJS to React
AngularJS to React

こんにちは、みんなのマーケットでフロントエンドエンジニアをしている山本です。

先日、AngularJSで書かれていた一部のページをReactでリプレースするプロジェクトのリリースが無事終わったため、経緯や知見を共有いたします。

前提の共有

リプレースの背景を説明する前に、必要な前提の共有をいたします。

弊社が提供するサービス、くらしのマーケットのWebアプリケーションには大きく分けると2つのページが存在します。
1つ目が、一般ユーザーが利用する「ユーザー側」と呼ばれるページです。「くらしのマーケット」と検索して出てくるページがこちらです。
2つ目が、くらしのマーケットに出店している店舗向けの「店舗管理システム」と呼ばれるページです。

今回リプレースを行ったのは、2つ目の店舗管理システムの「カレンダー」を表示するページです。
この店舗管理システムのカレンダー周りの改善施策を行う予定があったのですが、まず先にAngularJSからのリプレースを行ったほうが良いのでは?という話になりました。

フレームワークの選定について

AngularJSからのリプレースというと、メジャーバージョンアップのAngularではないのか?と思う人も多いかもしれません。
しかし AngularJSAngular では、名前こそ踏襲されているものの文法や設計がまるで異なります
特にコンポーネントの定義方法やデータバインディングの文法が違うため、実質的にフルリプレースになることは避けられないと考えられました。

そのため、リプレースすると決まった段階で「どうせ大きく変わるのであればその他のフレームワーク(ここでは便宜上VueやReactもフレームワークという括りにします)との比較をしても良いのでは」という提案がありました。

タイトルでも分かる通り、最終的に React に移行することに決定しましたが、なぜReactを選定したのかという理由を以下で説明いたします。

「前提の共有」で話した通り、今回リプレース対象となったのは店舗側システムのカレンダー画面であり、カレンダーの実装がもっとも重要なポイントでした。
そこで以下の理由から、リプレース対象となるフレームワークはfullcalendarというライブラリが対応しているフレームワークにすることになりました。

  • 現行の実装では fullcalendar というライブラリが用いられている
  • fullcalendar はカレンダーの表示や予定の登録・移動などの実装を非常に簡単に行うことができるライブラリであり、その他のライブラリではリプレースに長い時間がかかってしまうことが懸念される
  • 直近の開発で新しいバージョンの fullcalendar を用いた実績があり、ノウハウも一定数存在した

公式ホームページのGetting Started見ると分かる通り、 fullcalendarReact/Vue/Angular の3種類のフレームワークに対応しています1

さて、この3種類のフレームワークは2021年時点でもっとも有名なフロントエンドフレームワークだと思います。 それぞれのメリット・デメリットを比較した結果、以下のようになりました。
ただし、弊社・チーム特有の事情も含まれているためすべての状況で以下の通りになるとは限らないという点に注意してください。

メリット デメリット
Angular すでにユーザー側のページで広く使われている
周辺ライブラリの選定が不要
TypeScriptとの相性が良い
学習コストが比較的高い
State of JSでネガティブな意見が多い
Vue 入門ハードルが低い
SFCを使うことでデザイナーとの協力もしやすい
TypeScriptとの相性が発展途上
チーム内に経験者がいない
React TypeScriptとの相性が良い
利用者数・満足度共に高い
ライブラリ選定が必要
JSX記法に対する慣れが必要

次にこの表の中身についてより詳しく解説いたします。

Angular

Angularの一般的なメリットとして、フルスタックフレームワークとして設計されており周辺ライブラリの選定に時間が取られず開発に集中できるというものがあります。
Reactでは状態管理や通信、フォームの実装などさまざまな場面でサードパーティ製のライブラリが必要となっており、それぞれのデファクトが数種類あるということも珍しくありません(Vueの事情についてはここで書けるほど詳しくないため言及しないことにします)。

他にも、TypeScriptで設計・開発が行われておりTypeScriptとの相性も良いことが知られています。

また、これは弊社特有の事情ですが、くらしのマーケットのユーザー側のページはAngularで作られているページが多く、ある程度触れるエンジニアがいることもメリットの一つとして挙げられます。

一方でデメリットとして、学習コストの高さが挙げられます。
先ほど「ある程度触れるエンジニアがいることもメリットの一つ」と言いましたが、残念ながら現時点では、Angularを知らないエンジニアに対する教育環境が整っているわけではありません。
そのため開発時には基本的な機能のみを使うことが多いのですが、「もっと良い書き方があるのではないか」「これはAngular的に正しいのだろうか」と思いながらコードを書くことも多いです。

また、JavaScriptに興味を持つ世界中のエンジニアへアンケートを取った結果をまとめている State of JS というサイトで、Angularに対する否定的な意見を持つ人が増えたり人気が下落傾向であることも懸念点の一つでした。

Vue

Vueのメリットとして、入門ハードルが低いというものが挙げられます。
AngularではTypeScriptやAngularのモジュールシステムが、ReactではHTMLとJSXの違いがほぼ必須知識と言えるでしょう。しかしVueでは、HTML/CSS/JavaScriptを知っていればコードを書き始めることができます。

一方でデメリットとして、テンプレート内の式に対する型チェックが効かないなど、TypeScriptとの相性がAngularやReactほど良くないという点が挙げられます。
調べた限りだと Vetur というツールで解決できそうですが、こちらを見ると「頑張って対応している」という印象が強く、TypeScript単体で型チェックが行えるReactと比べるとレイヤーが1つ増える分バグが発生する可能性も高まってしまいます。
今後機能開発やバグ修正を行う際に、型によって守られた開発ができないというのは大きなデメリットだと考えられます。

また、Vueは学習コストが低いという話もよく聞きますが、個人的には懐疑的です。
Vueのディレクティブの一覧を見てみると、16種類の異なる機能を持ったディレクティブが掲載されています。
ディレクティブ以外も含めるとさらに多くの機能を覚える必要があります。
これを踏まえると、「入門しやすい」「学習曲線が緩やか」というのはおそらく真実だと思いますが、「学習コストが低い」は嘘だと思っています。

最も大きな懸念点となったのが、チーム内にVueを(少なくともAngularと同程度以上に)使える人間がいなかった点です。
Angularであればチーム内の全員が最低限は扱うことができますし、Reactであれば私がメンバーに教えることができるものの、Vueを扱えるメンバーがいない状況だとリプレースにどれくらい時間がかかるか見積もることが難しく、これが最大の懸念でした。

React

最後にReactのメリット・デメリットについてです。

Angularの際にも挙げたState of JSによると、2020年のランキングは2位(1位は選定外の svelte でした)、満足度も非常に高いという非常に人気を博しているフレームワークです。
もちろん人気のみでフレームワーク選定をするわけにはいかないので、「なぜ人気があるのか」「具体的にどのようなメリットがあるのか」を検討する必要があります。

Reactを選定した際に得られる大きなメリットとして、TypeScriptの恩恵を最大限に得られるというものがあります。
JSXというJavaScript内にHTMLのような構文を書ける記法をTypeScript自体がサポートしているため、Vueの際に課題となっていたテンプレート内での型チェックも問題なく行うことができます。
Reactの周辺ライブラリも型安全性を意識したライブラリが多いため、周辺ライブラリまで含めて恩恵を受けられると言えるでしょう。

一方でデメリットとして、スタイル方法が統一されていないというものがあります。
VueやAngularにはデフォルトでコンポーネントとスタイルシートを紐付ける仕組みがありますが、対してReactには、コンポーネントにスタイルを適用する方法が複数存在します
選択肢が多く状況に適した方法を取れるという見方もできますが、周辺ライブラリの選定コストがさらにかかるという問題にも繋がります。
しかし今回、既存のアプリケーションをリプレースするという都合上このデメリットは軽減可能でした。
というのも、すでにCSSファイル2が存在するためこれを流用するという方法が第一選択肢となったためです。

JSX記法に対してネガティブな印象を持つ方もいますが、個人的にはこれは慣れの問題が大きいと感じています。
実際、今ではReactが大好きな筆者も、実際に使い始める前まではJSX記法に対して非常に気持ち悪いという印象を持っていたためです。
上記の表ではデメリットの欄に入れましたが、現実的なデメリットではないと考えられます。

結論

今回私達は、以下の理由で React を使うことを選びました。

  1. TypeScriptとの相性の良さに起因する開発体験の良さや開発効率の高さが優秀かつ重要だと考えられたこと
  2. Angularの学習コストの大きさや人気の低下がリスクだと考えられたこと
  3. Reactのデメリットとして考えられる項目は回避、対応可能であると判断できたこと

リプレースの流れ

Reactでリプレースすることが決まったあとは以下の流れでリプレースを進めていきました。

  1. 以下の流れでチームのメンバーにReactの勉強をしてもらう
  2. 上記と平行して、既存のビルドシステムにReactを組み込んで開発が進められるように環境の構築
  3. リプレース対象の画面に含まれる機能の洗い出しとストーリーポイントの算出
  4. ゴリゴリ実装&QAフェーズ
    • 実装とQAを含めておよそ一ヶ月の期間が必要でした
  5. リリース🎉

リプレースを進める中で気をつけたこと

チーム内の他のメンバーがReact未経験であったため、リプレースを進める際には以下の点に気をつけていました。

1. チームメンバーから質問があった際には可能な限りすぐに回答する

チームメンバーにはReactの公式サイトや役に立つチュートリアルページを用いて事前にある程度勉強してもらっていましたが、実際に開発を進めてもらっているとチュートリアルでは不十分だった点が出てきます。

Reactに関する知識が少ない場合、玉石混交なインターネット上の記事を自分で調べてもらうより私に聞いてもらった方が早く解決する可能性が高いと考え、一旦私に聞いてもらうようにしていました。

2. デイリーミーティング後にエンジニア質問タイムを設ける

チームで行うデイリーミーティングでは、その日やることや困っていることについてエンジニアやPMを交えて話しているのですが、デイリーミーティングが終わったあとにエンジニアのみで集まりReactやその周辺ライブラリについてよくわからなかった部分について質問する会を行っていました。

3. メンバーからの提案に真摯に向き合う

これは言ってしまえば当たり前とも思えることなのですが、個人的に大切にしていたことなので書いておきます。

チームメンバーから「このような書き方もあると思いますがどうでしょうか?」というような提案があった際に、「初心者からの提案だ」という意識で適当に聞いてしまうことが無いように努めました。

慣れないライブラリを使って試行錯誤しつつ実装していく中で一生懸命考えた提案を適当に聞き流されてしまうと、モチベーションに大きな悪影響を与えると考えたためです。

大変だった点

リプレースを進めるに当たり、大変だった点がいくつかあったためそちらも共有します。

1. タイムゾーンの扱い

今回リプレースする対象がカレンダー画面ということもあり、タイムゾーンの扱いに少し苦労する点がありました。

くらしのマーケットは日本国内でのみ提供しているため、デバイスのタイムゾーン設定によらず日本時間で予定を表示するという仕様でのリプレースとなりました。
fullcalendar 自体にタイムゾーンを固定して表示する機能があるものの、別途プラグインを追加する必要があった上にプロジェクトで使用している時刻操作ライブラリである date-fns に対応したプラグインがありませんでした。

しかし、fullcalendar公式が提供している moment.js 向けのプラグインのソースコードを読んでみたところ20行以下で実装されていたため、これと同じような処理を date-fns-tz というライブラリで再実装することで無事にタイムゾーン問題を解決することができました。

2. 既存のCSSを流用できなかった

もともとAngularJS版のfullcalendarを使っていたため、CSSファイルは同じものを流用できるのではないかと考えていました。
しかし実際には、fullcalendarのバージョンアップにともなうCSSクラス名やDOM構造の変更によって、同じCSSファイルを流用することはできませんでした。

これに関しては地道に移植をして動作確認時に問題ないかを見ていくことで対応しました。
概ね同じスタイルであれば厳密に同じで無くても良かったのが幸いでした。

今後について

先日リプレースとリリースが完了しましたが、もともとリプレースを行う理由であった店舗管理システムのカレンダー機能への改善施策はこれからです。
Reactに置き換えたことで本当に良かったのか、これから判断していければと思っています。

みんなのマーケットでは、くらしのマーケットの開発を手伝ってくれるエンジニアを募集しています!
詳細はこちらをご覧ください!


  1. もちろんその他のフレームワーク向けのラッパーを書けばReact/Vue/Angular以外のフレームワークを用いることもできます。

  2. 厳密にはscssでした

ISUCONの過去問にチャレンジしてみた: Part-1

こんにちは!バックエンドエンジニアのカーキです。

今年の9月に初めてISUCONに挑戦してみたのですが、予選で手も足も出なくて悔しい思いをしました。問題は面白かったので、同じ問題をもう一度本気で解いてみました。

ISUCONとは

ISUCONは8時間で渡されたWebアプリケーションのボトルネックを解消し、パフォーマンスを上げるコンテストです。評価はISUCON側で用意してあるベンチマーカーを実行してから出る最終スコアでされます。

環境準備

ISUCONはGo, Rust, Pythonなどいろんな言語で挑戦できますが、仕事で長くNode.jsを使ってるので言語はJavaScript (Node.js)にしました。

もらえるソースコードには監視ツールがついてないので、ボトルネックを可視化するために、以下のツールをアプリケーションに組みました:

ログ分析ツール: Goaccess

Webアプリケーションを何もいじってない状態でGoaccessでベンチマーク時のリクエストを分析してみたら、こんなレポートが出ました。 f:id:curama-tech:20210922155044p:plain

APMツール: Elastic APM

Elastic APMを使うことでエンドポイント毎にかかってる時間だけではなく、繋がってるシステム全ての細かいtraceまで見れてとても便利でした。 f:id:curama-tech:20210922165441p:plain

試してみたこと

GoaccessElastic apm の結果の基に以下の改善点を試してみました

1. Staticファイルの配信

現行のコードではExpressからstaticファイル (css, jsなど)が配信されています。これによってExpressに無駄に負荷がかかってたので、staticファイルをNginxから配信するようにしました。

location /assets/ {
    access_log  /var/log/nginx/static-access.log;
    error_log  /var/log/nginx/static-error.log;
    root /static/;
}

2. テーブルにIndexの追加

SQLでSELECTしてるクエリをみてみたら絞り込みやソート条件になってるカラムにindexが貼られてなさそうだったので、DB初期化スクリプトにindex作成のクエリを追加しました。

3. INSERTの効率化

椅子の状態を大量にPOSTする /api/condition/:椅子のID APIではリクエストのbodyに複数件のisu_conditionが入っていて、現行のコードではloopで一つずつデータベースにINSERTするようになっています。ここをバルクINSERTに変えたら効率よくINSERTができ、スコアが改善しました。

4. Database設計の見直し

今回の課題では登録済みの椅子が自分の状態 (壊れてるかどうか、汚れてるかどうか、座られてるかどうかなど) を定期的にWebアプリケーションにPOSTする流れになっていて、当然このPOSTのエンドポイントへのリクエストが多いのでボトルネックになりがちです。既存のコードだとPOSTされたデータが以下のテーブルに入ります

CREATE TABLE `isu_condition` (
  `id` bigint AUTO_INCREMENT,
  `jia_isu_uuid` CHAR(36) NOT NULL,
  `timestamp` DATETIME NOT NULL,
  `is_sitting` TINYINT(1) NOT NULL,
  `condition` VARCHAR(255) NOT NULL,
  `message` VARCHAR(255) NOT NULL,
  `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY(`id`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

このテーブルはINSERT onlyなので、椅子の新しい状態のデータがどんどんINSERTされ続ける設計になっていて膨大になりがちです。INSERT only自体は問題がないんですが、別の/api/trendというAPIでは各椅子の最新状態が必要になり、リアルタイムで isu_condition テーブルをクエリして椅子毎の最新の状態を取得にはすごくコストがかかります。また、trend 取得時に isu_conditioncondition カラムのデータからアラートのステータスを毎回作成してるところも効率が悪いです。

改善1: isu_condition テーブルのリファクタリング

/api/trend では condition の値を毎回サーバー側で is_dirty, is_overweight, is_brokencondition_level にparseしてるので、取得時のコストを下げるために、INSERTの時点でそれぞれの値をテーブルに入れるようにしました。

新しいテーブル設計は以下のようにしました:

CREATE TABLE `isu_condition` (
  `id` bigint AUTO_INCREMENT,
  `jia_isu_uuid` CHAR(36) NOT NULL,
  `timestamp` DATETIME NOT NULL,
  `is_sitting` TINYINT(1) NOT NULL,
  `condition` VARCHAR(255) NOT NULL,
  `is_dirty` BOOLEAN,
  `is_overweight` BOOLEAN,
  `is_broken` BOOLEAN,
  `condition_level` VARCHAR(255),
  `message` VARCHAR(255) NOT NULL,
  `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY(`id`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

改善2: 椅子毎の最新の状態を持つテーブルをべつで用意 isu_condition から毎回最新の状態を取得するのは効率が悪いので最新の状態のみを持つ latest_isu_condition という別のテーブルを用意し、isu_conditionINSERT が走った時に毎回MySQLのトリガーでこの新しいテーブルにUPSERTするようにして、latest_isu_condition には常に最新の状態が反映されるようにしました。 また、/api/trend で毎回 isu テーブルとジョインしてnamecharacter を取得していたので、latest_isu_condition に最初から椅子のnamecharacterも入るようにして効率を上げました。

CREATE TABLE `latest_isu_condition` (
  `isu_id` bigint,
  `isu_character` VARCHAR(255),
  `jia_isu_uuid` CHAR(36) NOT NULL,
  `timestamp` DATETIME NOT NULL,
  `is_sitting` TINYINT(1) NOT NULL,
  `condition` VARCHAR(255) NOT NULL,
  `is_dirty` BOOLEAN,
  `is_overweight` BOOLEAN,
  `is_broken` BOOLEAN,
  `condition_level` VARCHAR(10),
  `message` VARCHAR(255) NOT NULL,
  `created_at` DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY(`jia_isu_uuid`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

5. n+1 問題

現行の GET /api/isu エンドポイントで椅子一覧を取得してからloopで各椅子の最新の isu_condition を取得するようになっていて、 n+1 問題があります。 例:

const [isuList] = await db.query<Isu[]>(
      "SELECT * FROM `isu` WHERE `jia_user_id` = ? ORDER BY `id` DESC",
      [jiaUserId]
    );
    const responseList: Array<GetIsuListResponse> = [];
    for (const isu of isuList) {
      let foundLastCondition = true;
      const [[lastCondition]] = await db.query<IsuCondition[]>(
        "SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = ? ORDER BY `timestamp` DESC LIMIT 1",
        [isu.jia_isu_uuid]
      );

ここは JOIN + latest_isu_condition テーブルを使うことで n+1 問題を解消し、パフォーマンスをかなり上げることができました。

6. Nodeアプリケーションのクラスター化

Node.jsのinstanceはシングルスレッドで動くのでサーバーがマルチコアだとしてもそのままでは最大限に力が発揮できません。そこで、Node.jsのClusterモジュールを使うことで、複数の子processでNode.jsを動かすことができ、マルチコアなサーバーの力を最大限に引き出すことができそうだったので cluster モジュールを使ってみたらさらにスコアを上げることができました。

7. アイコン配信の効率化

Goaccess の分析結果をみたら icon の取得にも数百ms時間かかってそうだったので改善してみました。 現行のコードだとDBに画像をBlobとして保存してるので、DBに無駄に負荷がかかってる状態だったのでiconもNginxから配信するように設計し直しました。

アイコンのファイルを椅子とユーザーIDの組み合わせでファイルシステムに保存するようにして、Expressからではなく、Nginxから配信するようにしたらかなり速度が上がりました。icon取得の認証にはNginxのauth_request機能を初めて触ってみたんですが、かなり役に立ちました。

結果

重たいエンドポイント一覧 (降順)

BEFORE f:id:curama-tech:20210922165112p:plain

AFTER

f:id:curama-tech:20210922165230p:plain /post/initialize はベンチマーク時の初期化のスクリプトで、スコアに影響しないエンドポイントです

エンドポイント毎: /api/isu/<椅子のID>

BEFORE

f:id:curama-tech:20210922165351p:plain

AFTER

f:id:curama-tech:20210922165404p:plain

エンドポイント毎: /api/trend

BEFORE

f:id:curama-tech:20210922165441p:plain

AFTER

f:id:curama-tech:20210922181709p:plain

次に試したいこと

まだアイディアベースですが、次に以下の改善策を試してみようと思っています

  • Endpoint毎の細かい改善
  • Nginxでロードバランス
  • Session storeをRedisにする
  • Queueの導入し、非同期で実行していいものはQueueに入れて、コンシューマーで実行するようにする
  • Kafka Streamsを使ってisu_condition のストリームを作ってTrendやグラフデータの作成を楽にする

まとめ

ISUCONに参加して何もできなかったことで、色々勉強し直す気になり、いい刺激を受けました。くらしのマーケットのシステムにも適用できる部分が沢山あったので、おかげで日々の仕事でもボトルネックを意識するようになり、参加してかなり良かったと思いました。今回のアプリケーションにはまだ見えてない改善点がいっぱいあると思うので継続して改善してみます!