Lỗi thú vị nhất tôi từng gặp: Khi Emoji làm hỏng tính năng đồng bộ hóa
Bài viết kể lại câu chuyện về một lỗi hiếm gặp trong trình soạn thảo cộng tác, nơi việc chèn các biểu tượng cảm xúc (emoji) cụ thể cạnh nhau đã làm hỏng quá trình đồng bộ hóa dữ liệu. Tác giả giải thích nguyên nhân kỹ thuật sâu xa liên quan đến UTF-16 và cách JavaScript xử lý chuỗi ký tự, đồng thời đưa ra giải pháp hiện đại để tránh vấn đề này.

Nếu bạn làm việc trong lĩnh vực xây dựng phần mềm đủ lâu, tôi tin rằng bạn sẽ sớm tích lũy được những câu chuyện về lỗi (bug) yêu thích của riêng mình. Đây là một câu chuyện ngắn về lỗi mà tôi ấn tượng nhất. Tôi cũng đã xây dựng một công cụ tương tác để bạn có thể khám phá các khái niệm cốt lõi tạo nên lỗi này.
Lỗi: Hai emoji vào, không cái nào thoát ra được
Tôi đang thực hiện việc chuyển đổi một trình soạn thảo cũ (legacy editor) sang trải nghiệm cộng tác tốt hơn cùng với đội ngũ của mình. Chúng tôi sử dụng TipTap (bản bao bọc của ProseMirror) ở phía trên và Yjs bên dưới để xử lý "phép thuật" CRDT cho đồng bộ hóa thời gian thực. Nó hoạt động rất tốt! Ít nhất là phần lớn thời gian.
Trong những ngày đầu ra mắt alpha, khi ứng dụng chủ yếu dùng nội bộ hoặc dành cho người dùng sớm, đôi khi trình soạn thảo đơn giản ngừng lưu nội dung của bạn. Một cách im lặng. Bạn vẫn tiếp tục gõ phím và mọi thứ trông có vẻ bình thường, nhưng các chỉnh sửa của bạn ngừng đồng bộ sang tài liệu Yjs. Lần sau khi bạn mở trang, mọi thứ bạn đã viết sau điểm thất bại đều biến mất.
Thật đáng sợ, lỗi này rất hiếm và gần như không thể chẩn đoán vì chúng tôi không bao giờ tái tạo lại được nó. Chúng tôi đã thực sự cố gắng! Những nghi ngờ ban đầu của tôi thường xoay quanh kết nối wifi chập chờn và hành vi websocket bất ổn, nhưng bất kể việc tiết lưu (throttling) hay bật tắt wifi bao nhiêu lần cũng dường như không tái hiện được vấn đề. Trải nghiệm thực tế lại bất ngờ ổn định trong những tình huống đó theo trí nhớ của tôi. Cảm giác như nó xảy ra ngẫu nhiên, không bao giờ khi ai đó đang nhìn vào. Không có lỗi rõ ràng trong bảng điều khiển, không có stack trace, không có sự cố. Chỉ đơn giản là... "Này, tôi nghĩ thay đổi của tôi không được lưu."
Công cụ khám phá Surrogate Pair
Sau đó một ngày nọ, quản lý sản phẩm của chúng tôi đã phát hiện ra nguyên nhân. Đây không phải là một điều dễ dàng để tìm ra. Anh ấy đã gặp phải vấn đề này nhiều hơn bất kỳ ai khác (có lẽ vì anh ấy là người tốt nhất trong việc dùng thử sản phẩm của chính mình) và đã thu hẹp nó một cách có hệ thống.
"Tôi cảm thấy mình sắp phát điên, nhưng tôi nghĩ nó xảy ra khi tôi gõ các ký tự cụ thể cùng nhau, rồi quay lại và chèn một ký tự ở giữa chúng..."
Anh ấy đã sử dụng 🟢 và 🔴 trong các email cập nhật tình trạng dự án hàng tuần để truyền đạt tình trạng chung. Màu xanh cho đang trên đúng tiến độ, màu đỏ cho có rủi ro. Mỗi tuần, mẫu anh ấy sử dụng đều đã có sẵn cả hai ký tự này và anh ấy chỉ cần xóa ký tự anh ấy không cần (thường là màu đỏ, tôi rất vui để báo cáo điều đó!).
Vào một dịp đó, anh ấy đã sao chép vòng tròn màu xanh và dán nó trước vòng tròn màu đỏ vào một thời điểm nào đó, hoặc có thể là ngược lại. Hoạt động cụ thể đó — chèn một emoji đa byte liền kề với một emoji khác — đã kích hoạt một thao tác cắt (splice) trong thư viện CRDT bên dưới, điều này đã chia đôi một cặp surrogate ở giữa.
Tôi nhớ như in cuộc gọi đó khi anh ấy chỉ cho tôi và một nhân viên trực tiếp của tôi, người đã vất vả để chuyển đổi sang trình soạn thảo cộng tác. Tôi có lẽ đã hơi quá hào hứng — tôi sống vì những lỗi bí ẩn — "Tôi cảm thấy như bạn bị tràn đầy năng lượng vì điều này," anh ấy nói. Anh ấy không sai.
Thêm vào sự thú vị, không phải mọi emoji đều kích hoạt nó. Chỉ những emoji ở trên U+FFFF yêu cầu cặp surrogate mới gây ra. Và không phải mọi chỉnh sửa đều dẫn đến vấn đề — chỉ những thao tác gây ra cắt splice tại đúng độ lệch byte sai mới gây ra. Đó là một lỗi hoang dã để gỡ lỗi trước khi chúng tôi biết chuyện gì đang xảy ra.
Đơn vị mã, điểm mã và cụm hình vị
Vậy chuyện gì đã xảy ra? "Những cái ở trên U+FFFF" trong đoạn cuối cùng có nghĩa là gì? Độ lệch byte là gì?
Để hiểu lỗi này, chúng ta cần giới thiệu ba thuật ngữ:
Đơn vị mã (Code Units) là các giá trị 16-bit thô mà JavaScript sử dụng để lưu trữ chuỗi nội bộ (UTF-16). Đây là những gì .length đếm. Đây cũng là những gì .slice() và .charCodeAt() hoạt động. JavaScript hoạt động ở mức đơn vị mã theo mặc định.
Điểm mã (Code Points) là những gì Unicode thực sự định nghĩa là một ký tự đơn lẻ. Một điểm mã như U+1F920 (🤠) là một ký tự trong góc nhìn của Unicode, nhưng nó quá lớn để vừa trong một đơn vị mã 16-bit. Vì vậy, UTF-16 chia nó thành hai đơn vị mã được gọi là cặp surrogate (surrogate pair): một surrogate cao và một surrogate thấp. Các ký tự ASCII đơn giản và nhiều biểu tượng phổ biến vừa trong một đơn vị mã, nên sự phân biệt này không quan trọng với chúng. Nhưng emoji thì sao? Hầu như luôn là hai.
Cụm hình vị (Grapheme Clusters) là những gì con người cảm nhận là "một ký tự". Phi hành gia nữ 👩🚀 trông giống như một ký tự nhưng thực tế là ba điểm mã dán lại với nhau: 👩 (phụ nữ) + bộ kết nối độ rộng không + 🚀 (tên lửa). Năm đơn vị mã, ba điểm mã, một hình vị. Emoji 👨👨👧👧 (Gia đình: Người đàn ông, Người đàn ông, Cô gái, Cô gái) có vẻ đơn giản nhưng thực sự bao gồm mười một điểm mã ấn tượng! ☃ bí ẩn chỉ là 1.
Dưới đây là cách các con số này phân kỳ:
| Emoji | Đơn vị mã | Điểm mã | Hình vị |
|---|---|---|---|
| A | 1 | 1 | 1 |
| 🤠 | 2 | 1 | 1 |
| 👩🚀 | 5 | 3 | 1 |
| 👨👨👧👧 | 11 | 7 | 1 |
Tôi sẽ dừng lại một lần nữa để giới thiệu công cụ khám phá surrogate tương tác mà tôi đã đề cập ở trên. Bạn có thể gõ bất kỳ emoji nào và xem sự phân chia này chính mình!
Cách .slice() phá hỏng mọi thứ
Chàng cao bồi 🤠 là một điểm mã được lưu trữ dưới dạng hai đơn vị mã (một cặp surrogate). Nếu bạn cắt giữa chúng:
"🤠".slice(0, 1); // → '\uD83E' (high surrogate cô đơn)
"🤠".slice(1, 2); // → '\uDD20' (low surrogate cô đơn)
Những mảnh vỡ này không phải là các ký tự hợp lệ. Chúng là một nửa cặp không có bạn đồng hành. Một mình, chúng hiển thị dưới dạng các ký tự thay thế () hoặc bị nuốt chửng một cách âm thầm. Nhưng vấn đề thực sự phát sinh khi bạn cố gắng mã hóa một trong số chúng:
encodeURIComponent("🤠".slice(0, 1));
// URIError: URI malformed
Đó chính là thứ đã làm sập công cụ của chúng tôi.
Thực sự đã xảy ra chuyện gì
Yjs phụ thuộc vào một thư viện tiện ích gọi là lib0. Phương thức splice của lib0 sử dụng .slice() của JavaScript nội bộ. Khi một thao tác CRDT tình cờ rơi vào giữa hai nửa của cặp surrogate của một emoji, lib0 sẽ tạo ra một chuỗi với một surrogate mồ côi. Chuỗi đó cuối cùng sẽ được chuyển đến encodeURIComponent trong quá trình đồng bộ, thứ sẽ ném ra một URIError chưa được bắt.
Lỗi đó không bị bắt. Không có gì trong việc xử lý lỗi của Yjs hay TipTap bắt được nó. Vì vậy, đồng bộ hóa chỉ đơn giản là... dừng lại. Trình soạn thảo vẫn hoạt động cục bộ, cho bạn mọi dấu hiệu cho thấy mọi thứ ổn, trong khi các thay đổi của bạn âm thầm đi đến không đâu.
Nó chỉ xuất hiện ở các thao tác chỉnh sửa bệnh lý: thay thế một emoji bằng một emoji khác, hoặc chèn một ký tự ngay giữa hai emoji.
Giải pháp tạm thời chúng tôi triển khai
Chúng tôi không thể sửa lib0 — mặc dù tôi rất vui mừng để báo cáo rằng cuối cùng nó đã được sửa! Chúng tôi không thể vá Yjs. Chúng tôi cần phải triển khai điều gì đó.
Vì vậy, chúng tôi đã làm hai việc:
Mặc dù ban đầu chúng tôi không quan tâm đến hỗ trợ ngoại tuyến cho sản phẩm của mình, nhưng việc thêm nó khá đơn giản. Suy nghĩ của chúng tôi là nó có thể cứu chúng tôi trong tình huống tương lai nếu người dùng bị ngắt kết nối và tiếp tục gõ. Chúng tôi sẽ tiếp tục cập nhật CRDT cục bộ, và lần tiếp theo họ quay lại tài liệu, các thay đổi của họ sẽ được cập nhật và hợp nhất với trạng thái hiện tại của mọi thứ. Đây là một biện pháp phòng ngừa và tận dụng những gì CRDT thực sự giỏi và được thiết kế để làm.
Một lựa chọn hạt nhân đáng xấu hổ (quyết định của tôi, với dấu vân tay của tôi khắp nơi): chúng tôi đính kèm một trình lắng nghe window.addEventListener("error", ...) toàn cầu khớp regex cho URIError: URI malformed. Khi nó bắt được lỗi, nó ghi lại sự kiện để theo dõi và đặt một phần trạng thái mà trình soạn thảo của chúng tôi sẽ kiểm tra. Nếu chúng tôi thấy lỗi, chúng tôi sẽ hiển thị một modal thông báo cho người dùng rằng có gì đó sai và yêu cầu họ tải lại trang. Tôi đã theo dõi chỉ số này như một chim ưng và nhẹ nhõm vì cuối cùng nó rất hiếm.
Chúng tôi không phải là người duy nhất. Các vấn đề thượng nguồn (yjs#303, tiptap#3020) có các trình soạn thảo khác báo cáo cùng một vấn đề với các giải pháp tương tự.
Giải pháp thực sự
Hai điều cuối cùng đã sửa nó thực sự:
lib0 được vá. Bản sửa lỗi thượng nguồn là phát hiện xem ký tự đầu tiên của một chuỗi đã cắt là một surrogate cao không có surrogate thấp phù hợp, và thay thế nó bằng U+FFFD (ký tự thay thế Unicode, ). Không hoàn hảo, nhưng nó ngăn chặn URIError xảy ra và ngăn đồng bộ hóa bị chết.
Chúng tôi biến emoji thành một loại nút nguyên tử (atomic node type). Trong ProseMirror (và do đó TipTap), bạn có thể định nghĩa các loại nút tùy chỉnh. Chúng tôi thiết lập một tiện ích mở rộng làm cho emoji trở thành nút riêng của chúng, điều này có nghĩa là trình soạn thảo coi từng cái là một đơn vị không thể chia nhỏ. Các thao tác di chuyển con trỏ và chỉnh sửa không thể chia đôi một emoji. Điều này không sửa lỗi lib0, và có một số tác dụng phụ khác ở đây đầy thách thức, nhưng nó loại bỏ hầu hết các mẫu chỉnh sửa kích hoạt nó.
Tôi rất vui mừng để báo cáo rằng lỗi xuất hiện rất hiếm trong giai đoạn tạm thời chắp vá này... nhưng tôi khá vui khi phiên bản lib0 đã được vá cuối cùng được tung ra.
Câu trả lời hiện đại
Nếu bạn đang thao tác chuỗi trong JavaScript và quan tâm đến việc không làm hỏng ký tự, hãy sử dụng Intl.Segmenter:
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const segments = [...seg.segment("👩🚀A👍")].map((s) => s.segment);
// → ['👩🚀', 'A', '👍']
Điều này chia theo cụm hình vị thay vì đơn vị mã. Không có surrogate mồ côi, không có emoji bị chia đôi. Đây là điều mà .slice() lẽ ra nên làm từ đầu, nhưng tất nhiên UTF-16 xuất hiện trước emoji vài thập kỷ.
Sự nổi tiếng (theo cách xấu)
Sau khi chúng tôi tung ra bản sửa lỗi, tôi đã viết về nó trong bản tin nội bộ của mình.
Lỗi này trở thành một câu chuyện đùa nội bộ. Đồng nghiệp sẽ nhắn tin cho tôi bằng 🟢🔴 — cặp emoji đã phá hủy mọi thứ.
Nhiều năm sau, tôi vẫn nhận được các meme và tin nhắn từ những đồng nghiệp cũ về vấn đề này. Một số lỗi bạn sửa chữa, và một số lỗi sửa... chính bạn?
Vấn đề Unicode khó để bỏ qua
Một khi bạn biết về nó, bạn bắt đầu thấy nó trong tự nhiên. Bất kỳ mã nào thực hiện str.slice(0, 1) hoặc str[0] để lấy "ký tự đầu tiên" đều có khả năng bị lỗi. Kẻ phạm tội phổ biến nhất: các công cụ tạo tên viết tắt từ tên người dùng. Hãy thử đặt một emoji làm ký tự đầu tiên của tên hoặc họ của bạn trong bất kỳ ứng dụng nào hiển thị ảnh đại diện của bạn dưới dạng tên viết tắt. Hầu hết trong số chúng sẽ làm một cái gì đó như firstName[0] + lastName[0] và kết thúc với một nửa cặp surrogate. Một số hiển thị rác. Một số bị sập.
Đó là cùng một lớp lỗi mỗi lần. JavaScript cung cấp cho bạn đơn vị mã khi bạn muốn ký tự, và không ai nhận thấy cho đến khi ai đó gõ một cái gì đó ngoài Basic Multilingual Plane (Mặt phẳng đa ngôn ngữ cơ bản).
Tôi lặp lại sự thật tôi trân trọng nhất: thật đáng Remarkable là bất cứ thứ gì hoạt động được.
Liên kết cuối cùng
Monica Dinculescu có một bài đăng tuyệt vời về cách emoji hoạt động bên dưới nếu bạn muốn đi sâu hơn. Tôi rất khuyên bạn nên đọc nó!
Và tôi sẽ kết thúc với một lời giới thiệu nữa cho công cụ khám phá cặp surrogate tương tác của tôi, nơi bạn có thể gõ bất kỳ emoji nào và xem sự phân chia này chính mình, trong trường hợp bạn bỏ lỡ liên kết ở trên! Tôi nghĩ đó là một cách hay để nhìn thấy và tương tác trực quan với các khái niệm được thảo luận ở đây.
Bài viết liên quan

Công nghệ
Cerebras, đối tác thân thiết của OpenAI, sẵn sàng cho đợt IPO kỷ lục định giá tới 26,6 tỷ USD
04 tháng 5, 2026

Công nghệ
Microsoft giới thiệu Surface Pro 12 và Surface Laptop 8: Sức mạnh chip Intel, giá thành gây sốc
19 tháng 5, 2026

Công nghệ
Substrate (YC S24) tuyển dụng Technical Success Manager cho nền tảng AI chuyên xử lý thanh toán y tế
13 tháng 5, 2026
