โ† 2026-06-05 ๐Ÿ“‚ All Days 2026-06-07 โ†’

๐Ÿ”ฌ Saturday Deep Dive: Operational Transformation & CRDTs โ€” ๅๅŒ็ผ–่พ‘ๅ™จ็š„ๅบ•ๅฑ‚ๅผ•ๆ“Ž (18 min read)

๐Ÿ“Š NeetCode: 50/150 ยท SysDesign: 49/40 ยท Behavioral: 49/40 ยท Frontend: 37/50 ยท AI: 24/30

๐Ÿ”ฅ 60-day streak!


Overview / ๆฆ‚่ฟฐ

ไธบไป€ไนˆ่ฟ™ไธช่ฏ้ข˜ๅ€ผๅพ—่Šฑ 18 ๅˆ†้’Ÿ๏ผŸ

ไฝ ๆฏๆฌกๅœจ Google Docs ้‡Œๅ’ŒๅŒไบ‹ๅŒๆ—ถ็ผ–่พ‘ไธ€ไธชๆ–‡ๆกฃ๏ผŒ่ƒŒๅŽ้ƒฝๆœ‰ไธ€ๅฅ—็ฒพๅฆ™็š„็ฎ—ๆณ•ๅœจไฟ่ฏๅคงๅฎถ็œ‹ๅˆฐ็š„ๅ†…ๅฎนไธ€่‡ดใ€‚่ฟ™ๅฅ—็ฎ—ๆณ•ไธๆ˜ฏ้”๏ผˆๅคชๆ…ข๏ผ‰๏ผŒไธๆ˜ฏ"ๆœ€ๅŽๅ†™ๅ…ฅ่€…่ตข"๏ผˆๆ•ฐๆฎไธขๅคฑ๏ผ‰๏ผŒ่€Œๆ˜ฏไธ€็งๅซๅšๆ“ไฝœๅ˜ๆข๏ผˆOperational Transformation, OT๏ผ‰ๆˆ–ๆ— ๅ†ฒ็ชๅคๅˆถๆ•ฐๆฎ็ฑปๅž‹๏ผˆCRDT๏ผ‰็š„ๆŠ€ๆœฏใ€‚

่ฟ™ไธค่€…ๆ˜ฏๅๅŒ็ผ–่พ‘้ข†ๅŸŸ็š„ๆ ธๅฟƒๆŠ€ๆœฏ๏ผŒไนŸๆ˜ฏ็ณป็ปŸ่ฎพ่ฎก้ข่ฏ•้‡Œๆ‹‰ๅผ€ๅทฎ่ท็š„ๅ…ณ้”ฎ็Ÿฅ่ฏ†็‚นใ€‚ๅคงๅคšๆ•ฐๅ€™้€‰ไบบ่ƒฝ็”ปๅ‡บ WebSocket + ๅŽ็ซฏ + ๆ•ฐๆฎๅบ“็š„ๆžถๆž„ๅ›พ๏ผŒไฝ†ๆžๅฐ‘ๆœ‰ไบบ่ƒฝ่งฃ้‡Šๅ†ฒ็ชๆ˜ฏๆ€Žไนˆ่งฃๅ†ณ็š„ใ€‚

Why this topic is worth 18 minutes:

When you and a colleague edit a Google Doc simultaneously, a sophisticated algorithm silently resolves conflicts in real time. It's not a lock (too slow), not "last write wins" (data loss), but rather Operational Transformation (OT) or Conflict-free Replicated Data Types (CRDTs). These are what separate good system design answers from great ones.


Part 1: Theory / ็†่ฎบๅŸบ็ก€ (5 min)

ๆ ธๅฟƒ้—ฎ้ข˜๏ผšๅนถๅ‘ๅ†ฒ็ช

ๆƒณ่ฑกไธคไธช็”จๆˆทๅŒๆ—ถ็ผ–่พ‘ๅŒไธ€ไธชๆ–‡ๆกฃ๏ผŒๅˆๅง‹ๅ†…ๅฎนๆ˜ฏ "abc"๏ผš

