フロントエンドでデータベースを作り直さないようにしよう
(sqlsync.dev)- 複雑なフロントエンドアプリは API レスポンスのキャッシュから出発し、手動インデックスやキャッシュ無効化まで抱え込むことで、プロジェクトごとに小さなデータベースを作り直している状態に近づいていく
- React のような宣言的フレームワークでは、レンダーのたびに API を呼ばないようにするため、ローカル状態や Redux レイヤーにキャッシュを置き、このレイヤーが次第に中央ストアの役割を担うようになる
- ID ベースのストアと日付別参照構造は参照を高速化するが、
CACHEとENTRIES_BY_DATEのような複数構造のあいだで一貫性を維持する負担が、テストやコードレビューの重荷として大きくなる - 楽観的変更はサーバー応答前に UI を即座に更新して体感速度を高めるが、クライアント・サーバー間のロジック重複、進行中変更の追跡、エラーロールバック、アプリ再起動後の調整といった整合性コストを伴う
- SQLSync は SQLite ベースのローカルデータベースと Git Rebase に似た同期によって、永続キャッシュ、インデックス、制約、楽観的変更、リアクティブクエリをフロントエンドスタック内で提供しようとしている
フロントエンドキャッシュがデータベースへと肥大化する過程
- フロントエンドのデータ管理は、API レスポンスをローカル変数に保存する単純なキャッシュから始められる
- React のような宣言的フレームワークは、ユーザー操作中にツリーを何度も再レンダーする
- レンダーのたびに API リクエストを送らないため、
useStateとuseEffectでリクエスト結果やエラーをコンポーネント状態に保存できる - 例は明確さのために単純化されており、実際には検証済みの API ライブラリを使う選択肢もある
- キャッシュは UI ツリーのより高い階層や、UI の外側へ移すこともできる
- Redux は状態を統合し、時間に沿った原子的変更を調整する React の状態管理ライブラリである
- Redux のエコシステムは、API データキャッシュを管理するツールやパターンへと拡張されてきた
- こうした使い方の目的は、キャッシュロジックを中央集約し、更新を調整し、コンポーネント間でキャッシュ結果を共有することにある
- キャッシュ層が大きくなるほど、レンダリングエンジンやユーザーアクションに合わせてデータを効率よく扱う中央ストレージシステムに近づいていく
手動インデックスと一貫性維持の負担
- フロントエンドでも、サーバーから受け取ったデータを ID をキーにしたオブジェクトへ保存することで、高速に参照・更新できる
- REST API を使うアプリでは、データをバッチで読み込み、必要なオブジェクトを追加で補強することがよくある
- オブジェクトを ID ごとに保存すると、API 結果をキャッシュへマージしやすい
- この構造は ID ベースの作成、読み取り、更新、削除に最適化されている
- 複数項目を走査する必要があるフィルタリングでは、全エントリ検査を避けるために別のインデックスを作るようになる
createdAtの年・月・日を基準にENTRIES_BY_DATE構造を作れば、特定日のエントリを素早く見つけられる- その代わり、
CACHEとENTRIES_BY_DATEのあいだの一貫性を継続的に保たなければならない - 日付範囲の参照には複数回のアクセスが必要であり、日付順ソート済み配列や、より複雑な参照・更新ロジックのほうが適した構造かもしれない
- インデックスが増えるほど、各インデックスごとに作成、更新、参照ロジックが必要になる
- 正しさの検証はテストやコードレビューの負担として大きくなる
- 1 つのインデックスで削除や更新を漏らすと、見つけにくいバグが生じうる
- 新しいアプリケーション機能よりも、この複雑さを管理するインフラに多くの時間を費やすことになりかねない
- 実際のデータベースインデックスは、単にデータを別形式で保存するよりもはるかに複雑である
- 統計収集、データバージョン管理、トランザクション制御、ロック、クエリ最適化との相互作用といった要素が含まれる
楽観的変更が加える整合性の問題
- 楽観的変更は、サーバー応答前に特定操作の効果をローカルで先にシミュレーションする
- UI はネットワーク遅延がないかのように即座に反応できる
- サーバーが予想と異なる判断を下したりエラーが発生したりすると、UI は変更をロールバックし、ユーザーに問題の修正を求める必要があるかもしれない
- クライアントがサーバー結果をうまく予測でき、エラーをクライアント側で処理でき、ロジックが密接に同期しているときには強力な手法となる
- 楽観的更新は通常、4 段階で進む
- UI が書き込み操作を発生させる
- サーバーが同意すると仮定してローカルキャッシュに変更を適用し、UI を即座に再レンダーする
- 変更操作を非同期でサーバーへ送信する
- サーバー応答をローカルキャッシュへマージして以前の楽観的変更を上書きし、必要なら UI を再レンダーする
- サーバーとの一貫性を維持するためには、いくつもの負担が生じる
- 結果を予測するために、クライアントとサーバーの両方へロジックを重複実装しなければならない
- 非同期エラーやサーバー不一致を処理するには、進行中の各変更を追跡する必要がある
- より良いユーザー体験のために、楽観的キャッシュ部分を永続化し、アプリ再起動後に変更を調整する必要が出ることもある
- この過程は開発時間と正しさ検証のコストを押し上げ、データ管理がユーザー価値や差別化機能の開発より前面に出てしまいかねない
再帰的キャッシュ無効化の複雑さ
- データ量の多いアプリでは、同じ情報がキャッシュ内の複数箇所に現れる
- 例のキャッシュは
projects、tasks、usersをまとめて保存している - タスク完了後、プロジェクト進捗、ユーザーに割り当てられたタスク、新しいタスク情報など複数部分に影響が及ぶ可能性がある
- 例のキャッシュは
- タスク完了後にキャッシュをサーバーと一致させるには、複数回の往復が必要になることがある
- サーバーにタスク完了を通知する
- プロジェクト進捗が変わったのでプロジェクトを再取得する
- 新しいタスク割り当てがあるか確認する
- 新しい割り当てがあれば、そのタスクを取得する
- より複雑な API にすれば往復回数を減らせるが、API やクライアントロジックが基底データモデルと結びつくという結果は残る
- GraphQL はこの問題への 1 つのアプローチだが、完全な解決策ではない
- UI が変更のたびにキャッシュのどの部分が関係するかを把握しなければならない構造は、規模が大きくなるほど脆くなる
- データの関係性や集計が、ローカルキャッシュの複数部分に影響しうる
- エンジニアリングチームが大きくなると、問題はチーム境界をまたぐようになり、大規模ソフトウェアプロジェクトにおける変更可能なグローバル変数に近い感覚になりうる
- 楽観的変更と組み合わさると、クライアントはサーバー変更を予測するためにさらに多くのバックエンドロジックを複製することになる
- 例では task 1 を user 1 から外し、総タスク数を使って進捗率を新しい比率に計算し直そうとするかもしれない
- 入れ子の変更をローカルで予測させるほど、クライアントはバックエンドスタックをより多く重複させる
SQLSync が提案するフロントエンド向けデータベーススタック
- SQLSync は SQLite 上に構築された、フロントエンド最適化データベーススタックである
- サンプルの Todo app は、データ層全体を Rust 60 行 と、コンポーネントに散在するいくつかの SQL クエリ で実装している
- SQLSync は永続キャッシュ、SQLite のインデックス・制約・トリガー・クエリ最適化、楽観的変更、スマートなキャッシュ無効化、リアクティブクエリを提供する
- ローカルデータは 1 つ以上の SQLite データベースに保存される
- インデックスを簡単に作成でき、データと自動的に同期される
- データベースはバックエンドと同様に、インデックスを自動使用してクエリを高速化できる
- SQL では複雑なデータ参照を表現でき、triggers、foreign keys、constraints、full-text search といった機能も使える
- 楽観的変更は reducer で処理される
- 構造は Redux の中核概念 に似ている
- reducer は WebAssembly にコンパイル可能な任意の言語で書ける
- SQLSync はクライアント側で変更を楽観的に実行し、サーバー側ではグローバルに一貫した順序で実行する
- その後クライアントは、Git Rebase に似た処理でサーバーと同期する
- このアーキテクチャには、再帰的キャッシュ無効化の必要をなくせるという利点がある
- すべてのデータ変更ロジックを、クライアントとサーバーで共有しやすい reducer 内に書ける
- 変更中に発生したすべてのデータ変化が自動的に可視化される
- 同期が Git Rebase のように動作するため、サーバーがクライアントと異なる変更を行っても、クライアントは同じ一貫した結果に到達できる保証を持つ
関連する取り組み
- Riffle の “Building data-centric apps with a reactive relational database” は、UI 状態を含むすべてのアプリケーション状態を 1 つのリアクティブデータベースに保存するというアイデアを扱っている
- リアクティブクエリは明快な思考モデルを提供し、React のような宣言的システムと相性がよい
- クライアントアプリ開発の問題を、データベースコミュニティのアイデアで解決しようとしている
- 関係データモデルと実際のインデックスで状態をモデル化する利点を扱っている
- Instant.db の Stepan は、ブラウザ内データベースを扱う 2 本の記事を書いている
- Database in the Browser, a Spec
- A Graph-Based Firebase
- 2 本の記事はフロントエンドとバックエンドスタックの関係により焦点を当てつつ、似た問題を扱い、Firebase のグラフベース後継として Instant.db が作られた動機を説明している
- Matt Wonlaw の CR-SQLite は SQLite 拡張である
- Conflict-free Replicated Data Type(CRDT)と因果順序イベントログを使ってデータを一貫してマージする
- 中央コーディネーターなしで、ピアツーピアアプリが SQLite にデータを保存し、共同作業できるようにする
- ブラウザで SQLite を実行する実例でもある
- Matt Wonlaw は関連アイデアも探求している
- incremental computation
- typed-sql による SQL の使い勝手改善
1件のコメント
Hacker Newsのコメント
このプロジェクトのことはよく知っていて、作った人が友人なので、ここに来て質問に答えてもらうようにしてみる
彼は熟練したデータベースアーキテクトです。SQLsyncは、フロントエンド開発者がリモートデータベースを、まるでブラウザ内に完全に存在するかのように照会・更新できるように作られています。実際、ほぼそうで、WASMのおかげでSQLiteデータベース全体をブラウザに送れます。肝は、複数クライアント間で同期する賢いが単純なリアクティブアルゴリズムにあります
開発作業のかなりの部分がデータ同期だと考えると、ReactとREST APIも一種の同期手順と見なせますし、このアプローチは新しい可能性を開きます。APIから取得してキャッシュしたオブジェクトツリーで妙な独自データベースをさらに作る必要はなく、リレーショナルデータベースの力でローカルで直接更新・照会すればよいのです
ただし従来型のWeb企業では、専門化されたバックエンド/フロントエンドチームのため導入が難しいです。データベース、バックエンド、転送、認証レイヤーを取り除いて、1つのブロックシステムに置き換えるようなもので、システムアーキテクトの大半はバックエンド出身なので、この問題をよく知りません。両側に深く触れるため既存システムにはうまく合わず、結局は新規開発向きです。バックエンドはAWSやAzureのサービスでもなく、Lambdaフレンドリーでもないので、私が出会うアーキテクトタイプの人たちはたいてい手を出そうとしないでしょう
この方式は、昔からある技術であるCouchDB+PouchDBでも、ある程度はすでに存在しています。一部の用途にはかなりうまく合いますが、クエリシステムが理想的ではなく、認証とデータスコープ指定のやり方が多くの人にとってなじみがありません。データが単一ユーザーに完全に帰属し、ユーザーごとのデータベースモデルをそのまま使う場合が最も簡単です。CRDTでデータを強く分割すると、競合問題もかなり減ります
しかしスケーラビリティの問題があります。CouchDBは1万〜10万人のユーザーが接続するとCPU要求が非常に高く、技術も古いものの、メンテナンスはされています。システム設計の観点では、ユーザー間でデータ共有を始めると複雑さが急激に増し、複雑さを解決するというより場所を移すだけになってしまい、適合性が下がります
このアプローチも同じ目標を狙っているようですが、似たスケーラビリティ問題に直面する可能性が高いです。今後どう発展するか楽しみで、最初の一歩のように見えます
以前Chromeがブラウザに文字どおりSQLデータベースを入れようとしていた記憶がありますが、うまくいかず、localStorageが主流になりました。有用性をけなしたいわけではなく、普通はブラウザが提供するものを選ぶものです。WASMと、それがさらに成熟したり機能が増えたりすることで、ブラウザに持ち込める可能性には大いに期待しています
以前勤めていた会社では、変更作業にチェックアウト/チェックイン機構があるプロジェクト管理ソフトウェアを使っていました。プロジェクトをチェックアウトするとローカルで編集するコピーをダウンロードし、チェックインするとサーバーに戻す方式です。チェックアウト中はプロジェクトがロックされます。ライブ更新アプリの時代には、誰もが古い方式だと感じていました
ところが10年間SPA Webアプリを作ってきた後では、そのデータ同期方式は時代を先取りしていたように感じます
結局、複数の同時更新間の不整合を一貫して解決する手順を実装できるかどうかに帰着します。可能な場合も不可能な場合もあり、これは技術力よりビジネスルールに依存します
ビジネスルール上、解決メカニズムを実装できないなら、同時更新をサポートする技術的能力があっても、一度に1つの更新だけにするためのロックが必要です
しかし実際に欲しいものがこの方式なのだと説得するのは難しいです。すべてが常に利用可能であるべきだという大きな幻想に陥りがちですが、現実には通常、1人が一度に変更し、2人以上が作業する必要があるなら、どうせ互いに話したりコミュニケーションして調整しなければならない場合が多いです
Gitのように完全に分散された開発でも、競合を自動で魔法のように解決することはできません。正しい変更を選ぶには、依然として他の人とコミュニケーションし、文脈を理解する必要があります
ものによっては、実証済みの解法が必要です
以前会社がRCSからCVSへ移行したとき、同僚の1人がCVSはロック付きチェックアウトをサポートしていないと苛立っていたのを覚えています
https://en.wikipedia.org/wiki/Concurrent_Versions_System
単一所有者のロック戦略もSQLSyncでシミュレートできると思います。ただしアプリによっては不要かもしれません。目標がオフライン作業後、準備できたときにマージすることなら、SQLSyncはこのパターンを標準で提供します。目標がただ1つのクライアントだけに変更を許すことなら中央ロックパターンが必要で、これもSQLSyncを通じて調整できる可能性があります
ここには「測定されるものは管理される」という原則とサンクコストの誤謬が絡んでいる
データベースの本当の問題は複雑さにある。個々の機能はたいてい安全だが、信頼性・キャッシュ・インデックスが噛み合う時点で複雑さが爆発し、普通はドメイン特化DBを実装するのは割に合わない。
ところが会社がその3つの機能の実装にすでに投資し、多くのリソースを注ぎ込んだと気づく頃には、政治的にそれを取り除こうと勧めるのは難しく、技術的負債を一度に取り除く実際のコストも大きい。
本当の問題はSQLの構文だと思う。標準的なリレーショナルデータベースを使う体験が、壊れた英語ではなく、慣れ親しんだC風の構文のように快適だったなら、自作するよりDBを使おうというインセンティブはもっと大きかったはずだ。NoSQLデータベースはその方向への良い一歩だったが、たいてい日常的な有用性よりビッグデータに過度に集中していた。Redisのようなものは定着していて、悪くない。
SQLを簡単に実行できるようにするのは合理的なアプローチだが、優れたデータベース、たとえば私の好きなPostgresではSQLが基本言語なので、その言語を使わずに効率を得るのは難しい。Postgresを完全に複製しつつ、標準パーサが良い構文の言語をサポートするPostgresPostSQLのようなデータベースが本当に必要だ。
一般的なプログラミングでは数十の言語が使われ、進化し続けている。ブラウザが実行し、ユーザーのブラウザを制御できないため変えにくいJavaScriptでさえ、トランスパイラやWebAssemblyを通じて進化している。
ところがデータベースには事実上SQLひとつしかない。代替はあるが、使用量の面でSQLに近いものはない。もしかするとSQLはそこまで悪くないのかもしれない。
理由はリレーショナルモデルが本当に優れているからかもしれない。ここから外れる試みはニッチでしか通用しない可能性が高い。宣言的スタイルも非常に良く、ここから外れても大きな成功を収めるのは難しい。結局、構文だけが違うSQLを作ることになるなら、ほとんどの人にとって方式を変えるほどの大きな改善にはならないだろう。
このAPIを対象に書かれたアプリケーションはSQL DBを実装できる。SQLをパースし、このAPIに合うクエリプランを吐き出すクエリプランナを実装すればよい。
著者です。ほとんどの質問には何とか目を通したところで、見落とした質問がないか定期的に確認し続けます。HNの議論をもっと追跡しやすくする方法を誰かが作っていないのかも気になります。
これまでの議論はとても嬉しいです。最初の記事では、SQLSyncが具体的にどう動作するかよりも、私がSQLSyncを作ることになったフロントエンドエンジニアリング上の動機に焦点を当てました。次の記事で動作方式を扱う予定です。
ユーザーに、現実をひどく、あるいは目に見えない形で壊してしまうようなメンタルモデルを持たせてはいけない。
クライアント・サーバーモデルの代わりにデータベースを同期する方式が、その一例なのではないかと心配している。同期メカニズムが単に溶け落ちたり、満たされていない深い前提があったりするかもしれない。
高速なUIが必要なら、CRDTのプリミティブ集合を作って使うほうがより安全に感じられ、残りはフォーム送信にとどまるように思う。
クライアントとサーバー間の状態同期は呪われた問題だ。
多少のユーザー体験の犠牲を受け入れてPHP/サーバーサイドレンダリングモデルに近い方式へ戻れば、この問題を丸ごと避けられる。SPAは良いが、multipartフォーム送信も依然として機能する。ごく少量のJavaScriptだけでも、残った粗い部分の大半を整えられる。
最近のWeb製品でクライアント側の状態といえば、サードパーティIdPの認証クレーム、クエリパラメータ内のファーストパーティセッションID、現在のドキュメントだけだ。最初のものがどこに保存されるのかは、正直私にも分からない。それはMicrosoftの問題であって、私たちの問題ではない。その他すべての状態はサーバーにある。
クライアントを、一日中入力だけを打ち出す愚かな端末のように扱っている。ファーストパーティCookieもローカルストレージも使わない。この方式はiOS/Safari向けの開発体験を大きく改善した。
だから、実際に提供しようとしている体験は何で、なぜクライアントとサーバーの状態を分離するほど正当化されるのかを聞きたい。
参考: https://news.ycombinator.com/item?id=37584049
SQLite ベースのオフライン/ローカルファーストが最近熱いように思える。今週これを読むのは3回目で、良さそうに見える。
ところで ElectricSQLhttps://electric-sql.com/ や PowerSynchttps://powersync.com/ と比べるとどうなのだろう?
ElectricSQL と PowerSync はどちらも、部分レプリケーションという非常に難しい問題に取り組んでいる。従来型の中央データベースが、クライアントに必要なものだけを双方向同期しつつ、楽観的な変更と、それに伴う整合性/競合処理もサポートする汎用的な解を作ろうとしている。
欠点は実装の複雑さだ。データベース全体のうち、どのサブセットを各クライアントが持っているのかを正確に追跡しなければ、そのサブセットにだけ変更をプッシュできない。また、データベース状態のうちどのサブセットをダウンロードするか指定するには新しい DSL が必要で、それも新たに学び、最適化しなければならない。それでも、彼らが非常に難しい問題を解こうとしているのは喜ばしいし、SQLSync が部分レプリケーションをサポートする準備ができた頃には、すでにベストプラクティスが整理されているはずだ。
一方で SQLSync は現在、DB 全体の同期のみをサポートしている。すべてのクライアントがデータベース全体について一貫したビューを見ることになる。これが良い考えなのかはすぐ疑問に思うかもしれないし、アプリによっては合わない。しかし個人向け家計管理アプリを考えると、主な目標はデバイス間同期、クラウドバックアップ、オフライン利用などなので、すべてのデバイスに DB 全体が保存されるのは、むしろ望ましい方式かもしれない。Airtable のようなドキュメント指向のデータモデルも例になり得る。各 Airtable を別々のデータベースにすれば、どのテーブルを気にするかはクライアントが管理すればよい。
DB 全体の同期に集中すると、部分レプリケーションをサポートする解決策よりも同期エンジンははるかに単純になる。その利点の1つは、バックエンドが非常に軽量になることだ。現在のデモ(https://sqlsync-todo.pages.dev)は、Cloudflare Durable Objects の中で、ごくわずかなストレージと CPU 時間だけを使って完全に動作している。
SQLSync がこうしたユースケースを可能にするにはまだやることが多く、依然としてプロトタイプに近いが、初期テストは非常に良好だった。
大規模なマルチテナントアプリで、個々のデータセットが比較的小さい場合に、「単にデータベースをクライアントに送ればよいのでは」と何度も考えたことがある。十分に標準から外れた、呪われたアーキテクチャパターンのように見えたので、深くは掘り下げなかった。自分が間違っていたと分かるといいのだが。
きちんと証明するにはやることが多いが、このまま押し進めてどこへ行くのか見てみたいので、かなり期待している。
これは SPA を捨てれば完全になくなる問題の1つに見える。
Hotwire や htmx 系の解決策を使えば、クエリは単なるサーバークエリになり、そのクエリを高速化する問題ははるかによく理解されている。
ocaml + web components と一緒に使ってみたが、生産性 10/10の体験だった。まばたきより速くコンパイルされるビルドツールが1つあればよく、フロントエンドとバックエンドの間で JSON マッピングを配線する必要もないので、本当に生産的だ。
個人的には InertiaJs https://inertiajs.com のほうが好みだ。サーバーと「昔ながらのやり方」で状態を同期する、一種のフロントエンドルーターシステムだ。
インターネットが不安定な地域でも動作しなければならない製品なら、なおさらそうだ。
いま「フルスタックデータベース」について、かなり似た記事を書いています。多くのアプリが、バックエンドとデータベースのロジックをフロントエンドのクライアントコードで作り直しているパターンを扱っています。私たちが勧める解決策は、サーバーとクライアントの両方で実行できるデータベースを選び、その間を同期することです
私たちの製品でSQLiteを使っていない理由は、率直に言って、SQLがアプリケーションデータをクエリするのに適した道具ではないからです。クライアントコードで欲しいデータ構造とうまくかみ合わず、ほとんどすべてのSQLデータベースには、クエリを繰り返しポーリングせずにクエリの変更を購読する方法がありません
クライアントに完全なデータベースを置くというアイデアが気に入っていて、TypeScript/JavaScriptとの深い統合を望むなら、私たちが作っている https://github.com/aspen-cloud/triplit を見てくれるとうれしいです
https://github.com/cpursley/walex
私はごく単純に使っています。基盤となるテーブルのデータが変わると、クエリを自動的に再実行します。結果を差分で更新するほど効率的ではないでしょうが、SQLiteのクエリはたいてい非常に速いので、大きな問題ではないと思います