TL;DR
- CRDTの魔法: 複数ユーザーが同時に異なるレプリカを変更しても 自動的に同じ状態へ収束。ロックなし、中央サーバーなし
- 2つのアプローチ: State-based(状態全体を送信) vs Operation-based(操作を送信)。それぞれに長所と短所
- ローカルファースト運動: Ink & Switch研究所が始動。Figma、Linear、Notionが採用
- Yjs vs Automerge: Yjsは性能とテキスト協業に強く、AutomergeはJSONデータモデルに強い
- 利用場面: 協業エディタ、オフラインファーストアプリ、P2P同期、分散メモ
1. CRDTとは何か?
1.1 問題 — 分散環境での衝突
シナリオ: 2人のユーザーが同時に同じドキュメントを編集する。
初期: "Hello"
User A 追加: "Hello World"
User B 同時追加: "Hello Friend"
マージ時はどうする? どちらが勝つ?
従来の解決策:
- Last Write Wins — 一方の変更が消える
- 手動マージ — ユーザーが直接解決
- Locking — 一人だけ編集可能、協業不可能
CRDTの答え: 両方の変更を 自動的かつ意味的にマージ。両方保存される。
1.2 CRDTの数学的定義
CRDTは 3つの性質 を持つデータタイプです:
- 結合律 (Associativity):
(a + b) + c = a + (b + c) - 交換律 (Commutativity):
a + b = b + a - 冪等性 (Idempotence):
a + a = a
この3つを満たせば、どの順序でマージしても結果が同一 になります。これをACI特性または semilattice と呼びます。
1.3 2つのアプローチ
State-based CRDT (CvRDT)
- 状態全体を送信
- マージ関数:
merge(a, b) -> c - 長所: シンプル、メッセージ順序に依存しない
- 短所: 大きな状態 = 大きなネットワークコスト
Operation-based CRDT (CmRDT)
- 操作のみを送信
- 全ノードが同じ操作を同じ回数だけ適用する必要がある
- 長所: メッセージが小さい
- 短所: 信頼性のあるメッセージ配送が必要 (at-least-once)
実用的選択: ほとんどのライブラリ(Yjs、Automerge)は ハイブリッド アプローチを使用します。
2. 基本CRDTタイプ
2.1 G-Counter (Grow-only Counter)
インクリメントのみ可能なカウンタ。
class GCounter:
def __init__(self, node_id):
self.node_id = node_id
self.counts = {} # {node_id: count}
def increment(self):
self.counts[self.node_id] = self.counts.get(self.node_id, 0) + 1
def value(self):
return sum(self.counts.values())
def merge(self, other):
# 各ノードのカウントはmaxでマージ
for node, count in other.counts.items():
self.counts[node] = max(self.counts.get(node, 0), count)
なぜmax?: 同じノードのカウントは単調増加(monotonic)。maxを取れば、どのノードがより新しいかを自動で判定できる。
利用例: ページビュー数、いいね数。
2.2 PN-Counter (Positive-Negative Counter)
インクリメント/デクリメントの両方が可能。
class PNCounter:
def __init__(self, node_id):
self.positive = GCounter(node_id) # 増加用
self.negative = GCounter(node_id) # 減少用
def increment(self):
self.positive.increment()
def decrement(self):
self.negative.increment()
def value(self):
return self.positive.value() - self.negative.value()
def merge(self, other):
self.positive.merge(other.positive)
self.negative.merge(other.negative)
主要なトリック: 2つのG-Counterに分離。減少は「負の増加」として表現する。
2.3 G-Set (Grow-only Set)
要素の追加のみ可能なSet。
class GSet:
def __init__(self):
self.elements = set()
def add(self, e):
self.elements.add(e)
def merge(self, other):
self.elements |= other.elements # 和集合
限界: 削除不可能。永遠に増えるSet。
2.4 2P-Set (Two-Phase Set)
追加と削除の両方が可能。ただし 一度削除された要素は再追加不可。
class TwoPhaseSet:
def __init__(self):
self.added = set()
self.removed = set()
def add(self, e):
if e not in self.removed:
self.added.add(e)
def remove(self, e):
self.removed.add(e)
def contains(self, e):
return e in self.added and e not in self.removed
def merge(self, other):
self.added |= other.added
self.removed |= other.removed
限界: tombstone(削除マーク)が永遠に蓄積する。
2.5 LWW-Register (Last-Write-Wins Register)
タイムスタンプベースの単一値。
class LWWRegister:
def __init__(self):
self.value = None
self.timestamp = 0
def write(self, value, timestamp):
if timestamp > self.timestamp:
self.value = value
self.timestamp = timestamp
def merge(self, other):
if other.timestamp > self.timestamp:
self.value = other.value
self.timestamp = other.timestamp
問題: 時計の同期。Hybrid Logical Clock (HLC) で解決。
2.6 OR-Set (Observed-Remove Set)
最も実用的なSet CRDT。自由な追加/削除。
class ORSet:
def __init__(self):
# {element: set of unique tags}
self.elements = defaultdict(set)
self.tombstones = defaultdict(set)
def add(self, e):
tag = uuid.uuid4()
self.elements[e].add(tag)
def remove(self, e):
# 現在見えるすべてのタグをtombstoneへ
self.tombstones[e] |= self.elements[e]
def contains(self, e):
return bool(self.elements[e] - self.tombstones[e])
def merge(self, other):
for e, tags in other.elements.items():
self.elements[e] |= tags
for e, tags in other.tombstones.items():
self.tombstones[e] |= tags
中核のアイデア: 各追加に固有タグ。削除は「現在見えているタグ」をtombstoneへ。新しい追加は新しいタグ -> 生き残る。
3. テキスト協業 — 最も難しいCRDT
3.1 なぜテキストは難しいのか?
シナリオ: 2人のユーザーが同じ位置に同時に挿入。
初期: "ABCDE"
User A: 位置2に"X"挿入 -> "ABXCDE"
User B: 位置2に"Y"挿入 -> "ABYCDE"
マージ: "ABXYCDE" または "ABYXCDE"?
問題: 位置は相対的。あるユーザーの挿入が他ユーザーの位置を無効化してしまう。
3.2 RGA (Replicated Growable Array)
各文字に 固有ID を付与。位置ではなくIDを参照する。
初期ドキュメント:
[start] - A(id1) - B(id2) - C(id3) - [end]
User A: Bの後にX挿入 (id4)
[start] - A(id1) - B(id2) - X(id4) - C(id3) - [end]
User B: Bの後にY挿入 (id5)
[start] - A(id1) - B(id2) - Y(id5) - C(id3) - [end]
マージ (idで比較、小さいidが先):
[start] - A(id1) - B(id2) - X(id4) - Y(id5) - C(id3) - [end]
主要点: 固有ID + 順序規則により決定的マージを実現。
3.3 YjsのYATAアルゴリズム
Yjsは YATA (Yet Another Transformation Approach) という変形アルゴリズムを使用。RGAより効率的です。
各文字は以下を持ちます:
- 固有ID
(client_id, clock) origin_left: 挿入時の左隣のIDorigin_right: 挿入時の右隣のID
マージ規則:
- 同じoriginを持つ挿入はclient_id順でソート
- originが異なる場合は位置情報でソート
効率性: Yjsは同じ文字列の連続入力を単一オブジェクトに圧縮 -> メモリ効率100倍以上。
4. Yjs — JavaScript CRDTのスタンダード
4.1 基本的な使い方
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
// 共有ドキュメント生成
const doc = new Y.Doc()
// 共有テキスト
const ytext = doc.getText('shared-text')
ytext.insert(0, 'Hello, ')
ytext.insert(7, 'World!')
console.log(ytext.toString()) // "Hello, World!"
// P2P同期 (WebRTC)
const provider = new WebrtcProvider('my-room', doc)
4.2 さまざまなデータタイプ
// テキスト
const ytext = doc.getText('text')
ytext.insert(0, 'Hello')
// 配列
const yarray = doc.getArray('list')
yarray.push(['item1', 'item2'])
// マップ (オブジェクト)
const ymap = doc.getMap('config')
ymap.set('theme', 'dark')
// XML/JSON
const yxml = doc.getXmlFragment('content')
4.3 変更検知
ytext.observe((event) => {
console.log('Changes:', event.changes.delta)
// [{ retain: 7 }, { insert: 'World!' }]
})
4.4 永続化 — IndexedDB
import { IndexeddbPersistence } from 'y-indexeddb'
const persistence = new IndexeddbPersistence('my-doc', doc)
persistence.on('synced', () => {
console.log('Loaded from IndexedDB')
})
ブラウザをリロードしても状態を保持 — 真のローカルファースト。
4.5 複数プロバイダ
Yjsはtransport-agnostic — 複数の同期方式をサポート:
y-websocket— 中央サーバー同期y-webrtc— P2P同期y-indexeddb— ローカル永続化y-leveldb— Node.jsサーバー- カスタムプロバイダの作成も可能
5. Automerge — JSONデータモデル
5.1 JSONに優しいインターフェース
import * as Automerge from '@automerge/automerge'
let doc = Automerge.init()
doc = Automerge.change(doc, 'Initial', d => {
d.todos = []
d.todos.push({ text: 'Buy milk', done: false })
})
// 別デバイス
let doc2 = Automerge.merge(Automerge.init(), doc)
doc2 = Automerge.change(doc2, 'Add task', d => {
d.todos.push({ text: 'Walk dog', done: false })
})
// マージ
const merged = Automerge.merge(doc, doc2)
console.log(merged.todos) // [Buy milk, Walk dog]
5.2 Yjs vs Automerge
| Yjs | Automerge | |
|---|---|---|
| 実装言語 | JavaScript | TypeScript + WASM |
| データモデル | CRDTタイプ (Y.Text, Y.Map) | JSON-like |
| テキスト性能 | 非常に高速 | 良い |
| メモリ効率 | 優秀 | 普通 |
| 学習曲線 | 中 | 低 |
| ユースケース | 協業エディタ | 汎用データ同期 |
| ドキュメントサイズ | 小 | 普通 |
| 採用先 | Notion、Linear、Affine | Local-firstアプリ |
選択ガイド:
- テキスト協業 (エディタ、ノート): Yjs
- JSONデータ (アプリ状態、フォーム): Automerge
- 両方検討: プロトタイプはAutomergeの方が早い
6. Local-First Software運動
6.1 7つの理想
Ink & Switch研究所の Local-First Software マニフェスト:
- 速い — ネットワークのラウンドトリップなし
- マルチデバイス動作 — 同期はバックグラウンド
- ネットワーク任意 — オフラインで完全動作
- 他者との協業 — CRDTで衝突解決
- 長期保存 — クラウドが消えてもデータが残る
- 基本的なセキュリティとプライバシー — データはユーザーデバイス上に
- 究極のユーザー制御 — データの所有権
6.2 クラウドファースト vs ローカルファースト
| クラウドファースト | ローカルファースト | |
|---|---|---|
| データの場所 | サーバー | デバイス |
| オフライン | 動作不可 | 完全動作 |
| 応答時間 | ネットワーク依存 | 即時 |
| 協業 | サーバー中継 | P2Pまたはサーバー |
| 企業の閉鎖 | データ損失 | データ保持 |
| 例 | Google Docs | Obsidian、Logseq、Linear |
6.3 ローカルファーストの採用事例
Linear — プロジェクト管理:
- 全データをローカルIndexedDBへ
- 即時応答 (latency 0)
- WebSocketでバックグラウンド同期
- オフライン変更はキューに保存し再接続時に送信
Figma — デザイン協業:
- 自前のCRDT実装 (RGAベース)
- リアルタイムマルチカーソル
- オフライン編集後に同期
Affine — ノート:
- Yjs使用
- 完全にローカルファースト
- クラウドは任意同期
Notion — ノート/Wiki:
- 部分的CRDT (ブロック単位)
- テキスト編集はOTからCRDTへ移行中
7. CRDT vs OT (Operational Transformation)
7.1 OTとは?
Google Docsが使用してきた従来型の協業方式。操作変換 — 同時操作を変換して一貫性を維持する。
初期: "ABC"
User A: insert(1, "X") -> "AXBC"
User B: delete(2) -> "AB"
User Bの操作をUser Aの変更後に変換:
delete(2) -> delete(3) (位置調整)
結果: "AXB"
7.2 比較
| OT | CRDT | |
|---|---|---|
| 中央サーバー | 必須 | 任意 |
| オフライン対応 | 困難 | 自然 |
| アルゴリズム複雑度 | 非常に複雑 | 複雑(だが検証可能) |
| P2P | 困難 | 自然 |
| Google Docs | Yes (現在) | No |
| Figma、Linear | No | Yes |
| 学術研究 | 1990年代〜 | 2000年代〜 |
トレンド: OT -> CRDTへの移行(Notion、Confluent など)。CRDTは分散環境により適している。
8. CRDTの限界と落とし穴
8.1 メタデータ爆発
問題: tombstone、タグ、クロックなどが蓄積し、ドキュメントサイズが実コンテンツを上回る。
解決策:
- Compaction: 不要となったメタデータを整理
- デルタ圧縮: Yjsで採用。連続する操作を単一オブジェクトに
- チェックポイント: 周期的にベースラインを生成
8.2 意味的衝突
CRDTは 構文的衝突 (同じ位置の同時編集) を解決します。意味的衝突 は解決できません。
例: カレンダーアプリ。2人のユーザーが同じ会議室を別の会議に同時予約。CRDTは両方の予約成功として扱う — ビジネスルール違反。
解決策: CRDTの上にビジネスロジック層を追加。あるいは強い一貫性が必要な部分には別機構を使う。
8.3 部分的CRDTの方が実用的
Notion のアプローチ: ブロックレベルでCRDT、ブロック内部は通常のテキスト。すべてをCRDTにする必要はない。
Linear のアプローチ: 一部フィールドのみCRDT、残りはLWW。シンプルさと性能のバランス。
9. 実践 — 協業テキストエディタを作る
9.1 基本構造
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { EditorView, basicSetup } from 'codemirror'
import { yCollab } from 'y-codemirror.next'
// 1. Yjsドキュメントを生成
const ydoc = new Y.Doc()
const ytext = ydoc.getText('codemirror')
// 2. 同期プロバイダ
const provider = new WebsocketProvider(
'wss://my-server.com',
'document-id-123',
ydoc
)
// 3. CodeMirrorエディタ + Yjs統合
const view = new EditorView({
doc: ytext.toString(),
extensions: [
basicSetup,
yCollab(ytext, provider.awareness) // 協業拡張
],
parent: document.body
})
コード100行未満で Google Docsのような協業エディタ を実現できる。
9.2 ユーザー認識 (Awareness)
provider.awareness.setLocalStateField('user', {
name: 'Alice',
color: '#ff0000'
})
provider.awareness.on('change', () => {
const users = Array.from(provider.awareness.getStates().values())
console.log('Online users:', users)
})
他ユーザーのカーソル、選択範囲が自動で表示されます。
クイズ
1. CRDTの数学的性質3つとは?
回答: (1) 結合律 (Associativity): (a + b) + c = a + (b + c)、(2) 交換律 (Commutativity): a + b = b + a、(3) 冪等性 (Idempotence): a + a = a。この3つを満たせば、どの順序でマージしても結果が同一となります。これをACI特性または semilattice と呼びます。これがCRDTの自動衝突解決の数学的基盤です。
2. State-basedとOperation-based CRDTの違いは?
回答: State-based (CvRDT) は状態全体を送信し、マージ関数で結合します。メッセージ順序に依存せずシンプル。欠点は大きな状態 = 大きなネットワークコスト。Operation-based (CmRDT) は操作のみを送信。メッセージは小さいが、信頼性のある配送が必要 (at-least-once delivery + 冪等性)。実用的なライブラリ(Yjs、Automerge)は ハイブリッド アプローチを使用します。
3. YjsとAutomergeのどちらを選ぶべき?
回答: テキスト協業 (エディタ、ノート、IDE) -> Yjs (性能とメモリ効率に優れる、採用先: Notion、Linear、Affine)。JSONデータ同期 (アプリ状態、フォーム、設定) -> Automerge (JSON-like API、学習曲線が低い)。両方検討する場合は、プロトタイプはAutomergeで素早く作り、プロダクションではYjsで最適化するパターンが一般的です。
4. Local-First Softwareの中核価値は?
回答: Ink & Switchの7つの理想: (1) 速い(ネットワーク非依存)、(2) マルチデバイス、(3) ネットワーク任意(オフライン完全動作)、(4) 協業、(5) 長期保存(クラウドが消えてもデータ保持)、(6) セキュリティ/プライバシー(データはデバイス上)、(7) ユーザーのデータ制御。クラウドファースト は企業が倒れるとデータも失われますが、ローカルファースト はデータがユーザーの手元に残ります。Linear、Figma、Notionが採用しています。
5. CRDTが解決できない衝突とは?
回答: 意味的衝突 (semantic conflict) です。CRDTは同じデータ構造への同時変更を自動マージしますが、ビジネスルール違反は防げません。例: 2人のユーザーが同じ会議室を別の会議に同時予約 -> CRDTは両方成功として扱います。解決策: CRDTの上にビジネスロジック層を追加、あるいは強い一貫性が必要な部分には別機構(中央検証、分散ロックなど)を使用。
参考資料
- CRDT.tech — CRDT総合資料
- Local-First Software — Ink & Switchマニフェスト
- Yjs — 公式ドキュメント
- Automerge — 公式ドキュメント
- Conflict-Free Replicated Data Types — 原論文 (Shapiro 2011)
- A Conflict-Free Replicated JSON Datatype — Automerge論文
- YATA: Yet Another Transformation Approach — Yjsアルゴリズム
- Designing Data-Intensive Applications — Martin Kleppmann (CRDTチャプター)
- Linear's offline mode — Linear同期エンジン
- Figma's multiplayer technology — Figma協業
- Diamond Types — Rustで書かれた高性能CRDT
현재 단락 (1/320)
- **CRDTの魔法**: 複数ユーザーが同時に異なるレプリカを変更しても **自動的に同じ状態へ収束**。ロックなし、中央サーバーなし