Nhật ký kỹ thuật · Hà Nội · Backend & CloudVol. 2026
← Tất cả bài viết
Số 0213 phút đọc

Twitter, Lady Gaga và một Telegram Channel: fan-out lúc ghi hay lúc đọc?

Đăng một tweet nghe nhẹ nhàng, nhưng sau lưng có thể là ba chục triệu lần ghi. Load parameter, hai cách dựng timeline, vì sao người nổi tiếng làm vỡ một trong hai cách, lời giải hybrid, và vì sao nhóm chat 500 người với kênh triệu người cần hai thiết kế ngược nhau. Bài đầu trong loạt ghi chú khi mình đọc DDIA.

  • system-design
  • ddia
  • scalability
  • fan-out
  • caching
Tranh khắc gỗ một bưu điện thế kỷ 19: các thư ký bỏ bản sao của một lá thư vào hàng trăm ô thư, trong khi người rao tin trên ban công đọc một thông báo duy nhất để đám đông tự đến nghe.
Trong bài này
  1. Một tweet, rất nhiều việc
  2. Load parameter: mô tả tải trước khi thiết kế
  3. Phép tính trên mặt sau phong bì
  4. Hai cách dựng timeline
  5. Cách 1: ráp lúc đọc
  6. Cách 2: giao lúc ghi
  7. Rồi Lady Gaga đăng tweet
  8. Hybrid: giao tận nhà, hoặc ra kho lấy
  9. 500 người trong nhóm chat, một triệu người trong kênh
  10. Phản xạ phỏng vấn: hỏi scale theo chiều nào
  11. Thần chú

Một tweet, rất nhiều việc

Gần nửa đêm, ly cà phê bên cạnh đã nguội, và mình đang ở trang của Designing Data-Intensive Applications làm mình đặt sách xuống rồi nhìn chằm chằm vào tường. Hãy hình dung một ngôi sao nhạc pop gõ một dòng tweet rồi bấm gửi. Với cô ấy đó là một thao tác; với database là một dòng dữ liệu. Rồi sách làm phép nhân: đăng một tweet là một lần ghi, nhưng đưa nó tới mọi người đang follow có thể là ba chục triệu lần ghi. Đây là bài đầu trong loạt ghi chú khi mình đọc cuốn sách này (số chương trong cả loạt theo bản 1, năm 2017), và cả chương về scalability nằm gọn trong khoảng cách đó.

Hãy hình dung một bưu điện thị trấn thế kỷ 19. Người thư ký nhận một lá thư gửi “tới tất cả độc giả của tôi”. Có hai cách xử lý đều hợp lý. Chép ngay một trăm bản rồi bỏ vào ô thư của từng độc giả, để ai bước vào cũng thấy thư đã chờ sẵn. Hoặc ghim bản gốc duy nhất lên tường, ai đến thì tự đọc. Không cách nào sai. Cách nào đúng phụ thuộc vào có bao nhiêu độc giả và họ ghé vào thường xuyên cỡ nào. Câu đó là toàn bộ bài viết; phần còn lại chỉ là làm cho nó chính xác.

Load parameter: mô tả tải trước khi thiết kế

Trước khi chọn cách phân thư, phải đếm thư đã. Việc đầu tiên của Kleppmann là gác câu hỏi “có scale được không?” sang một bên và hỏi: scale theo con số nào? Ông gọi những con số này là load parameter: số request mỗi giây vào web server, tỉ lệ đọc/ghi của database, số user hoạt động đồng thời, hit rate của cache. Mỗi hệ thống có một hai con số riêng quyết định sống chết, và con số trung bình thường không phải con số quan trọng. Đôi khi đó là cực trị: vài tài khoản nằm tít ngoài đuôi phân phối.

Với Twitter, sách đưa ra hai thao tác. Đăng tweet: một user phát hành tin nhắn tới các follower, khoảng 4.600 request mỗi giây trung bình (đỉnh trên 12.000). Xem home timeline: một user xem tweet của những người họ follow, khoảng 300.000 request mỗi giây. Đó là số liệu của sách cho thời điểm nó mô tả, nên hãy coi như minh họa cho hình dạng của bài toán, không phải số hôm nay.

