- 3年前、NotionはSQLiteデータベースを使ってクライアントにデータをキャッシュすることで、MacおよびWindows向けNotionアプリの速度向上に成功した
- 今回は、ブラウザ経由でNotionにアクセスするユーザーにも同じ改善を提供できるようになった
- この記事は、WebAssembly(WASM)実装のsqlite3を使ってブラウザ上でNotionの性能を改善した方法を詳しく分析した内容
- SQLiteを使うことで、すべての最新ブラウザでページ遷移時間が20%改善した
- 特に、インターネット接続などの外部要因によりAPI応答時間がとりわけ遅いユーザーでは、その差がさらに顕著だった
- たとえば、オーストラリアのユーザーではページ遷移時間が28%、中国のユーザーは31%、インドのユーザーは33%速くなった
中核技術: OPFSとWeb Workers
- WASM SQLiteライブラリは、セッション間でデータを保持するために、Origin Private File System(OPFS)という最新のブラウザAPIを使用する
- OPFSはWeb Workersでのみ利用できる
- Web Workerは、ブラウザで大半のJavaScriptが実行されるメインスレッドとは別の独立したスレッドで動くコードと考えられる
- NotionはWebpackと一緒にバンドルされており、Web Workerを読み込むための使いやすい構文を提供する
- Web Workerを設定し、OPFSを使ってSQLiteデータベースファイルを作成または既存ファイルを読み込むようにし、このWeb Workerで既存のキャッシュコードを実行した
- Comlinkライブラリを使って、メインスレッドとWorker間のメッセージ受け渡しを簡単に管理した
SharedWorkerベースのアプローチ
- 最終アーキテクチャは、Roy HashimotoがGitHubディスカッションで提示した新しいソリューションに基づいている
- 一度に1つのタブだけがSQLiteにアクセスしつつ、他のタブからもSQLiteクエリを実行できるようにするアプローチ
- この新しいアーキテクチャはどのように動作するのか?
- 簡単に言えば、各タブにはSQLiteへ書き込める専用のWeb Workerがある
- ただし、実際にそのWeb Workerを使えるのは1つのタブだけ
- SharedWorkerは「アクティブなタブ」がどれかを管理する役割を担う
- アクティブなタブが閉じられると、SharedWorkerは新しいアクティブタブを選ぶ必要があることを認識する
- SQLiteクエリを実行するには、各タブのメインスレッドがそのクエリをSharedWorkerに送り、SharedWorkerがアクティブタブの専用Workerへリダイレクトする
- タブは同時に好きなだけSQLiteクエリを実行でき、常に単一のアクティブタブへルーティングされる
- 各Web Workerは、すべての主要ブラウザで動作するOPFS SyncAccessHandle Pool VFS実装を使ってSQLiteデータベースにアクセスする
シンプルなアプローチが機能しなかった理由
- 前述のアーキテクチャを構築する前に、タブごとに専用Web Workerを置き、各Web WorkerがSQLiteデータベースへ書き込むより単純な方法でWASM SQLiteを動かそうと試みた
- しかし、どれもそのままではNotionの要件を満たすには不十分だった
障害 #1: クロスオリジン分離
- OPFS via sqlite3_vfsを使うには、サイトが「クロスオリジン分離」状態である必要がある
- クロスオリジン分離をページに追加するには、読み込めるスクリプトを制限するいくつかのセキュリティヘッダーを設定しなければならない
- これらのヘッダーを設定するのはかなり大きな作業になりうる
- NotionはWebインフラのさまざまな機能を動かすために多くのサードパーティスクリプトに依存しており、完全なクロスオリジン分離を実現するには、各ベンダーに新しいヘッダー設定とiframeの動作変更を依頼する必要があった。これは現実的に難しい要求だった
- テストでは、ChromeとEdgeブラウザで利用可能なSharedArrayBuffer向けOrigin Trialsを使い、ユーザーの一部にこのバリアントを提供することで重要な性能データを得られた
- これらのOrigin Trialsを使うことで、クロスオリジン分離の要件を一時的に回避できた
障害 #2: 破損の問題
- OPFS via sqlite3_vfsを少数のユーザーに提供したところ、一部のユーザーで深刻なバグが発生し始めた
- これらのユーザーはページ上で誤ったデータを見るようになった
- たとえば、誤った同僚に割り当てられたコメントや、プレビューがまったく別のページになっている新規ページへのリンクなど
- このバグの影響を受けたユーザーのデータベースファイルを見ると、SQLiteデータベースが何らかの形で破損しているパターンがあった
- 特定テーブルの行を選択するとエラーが発生し、行自体を調べると、同じIDを持つ複数の行で内容が異なるなどのデータ整合性の問題が見つかった
- SQLiteデータベースがなぜそのような状態になったのかについては、並行性の問題が原因だと推測した
- 複数のタブが開かれており、各タブにはSQLiteデータベースへのアクティブな接続を持つ専用Web Workerがあったため
- Notionアプリケーションはサーバーから更新を受け取るたびにキャッシュへ頻繁に書き込むので、タブが同時に同じファイルへ書き込むことになっていた
- すでにSQLiteクエリをまとめてバッチ処理するトランザクション方式を使っていたが、OPFS API側の並行性処理の不足によって破損が起きた可能性が高いと強く疑った
- そこで破損エラーのログ取得を始め、Web Locksを追加したり、フォーカスされたタブだけがSQLiteへ書き込むようにしたりと、いくつかの応急処置的アプローチを試した
- これらの調整で破損率は下がったが、本番トラフィックで再び機能を有効にできるほど十分ではなかった
- それでも、並行性の問題が破損に大きく寄与していることは確認できた
- Notionデスクトップアプリではこの問題は発生しなかった
- そのプラットフォームでは単一の親プロセスだけがSQLiteへ書き込む
- アプリで好きなだけ多くのタブを開けても、常に単一スレッドだけがデータベースファイルにアクセスする
障害 #3: 代替案は一度に1つのタブでしか実行できない
- OPFS SyncAccessHandle Pool VFSバリアントも評価した
- このバリアントはSharedArrayBufferを必要としないため、Safari、Firefox、およびSharedArrayBuffer向けOrigin Trialがないその他のブラウザでも利用できる
- このバリアントの欠点は、一度に1つのタブでしか動かせないこと
- 後から開いたタブでSQLiteデータベースを開こうとすると、単純にエラーになる
- 一方で、これはOPFS SyncAccessHandle Pool VFSがOPFS via sqlite3_vfsバリアントのような並行性の問題を持たないことを意味する
- 少数のユーザーに提供した際に破損問題が見つからなかったことで、それを確認した
- 他方では、すべてのユーザータブがキャッシュの恩恵を受けられるようにしたかったため、このバリアントをそのままリリースすることはできなかった
問題の解決
- どのバリアントもそのままでは使えないという事実が、上で説明したSharedWorkerアーキテクチャを構築するきっかけになった
- このアーキテクチャは、これらのSQLiteバリアントのいずれかと互換性がある
- OPFS via sqlite3_vfsバリアントを使う場合は、一度に1つのタブだけが書き込むため、破損問題を回避できる
- OPFS SyncAccessHandle Pool VFSバリアントを使えば、SharedWorkerのおかげですべてのタブでキャッシュが可能になる
- このアーキテクチャが両方のバリアントで動作し、測定指標で性能向上が明確に見られ、破損問題もないことを確認したあと、どのバリアントを提供するか最終的に選択する段階になった
- OPFS SyncAccessHandle Pool VFSを選んだ。クロスオリジン分離の要件がなく、ChromeとEdge以外のブラウザへの展開を妨げないためだ
性能低下の緩和
- この改善をユーザーに提供し始めたとき、読み込み時間の悪化など、修正すべきいくつかの性能低下が見つかった
ページ読み込みが遅くなる
- 最初の発見は、Notionページ間の移動は速くなった一方で、初回ページ読み込みは遅くなったことだった
- プロファイリングの結果、ページ読み込みでは通常データ取得がボトルネックではないと気づいた
- Notionのアプリ起動コードは、API呼び出しの完了を待つ間に他の作業(JSのパース、アプリ設定など)を実行するため、ページ遷移ほどSQLiteキャッシュの恩恵を受けない
- 遅くなった理由は、ユーザーがWASM SQLiteライブラリをダウンロードして処理する必要があったため
- これがページ読み込み処理をブロックし、他のページ読み込み作業が同時に進まないようにしていた
- このライブラリは数百キロバイトのサイズがあるため、追加時間が測定指標に目立って現れた
- これを解決するために、ライブラリの読み込み方法を少し変更した
- WASM SQLiteを完全に非同期で読み込み、ページ読み込みをブロックしないようにした
- これは、初回ページデータがSQLiteから読み込まれることはほとんどないことを意味していた
- それでも問題なかった。SQLiteから初回ページを読み込んで得られる速度向上は、ライブラリのダウンロードによる速度低下を上回らないと客観的に判断したためだ
- 変更を適用した後、初回ページ読み込みの測定指標は実験のテストグループと対照グループで同じになった
低速なデバイスはキャッシュの恩恵を受けない
- 測定指標で見つかったもう1つの現象は、Notionページから別のページへ移動する中央値の時間は速くなった一方、95パーセンタイルの時間は遅くなったことだった
- Notionを表示するブラウザを持つモバイルフォンのような特定のデバイスでは、キャッシュの恩恵を受けず、むしろ悪化していた
- この謎への答えは、モバイルチームが以前に行った調査で見つかった
- ネイティブモバイルアプリケーションでこのキャッシュを実装した際、古いAndroidフォンのような一部デバイスではディスクからの読み込みが非常に遅かった
- そのため、ディスクキャッシュからデータを読み込むほうがAPIから同じデータを読み込むより速いとは限らなかった
- モバイル調査の結果、ページ読み込みにはすでに2つの非同期リクエスト(SQLiteとAPI)を互いに「競争」させるロジックがあった
- ページ遷移クリックのコードパスにこのロジックをそのまま再実装した
- これにより、実験の2グループ間でページ遷移時間の95パーセンタイルが同等になった
結論
- ブラウザ上でNotionにSQLiteの性能改善を提供することには、それなりの難しさがあった
- 特に新しい技術に関して、一連の未知の問題に直面し、その過程でいくつかの教訓を得た:
- OPFSは基本的に並行性をうまく処理しない。開発者はこれを認識し、それに合わせて設計する必要がある
- Web WorkersとSharedWorkers(そしてこの記事では触れていない親戚のService Workers)はそれぞれ異なる機能を持っており、必要に応じて組み合わせることが有用になりうる
- 2024年春時点では、高度なWebアプリケーションでクロスオリジン分離を完全に実装するのは簡単ではない。特にサードパーティスクリプトを使う場合はなおさら
- ユーザー向けにブラウザへSQLiteでデータをキャッシュした結果、前述のページ遷移時間20%向上が見られ、他の測定指標の悪化は見られなかった
- 重要なのは、SQLite破損による問題が観測されなかったこと
- この最終アプローチの成功と安定性は、SQLiteの公式WASM実装を担当したチーム、そして実験的アプローチを一般に提供したRoy Hashimotoのおかげだと考えている
6件のコメント
だからこそ、サードパーティと連携しなければならないサービスは、最初のリリース時からクロスオリジン分離を有効化しておくべきなんだよな……
おっと、cometkimさん、はじめまして〜
私の Firefox では Notion のページを開くとフリーズして使えなくなるのですが、これが原因なんでしょうか..(Notion アプリは問題なく動作するので、とりあえずそちらを使っています)
たぶんそうだと思います。Enda も Chrome と Edge でしかローカルファイル書き込みをサポートしていませんでした
昔の古いLinuxノートPCでこういうことがあったんですが、プライベートモードで開くと大丈夫でした