Trong bài này
- Một tweet, rất nhiều việc
- Load parameter: mô tả tải trước khi thiết kế
- Phép tính trên mặt sau phong bì
- Hai cách dựng timeline
- Cách 1: ráp lúc đọc
- Cách 2: giao lúc ghi
- Rồi Lady Gaga đăng tweet
- Hybrid: giao tận nhà, hoặc ra kho lấy
- 500 người trong nhóm chat, một triệu người trong kênh
- Phản xạ phỏng vấn: hỏi scale theo chiều nào
- 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:
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 tweetCó 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.
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ọ.
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.
// 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 đọc | Fan-out lúc ghi | |
|---|---|---|
| Chi phí rơi vào đâu | Mỗi lần đọc timeline | Mỗi tweet được đăng |
| Chi phí ghi | 1 lệnh insert | 1 insert + N lần ghi hòm thư |
| Chi phí đọc | Một phép join trên mọi người bạn follow | 1 lần tra danh sách của bạn |
| Bộ nhớ thêm | Không | Một hòm thư mỗi user |
| Thắng khi | Ghi áp đảo, hoặc ít đọc | Đọc áp đảo và N nhỏ |
| Vỡ khi | Lượ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ờ.

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.
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.
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.
| Hệ thống | N mỗi tin | Thiết kế | Vì sao |
|---|---|---|---|
| Nhóm chat 500 người | 500 | Fan-out lúc ghi | 500 bản rẻ; mở là thấy |
| Timeline, tài khoản thường | ~75 | Fan-out lúc ghi | Đọc áp đảo; N nhỏ |
| Timeline, người nổi tiếng | hàng triệu | Fan-out lúc đọc | Đẩy quá đắt; ít người được follow |
| Kênh broadcast | hàng triệu | Fan-out lúc đọc | Mộ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é.