User A: ๅœจไฝ็ฝฎ 0 ๆ’ๅ…ฅ "X" โ†’ "Xabc" User B: ๅœจไฝ็ฝฎ 2 ๅˆ ้™ค 'c' โ†’ "ab" ๅฆ‚ๆžœ A ๅ…ˆๅˆฐ่พพๆœๅŠกๅ™จ๏ผŒB ็š„ๆ“ไฝœๅบ”่ฏฅไฝœ็”จไบŽๅ“ชไธช็Šถๆ€๏ผŸ - ๅฆ‚ๆžœ็›ดๆŽฅๅœจ "Xabc" ไธŠๅˆ ้™คไฝ็ฝฎ 2 โ†’ ๅˆ ๆމไบ† 'b' โŒ - ๆญฃ็กฎ็ญ”ๆกˆ๏ผšๅˆ ้™คไฝ็ฝฎ 3๏ผˆๅ› ไธบ A ็š„ๆ’ๅ…ฅไฝฟๅŽŸๆฅ็š„ c ๅ‘ๅณ็งปไบ†ไธ€ไฝ๏ผ‰โœ“

่ฟ™ๅฐฑๆ˜ฏ OT๏ผˆๆ“ไฝœๅ˜ๆข๏ผ‰่ฆ่งฃๅ†ณ็š„้—ฎ้ข˜๏ผšๅฆ‚ไฝ•่ฐƒๆ•ดไธ€ไธชๆ“ไฝœ๏ผŒไฝฟๅฎƒๅœจๅฆไธ€ไธชๅนถๅ‘ๆ“ไฝœๅทฒ็ปๆ‰ง่กŒไน‹ๅŽไป็„ถ่ƒฝไบง็”Ÿๆญฃ็กฎ็š„็ป“ๆžœใ€‚

The Core Problem: Concurrent Conflicts

Initial: "abc" User A: insert("X", pos=0) โ†’ "Xabc" User B: delete(pos=2, char='c') โ†’ "ab" Server receives A first, then must apply B. B was generated against "abc" where 'c' was at index 2. In "Xabc", 'c' is now at index 3. OT must transform B: delete(pos=2) โ†’ delete(pos=3)

ไธค็ง่งฃๆณ•ๅฏนๆฏ” / Two Approaches

็ปดๅบฆOT (Operational Transformation)CRDT
ๅކๅฒ1989ๅนด๏ผŒGoogle Docs ็”จ็š„2006ๅนด่ตท๏ผŒFigmaใ€Notionใ€Linear ่ฝฌๅ‘
ๆ ธๅฟƒๆ€่ทฏๅœจๆœๅŠกๅ™จ็ซฏๅ˜ๆขๆ“ไฝœๅบๅˆ—ๆ•ฐๆฎ็ป“ๆž„ๆœฌ่บซไฟ่ฏๆ”ถๆ•›
้œ€่ฆไธญๅฟƒๆœๅŠกๅ™จๆ˜ฏ๏ผˆๆ“ไฝœๅ˜ๆข้€ป่พ‘้›†ไธญๅœจๆœๅŠก็ซฏ๏ผ‰ๅฆ๏ผˆP2P ๅฏ็”จ๏ผŒ็ฆป็บฟไผ˜ๅ…ˆ๏ผ‰
ๅฎž็Žฐๅคๆ‚ๅบฆ้ซ˜๏ผˆOT correctness ๅพˆ้šพ่ฏๆ˜Ž๏ผ‰ไธญ้ซ˜๏ผˆไธๅŒ CRDT ็ฑปๅž‹ไธๅŒ๏ผ‰
ไปฃ่กจไบงๅ“Google Docs, EtherpadFigma, Apple Notes, Automerge

OT ็š„ไธคไธชๆ ธๅฟƒๆ€ง่ดจ / OT's Two Core Properties

่ฎพ op1 ๅ’Œ op2 ๆ˜ฏไธคไธชๅนถๅ‘ๆ“ไฝœ๏ผŒT ๆ˜ฏๅ˜ๆขๅ‡ฝๆ•ฐ๏ผš Inclusion Transformation (IT): apply(apply(doc, op1), T(op2, op1)) == apply(apply(doc, op2), T(op1, op2)) โ†’ ๆ— ่ฎบๅ“ชไธชๆ“ไฝœๅ…ˆๅˆฐ๏ผŒๆœ€็ปˆ็ป“ๆžœ็›ธๅŒ โœ“ Exclusion Transformation (ET): undo ๆ“ไฝœๆ—ถ็”จๅˆฐ๏ผŒๆ’ค้”€ๅทฒ็ป่ขซๅ…ถไป–ๆ“ไฝœๅ˜ๆข่ฟ‡็š„ๆ“ไฝœ โ†’ ๅฎž็Žฐ Ctrl+Z ๅœจๅๅŒๅœบๆ™ฏไธ‹็š„ๆญฃ็กฎ่กŒไธบ