tweet được đăng (trung bình)
4.6k/s
lượt đọc home timeline
300k/s
đọc nhiều hơn ghi
~65×
follower của các tài khoản lớn nhất
30M+

Và đây là bài học đầu tiên, mình phải đọc hai lần mới thấm: 4.600 tweet mỗi giây không phải chỗ khó. Database tử tế nào cũng chịu được. Thứ làm bài toán khó là fan-out: mỗi user follow nhiều người, và mỗi user lại được nhiều người follow. Thuật ngữ này đến từ điện tử, một cổng logic có đầu ra nối vào nhiều đầu vào. Vào một, ra cả đám đông.

Phép tính trên mặt sau phong bì

Mình làm phép tính đã thuyết phục mình. Sách đưa thêm một con số nữa: một tài khoản trung bình có khoảng 75 follower. Vậy nếu mỗi tweet được chép vào timeline riêng của từng follower:

phép tính fan-outtext
ghi vào hòm thư     = 4.600 tweet/s x 75 follower
                    = 345.000 lần ghi/s      (cùng cỡ với 300k lượt đọc/s)

một tweet của celeb = 30.000.000 follower
                    = 30.000.000 lần ghi cho MỘT tweet

Có hai điều đập vào mắt. Một, về trung bình hai phía cùng cỡ nhau: 345 nghìn lần ghi hòm thư mỗi giây so với 300 nghìn lượt đọc timeline mỗi giây. Không bên nào hiển nhiên là bên cần tối ưu, và đó chính là lý do quyết định này không hiển nhiên. Hai, con số trung bình giấu con quái vật: một tài khoản 30 triệu follower biến một tweet thành 30 triệu lần ghi, và đó đúng là những tài khoản mà ai cũng muốn thấy tweet ngay lập tức.

Hai cách dựng timeline

Vậy là phép tính để lại hai thiết kế ứng viên, và chúng là ảnh phản chiếu của nhau: một bên để tác giả nhẹ gánh và bắt mỗi người đọc trả giá, bên kia làm ngược lại.

DWG 01-ASCALE · NTSA · FAN-OUT LÚC ĐỌCB · FAN-OUT LÚC GHINgười đọcmở apptweetsmỗi tweet một bản@a @b @c @d …JOINngười mình followghi: 1 dòngđọc: một phép join mỗi lần mởTác giảđăng tweetinbox · u1timeline dựng sẵninbox · u2timeline dựng sẵninbox · u3timeline dựng sẵn⋮ ×~75một bản cho mỗi followerghi: N bản (N = số follower)đọc: 1 lần tra hòm thư của mìnhghi rẻ, đọc đắtghi đắt, đọc rẻ
Hình 1. Cùng một tweet, hai thiết kế. Bên trái tweet được lưu một bản và mỗi người đọc trả giá bằng một phép join mỗi lần mở app. Bên phải mỗi follower có một hòm thư và tác giả trả giá bằng các bản sao.

Cách 1: ráp lúc đọc

Thiết kế thứ nhất là thứ ai trong chúng ta cũng viết ngay ngày đầu: tweet nằm trong một bảng, và timeline là một câu query. Khi bạn mở app, hệ thống tìm mọi người bạn follow rồi gộp các tweet gần đây của họ.

timeline_on_read.sqlsql
SELECT t.id, t.author_id, t.body, t.created_at
FROM   follows f
JOIN   tweets  t ON t.author_id = f.followee_id
WHERE  f.follower_id = $1            -- mình
ORDER  BY t.created_at DESC
LIMIT  50;

Ghi rẻ hết mức: một lệnh insert. Đọc là chỗ trả giá, và trả đúng vào lúc tệ nhất, khi một con người đang chờ. Với 300k lượt mở mỗi giây, mỗi lượt chạm tweet gần đây của ~75 tài khoản, tổng là khoảng 22 triệu lượt tra tweet mỗi giây (300.000 × 75, phép nhân thô của mình, không phải số của sách). Theo sách, phiên bản đầu của Twitter làm đúng như vậy. Đây là thiết kế đúng khi sản phẩm còn nhỏ và là thiết kế sai khi lượt đọc áp đảo.

