
2026/03/04 4:22
**「CRDT入門(対話型)―2023」**
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
記事では、状態ベースの Conflict‑Free Replicated Data Types (CRDTs) を解説し、複数のユーザーがローカルでデータを更新しても中央サーバーなしに同じ最終状態へ収束できる仕組みを示しています。
CRDT はインターフェース
CRDT<T,S> { value: T; state: S; merge(state:S): void; } と定義され、merge は交換可能・結合的・冪等である必要があります。状態ベース(完全な状態を送信)と操作ベース(操作のみを送信)の 2 つの主流スタイルが対比されますが、本記事は状態ベース設計にのみ焦点を当てています。
具体例で概念を説明しています:
- Last‑Write‑Wins (LWW) Register – 値とタイムスタンプ、ピア ID を
配列state
に格納します。マージ時に新しいエントリを保持し、タイムスタンプが同じ場合はより大きいピア ID が勝ちます。[peer, timestamp, value] - LWW Map – 複数の LWW レジスター(キーごと)で構成され、状態は各レジスターの状態をマッピングしたオブジェクトです。
・set
・get
は内部レジスターに委譲し、has
は値をdelete
に設定して墓石を書き込みます。マップのnull
は受信キーを反復処理し、既存レジスターをマージまたは新規作成しながら単調増加と最終的一致性を保証します。merge
記事では実務上の注意点も扱っています:
- 墓石(tombstones)は削除メタデータを保持し、マージ時に情報が失われないようにします。
- Delta‑CRDTs は効率化のため変更部分のみを送信します。
- 論理クロック はマージ中に更新を順序付けます。
将来的な取り組みとして、リアルタイム共同ピクセルアートエディタを構築し、墓石のガーベジコレクションとハイブリッドデルタ/状態アプローチの探索が計画されています。
明確なコード例と具体的ロードマップを提供することで、本記事はデザインツールやゲーム、中央サーバーに依存せずリアルタイム共有編集が必要なあらゆるドメイン向けにスケーラブルでオフラインファーストの協働システムを構築できるよう開発者に装備させます。
本文
CRDT(Conflict‑Free Replicated Data Type) – 優しい入門
CRDTについて聞いたことがありますか?何か知ろうとしたけれど、学術論文や数式の壁にぶつかった経験はありませんか。私もRecurse Center に入ってからそのような状況でした。 Recurse Center はニューヨーク市で開催される、自律的・コミュニティ主導のプログラマー向け教育リトリートです:https://www.recurse.com。
ここ1か月、調査とコードを書き続けてきましたが、実はほんの数本のシンプルなアイデアだけで多くを構築できることが分かりました!
このシリーズでは:
- CRDTとは何か学びます。
- プリミティブなCRDTを書き、それを組み合わせてより複雑なデータ構造へと発展させ、
- その知識を使って協調ピクセルアートエディタを作ります。
これらは CRDT の事前知識がなくても、TypeScript の基礎程度の理解だけで始められます。
CRDTとは?
CRDT(Conflict‑Free Replicated Data Type) とは、複数のコンピュータ(ピア)上に存在できるデータ構造です。各ピアは自分自身の状態を即座に更新し、他のピアへ問い合わせることなく動作します。ピア同士が一時的に異なる状態になるかもしれませんが、最終的には必ず同じ状態に収束する保証があります。
主なタイプは二つです:
| タイプ | 仕組み | 要件 |
|---|---|---|
| State‑based | 完全な状態を送信し、すべての状態を結合してマージ | マージの性質以外に要件なし |
| Operation‑based | 実行された操作のみを送信 | メッセージは正確に一度だけ届き、因果関係順で配信される必要がある |
この記事では state‑based CRDT に焦点を当てます。
コアインターフェース
CRDT は次のようなインターフェースを実装します:
interface CRDT<T, S> { value: T; // 我々が扱うデータ本体 state: S; // 収束に必要なメタデータ merge(state: S): void; }
merge 関数は以下の三つの性質を満たす必要があります:
- 可換性 –
A ∨ B = B ∨ A - 結合律 –
(A ∨ B) ∨ C = A ∨ (B ∨ C) - 冪等性 –
A ∨ A = A
これらを一から証明する代わりに、既に検証済みの CRDT を組み合わせることができます。
Last‑Write‑Wins レジスタ
レジスタは単一の値を保持します。最もシンプルなのが Last Write Wins (LWW) Register で、タイムスタンプに基づいて最新の書き込みだけを残します。
class LWWRegister<T> { readonly id: string; state: [peer: string, timestamp: number, value: T]; get value() { return this.state[2]; } constructor(id: string, state: [string, number, T]) { this.id = id; this.state = state; } set(value: T) { // ローカル書き込み this.state = [this.id, this.state[1] + 1, value]; } merge(state: [peer: string, timestamp: number, value: T]) { const [remotePeer, remoteTimestamp] = state; const [localPeer, localTimestamp] = this.state; if (localTimestamp > remoteTimestamp) return; if (localTimestamp === remoteTimestamp && localPeer > remotePeer) return; // それ以外は上書き this.state = state; } }
は最後に書き込んだピアの ID、論理タイムスタンプ、そして値を保持します。state
は上記のルールに従い、最も新しい書き込みを残します。merge
Last‑Write‑Wins マップ
複数の値が必要な場合は、各キーごとに LWW レジスタを組み合わせて Last Write Wins Map (LWW Map) を作ります。
type Value<T> = { [key: string]: T }; type State<T> = { [key: string]: LWWRegister<T | null>["state"] }; class LWWMap<T> { readonly id: string; #data = new Map<string, LWWRegister<T | null>>(); constructor(id: string, state: State<T>) { this.id = id; for (const [key, reg] of Object.entries(state)) this.#data.set(key, new LWWRegister(this.id, reg)); } // ---- CRDT API ---- get value(): Value<T> { const out: Value<T> = {}; for (const [k, r] of this.#data.entries()) if (r.value !== null) out[k] = r.value; return out; } get state(): State<T> { const out: State<T> = {}; for (const [k, r] of this.#data.entries()) out[k] = r.state; return out; } merge(state: State<T>) { for (const [k, remote] of Object.entries(state)) { const local = this.#data.get(k); if (local) local.merge(remote); else this.#data.set(k, new LWWRegister(this.id, remote)); } } // ---- Map‑like helpers ---- has(key: string): boolean { return this.#data.get(key)?.value !== null; } get(key: string): T | undefined { return this.#data.get(key)?.value; } set(key: string, value: T) { const reg = this.#data.get(key); if (reg) reg.set(value); else this.#data.set(key, new LWWRegister(this.id, [this.id, 1, value])); } delete(key: string) { // トンブレストーン:削除を正しく伝搬させるために null を保持 this.#data.get(key)?.set(null); } }
- トンブレストーン(
)はdelete
値を残しておくことで、削除操作が他のピアへ確実に伝搬されます。null - マップは 単調増加 であり、キーを本当に削除するわけではなく「削除済み」とマークします。
次のステップ
LWW レジスタと LWW マップを備えたら、実際にアプリケーションを作り始めることができます。例えば、協調ピクセルアートエディタ(https://jakelazaroff.com/words/building-a-collaborative-pixel-art-editor-with-crdts/)の構築です。
さらに効率化するためには:
- Delta CRDTs – 状態の変更部分だけを送信
- Garbage collection – トンブレストーンを削除してメモリ使用量を制御
という最適化があります。性能に関心がある方は「Making CRDTs 98 % More Efficient」(https://jakelazaroff.com/words/making-crdts-98-percent-more-efficient/)をご覧ください。
それでは、コーディングを楽しみましょう!