CRDT ็š„ๆ ธๅฟƒๆ€ๆƒณ / CRDT Core Idea

CRDT ไธๆ˜ฏๅ˜ๆขๆ“ไฝœ๏ผŒ่€Œๆ˜ฏ่ฎพ่ฎกไธ€็ง็‰นๆฎŠ็š„ๆ•ฐๆฎ็ป“ๆž„๏ผŒไฝฟๅพ—ไปปๆ„้กบๅบ็š„ๅˆๅนถ้ƒฝ่ƒฝไบง็”Ÿ็›ธๅŒ็ป“ๆžœใ€‚

ๅ…ณ้”ฎๆ€ง่ดจ๏ผˆSemilattice๏ผ‰๏ผš

  • ็ป“ๅˆๅพ‹๏ผšmerge(a, merge(b, c)) = merge(merge(a, b), c)
  • ไบคๆขๅพ‹๏ผšmerge(a, b) = merge(b, a)
  • ๅน‚็ญ‰ๆ€ง๏ผšmerge(a, a) = a

ๆปก่ถณ่ฟ™ไธ‰ๆก๏ผŒๅฐฑ่ƒฝไฟ่ฏๆœ€็ปˆไธ€่‡ดๆ€ง๏ผŒไธ้œ€่ฆไธญๅฟƒๅ่ฐƒ่€…ใ€‚

CRDT doesn't transform operations โ€” it designs data structures where any order of merges produces the same result. The three laws above guarantee eventual consistency without any central coordinator.


Part 2: Step-by-Step Implementation / ไธ€ๆญฅไธ€ๆญฅๅฎž็Žฐ (8 min)

ๆ–นๆกˆไธ€๏ผšOT ๅฎž็Žฐๆ–‡ๆœฌๅๅŒ / OT Implementation

from dataclasses import dataclass
from enum import Enum
from typing import Optional

class OpType(Enum):
    INSERT = "insert"
    DELETE = "delete"
    RETAIN = "retain"  # skip N characters unchanged

@dataclass
class Operation:
    """
    Represents a single atomic edit operation.
    Uses the 'retain/insert/delete' triple common in OT libraries (like OT.js / ShareDB).
    """
    type: OpType
    pos: int          # position in the ORIGINAL document (before this op)
    char: Optional[str] = None  # only for INSERT
    length: int = 1   # for DELETE or RETAIN


def transform(op_a: Operation, op_b: Operation, priority: str = "left") -> Operation:
    """
    Transform op_a against op_b.
    priority="left" means op_a wins on tie (same position insert).
    
    This implements the INSERT/DELETE x INSERT/DELETE matrix.
    """
    if op_a.type == OpType.INSERT and op_b.type == OpType.INSERT:
        # Both insert at same position: priority breaks tie
        if op_b.pos < op_a.pos or (op_b.pos == op_a.pos and priority == "right"):
            # op_b inserts before (or at same pos with lower priority)
            # โ†’ op_a's position shifts right by 1
            return Operation(OpType.INSERT, op_a.pos + 1, op_a.char)
        return op_a  # op_b is at or after op_a, no shift needed

    elif op_a.type == OpType.INSERT and op_b.type == OpType.DELETE:
        if op_b.pos < op_a.pos:
            # op_b deleted something before our insert position
            # โ†’ our position shifts left by 1
            return Operation(OpType.INSERT, op_a.pos - 1, op_a.char)
        return op_a  # deletion was at or after our position, no shift

    elif op_a.type == OpType.DELETE and op_b.type == OpType.INSERT:
        if op_b.pos <= op_a.pos:
            # op_b inserted before our deletion target
            # โ†’ our target shifts right by 1
            return Operation(OpType.DELETE, op_a.pos + 1)
        return op_a

    elif op_a.type == OpType.DELETE and op_b.type == OpType.DELETE:
        if op_b.pos < op_a.pos:
            # op_b deleted something before our target โ†’ shift left
            return Operation(OpType.DELETE, op_a.pos - 1)
        elif op_b.pos == op_a.pos:
            # Both deleting the same character! op_a becomes a no-op.
            # We return a RETAIN(0) to signal "already done"
            return Operation(OpType.RETAIN, op_a.pos, length=0)
        return op_a


