ANTIGRAVITY LABEN
記事一覧/アプリ開発
アプリ開発/2026-03-14上級

CRDT と AI で支えるリアルタイム共同編集 — 競合を数学で解消するデータ同期設計

複数端末同期でユーザーデータを消した実体験から、CRDT による競合しない同期設計を掘り下げます。Yjs と Automerge の選定基準、トゥームストーン肥大への圧縮実装、AI 競合解決を同期経路に入れない設計判断まで、本番運用の視点で解説します。

CRDT共同編集リアルタイム同期2競合解決Yjsオフラインファースト

プレミアム記事

お気に入りデータの複数端末同期を、私は一度「最後に保存した方を正とする」という素朴な設計で実装したことがあります。個人開発している壁紙アプリでの話です。

机上では問題なさそうに見えたこの設計は、ユーザーが電車内のオフライン環境でお気に入りを整理し、帰宅後に Wi-Fi へつながった瞬間、別の端末で追加していた分を静かに上書きして消しました。レビュー欄での報告で気づいたときには、失われたデータはもう戻りません。同期の不具合は、クラッシュと違ってログにも残りにくい。あのときの冷や汗は今でも覚えています。

この苦い経験が、CRDT(Conflict-free Replicated Data Type)を本気で学ぶきっかけになりました。Google Docs や Figma、Notion が当たり前のように実現している「同時に編集しても壊れない」体験は、魔法ではなく数学です。操作を可換かつべき等にすることで、中央の調停サーバーなしに全員の状態が同じ結果へ収束していきます。この性質を一度理解すると、同期処理の設計がまるで違って見えてきます。

ここからは、CRDT が共同編集を成立させる仕組み、Yjs を軸にした本番実装、Yjs と Automerge の選び分け、運用で必ず直面する文書肥大への対処、そして AI をマージプロセスへ組み込むときの設計判断までを、実装コードを交えて順に掘り下げていきます。

CRDT:基礎の理解

CRDT が解く中心的な問題

従来のクライアント・サーバー システムでは、単一のサーバーがすべての変更を調整します。これはオフライン機能が不要で、P2P協力が不要で、低遅延の経験が不要な場合に機能します。サーバーの調整力を削除した瞬間、基本的な課題に直面します:どのように同時編集をマージするか?

ユーザーA: "Hello" → 位置0で編集、"Hi"を挿入 → "HiHello"
ユーザーB: "Hello" → 位置0で編集、"Hey"を挿入 → "HeyHello"

質問:両方の変更が同期すると、最終的な状態は何か?
従来のアプローチ:サーバーが決定(ラウンドトリップが必須)
CRDTアプローチ:数学的ルールが収束を保証

CRDT は操作を可換かつべき等にすることでこれを解決します。 操作が到達する順序は問題ではなく、ネットワーク遅延または同時編集に関わらず、最終状態は常に同じです。

CRDT タイプとそれらのトレードオフ

┌─────────────────────────────────────────────────────────┐
│ 操作ベース(可換複製データタイプ)                      │
├─────────────────────────────────────────────────────────┤
│ 操作をブロードキャスト → 操作が自動的に                │
│ 可換/構成                                             │
│ 最適:カウンター、セット、フラグ操作                  │
│ ネットワークオーバーヘッド:低                        │
│ 複雑さ:中                                            │
│ 例:カウンターを5増やす(順序は関係ない)            │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ 状態ベース(可換複製データタイプ)                      │
├─────────────────────────────────────────────────────────┤
│ 状態をブロードキャスト → 宛先でマージが発生           │
│ 最適:複雑なドキュメント、ツリー                      │
│ ネットワークオーバーヘッド:高                        │
│ 複雑さ:単純なマージの場合は低い                      │
│ 例:最後の書き込み優先(ただしデータが失われる)    │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ シーケンス CRDT(WOOT、RGA、Yjs)                    │
├─────────────────────────────────────────────────────────┤
│ テキスト/順序付きシーケンスに特化                     │
│ 各文字に一意のIDを割り当て                            │
│ 最適:テキストエディター、リスト                      │
│ ネットワークオーバーヘッド:初期は非常に高い         │
│ 複雑さ:高                                            │
│ 例:すべての文字が(siteID、clock) IDを取得         │
└─────────────────────────────────────────────────────────┘
💡
**CRDT を選択:** テキスト編集ではシーケンス CRDT(Yjs、Automerge)を使用します。アプリケーション状態(ユーザープレゼンス、カーソル、選択)には、操作ベース CRDT を使用します。ハイブリッドアプローチは両方を組み合わせます。

Yjs の実装:本番 CRDT

Yjs は共同テキスト編集の事実上の標準です。Notion、Figma、その他多くが使用しています。Yjs を理解することで、あらゆる CRDT に適用可能なパターンを教えます。

コア アーキテクチャ