Cách 2: giao lúc ghi

Thiết kế thứ hai đảo chi phí. Mỗi user có một hòm thư riêng, là cache của home timeline của họ. Khi ai đó đăng tweet, hệ thống tìm các follower của tác giả rồi đẩy tweet vào hòm thư của từng follower. Mở app khi đó chỉ là một lần đọc rẻ trên danh sách của chính mình. Sách mô tả đây là bước Twitter đã làm khi lượt đọc vượt quá sức của phép join.

fanout_on_write.tsts
// hòm thư chỉ giữ id tweet, không giữ nội dung (nội dung lấy theo id)
async function postTweet(authorId: string, body: string) {
  const tweetId = await db.tweets.insert({ authorId, body });
  const followers = await db.follows.followersOf(authorId);   // trung bình ~75

  const pipe = redis.pipeline();
  for (const f of followers) {
    pipe.lpush(timeline:${f}, tweetId);
    pipe.ltrim(timeline:${f}, 0, 799);                      // chỉ giữ 800 tweet mới nhất
  }
  await pipe.exec();
}

// mở app: một lần đọc rẻ
const ids = await redis.lrange(timeline:${me}, 0, 49);

Chỉ lưu id giữ cho hòm thư nhỏ: 800 id, mỗi id 8 byte, khoảng 6,4 KB mỗi user, nên một trăm triệu hòm thư cỡ 640 GB RAM (con số minh họa cho riêng phần id, giả sử có chừng đó user hoạt động; list thật trong Redis còn tốn thêm overhead). Không miễn phí, nhưng là cái giá lập kế hoạch được, và nó được trả trước, ngoài đường găng của một con người đang chờ.

Fan-out lúc đọcFan-out lúc ghi
Chi phí rơi vào đâuMỗi lần đọc timelineMỗi tweet được đăng
Chi phí ghi1 lệnh insert1 insert + N lần ghi hòm thư
Chi phí đọcMột phép join trên mọi người bạn follow1 lần tra danh sách của bạn
Bộ nhớ thêmKhôngMột hòm thư mỗi user
Thắng khiGhi áp đảo, hoặc ít đọcĐọc áp đảo và N nhỏ
Vỡ khiLượt đọc bùng nổMột tác giả có N khổng lồ

Rồi Lady Gaga đăng tweet

Cách 2 trông như người thắng cuộc rõ ràng cho tới khi đọc lại phép tính. Chi phí của một lần ghi là N, số follower của tác giả, và follower không hề phân bố đều. Phần lớn tài khoản có vài chục người; một số ít có hàng chục triệu. Khi một trong số đó đăng bài, fan-out service phải làm 30 triệu lần ghi hòm thư cho một tweet. Sách nhắc rằng Twitter cố giao tweet tới follower trong vòng khoảng năm giây, và ở quy mô này đó là thử thách thật: những người cuối hàng thấy tweet trễ, trên đúng tài khoản mà ai cũng bấm refresh chờ.

Tranh in màu chiếc cân thư bằng đồng cổ cạnh chồng phong bì niêm phong giống hệt nhau, và một chiếc loa kèn bằng đồng.
Bản I. Chiếc cân và chiếc loa. Cân một lá thư thì gửi một trăm bản không sao; gửi ba chục triệu bản thì cái cân đổ. Chiếc loa là đáp án còn lại: nói một lần, để đám đông tự đến nghe.

Hybrid: giao tận nhà, hoặc ra kho lấy

Vậy người nổi tiếng được đối xử kiểu ghim lên tường, còn mọi người khác vẫn dùng ô thư. Lần đầu đọc câu về hybrid, mình thú thật là không theo kịp, nên đây là nó dưới dạng dịch vụ giao hàng, cách mà cuối cùng mình hiểu ra. Hàng nhẹ của các cửa hàng bình thường thì giao tận nhà. Còn một nhà cung cấp khổng lồ gửi cả pallet, và không shipper nào đem một pallet tới ba chục triệu căn nhà. Nên hàng của họ ở yên trong kho, và khi bạn mở app thì bạn ra kho lấy vài pallet mình thực sự đặt.