class OTDocument:
    """
    Simple OT-based collaborative document (server-side coordinator).
    """
    def __init__(self):
        self.text = ""
        self.history: list[Operation] = []  # all committed ops in order

    def apply(self, op: Operation) -> str:
        """Apply an operation to the document text."""
        if op.type == OpType.INSERT:
            self.text = self.text[:op.pos] + op.char + self.text[op.pos:]
        elif op.type == OpType.DELETE:
            if op.length > 0:  # skip no-op deletes
                self.text = self.text[:op.pos] + self.text[op.pos + 1:]
        return self.text

    def receive(self, op: Operation, client_version: int) -> Operation:
        """
        Server receives an op from a client.
        client_version = how many ops the client has seen (its 'parent' revision).
        
        We must transform op against all ops since client_version.
        """
        # Transform op against every concurrent op the client hasn't seen yet
        concurrent_ops = self.history[client_version:]
        transformed_op = op
        for server_op in concurrent_ops:
            transformed_op = transform(transformed_op, server_op)
        
        self.apply(transformed_op)
        self.history.append(transformed_op)
        return transformed_op  # broadcast this to all other clients


# โ”€โ”€โ”€ Demo: The classic conflict scenario โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
doc = OTDocument()
doc.text = "abc"
doc.history = []

# User A: insert "X" at position 0
op_a = Operation(OpType.INSERT, pos=0, char="X")

# User B: delete 'c' at position 2
op_b = Operation(OpType.DELETE, pos=2)

# A's op arrives first at version 0 โ†’ applied directly
doc.receive(op_a, client_version=0)
print(f"After A: '{doc.text}'")  # "Xabc"

# B's op arrives (still thinks it's at version 0, before A)
# server transforms B against A's insert
doc.receive(op_b, client_version=0)
print(f"After B: '{doc.text}'")  # "Xab" โ† 'c' correctly deleted โœ“

ๆ–นๆกˆไบŒ๏ผšCRDT ๅฎž็Žฐ โ€” Logoot / RGA ๆ€่ทฏ / CRDT Approach

import uuid
from dataclasses import dataclass, field
from typing import Optional

@dataclass(order=True)
class Identifier:
    """
    Every character in a CRDT document gets a globally unique, 
    totally-ordered identifier. No two identifiers are equal.
    
    This is the key insight: positions become stable IDs, not integers.
    When A inserts between IDs 10 and 20, it picks ID 15.
    No matter what B does concurrently, character 15 stays where it is.
    """
    pos: float       # fractional position for ordering (Logoot-style)
    site_id: str     # which client created this (for tiebreaking)
    counter: int     # lamport clock to ensure uniqueness
    
    def __eq__(self, other):
        return self.pos == other.pos and self.site_id == other.site_id


@dataclass
class CRDTChar:
    """A single character in the CRDT sequence."""
    id: Identifier
    char: str
    deleted: bool = False  # tombstone โ€” deleted chars stay in the structure