# crdt/yjs_bridge.py
"""
Python バックエンドと Yjs(JavaScript CRDT)の間のブリッジ。
本番では Yjs はクライアントで実行;サーバーストレージに同期します。
"""
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
from datetime import datetime
import json
 
@dataclass
class YjsUpdate:
    """Yjs ドキュメント内の変更を表現"""
    origin_client_id: str
    clock: int  # 順序付けのためのラムポート時計
    content: bytes  # エンコードされた Yjs 更新
    timestamp: datetime = field(default_factory=datetime.utcnow)
    parent_clock: int = 0  # 因果関係の順序付け
 
    def to_dict(self) -> Dict[str, Any]:
        return {
            "origin_client_id": self.origin_client_id,
            "clock": self.clock,
            "content": self.content.hex(),  # JSON 用 Base64
            "timestamp": self.timestamp.isoformat(),
            "parent_clock": self.parent_clock
        }
 
@dataclass
class CollaborativeDocument:
    """CRDT 同期で管理されるドキュメント"""
    doc_id: str
    client_id: str
    updates: List[YjsUpdate] = field(default_factory=list)
    vector_clock: Dict[str, int] = field(default_factory=dict)
    last_synced: Optional[datetime] = None
 
    def add_update(self, update: YjsUpdate) -> None:
        """ローカル変更をドキュメントに追加"""
        self.updates.append(update)
        # このクライアント用にベクトル時計を更新
        self.vector_clock[self.client_id] = (
            self.vector_clock.get(self.client_id, 0) + 1
        )
 
    def get_missing_updates(
        self,
        remote_vector_clock: Dict[str, int]
    ) -> List[YjsUpdate]:
        """リモートクライアントが見ていない更新を決定"""
        missing = []
 
        for update in self.updates:
            remote_clock = remote_vector_clock.get(
                update.origin_client_id, 0
            )
            if update.clock > remote_clock:
                missing.append(update)
 
        return missing
 
    def merge_remote_update(self, update: YjsUpdate) -> None:
        """別のクライアントからの変更を組み込む"""
        # 時計順序で更新を挿入
        insert_pos = len(self.updates)
        for i, existing in enumerate(self.updates):
            if existing.clock > update.clock:
                insert_pos = i
                break
 
        self.updates.insert(insert_pos, update)
 
        # ベクトル時計を更新
        self.vector_clock[update.origin_client_id] = max(
            self.vector_clock.get(update.origin_client_id, 0),
            update.clock
        )
 
# ドキュメント同期プロトコル
class CRDTSyncManager:
    """クライアント全体で CRDT ドキュメント同期を管理"""
 
    def __init__(self, storage_backend, max_batch_size: int = 100):
        self.storage = storage_backend
        self.max_batch_size = max_batch_size
        self.documents: Dict[str, CollaborativeDocument] = {}
 
    async def sync_document(
        self,
        doc_id: str,
        client_id: str,
        local_updates: List[YjsUpdate],
        remote_vector_clock: Dict[str, int]
    ) -> Dict[str, Any]:
        """
        クライアントとサーバー間でドキュメントを同期。
 
        サーバーは「因果関係のシーケンサー」として機能しますが、調整者ではありません。
        すべてのマージ決定はベクトル時計に基づいて決定的です。
        """
        doc = self.documents.get(doc_id)
        if not doc:
            doc = CollaborativeDocument(doc_id=doc_id, client_id=client_id)
            self.documents[doc_id] = doc
 
        # 受信更新をマージ
        for update in local_updates:
            doc.merge_remote_update(update)
 
        # 送信する更新を検索
        missing_updates = doc.get_missing_updates(remote_vector_clock)
 
        # 効率のためバッチ化
        update_batches = [
            missing_updates[i:i + self.max_batch_size]
            for i in range(0, len(missing_updates), self.max_batch_size)
        ]
 
        # ストレージに永続化
        await self.storage.save_document(doc)
 
        return {
            "doc_id": doc_id,
            "acknowledged": len(local_updates),
            "updates_to_apply": update_batches,
            "server_vector_clock": doc.vector_clock,
            "needs_full_sync": self._should_trigger_full_sync(doc)
        }
 
    def _should_trigger_full_sync(self, doc: CollaborativeDocument) -> bool:
        """完全なドキュメント状態を必要とする状況を検出"""
        # ドキュメントが非常に大きくなった場合、操作の代わりに状態を送信
        total_update_size = sum(
            len(u.content) for u in doc.updates
        )
        return total_update_size > 10_000_000  # 10MB 閾値
⚠️
**ストレージの影響:** 生の CRDT 更新を永遠に保存するとストレージが膨張します。「スナップショット」を実装してください—定期的にドキュメントの完全な状態を保存し、古い更新を破棄します。例:1000 更新ごと、または毎日スナップショット。

リアルタイム同期プロトコル

# sync/websocket_handler.py
import asyncio
from typing import Set
from fastapi import WebSocket, WebSocketDisconnect
import json
 
