Реалізація CRDT/OT для real-time синхронізації на сайті
Real-time синхронізація кількох клієнтів без конфліктів — задача, що на практиці складніша, ніж виглядає. Проста трансляція подій через WebSocket працює поки два користувачі не редагують один фрагмент одночасно. Без формальної моделі даних — каша.
OT проти CRDT: принципіальна різниця
Operational Transformation (OT) — алгоритм, що трансформує операції з урахуванням конкуруючих змін. Застосовується в Google Docs з 2006 року. Суть: якщо користувач A вставив символ на позицію 5, а користувач B видалив символ на позиції 3, то операція A повинна бути трансформована перед застосуванням у B.
Алгоритм працює коректно лише при наявності центрального сервера-арбітра, який упорядковує операції. Без нього реалізація OT для 2+ клієнтів стає експоненціально складною.
CRDT (Conflict-free Replicated Data Types) — структури даних, що математично гарантують eventual consistency без координації. Операції комутативні та ідемпотентні — порядок застосування не впливає на результат.
| Критерій | OT | CRDT |
|---|---|---|
| Центральний сервер | Обов'язковий | Опціональний (P2P можливий) |
| Offline-підтримка | Складна | Нативно |
| Продуктивність документа | Висока | Залежить від типу |
| Реалізація | Складна, багато edge cases | Простіша з бібліотеками |
| Rich text | Google Docs, Quill | Yjs, Automerge |
CRDT на практиці: Yjs
Найзрілішою CRDT-бібліотека для браузера та Node.js. Будується з Y.Doc, що містить shared types: Y.Text, Y.Map, Y.Array.
npm install yjs y-websocket y-protocols
Сервер (y-websocket):
const { setupWSConnection } = require('y-websocket/bin/utils');
const http = require('http');
const { WebSocketServer } = require('ws');
const server = http.createServer();
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
setupWSConnection(ws, req, {
docName: req.url.slice(1),
gc: true,
});
});
server.listen(1234);
Клієнт з інтеграцією редактора:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import Quill from 'quill';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
'ws://localhost:1234',
'my-document-room',
ydoc,
{ connect: true }
);
provider.on('status', ({ status }) => {
console.log('WS status:', status); // 'connected' | 'disconnected'
});
provider.on('sync', (isSynced: boolean) => {
if (isSynced) console.log('Document synced from server');
});
const ytext = ydoc.getText('quill-content');
const quill = new Quill('#editor', { theme: 'snow' });
const binding = new QuillBinding(ytext, quill, provider.awareness);
provider.awareness.setLocalStateField('user', {
name: 'Ivan Petrov',
color: '#4a9eff',
});
Y.Map та Y.Array для структурованих даних
Текстові редактори — окремий випадок. CRDT застосовується ширше: синхронізація стану форм, kanban дошки, діаграми.
// Синхронізована карта задач (Kanban-дошка)
const ytasks = ydoc.getMap<Y.Map<unknown>>('tasks');
function createTask(id: string, title: string, status: string) {
const task = new Y.Map<unknown>();
task.set('id', id);
task.set('title', title);
task.set('status', status);
task.set('createdAt', Date.now());
ytasks.set(id, task);
}
function moveTask(id: string, newStatus: string) {
const task = ytasks.get(id);
if (task) {
task.set('status', newStatus);
}
}
// Реактивне оновлення UI
ytasks.observe((event) => {
event.changes.keys.forEach((change, key) => {
if (change.action === 'add') {
renderTask(ytasks.get(key));
} else if (change.action === 'delete') {
removeTaskFromUI(key);
} else if (change.action === 'update') {
updateTaskInUI(key, ytasks.get(key));
}
});
});
Персистентність: зберігання Y.Doc на сервері
Ephemeral y-websocket втрачає документ при перезапуску. Production потребує persistence layer.
// Варіант 1: y-leveldb (single-node)
const { LeveldbPersistence } = require('y-leveldb');
const persistence = new LeveldbPersistence('./data');
setupWSConnection(ws, req, {
docName,
gc: true,
persistence,
});
// Варіант 2: Redis (multi-node via y-redis)
import { createRedisStorage } from 'y-redis';
const redisStorage = createRedisStorage({
host: 'localhost',
port: 6379,
});
// Варіант 3: PostgreSQL з Hocuspocus
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const server = Server.configure({
port: 1234,
extensions: [
new Database({
fetch: async ({ documentName }) => {
const { rows } = await pool.query(
'SELECT data FROM documents WHERE name = $1',
[documentName]
);
return rows[0]?.data ?? null;
},
store: async ({ documentName, state }) => {
await pool.query(
`INSERT INTO documents (name, data, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (name)
DO UPDATE SET data = $2, updated_at = NOW()`,
[documentName, Buffer.from(state)]
);
},
}),
],
});
server.listen();
Automerge як альтернатива
Automerge 2.x (Rust/WASM) краща продуктивність для великих документів, нативна підтримка JSON-подібних структур:
import * as Automerge from '@automerge/automerge';
let doc = Automerge.init<{ items: string[] }>();
doc = Automerge.change(doc, 'Add item', (d) => {
if (!d.items) d.items = [];
d.items.push('new item');
});
const binary = Automerge.save(doc);
const [newDoc, patch] = Automerge.applyChanges(doc, [remoteChange]);
Розрішення конфліктів у CRDT
CRDT не устраняє конфлікти — визначає детермінований переможця. Для Last-Write-Wins Map (LWW-Map) перемагає операція з пізнішим timestamp. Для текста Yjs позиція вставки визначається сусідами, не індексом — стійко до конкуруючих вставок.
Граничний випадок: два користувачі одночасно видаляють і редагують один елемент. У LWW видалення перемагає, але Yjs зберігає контент як "надгробок" (tombstone) — дозволяє корректно застосувати зміни, зроблені до видалення.
Масштабування: multi-node синхронізація
Один WebSocket-сервер не масштабується горизонтально — кожен клієнт підключений до конкретного процесу. Рішення:
Sticky sessions на рівні nginx (document_id → upstream):
upstream yjs_backend {
hash $arg_room consistent;
server yjs1:1234;
server yjs2:1234;
server yjs3:1234;
}
Pub/Sub через Redis — кожен сервер публікує оновлення у Redis канал, решта підписана. Hocuspocus з розширенням Redis підтримує з коробки.
Liveblocks/PartyKit — managed інфраструктура для CRDT, якщо немає бажання підтримувати власний кластер.
Терміни реалізації
Базова CRDT-синхронізація текстового редактора на Yjs + y-websocket: 3–5 днів. Додавання персистентності через PostgreSQL/Redis: ще 2–3 дні. Multi-node з Redis pub/sub та тестуванням під навантаженням: плюс тиждень. Кастомні типи даних (Kanban, діаграми) поверх Y.Map: залежить від UI-складності.