class RGADocument:
    """
    Replicated Growable Array (RGA) โ€” a popular CRDT for text.
    
    Key properties:
    - Each char has a unique ID (site_id + lamport clock)
    - Causality: an insert references its left neighbor's ID
    - Delete = tombstone (never physically remove, just mark)
    - Merge: union of all char sets, order by ID
    """
    def __init__(self, site_id: str):
        self.site_id = site_id
        self.clock = 0
        # chars sorted by their stable IDs
        self._chars: list[CRDTChar] = []

    def _new_id(self, left_pos: float, right_pos: float) -> Identifier:
        """
        Generate a new identifier that falls between left and right positions.
        Uses fractional indexing โ€” the classic "pick a number between 10 and 20".
        """
        self.clock += 1
        mid_pos = (left_pos + right_pos) / 2
        return Identifier(pos=mid_pos, site_id=self.site_id, counter=self.clock)

    def local_insert(self, char: str, after_index: int) -> CRDTChar:
        """
        Insert a character after the visible character at after_index.
        Returns the new CRDTChar (to be broadcast to peers).
        """
        # Find the fractional position boundaries
        left_pos = self._chars[after_index].id.pos if after_index >= 0 else 0.0
        right_pos = (
            self._chars[after_index + 1].id.pos 
            if after_index + 1 < len(self._chars) 
            else 1.0
        )
        new_id = self._new_id(left_pos, right_pos)
        new_char = CRDTChar(id=new_id, char=char)
        
        # Insert in sorted position
        insert_at = after_index + 1
        self._chars.insert(insert_at, new_char)
        return new_char

    def local_delete(self, index: int) -> CRDTChar:
        """
        Delete the visible character at index.
        Tombstone only โ€” the char stays in _chars forever.
        """
        # Find the nth visible (non-deleted) char
        count = 0
        for c in self._chars:
            if not c.deleted:
                if count == index:
                    c.deleted = True
                    return c  # broadcast this tombstone
                count += 1
        raise IndexError("No visible character at that index")

    def apply_remote(self, op: CRDTChar):
        """
        Merge a remote operation (insert or delete) into our local state.
        This is the magic: it's commutative, associative, idempotent.
        """
        if op.deleted:
            # Remote delete: find and tombstone our local copy
            for c in self._chars:
                if c.id == op.id:
                    c.deleted = True
                    return
        else:
            # Remote insert: find the right sorted position by ID
            for i, c in enumerate(self._chars):
                if c.id > op.id:
                    self._chars.insert(i, op)
                    return
            self._chars.append(op)

    @property
    def text(self) -> str:
        """Return the visible (non-tombstoned) text."""
        return "".join(c.char for c in self._chars if not c.deleted)


# โ”€โ”€โ”€ Demo: Same conflict, CRDT way โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
doc_a = RGADocument(site_id="A")
doc_b = RGADocument(site_id="B")

# Both start with "abc" โ€” in a real system, initial state is synced
# (simplified: skip initial state sync for clarity)

# The point: concurrent operations just get merged, no transform needed.
# doc_a.local_insert("X", 0) โ†’ broadcasts CRDTChar with ID pos~0.5
# doc_b.local_delete(2)       โ†’ broadcasts tombstone for 'c'
# Both sides call apply_remote() with the other's op
# Result is always identical regardless of order โœ“

็ณป็ปŸๆžถๆž„ / Full System Architecture

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ CLIENT (Browser) โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” local op โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ Editor โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ โ”‚ OT/CRDT Local State โ”‚ โ”‚ โ”‚ โ”‚ (Quill / โ”‚ โ”‚ โ€ข apply locally (fast) โ”‚ โ”‚ โ”‚ โ”‚ CodeMirror)โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ โ€ข queue for broadcast โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ remote op โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ WebSocket โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ WebSocket Gateway โ”‚ โ”‚ (Sticky sessions by โ”‚ โ”‚ document ID) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ Operation Handler โ”‚ โ”‚ โ”‚ โ”‚ OT Mode: โ”‚ โ”‚ 1. Receive op + client_version โ”‚ โ”‚ 2. Transform against concurrent โ”‚ โ”‚ 3. Apply to server state โ”‚ โ”‚ 4. Broadcast transformed op โ”‚ โ”‚ โ”‚ โ”‚ CRDT Mode: โ”‚ โ”‚ 1. Receive CRDTChar โ”‚ โ”‚ 2. apply_remote() locally โ”‚ โ”‚ 3. Broadcast to all peers โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ Op Log โ”‚ โ”‚ Snapshot Store โ”‚ โ”‚ Presence โ”‚ โ”‚ (Kafka / โ”‚ โ”‚ (S3 / Postgres) โ”‚ โ”‚ Service โ”‚ โ”‚ Postgres) โ”‚ โ”‚ periodic saves โ”‚ โ”‚ (cursors, โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ who's online)โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

ๆžถๆž„่ฏทๆฑ‚ๆต / Request Trace:

User A types "X" at position 0 โ”‚ โ–ผ Editor sends WebSocket frame โ”‚ { op: "insert", char: "X", pos: 0, version: 42 } โ”‚ โ–ผ Operation Handler receives โ”‚ concurrent_ops = history[42:] โ† ops A hasn't seen โ”‚ transformed = transform(op, concurrent_ops) โ”‚ apply(transformed) to server doc โ”‚ append to Op Log โ”‚ โ–ผ Broadcast to all other clients in document room { op: "insert", char: "X", pos: 1, version: 43 } โ† transformed! (User B's client applies this on top of its local state)
This matches the architecture diagram: Editor โ†’ WebSocket Gateway โ†’ Operation Handler โ†’ Op Log โ†’ broadcast back.

