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

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

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

新卒エンジニア向けお仕事紹介資料を公開しました

くらしのマーケットを支えるエンジニアのお仕事

こんにちは。みんなのマーケットでCTOをしている戸澤です。

みんなのマーケットでは、エンジニアの新卒採用を通年で行っています。

新卒の方はコードを書く以外の業務を、入社前の時点で想像することは難しいです。 チーム開発、特にPM、デザイナーなど様々な職種の人と協働する点や、継続的な機能改善などは学習の場では経験できず、業務ではじめての経験になることがほとんどだと思います。

このお仕事紹介資料はコードを書く以外の業務について入社前に理解を深めることを目的に、職種紹介、開発案件に対してチームでどう動くかなどを紹介したものです。

続きを読む