
2026/03/27 6:16
データのコピーやサーバーの立ち上げなしに、チーム横断でデータベースを統合する
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Summary
Datahikeは、データベースのすべての状態を不変スナップショットとして保存します。各スナップショットは、ノードがコンテンツアドレス化され、バージョン間で共有される永続的なB‑tree派生構造です。そのため、書き込み時には新しいノードが作成され、古いノードは変更されません。接続(
@conn)を参照すると、ブランチヘッドキーのみがロードされ、以降のクエリノードはストレージから遅延読み込みされローカルにキャッシュされるため、読み取りは高速です。
ライターは各トランザクションを直接永続ストレージへフラッシュするため、ストア自体が権威的であり、読みアクセスには別途トランザクタやAPI層は不要です。これによりDatahikeはサーバーレスで低遅延のリードエンジンとなります。
システムはKonserveを使用しており、S3、ローカルファイルシステム、JDBC、IndexedDBなど多くのバックエンド上に抽象化されています。ブラウザクライアントは
konserve-syncでデータベースをローカルにレプリケートでき、クエリはローカルレプリカに対して実行されるため往復通信が不要です。WebSocketによる同期では変更されたツリー・ノードのみが送信され、構造的共有を活用した効率的な更新が可能です。
Datahikeの値ベースモデルは、異なるバックエンドにある複数のデータベースを単一のDatalogクエリで結合できるようにします。また、
d/as-ofを使用して異なる時点のスナップショットを混在させることで、追加の調整なしに履歴データを監査・デバッグできます。
Clojure REPLのサンプルでは、2つの独立したデータベースを作成し、それらにデータを投入してクロスデータベース結合を実行する方法が示されています。バックエンドキーワードを変更するだけで同じコードが異なるストレージバックエンドでも動作します。従来のETLパイプライン、メッセージキュー、およびAPI層を排除することで、このモデルは遅延・保守負担と新たな障害モードを削減し、不変スナップショットデータベースを分散アプリケーションに実用的にします。
本文
協働するメモリ―2026年3月
二つのチームがデータを結合したいとき、一般的な回答はインフラストラクチャです:ETLパイプライン、API、メッセージバス。
それらはすべて遅延、保守負担、新たな障害モードを増やします。データは「同じ場所で共有できない」システム間の移動により流れます。
もっと単純なモデルがあります。もしあなたのデータベースがストレージ上の不変値(immutable value)なら、ストレージを読むことのできる者はすべてそれをクエリできます。サーバーを走らせる必要もなく、APIで交渉する必要もなく、データをコピーする手間もありません。そしてクエリ言語が複数入力をサポートしていれば、異なるチームのデータベースを一つの式で結合できます。
これが Datahike の動作原理です。機能として追加したわけではなく、アーキテクチャの二つの基本的な性質から自然に導かれます。
データベースは値
従来のデータベースでは、実行中のサーバーへの接続を通じてクエリします。
クエリ間でデータが変わる可能性がありますし、データベースは「何かを保持している」ものではなくサービスです。
Datahike はこれを逆転させます。接続(
@conn)を参照解除すると、不変のデータベース値―特定トランザクション時点で凍結されたスナップショットが得られます。それは変わりません。関数に渡したり、変数に保持したり、別スレッドへ渡すこともできます。同じスナップショットを持つ二人の同時読み取り者はロックや調整なしで常に合意します。
これは Rich Hickey が 2012 年 Datomic とともに導入したアイデアです:書き込み(単一ライターが管理)と観測(読み取り=値のみ)のプロセスを分離すること。観測の正しい実装は調整を必要としません。
Datomic のインデックスはストレージに保存されますが、トランザクタはフラッシュされていない最近のインデックスセグメントのメモリ上オーバーレイを保持します。読者は通常、完全で現在のビューを得るためにトランザクタと調整する必要があります。ストレージだけでは不十分です。
Datahike はその依存関係を取り除きます。書き手は毎回トランザクション時にフラッシュし、ストレージが常に権威を持ちます。ストアを読むことのできるプロセスなら、オーバーレイやトランザクタ接続なしで完全かつ現在のデータベースを見られます。この仕組みを理解するには、データ構造を見る必要があります。
ストレージにおける木
Datahike はインデックスを永続的なソート済み集合―ノードが不変である B‑tree の一種―として保持します。
各ノードは konserve でキー・バリュー対として保存され、S3、ファイルシステム、JDBC、IndexedDB といったストレージバックエンドを抽象化します。
トランザクションがデータを追加すると、Datahike は既存ノードを変更せず、葉から根までの変化したパスに対して新しいノードを作成し、変更されていない部分は以前のバージョンと共有します。これが構造的共有(structural sharing)であり、Clojure の永続ベクターや Git のオブジェクトストアで使われる同じ技術です。
具体例
データ量が 100 万件のデータベースは数千ノードを持つ B‑tree を持つかもしれません。
10 件のデータを追加するトランザクションでは、影響したパスに沿っておそらく十数ノードを書き直します。新しい木の根はこれら新ノードと以前から変更されていない千数ノードを指し示します。古いスナップショットと新しいスナップショットはともに有効な完全な木であり、ほぼ全構造を共有しています。
重要な性質:各ノードは一度書き込まれ、二度と変更されません。そのキーはコンテンツアドレッシング可能です。つまりノードは積極的にキャッシュでき、独立して複製でき、ストレージへアクセスできるプロセスなら誰でも読み取れます(書き手との調整不要)。構造的共有や枝分かれ、トレードオフについては The Git Model for Databases を参照。
分散インデックス空間
ここで全てが結びつきます。
@conn を呼ぶと、Datahike は konserve ストアから一つのキー(例::db)を取得します。それは各インデックスへのルートポインタ、スキーマメタデータ、現在のトランザクション ID を含む小さなマップです。他に何もロードされません ― データベース値は木への遅延ハンドルです。
クエリがインデックスを走査するとき、各ノードは必要時にストレージからフェッチされ、ローカル LRU にキャッシュされます。同じノードを次回以降のクエリで参照しても I/O は発生しません。
これが読み取りパス全体です。アクセスを仲介するサーバープロセスや接続プロトコル、公開ポートは不要です。インデックスはストレージにあり、ストレージを読むことのできるプロセスならブランチヘッドをロードし、木をたどり、クエリを実行できます。これが 分散インデックス空間 です。
同じデータベースを読み取る二つのプロセスは、独立して同じ不変ノードをフェッチします。互いに知覚しません。書き手は新しいツリーノードを書き込み、ブランチヘッドを原子操作で更新して新スナップショットを公開します。その後デコードすると新スナップショットが見えます。以前のスナップショットを保持した読者は影響を受けず、ノードは不変であるため到達可能な間はガベージコレクションされません。
データベース間の結合
データベースが値であり、Datalog が複数入力ソースをネイティブにサポートしているので、次のステップは自然です。異なるチーム、異なるストレージバックエンド、または異なる時点のデータベースを一つのクエリで結合します。
(def catalog (d/connect {:store {:backend :s3 :bucket "team-a"}})) (def inventory (d/connect {:store {:backend :s3 :bucket "team-b"}})) (d/q '[:find ?name ?price ?stock :in $cat $inv :where [$cat ?p :product/sku ?sku] [$cat ?p :product/name ?name] [$cat ?p :product/price ?price] [$inv ?i :stock/sku ?sku] [$inv ?i :stock/count ?stock] [(> ?stock 0)]] @catalog @inventory)
各
@ デリファレンスはそれぞれの S3 バケットからブランチヘッドを取得し、不変データベース値を返します。クエリエンジンがローカルで結合し、サーバー間調整やデータコピーは不要です。
さらに、異なる時点のスナップショットを混ぜることもできます:
;; 前四半期のカタログと現在の在庫 (def old-catalog (d/as-of @catalog #inst "2025-11-01")) (d/q '[:find ?name ?stock :in $cat $inv :where [$cat ?p :product/sku ?sku] [$cat ?p :product/name ?name] [$inv ?i :stock/sku ?sku] [$inv ?i :stock/count ?stock]] old-catalog @inventory)
古いスナップショットと現在のものはどちらも単なる値です。クエリエンジンはそれらがいつ作成されたかを気にしません。監査、規制再現性、デバッグ(「前四半期のデータでこのレポートは何を示しただろう?」)に有用です。
ストレージからブラウザへ
ここまで「ストレージ」は S3 やファイルシステムを指していましたが、konserve には IndexedDB バックエンドもあります。つまり同じモデルはブラウザでも動作します。
Kabel WebSocket sync と konserve‑sync を使えば、ブラウザクライアントはローカルの IndexedDB にデータベースをレプリケートできます。クエリはネットワーク往復なしでローカルレプリカに対して実行されます。更新は差分同期し、同じ構造的共有がサーバー側のスナップショットコスト低減と同様に、ネットワーク上でも安価になります。
使ってみよう
以下は Clojure REPL で動作する完全なクロスデータベース結合例です:
(require '[datahike.api :as d]) ;; 二つの独立したデータベース (def catalog-cfg {:store {:backend :memory :id (java.util.UUID/randomUUID)} :schema-flexibility :read}) (def inventory-cfg {:store {:backend :memory :id (java.util.UUID/randomUUID)} :schema-flexibility :read}) (d/create-database catalog-cfg) (d/create-database inventory-cfg) (def catalog (d/connect catalog-cfg)) (def inventory (d/connect inventory-cfg)) ;; チーム A:製品情報 (d/transact catalog [{:product/sku "W001" :product/name "Widget" :product/price 9.99} {:product/sku "G002" :product/name "Gadget" :product/price 24.50} {:product/sku "T003" :product/name "Thingamajig" :product/price 3.75}]) ;; チーム B:在庫レベル (d/transact inventory [{:stock/sku "W001" :stock/count 140} {:stock/sku "G002" :stock/count 0} {:stock/sku "T003" :stock/count 58}]) ;; 結合:在庫ありの製品と価格 (d/q '[:find ?name ?price ?stock :in $cat $inv :where [$cat ?p :product/sku ?sku] [$cat ?p :product/name ?name] [$cat ?p :product/price ?price] [$inv ?i :stock/sku ?sku] [$inv ?i :stock/count ?stock] [(> ?stock 0)]] @catalog @inventory) ;; => #{["Widget" 9.99 140] ["Thingamajig" 3.75 58]}
:memory を :s3、:file、または :jdbc に置き換えるだけで、同じコードがストレージバックエンドを横断して動作します。データベースは同一バックエンドにある必要はありません ― S3 データベースとローカルファイルストアを同じクエリで結合できます。