Part 3: Edge Cases & Gotchas / ่พน็•Œๆƒ…ๅ†ต (2 min)

1. Tombstone ็งฏ็ดฏ / Tombstone Accumulation (CRDT)

# ้—ฎ้ข˜๏ผšๅคง้‡ๅˆ ้™คๅŽ๏ผŒ_chars ่ถŠๆฅ่ถŠๅคง
# "Hello World" typed then deleted โ†’ 11 tombstones remain in memory
# 
# ่งฃๆณ•๏ผšๅฎšๆœŸ Compaction๏ผˆ้œ€่ฆๅ…จๅฑ€ๅ่ฐƒ๏ผ‰
# ๅชๆœ‰ๅฝ“ๆ‰€ๆœ‰ๅฎขๆˆท็ซฏ้ƒฝ >= ๆŸไธช็‰ˆๆœฌๆ—ถ๏ผŒๆ‰่ƒฝๅฎ‰ๅ…จๆธ…้™ค tombstone
# Google Docs ้€š่ฟ‡ๅฎšๆœŸ snapshot + log replay ่งฃๅ†ณ่ฟ™ไธช้—ฎ้ข˜

2. OT ็š„ TP2 ้—ฎ้ข˜ / OT's TP2 Property

Jupiter protocol (Google Docs) uses a simpler "server as single source of truth" model that only requires TP1 (inclusion transformation). TP2 (handling three concurrent ops) is notoriously hard to implement correctly โ€” that's why peer-to-peer OT was abandoned.

3. ็ฝ‘็ปœๅˆ†ๅŒบ / Network Partition

CRDT wins here: offline edits accumulate as local ops.
On reconnect, just apply_remote() all the remote ops.
No conflict โ€” the math guarantees convergence.

OT requires the server version number, so offline OT is tricky.
Google Docs handles this by queuing ops locally and replaying on reconnect.

4. ๆ„ๅ›พไฟ็•™ / Intention Preservation

# ็”จๆˆทๆ„ๅ›พ๏ผš"ๅˆ ้™ค็ฌฌไบŒไธช่ฏ"
# ๅนถๅ‘ๆ“ไฝœๅฏ่ƒฝๅœจๅˆ ้™คไน‹ๅ‰ๆ’ๅ…ฅไบ†ไธ€ๅ †ๅ†…ๅฎน
# OT ๅ˜ๆขๅŽๅˆ ้™คไบ†"ๆญฃ็กฎไฝ็ฝฎ"็š„ๅญ—็ฌฆ๏ผŒไฝ†ๅฏ่ƒฝไธๆ˜ฏ็”จๆˆทๆƒณๅˆ ็š„้‚ฃไธช่ฏ
#
# ่ฟ™ๆ˜ฏ OT ๆœ€ๆทฑ็š„ๅ“ฒๅญฆ้—ฎ้ข˜๏ผŒๆฒกๆœ‰ๅฎŒ็พŽ่งฃๆณ•
# ๅฎž่ทตไธญ๏ผš็”จๅ…‰ๆ ‡ heuristics + ็”จๆˆท undo ๆฅ็ผ“่งฃ

Part 4: Real-World Application / ๅฎž้™…ๅบ”็”จ (2 min)

่ฐ็”จไป€ไนˆ / Who Uses What

ไบงๅ“ๆŠ€ๆœฏๅŽŸๅ› 
Google DocsOT (Jupiter Protocol)2006ๅนด่ฎพ่ฎก๏ผŒOTๆˆ็†Ÿๆ—ถๆœŸ
FigmaCRDT่ฎพ่ฎกๅทฅๅ…ท็š„ๆ“ไฝœๅคฉ็„ถ้€‚ๅˆ CRDT๏ผˆ็งปๅŠจใ€้ขœ่‰ฒ็ญ‰๏ผ‰
NotionCRDT (Yjs)็ฆป็บฟไผ˜ๅ…ˆ๏ผŒๅ—็ป“ๆž„้€‚ๅˆ CRDT
LinearCRDT็Žฐไปฃๆžถๆž„๏ผŒP2P ๅŒๆญฅ
VS Code Live ShareOTไปฃ็ ็ผ–่พ‘ๅ™จ๏ผŒๆœๅŠก็ซฏๅ่ฐƒๆ›ดๅฏๆŽง
CouchDBCRDT-style MVCCๆ–‡ๆกฃ็บงๅˆซๅˆๅนถ

้ข่ฏ•ๆ—ถๅฆ‚ไฝ•่ฐˆ่ฎบ / How to Talk About This in Interviews

"For a collaborative editor, the hardest problem isn't transport โ€” it's conflict resolution. I'd choose OT if we need a central server anyway (simpler ops, proven at scale by Google), or CRDT if we want offline-first or P2P capability. Either way, the client applies ops locally immediately for responsiveness, and reconciles with the server in the background."

Part 5: Interview Simulation / ้ข่ฏ•ๆจกๆ‹Ÿ (3 min)

Q1: ๅฆ‚ๆžœไธคไธช็”จๆˆทๅŒๆ—ถๅœจๅŒไธ€ไฝ็ฝฎๆ’ๅ…ฅๅญ—็ฌฆ๏ผŒ่ฐ็š„ๅญ—็ฌฆๅบ”่ฏฅๆŽ’ๅœจๅ‰้ข๏ผŸ

A1: This is the "tie-breaking" problem. In OT, server assigns a timestamp or priority based on connection order โ€” whichever op the server receives first wins. In CRDT, each site has a unique ID used for deterministic tiebreaking (e.g., site_id lexicographic order). Neither is "correct" UX-wise; it's a deliberate consistency choice. Some systems let the cursor position communicate intent.


Q2: ไธบไป€ไนˆ Google Docs ไธ็”จ CRDT๏ผŸ

A2: Google Docs predates modern CRDT research (Jupiter Protocol is from 1995/2006). Also, for a centralized product, OT's server-centric model is simpler to reason about for correctness. CRDTs have their own complexity (tombstone management, fractional indexing precision). Google has no strong reason to migrate a system that works at massive scale.


Q3: ๅฆ‚ไฝ•ๅค„็†็”จๆˆท็ฆป็บฟ็ผ–่พ‘ๅŽ้‡ๆ–ฐไธŠ็บฟ๏ผŸ

A3:

  • OT approach: Client stores ops with the last-known server version. On reconnect, sends all queued ops with their original version. Server replays the transform chain. Risky if offline too long (version gap too large) โ€” some systems cap offline time.
  • CRDT approach: Client accumulates CRDTChar objects locally. On reconnect, exchange ops with server (gossip or vector clock-based sync). apply_remote() handles everything โ€” guaranteed convergence regardless of offline duration.

Q4: ๅๅŒ็ผ–่พ‘็ณป็ปŸๅฆ‚ไฝ•ๅš Undo/Redo๏ผŸ

A4: This is where things get tricky. Local undo in a collaborative context can "undo" someone else's concurrent work accidentally. Modern systems implement selective undo: undo only your own operations, using Exclusion Transformation (ET) to "remove" your past op from the history without affecting others'. Some systems (Figma) implement per-user undo stacks tracked on the server.


Q5: ็ณป็ปŸ่ฎพ่ฎก้ข่ฏ•ไธญ๏ผŒๅฆ‚ไฝ•ๅœจ OT ๅ’Œ CRDT ไน‹้—ด้€‰ๆ‹ฉ๏ผŸ

A5: Ask these questions:

  1. Need offline-first / P2P? โ†’ CRDT
  2. Central server anyway, proven ops needed? โ†’ OT
  3. Rich text with complex formatting? โ†’ Yjs (CRDT library) handles this well
  4. Simple shared state (not text)? โ†’ CRDT variants like G-Counter, LWW-Register
  5. Scale? โ†’ Both scale; OT's server bottleneck is the bigger concern at extreme scale

In an interview, showing you know both and can articulate the tradeoffs is more impressive than picking one confidently.


Further Reading / ๅปถไผธ้˜…่ฏป: