私はAccel RecordというTypeScript用ORMライブラリを開発しています。
Accel Recordは、他のTypeScript/JavaScript用ORMライブラリとは異なり、非同期APIではなく同期APIを採用することにしました。
しかしDBアクセスを同期的に実行するためには、技術的な調査を重ねる必要がありました。
この記事では、同期的なDBアクセスを実現するためにAccel Recordがどのような技術を採用したかを紹介します。
サポート対象のデータベース
Accel Recordは以下のデータベースをサポートしています。
- SQLite
- MySQL
- PostgreSQL
開発初期段階では、SQLiteとMySQLのサポートを優先的に行いました。
ですので、この記事ではSQLiteとMySQLに焦点を当てて紹介します。
SQLite
SQLiteは、Node.jsで利用する場合better-sqlite3というライブラリがよく使われています。
他のORMライブラリでも、SQLiteへのアクセスはbetter-sqlite3
を利用していることが多いです。
調べてみると、実はbetter-sqlite3
はそもそも同期APIを提供していました。
そのためSQLiteへのクエリを同期的に実行することは、better-sqlite3
を利用して簡単に実現することができました。
MySQL
問題はMySQLでした。
Node.jsでMySQLを利用する場合、mysql2というライブラリがよく使われています。 mysql2
は非同期APIのみを提供しているため、同期APIを利用することができませんでした。
他に同期APIを利用できるMySQL用のライブラリがあるか調査しましたが、最近までメンテナンスされているものは見つかりませんでした。
そこで次は、非同期APIを同期的に実行できる方法が無いか調査することにしました。
古いライブラリでは同期的にMySQLへのクエリを実行できると謳うものがいくつか見つかったので、それらがどのように同期処理を実現しているのか調査してみました。
1つ目はAtomics.wait()を使う方法でした。非同期処理を行うスレッドと、その結果を同期的に待ち受けるスレッドの2つを使うというやり方です。またこれをラップして使い勝手を良くするためのライブラリもsynckitなどが見つかりました。
ただsynckit
はメインスレッド以外から使うことができず、マルチスレッドでは簡単に利用できませんでした。Accel Recordのプロジェクトでは、テストにVitestを利用しています。Vitestは、Node.jsのworker_threads
を使ってマルチスレッドでテストを並列実行するため、この制約は導入の障壁になりました。
2つめの方法としてsync-rpcというライブラリにいきあたりました。これはNode.jsのchild_processモジュールを利用し非同期処理の実行用に別プロセスを立て、その結果を同期的に待ち受けるためのライブラリです。 手元で試してみると、sync-rpc
を使ってmysql2
の非同期APIを同期APIとして利用することができました。
ただsync-rpc
自体も古いライブラリで、全てが期待通り動くわけではありませんでした。 そこでsync-rpc
のソースコードを取り込み、必要な修正を加えることで、期待した動作を実現することができました。
sync-rpcの動作
sync-rpc
は以下のように動作します。
- メインプロセスからエントリーポイントとなるファイルを指定し、子プロセスを起動する
- 子プロセスはエントリーポイントのファイルを読み込み、サーバーとして起動する
- メインプロセスは子プロセスに関数の実行をリクエストし、同期的に結果を待ち受ける
- 子プロセスは非同期関数を実行し、結果をメインプロセスに返す
- メインプロセスは、子プロセスからの結果を受け取り、同期的に処理を続行する
- メインプロセスが終了すると、子プロセスも終了する
このようにsync-rpc
を使うことで、あらゆる非同期処理を(メインプロセスから見ると)同期的に利用できることがわかりました。
現在のAccel Recordの実装
sync-rpc
を使うと非同期処理を同期的に利用することができることがわかりました。
そこで現在の段階ではDBエンジンの種類によらず、SQLiteやPostgreSQLでもsync-rpc
経由でクエリを実行する作りにしています。
具体的には、発行するSQLの構築まではメインプロセスで行い、クエリの実行のみsync-rpc
を使って子プロセスで行うような形です。
今後の改善
現在の実装では、sync-rpc
を使って非同期処理を同期的に実行していますが、これは子プロセスを起動する仕組みになっています。
ただ、子プロセスを利用することはデメリットもあると思います。
- プロセス間通信のオーバーヘッド
- メインプロセスと子プロセス間でデータをやり取りするため、その分のオーバーヘッドが発生します
- ただし一般的に、DBアクセスのレイテンシに比べるとプロセス間通信のオーバーヘッドが特別大きいわけではなく、今回のケースでは大きな問題にはなりにくいのではないかと思っています。
- 運用の複雑さ
- 子プロセスを起動することで、運用が複雑になる可能性があります
- 現状では子プロセスの起動にNode.jsの
child_process
に依存しているため、Node.js以外の環境での運用が難しいかもしれません - 一般的なNode.js環境や、Node.jsが動くサーバーレス環境(AWS Lambda, Vercel Functions等)では正常に動作すると思われます
上記のデメリットが解消できる方法が見つかれば、採用を検討したいと思っています。
まとめ
同期的なDBアクセスを実現するためにAccel Recordがどのような技術を検討・採用したかを紹介しました。
調査段階ではマルチスレッドやプロセス間通信を利用することで非同期処理を同期的に実行する方法を検討しています。最終的には、別プロセスを立てるsync-rpc
を利用してクエリを同期的に実行する形になりました。
Accel Recordが同期APIを採用することでどのようなインターフェースを実現できているか『Active Recordパターンを採用したTypeScript用ORM「Accel Record」の紹介』やREADME(日本語)で是非チェックしてみてください。
(この記事は『Techniques for Synchronous DB Access in TypeScript』の原文です)