Skip to content
Published on

CRDT完全ガイド 2025: Conflict-Free Replicated Data Types、ローカルファースト協業、Yjs/Automerge

Authors

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つの性質 を持つデータタイプです:

  1. 結合律 (Associativity): (a + b) + c = a + (b + c)
  2. 交換律 (Commutativity): a + b = b + a
  3. 冪等性 (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: 挿入時の左隣のID
  • origin_right: 挿入時の右隣のID

マージ規則:

  1. 同じoriginを持つ挿入はclient_id順でソート
  2. 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

YjsAutomerge
実装言語JavaScriptTypeScript + WASM
データモデルCRDTタイプ (Y.Text, Y.Map)JSON-like
テキスト性能非常に高速良い
メモリ効率優秀普通
学習曲線
ユースケース協業エディタ汎用データ同期
ドキュメントサイズ普通
採用先Notion、Linear、AffineLocal-firstアプリ

選択ガイド:

  • テキスト協業 (エディタ、ノート): Yjs
  • JSONデータ (アプリ状態、フォーム): Automerge
  • 両方検討: プロトタイプはAutomergeの方が早い

6. Local-First Software運動

6.1 7つの理想

Ink & Switch研究所の Local-First Software マニフェスト:

  1. 速い — ネットワークのラウンドトリップなし
  2. マルチデバイス動作 — 同期はバックグラウンド
  3. ネットワーク任意 — オフラインで完全動作
  4. 他者との協業 — CRDTで衝突解決
  5. 長期保存 — クラウドが消えてもデータが残る
  6. 基本的なセキュリティとプライバシー — データはユーザーデバイス上に
  7. 究極のユーザー制御 — データの所有権

6.2 クラウドファースト vs ローカルファースト

クラウドファーストローカルファースト
データの場所サーバーデバイス
オフライン動作不可完全動作
応答時間ネットワーク依存即時
協業サーバー中継P2Pまたはサーバー
企業の閉鎖データ損失データ保持
Google DocsObsidian、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 比較

OTCRDT
中央サーバー必須任意
オフライン対応困難自然
アルゴリズム複雑度非常に複雑複雑(だが検証可能)
P2P困難自然
Google DocsYes (現在)No
Figma、LinearNoYes
学術研究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の上にビジネスロジック層を追加、あるいは強い一貫性が必要な部分には別機構(中央検証、分散ロックなど)を使用。


参考資料