DWG 01-BSCALE · NTSLÚC GHILÚC ĐỌCUser bình thường~75 followerNgười nổi tiếng30 triệu followerHòm thư của mìnhgiao tận nhàKho của celebmột bản, để yênpush1 writeTrộn theo giờhòm thư + vài celebNgười đọcmở app1 lần đọckéo: chỉ vài người mình followquy tắc: quá một ngưỡng follower thì thôi không đẩy nữa30.000.000 lần ghi thành 1 lần ghi + vài lần đọc
Hình 2. Hybrid. Tweet bình thường được đẩy vào hòm thư của follower lúc ghi. Tweet của người nổi tiếng chỉ ghi một lần và để yên trong kho; app của người đọc trộn hai luồng theo thời gian lúc đọc.

Vì sao việc ra kho lấy vẫn rẻ? Vì bạn chỉ follow một nhúm người nổi tiếng, dù mỗi người trong số đó có bao nhiêu follower. Chi phí fan-out lúc ghi tăng theo số follower của tác giả; chi phí fan-out lúc đọc cho phần này tăng theo số celeb bạn follow. Người follow năm ngôi sao chỉ tra năm lần nhỏ, không phải 300.000 lượt đọc. Sách mô tả Twitter chuyển dần sang đúng kiểu trộn hai cách này.

hybrid_timeline.tsts
const CELEB_THRESHOLD = 100_000;   // chỉnh theo dữ liệu, không phải con số thần thánh

async function postTweet(authorId: string, body: string) {
  const tweetId = await db.tweets.insert({ authorId, body });
  if (await followerCount(authorId) >= CELEB_THRESHOLD) return;   // ở yên trong kho

  for (const f of await db.follows.followersOf(authorId)) {
    await redis.lpush(timeline:${f}, tweetId);
  }
}

async function homeTimeline(me: string) {
  const pushed = await redis.lrange(timeline:${me}, 0, 49);              // đã giao tận nhà
  const celebs = await db.follows.celebritiesFollowedBy(me);                  // một nhúm
  const pulled = await db.tweets.latestByAuthors(celebs, 50);                 // ra kho lấy
  return mergeByTime(pushed, pulled).slice(0, 50);
}

500 người trong nhóm chat, một triệu người trong kênh

Khi đã tin ý tưởng này, mình bắt đầu thử nó trên các app trong điện thoại, và điện thoại hưởng ứng ngay: nhóm Zalo lớp cũ đang rộn ràng ảnh họp lớp. Cứ cho là nhóm có 500 người. Thiết kế nào? Fan-out lúc ghi, không phải nghĩ: 500 bản sao là cái giá vặt, đổi lại mở chat là thấy ngay, vì tin nhắn đã nằm sẵn trong hộp thư của chính bạn. Khớp với trực giác của mình. (Ở đây mình suy luận về hình dạng của bài toán, không mô tả Zalo hay Messenger thật sự được xây thế nào; nội bộ của chúng mình không biết.)

Rồi, ngón tay vẫn còn trên màn hình, mình tự hỏi một câu hiểm hơn: còn phòng chat của fan một ngôi sao trên Instagram, nơi ca sĩ gửi một tin nhắn thoại mà hàng triệu fan cùng ngồi trong một phòng thì sao? Mình bắt đầu trả lời “giống nhóm kia, mỗi thành viên một bản” rồi khựng lại giữa câu. Mỗi thành viên một bản nghĩa là hàng triệu lần ghi cho mỗi câu “chúc mọi người ngủ ngon”. Mặt đất như xoay đi. Mình không còn mô tả một nhóm chat nữa mà là một kênh broadcast, và đáp án mình đã biết từ Twitter. Lưu một bản duy nhất trong kênh, ai mở kênh thì kéo về. Đó là fan-out lúc đọc, và theo mình hiểu thì Telegram Channel được tổ chức kiểu này: một nhật ký, nhiều người đọc (đây là hình dạng, không phải khẳng định về nội bộ Telegram). Mình đã tự suy ra kiến trúc mà không để ý, một bất ngờ dễ chịu: ý tưởng này chuyển được vì nó nói về hình dạng của tải, không phải về tweet.