class CollaborativeDocumentManager:
    """ドキュメント同期のリアルタイムの WebSocket ハンドラー"""
 
    def __init__(self, crdt_sync: CRDTSyncManager):
        self.crdt_sync = crdt_sync
        # ルーム -> 接続されたクライアントのセット
        self.active_connections: Dict[str, Set[WebSocket]] = {}
        # どのクライアントがどのルームに接続されているかを追跡
        self.client_rooms: Dict[WebSocket, str] = {}
 
    async def connect(self, websocket: WebSocket, doc_id: str, client_id: str):
        """クライアントがドキュメント編集セッションに参加"""
        await websocket.accept()
 
        if doc_id not in self.active_connections:
            self.active_connections[doc_id] = set()
 
        self.active_connections[doc_id].add(websocket)
        self.client_rooms[websocket] = doc_id
 
        # 初期ドキュメント状態を送信
        doc = self.crdt_sync.documents.get(doc_id)
        await websocket.send_json({
            "type": "initial_state",
            "updates": [u.to_dict() for u in (doc.updates if doc else [])],
            "vector_clock": doc.vector_clock if doc else {}
        })
 
    async def handle_client_updates(
        self,
        websocket: WebSocket,
        doc_id: str,
        client_id: str,
        updates: List[Dict[str, Any]]
    ):
        """クライアントからの更新を処理して他のクライアントへブロードキャスト"""
        # JSON を YjsUpdate オブジェクトに変換
        yjs_updates = [
            YjsUpdate(
                origin_client_id=u["origin_client_id"],
                clock=u["clock"],
                content=bytes.fromhex(u["content"]),
                parent_clock=u.get("parent_clock", 0)
            )
            for u in updates
        ]
 
        # サーバーと同期
        sync_result = await self.crdt_sync.sync_document(
            doc_id=doc_id,
            client_id=client_id,
            local_updates=yjs_updates,
            remote_vector_clock={}
        )
 
        # 他のクライアントへブロードキャスト
        await self.broadcast_updates(
            doc_id,
            exclude_client=websocket,
            updates=yjs_updates,
            server_vector_clock=sync_result["server_vector_clock"]
        )
 
        # 受信を確認
        await websocket.send_json({
            "type": "ack",
            "acknowledged_count": sync_result["acknowledged"],
            "server_vector_clock": sync_result["server_vector_clock"]
        })
 
    async def broadcast_updates(
        self,
        doc_id: str,
        exclude_client: WebSocket,
        updates: List[YjsUpdate],
        server_vector_clock: Dict[str, int]
    ):
        """すべての接続されたクライアントに更新を送信"""
        if doc_id not in self.active_connections:
            return
 
        disconnected = set()
 
        for client in self.active_connections[doc_id]:
            if client == exclude_client:
                continue
 
            try:
                await client.send_json({
                    "type": "remote_updates",
                    "updates": [u.to_dict() for u in updates],
                    "server_vector_clock": server_vector_clock
                })
            except Exception:
                disconnected.add(client)
 
        # 切断されたクライアントをクリーンアップ
        for client in disconnected:
            await self.disconnect(client)
 
    async def disconnect(self, websocket: WebSocket):
        """クライアントがセッションから切断"""
        doc_id = self.client_rooms.pop(websocket, None)
        if doc_id and doc_id in self.active_connections:
            self.active_connections[doc_id].discard(websocket)
            if not self.active_connections[doc_id]:
                del self.active_connections[doc_id]

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
Yjs と Automerge を実測で比べる選定基準 — 更新サイズ・文書肥大・エコシステムの3軸
トゥームストーンによる文書肥大を抑えるスナップショット圧縮の実装(SQLite 永続化コード付き)
AI 競合解決を同期経路に入れない構成 — レイテンシ・コスト・フォールバックの設計判断
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

アプリ開発2026-03-18
Antigravity で SwiftUI × CloudKit の iCloud 同期アプリを本番実装する
SwiftUI と CloudKit を Antigravity で組み合わせ、デバイス間リアルタイム同期の iOS アプリを本番実装します。CKRecord 設計、オフライン対応、queryable インデックスとスキーマ昇格の落とし穴まで、実装の判断ごとに整理しました。
アプリ開発2026-07-18
iOS 27 パブリックベータをどの端末に入れるか — 検証機の決め方と、戻れない前提での備え
iOS 27 のパブリックベータが 7/13 に公開されました。ビルド番号が開発者向けベータ3と同一である事実を起点に、どの端末に入れるか、入れる前に何をアーカイブするか、アプリのどこから確認するかを個人開発の視点で整理します。
アプリ開発2026-07-18
「Compose ファースト」の後で、View の画面をどの順に移すか — 画面数ではなく変更頻度で決める
Google が Android 開発を Compose ファーストと表明しました。View ベースの画面をどの順で移すかを、画面数ではなく git の変更履歴から決める判断軸と、実際に動かしたスコアリングスクリプトをまとめます。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →