---
title: "CRDT là gì? Cách đồng bộ dữ liệu khi nhiều người cùng sửa"
description: "CRDT giúp đồng bộ dữ liệu khi nhiều người cùng chỉnh sửa lúc mất mạng. Các thay đổi được kết hợp ra sao, và xung đột được xử lý thế nào?"
canonical: "https://200lab.io/blog/crdt-la-gi"
published: "2026-11-07T07:37:23Z"
updated: "2026-11-07T07:37:23Z"
authors: ["Việt Trần"]
tags: ["Backend", "System Design"]
reading_minutes: 14
access: "public"
---

Giả sử An và Bình cùng soạn một bản ghi chú họp. Ban đầu tiêu đề là "Meeting notes", và danh sách việc đã có một mục "agenda".

Trong lúc làm việc, hai người không kết nối được với nhau. An mở ghi chú trên điện thoại, thêm việc "write RFC" và đổi tiêu đề thành "Team meeting notes". Cùng lúc đó, Bình dùng laptop, thêm việc "set deadline" và sửa tiêu đề thành "Meeting minutes".

Khi cả hai có mạng trở lại, chuyện gì xảy ra nếu hệ thống lấy nguyên file của người gửi sau để đè lên bản cũ? Giả sử Bình gửi sau, bản ghi chú biến thành bản của Bình, và việc "write RFC" mà An vừa thêm sẽ mất.

Hai việc mới không đụng nhau, nên hệ thống nên giữ cả hai. Riêng tiêu đề là cùng một trường bị sửa thành hai câu khác nhau: cần một luật để chọn, chứ không giữ được cả hai cách đặt tên nếu app không để người dùng tự quyết. Từ bài toán này, mình sẽ giải thích CRDT là gì, vì sao app cho phép sửa lúc mất mạng lại cần kiểu dữ liệu ấy, nó khác Operational Transformation ở chỗ nào, rồi chạy thử đúng kịch bản ghi chú họp ở trên.

## CRDT là gì?

An và Bình cần một kiểu dữ liệu để mỗi người giữ một bản sao của cùng ghi chú trên thiết bị của mình, thuật ngữ gọi là replica. Mỗi người sửa được bản trên máy khi chưa gặp nhau, rồi sau đó gộp các thay đổi lại, thuật ngữ hay gọi là merge, theo một luật đã định sẵn.

Theo [khảo sát của Shapiro, Preguiça, Baquero và Zawirski về kiểu dữ liệu nhân bản](https://hal.science/inria-00555588/), thay vì xử lý đồng bộ theo kiểu vá từng tình huống dễ sai, có thể thiết kế kiểu dữ liệu dùng chung dựa trên vài điều kiện hình thức đủ để các bản sao cuối cùng giống nhau. Nhóm tác giả gọi chúng là Convergent or Commutative Replicated Data Types; sau này cộng đồng hay gọi Conflict-free Replicated Data Type, viết tắt là CRDT. Dịch sang tiếng Việt cũng chẳng sáng hơn nên mình giữ nguyên CRDT.

Nói bằng lời thường: mỗi thiết bị giữ một replica. Khi các replica đã nhận cùng một tập thay đổi, chúng đi tới cùng một kết quả mà không cần một server làm trọng tài quyết định thứ tự các thay đổi, thuật ngữ gọi là coordinator, ngay lúc gộp.

Điều đó dễ bị hiểu nhầm. Không cần coordinator lúc gộp không có nghĩa là hệ thống không bao giờ cần server, mạng hay đăng nhập. Server vẫn có thể chuyển tin, lưu bản phụ hoặc xác thực người dùng. Cái không bắt buộc là một server đứng giữa sắp thứ tự sửa đổi khi hai bản sao gộp vào nhau.

Khảo sát chia CRDT thành hai họ, khác nhau ở cách hai máy trao đổi dữ liệu:

- **Hướng dựa trên trạng thái (state-based, hay Convergent):** Mỗi replica tự sửa dữ liệu của mình, rồi trao đổi trạng thái với máy kia để hai bên gộp lại.
- **Hướng dựa trên thao tác (operation-based, hay Commutative):** Mỗi replica gửi từng thao tác thay đổi để bên kia áp dụng lại vào dữ liệu của họ.

Mỗi họ có một điều kiện đủ để các bản sao gặp nhau ở cùng kết quả; bài này không đi vào công thức toán. Nếu bạn muốn xem CRDT dùng cho bài toán đếm, với G-Counter và PN-Counter, hãy đọc bài [Distributed Counter là gì](/blog/distributed-counter-la-gi). Bài đó đã viết kỹ phần bộ đếm; bài này đi tiếp với ghi chú họp của An và Bình.

## Vì sao cần CRDT khi nhiều người cùng sửa lúc mất mạng?

Ở nhiều app chạy trên cloud, bản trên server mới được coi là bản chính, còn dữ liệu trên máy người dùng chỉ như bộ nhớ tạm. Nếu An sửa trên điện thoại mà chưa gửi lên được, thay đổi đó coi như chưa xảy ra. Đó chính là chỗ kịch bản lúc nãy bị gãy: hai người vẫn làm việc được, nhưng lúc gặp lại thì một phía có thể bị đè mất.

Có một hướng làm phần mềm lấy bản ngay trên điện thoại hay laptop làm bản chính, còn server giữ bản phụ để hỗ trợ đồng bộ qua nhiều máy. Hướng này được trình bày trong [bài viết của Ink & Switch về phần mềm local-first](https://www.inkandswitch.com/essay/local-first/), và đi cùng khả năng dùng app khi mất kết nối, hay gọi là offline-first.

Với tư duy đó, mất mạng không chặn An hay Bình gõ thêm vào ghi chú. Khi có kết nối trở lại, hai thiết bị mới đồng bộ. Kênh truyền có thể đi qua một server, kết nối trực tiếp giữa hai máy, Bluetooth, hoặc chép file qua USB.

Essay local-first cũng lấy ví dụ danh sách việc, rất gần chuyện An và Bình. Một máy thêm một mục mới, máy kia đánh dấu xong mục đầu; sau khi gặp nhau, CRDT gộp để cả hai thay đổi đều còn. Nếu cả hai cùng thêm mục mới, bản sau khi gộp chứa đủ các mục vừa thêm, theo một thứ tự nhất quán.

Mạch làm việc của An trên điện thoại và Bình trên laptop như sau:

```mermaid
flowchart LR
  start["Ban đầu: Meeting notes, có mục agenda"] --> an["An trên điện thoại, chưa kết nối"]
  start --> binh["Bình trên laptop, chưa kết nối"]
  an --> aedit["Thêm write RFC và đổi tiêu đề"]
  binh --> bedit["Thêm set deadline và đổi tiêu đề"]
  aedit --> gop["Gộp thay đổi, không cần server quyết định thứ tự"]
  bedit --> gop
  gop --> done["Giữ đủ hai việc mới; tiêu đề theo luật đã chọn"]
```

Mỗi lần gửi có thể nhỏ tới từng phím để cộng tác thời gian thực, hoặc gom một cụm thay đổi rồi gửi một lượt, giống cách anh em gửi một pull request. Miễn mọi thay đổi cuối cùng đều tới đủ, bản sao trên máy An và máy Bình gặp nhau ở cùng kết quả, mà không cần server làm trọng tài sắp thứ tự lúc gộp.

## CRDT khác Operational Transformation chỗ nào?

Khi nhiều người cùng sửa chữ, bạn còn gặp một hướng rất quen, gọi là Operational Transformation, viết tắt là OT. Khác biệt nằm ở cách mỗi hướng xử lý vị trí dữ liệu khi có thay đổi xảy ra cùng lúc.

OT biến đổi lại vị trí các thao tác chèn hoặc xóa, tức các con số index, để mọi thiết bị cuối cùng hiện cùng một nội dung. CRDT thường dựa vào mô hình của chính cấu trúc dữ liệu, ví dụ coi chuỗi ký tự như một danh sách liên kết, nên thường không phải biến đổi lại các số index đó.

OT hiện vẫn là cách rất phổ biến cho hệ thống cùng sửa văn bản. Các hệ thống dùng OT thường đặt một server ở giữa làm nguồn sự thật trung tâm, để biến đổi thao tác của mọi người cho khớp nhau. Những cách làm OT không dùng nguồn sự thật trung tâm thì sổ sách theo dõi quá nặng, nên khó đưa vào thực tế.

Mình muốn nói rõ để khỏi kết luận cứng: OT không phải lúc nào cũng bắt buộc có server, và CRDT không phải là hệ thống không bao giờ có server. Khác biệt thực tế là CRDT không cần một nguồn sự thật trung tâm để giải quyết xung đột lúc gộp. Các bản cập nhật có thể được áp dụng theo thứ tự khác nhau; miễn cuối cùng mọi thay đổi đều tới thì tài liệu trên các máy sẽ giống nhau.

Gom lại thành bảng cho dễ nhìn:

| Tiêu chí so sánh | Operational Transformation (OT) | CRDT |
| --- | --- | --- |
| Cách xử lý khi cùng sửa | Biến đổi lại vị trí index của thao tác chèn và xóa. | Dựa vào mô hình của cấu trúc dữ liệu, thường không biến đổi index. |
| Vai trò của server khi gộp | Thường cần một server làm nguồn sự thật trung tâm để biến đổi thao tác. | Không cần nguồn sự thật trung tâm để giải quyết xung đột lúc gộp. |
| Thứ tự nhận bản cập nhật | Phụ thuộc thứ tự các thao tác đã được biến đổi qua chỗ chuẩn. | Có thể áp dụng theo thứ tự bất kỳ, miễn cuối cùng nhận đủ. |
| Trường hợp dùng phổ biến | Phổ biến ở app cùng sửa văn bản, client thường đang kết nối tới server. | Phù hợp hệ thống phân tán, app local-first và offline-first. |

## Hai người sửa ghi chú họp rồi gộp lại như thế nào?

Mình kéo câu chuyện của An, tương ứng replica `phone`, và Bình, tương ứng replica `laptop`, xuống một bản chạy thử. Ghi chú được tách thành hai phần, mỗi phần một luật gộp:

- **Tiêu đề:** Đây là một trường chỉ giữ một câu, nên mình dùng kiểu thanh ghi theo luật người ghi sau thắng, gọi là LWW-Register (Last-Writer-Wins Register). Nó lưu cặp giá trị và mốc thời gian; lúc gộp giữ cặp có mốc lớn hơn. Bản chạy thử không dùng đồng hồ treo tường mà dùng đồng hồ logic kèm tên máy. Nếu số đếm bằng nhau thì so tiếp tên máy theo thứ tự chữ cái.
- **Danh sách việc:** Mình dùng một tập hợp chỉ cho thêm phần tử, gọi là G-Set (grow-only set). Lúc gộp là lấy hợp hai tập, nên việc đã thêm trên máy nào cũng còn. G-Set trong bản chạy thử này không hỗ trợ xóa việc khỏi danh sách.

Mình chạy một mô phỏng nhỏ cho phần danh sách việc. Widget này chỉ minh họa hai máy mỗi bên thêm một mục lúc chưa kết nối; sau khi đồng bộ cả hai đều giữ đủ các mục mới. Nó không mô tả việc đổi tiêu đề, cũng không hỗ trợ xóa việc hay xung đột trên đoạn văn.

Chúng ta thử xem đoạn code dưới đây nha:

```python
# Taken from the demo script: title uses an LWW-Register, the item list uses a G-Set.

class LWWRegister:
    def assign(self, value):
        clock = self.ts[0] + 1
        self.ts = (clock, self.replica_id)
        self.value = value

    def merge(self, other):
        if other.ts > self.ts:
            self.value = other.value
            self.ts = other.ts

class GSet:
    def add(self, item):
        self.items.add(item)

    def merge(self, other):
        self.items |= other.items

# An on the phone replica, Binh on the laptop replica
phone.title.assign("Team meeting notes")
phone.items.add("write RFC")
laptop.title.assign("Meeting minutes")
laptop.items.add("set deadline")
```

Trong ví dụ trên, cả hai máy xuất phát từ cùng ghi chú `"Meeting notes"` với mốc `(1, "seed")` và mục `"agenda"`. An trên `phone` đổi tiêu đề rồi thêm `"write RFC"`; Bình trên `laptop` đổi tiêu đề rồi thêm `"set deadline"`.

Nếu hệ thống lấy nguyên file JSON của người gửi sau, giả sử `laptop` của Bình gửi sau, kết quả như sau:

```text
=== Naive sync: laptop uploads last and overwrites the whole document ===
result: {'title': 'Meeting minutes', 'items': ['agenda', 'set deadline']}
lost from phone: ['write RFC']
```

_Kết quả khi lấy nguyên file của Bình, giả sử laptop gửi sau_

Việc `'write RFC'` của An đã mất. Khi hai máy trao đổi trạng thái và gọi `merge` theo luật trên, không có server nào đứng giữa chấm điểm thứ tự, kết quả như sau:

```text
=== CRDT merge: exchange state, no coordinator ===
phone : {'title': 'Team meeting notes', 'items': ['agenda', 'set deadline', 'write RFC']}
laptop: {'title': 'Team meeting notes', 'items': ['agenda', 'set deadline', 'write RFC']}
same_state: True
title_winner: Team meeting notes ts= (2, 'phone')
kept_items: ['agenda', 'set deadline', 'write RFC']
```

_Kết quả khi hai máy gộp trạng thái theo luật của demo, không có server chấm điểm thứ tự_

Sau khi gộp hai chiều, `phone` và `laptop` ra cùng một trạng thái, thể hiện qua `same_state: True`. Danh sách còn đủ `'agenda'`, `'set deadline'` và `'write RFC'` nhờ lấy hợp của G-Set.

Tiêu đề thắng là `"Team meeting notes"` với mốc `(2, 'phone')`; `"Meeting minutes"` của Bình bị bỏ. Cả hai máy đều tự tăng đồng hồ logic từ `1` lên `2`, nên khi so sánh thì `(2, 'phone')` lớn hơn `(2, 'laptop')` vì chuỗi `'phone'` đứng sau `'laptop'` theo bảng chữ cái.

Luật Last-Writer-Wins ở đây chọn một tiêu đề theo cách hòa đồng hồ đã quy ước. Nó không giữ cả hai tiêu đề của An và Bình, và cũng khác kiểu ai gửi file sau thì đè mất toàn bộ dữ liệu của người kia.

## Thư viện hay dùng: Yjs và Automerge

Nếu An và Bình muốn cùng sửa từng câu trong một đoạn văn, chứ không chỉ thêm việc vào danh sách, thì G-Set ở trên không đủ. Sửa văn bản cần kiểu dữ liệu cho chuỗi ký tự hoặc danh sách có thứ tự. Khi làm thực tế, bạn thường dùng thư viện đã đóng gói những cấu trúc phong phú hơn; hai cái tên hay gặp là Yjs và Automerge.

[Yjs trên GitHub](https://github.com/yjs/yjs) là một khung CRDT với các kiểu dùng chung như `Y.Map`, `Y.Array` và `Y.Text`. Nó không gắn chết một kiểu mạng, hỗ trợ sửa lúc mất mạng, lưu bản chụp phiên bản, hoàn tác, hiện con trỏ dùng chung, và gắn được với nhiều trình soạn thảo như ProseMirror, Tiptap, Monaco hay Quill.

Bản cập nhật trong Yjs có thể được áp dụng theo thứ tự bất kỳ, hoặc lặp lại nhiều lần, mà kết quả vẫn đúng. Hai tài liệu đồng bộ bằng cách mã hóa trạng thái qua `Y.encodeStateAsUpdate` rồi áp sang bên kia bằng `Y.applyUpdate`. Docs của họ mô tả hai bên cùng giữ một `Y.Map`, mỗi bên gán một khóa khác nhau; sau khi trao đổi, cả hai đều có đủ `{ keyA: 'valueA', keyB: 'valueB' }`.

CRDT dành cho văn bản thường phình dần vì phải giữ dấu vết của phần đã xóa. Yjs giảm phần đó bằng cách gộp các cấu trúc viết liên tiếp, bỏ nội dung trong cấu trúc đã xóa, và dọn khi phần tử cha đã bị xóa.

[Automerge trên GitHub](https://github.com/automerge/automerge) cung cấp nhiều kiểu CRDT, kèm cách lưu nén và giao thức đồng bộ để gửi thay đổi qua mạng. README nói Automerge muốn làm chỗ dựa dữ liệu cho app local-first, giống vai trò của PostgreSQL với app phía server. Lõi viết bằng Rust, dùng được từ JavaScript qua gói `@automerge/automerge`.

## Khi nào không nên dùng CRDT?

Nhìn lại chuyện An và Bình, CRDT là công cụ có đánh đổi, không phải thứ giữ được mọi ý của mọi người.

1. **Cùng lúc sửa một trường và bạn muốn giữ đúng ý người viết:** Như An và Bình cùng đổi tiêu đề, CRDT không tự hiểu câu nào hay hơn. Luật LWW-Register trong bản chạy thử sẽ bỏ một tiêu đề. Cũng có kiểu thiết kế giữ các giá trị đang xung đột để app hoặc người dùng tự chọn, như essay local-first đã mô tả.
2. **Quy tắc toàn cục không được phá:** Ví dụ tồn kho không được âm. Vì mỗi replica cho sửa ngay trên máy, mình suy ra nếu hai máy mất mạng cùng thấy còn một món rồi cùng trừ, thì sau khi gộp kho có thể âm.
3. **Lịch sử dữ liệu phình:** CRDT cho văn bản thường lớn dần theo thời gian. Essay local-first kể prototype của họ bị chậm vì lưu lịch sử tới từng ký tự, và khó cắt vì không biết khi nào một thiết bị mất mạng lâu sẽ quay lại.
4. **App gần như luôn có mạng và đã có server làm chuẩn:** Khi ấy OT, hoặc mô hình để server quyết định, vốn phổ biến và có thể đơn giản hơn. Nếu bài toán cần một thứ tự thống nhất trên toàn hệ thống theo kiểu thuật toán đồng thuận, CRDT cũng không thay thế được.

> 💡 CRDT giúp các bản sao gặp nhau ở cùng một kết quả theo luật bạn đã chọn, chứ không tự đọc hiểu và giữ hết ý của con người khi hai người cùng sửa một chỗ.

Nếu bạn muốn đi sâu vào app realtime và offline-first, gồm phần đồng bộ dữ liệu và CRDT, thì có thể tham khảo khóa học bên dưới tại 200Lab nhé.

![Thiết kế ứng dụng realtime và offline-first - WebSocket, SSE, đồng bộ dữ liệu và CRDT](https://assets.200lab.io/sha256/e0/e04aa7d55867cfe265aa52f0a6684855d2b9d424dc54407bebe6681a87bb6788/generations/4bc9a52d0f7551c48a1991ed4ce7508fd817dc792b66a888a843a83c49f94d32)

- [Thiết kế ứng dụng realtime và offline-first - WebSocket, SSE, đồng bộ dữ liệu và CRDT](https://200lab.io/courses/thiet-ke-ung-dung-realtime-va-offline-first-websocket-sse-dong-bo-du-lieu-va-crdt.md)

Chúc các bạn thiết kế được cách đồng bộ để sau mỗi lần họp, không ai mất oan việc mình vừa thêm vào ghi chú nhé 🙂

## Đọc thêm

- [Composite index là gì? Thứ tự cột và EXPLAIN trên MySQL, PostgreSQL](https://200lab.io/blog/composite-index-la-gi.md): Composite index là gì, vì sao tạo index rồi mà query vẫn Seq Scan? Cùng thử trên PostgreSQL, MySQL để nắm quy tắc đặt cột bằng trước, cột khoảng sau.
- [Transaction là gì, isolation level là gì? Chạy thử trên PostgreSQL, MySQL](https://200lab.io/blog/transaction-isolation-level-la-gi.md): Transaction là gì mà trừ kho vẫn sai? Chạy thử 4 mức isolation level trên PostgreSQL, MySQL để hiểu dirty read, phantom read, MVCC và cách chặn lost update.
- [Reverse proxy là gì? Cách cấu hình Nginx và phân biệt với API gateway](https://200lab.io/blog/reverse-proxy-la-gi.md): Reverse proxy là gì, khác forward proxy và API gateway ra sao? Cấu hình reverse proxy Nginx với proxy_pass, upstream, X-Forwarded-For và cách xử lý lỗi 502.
- [Idempotency là gì? Idempotency key chống trừ tiền hai lần khi retry](https://200lab.io/blog/idempotency-key-la-gi.md): Idempotency key giúp POST retry an toàn khi timeout. Middleware Go chạy thật: giữ chỗ atomic, 409 khi trùng đồng thời, replay đúng response cũ.
- [Game System Design GonnaCrash được làm như thế nào](https://200lab.io/blog/game-system-design-gonnacrash-duoc-lam-nhu-the-nao.md): Kéo thả một hệ thống rồi nhìn nó trụ hay sập: cách mình dựng GonnaCrash, từ server làm trọng tài, con bot giải đề đến skin whiteboard.
- [Mọi bài viết chủ đề Backend](https://200lab.io/blog/tags/backend.md)

Toàn bộ nội dung: https://200lab.io/llms.txt