DWG 01-CSCALE · NTSNHÓM CHAT · 500 NGƯỜIKÊNH · 3.000.000 NGƯỜI THEO DÕINgười gửi1 tin nhắn500 hòm thưmỗi thành viên một bản500 lần ghi: nhỏmở chat: 1 lần đọcAdmin1 bài đăngNhật ký của kênhlưu một bản, 1 lần ghi3 triệu người theo dõimỗi người tự mở kênhkéo vềđẩy: 500 bản là chuyện nhỏ, mở chat tức thìkéo: một bản duy nhất; đẩy sẽ là 3 triệu lần ghi mỗi bàicùng câu hỏi như timeline, đáp án khác nhau
Hình 3. Cùng một câu hỏi, hai đáp án. Nhóm 500 thành viên đẩy một bản vào từng hộp thư và mở ra là thấy. Kênh nhiều triệu người giữ một nhật ký duy nhất và để người theo dõi tự kéo về.
Hệ thốngN mỗi tinThiết kếVì sao
Nhóm chat 500 người500Fan-out lúc ghi500 bản rẻ; mở là thấy
Timeline, tài khoản thường~75Fan-out lúc ghiĐọc áp đảo; N nhỏ
Timeline, người nổi tiếnghàng triệuFan-out lúc đọcĐẩy quá đắt; ít người được follow
Kênh broadcasthàng triệuFan-out lúc đọcMột bản; người theo dõi kéo về

Phản xạ phỏng vấn: hỏi scale theo chiều nào

“Hệ thống của em có scale được không?” là cái bẫy nếu bạn trả lời có hoặc không. Scalability không phải cái nhãn; nó là một mệnh đề điều kiện: nếu tải tăng theo kiểu này thì chúng ta chịu bằng cách này. Nên phản xạ của người có kinh nghiệm là hỏi ngược lại trước khi vẽ một cái hộp nào:

  • Chính xác bài toán là gì, và thao tác nào là nóng: đọc, ghi hay fan-out?
  • Chiều nào tăng: số user, request mỗi giây, khối lượng dữ liệu, follower mỗi tài khoản?
  • Số hiện tại là bao nhiêu, và tỉ lệ đọc/ghi ra sao?
  • Phân phối có phẳng không, hay có cái đuôi nặng (người nổi tiếng)?

Bốn câu hỏi đó chính là load parameter khoác bộ đồ phỏng vấn. Chúng cũng cho bạn biết nên đề xuất thiết kế nào trong ba thiết kế ở trên, vì thế người phỏng vấn thường hài lòng với câu hỏi hơn là một đáp án vội.

Thần chú

Không có kiến trúc nào đúng một cách trừu tượng. Kiến trúc đúng là kiến trúc khớp với phân phối tải của bạn.

Duy Anh, ghi chú về DDIA chương 1
  • Gọi tên load parameter trước: request mỗi giây, tỉ lệ đọc/ghi, follower mỗi tài khoản.
  • Fan-out lúc ghi khi đọc áp đảo và N nhỏ; fan-out lúc đọc khi N khổng lồ hoặc ghi áp đảo.
  • Khi phân phối có đuôi, kết hợp cả hai: hàng nhẹ thì giao, hàng nặng thì để ở kho.
  • Khi bị hỏi “có scale không?”, hỏi ngược: chiều nào?

Quay lại bưu điện, người thư ký với chồng thư cuối cùng đã có kế hoạch. Thư cho hàng xóm thì bỏ vào ô thư. Lá thư mà nửa nước muốn đọc thì ghim lên tường, đám đông tự bước tới đọc. Cùng một bưu điện, hai cách làm, chọn theo hình dạng của đống thư. Bài kế tiếp trong loạt: hai người cùng bấm “mua” chiếc vé concert cuối cùng trong cùng một khoảnh khắc, và database phải quyết định ai được vé.

Hết bài

Bài viết của Vũ Duy Anh, kỹ sư backend ở Hà Nội. Câu hỏi hay góp ý sửa sai xin gửi qua email.