News feed: trả giá lúc ghi hay lúc đọc
6/8
Bài 6 / 8~13 phútFan-out và timelineMiễn phí… lượt xem

Fan-out chạy trễ: người đăng, người theo dõi và bài đã xoá thấy gì?

Fan-out đẩy sang worker nền thì bài tới timeline chậm hơn. Thiết kế để người đăng vẫn thấy bài mình, bài đã xoá biến mất, và biết cần bao nhiêu worker.

TL;DR: Fan-out trong request đăng bài làm tài khoản lớn treo hàng chục giây, nên nó đi vào hàng đợi cho worker nền. Đổi lại, bài tới timeline trễ: người đăng có thể không thấy bài mình và bài đã xoá có thể vẫn hiện. Người đăng muốn thấy bài mình thì phải gộp thêm một nhánh lúc đọc, tốn khoảng 1,35 lần chi phí đọc. Lọc bài đã xoá lúc đọc thì gần như miễn phí, còn xoá thật khỏi mọi timeline thì cần thêm index 85 MB.

Bạn vừa đăng một bài rồi quay lại trang chủ. Bài đâu? Ở bài 03 và 04, fan-out là một câu lệnh chèn hàng loạt, và bạn chưa hỏi nó chạy lúc nào, ở đâu. Bài này đổi trục sang thời gian: từ lúc đăng đến lúc bài có mặt ở timeline người khác là bao lâu, và trong khoảng đó ai thấy gì.

1. Đưa fan-out ra khỏi request đăng bài

Từ bảng ràng buộc ở bài 01: bài mới tới feed người theo dõi trong 5 giây, và người đăng thấy bài mình ngay.

Từ bài 03 bạn đã chọn dựng sẵn timeline lúc ghi. Nhưng một bài của tài khoản 5 triệu người theo dõi là 5 triệu hàng, khoảng 45 giây trên một worker (ước lượng bài 01). Nếu việc đó nằm trong request đăng bài, người đăng nhìn vòng quay gần một phút. Bài 04 đã tách tài khoản từ ngưỡng T trở lên khỏi fan-out; hàng đợi này chỉ phục vụ phần còn lại.

Thử đoán trước

Muốn request đăng bài trả về trong vài mili-giây dù tài khoản có 5 triệu người theo dõi, bạn cần lưu lại điều gì, và ai làm phần việc còn lại? Hai worker cùng nhìn danh sách việc thì làm sao không nhặt trúng cùng một việc? Ghi ra hai câu trả lời trước khi đọc tiếp.

Lời giải gồm hai mảnh. Request đăng bài chỉ ghi bài vào posts và một dòng công việc vào fanout_jobs trong cùng một transaction rồi trả về. Phần chèn hàng loạt vào timeline là việc của worker, tiến trình nền lặp: lấy một job chưa làm, fan-out, đánh dấu xong.

Mảnh thứ hai là chỗ nhiều worker không giẫm chân nhau. SELECT ... FOR UPDATE khoá dòng job đã chọn; thêm SKIP LOCKED thì dòng nào đang bị worker khác khoá sẽ bị bỏ qua thay vì chờ, nên mỗi worker nhặt một job riêng. Job nào bị bỏ sót lượt này sẽ được nhặt lượt sau (tài liệu PostgreSQL 16).

SQL
CREATE TABLE fanout_jobs (
  id        bigserial PRIMARY KEY,
  post_id   bigint NOT NULL,
  author_id int NOT NULL,
  posted_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  status    text NOT NULL DEFAULT 'pending',
  done_at   timestamptz
);
CREATE INDEX fanout_jobs_pending_idx ON fanout_jobs (id) WHERE status = 'pending';

Index partial chỉ phủ job còn pending nên tìm job kế tiếp luôn rẻ. Một lượt worker là một câu, một transaction. Tự điền hai chỗ TODO trước khi xem lời giải:

SQL
-- worker.sql skeleton: one job per run
WITH j AS (/* TODO: take the smallest pending job that no other worker holds */),
ins AS (INSERT INTO timeline (user_id, post_id, created_at)
  /* TODO: followers of the author (only if author is under T), plus the author */
  RETURNING 1)
UPDATE fanout_jobs SET status = 'done', done_at = clock_timestamp()
FROM j WHERE fanout_jobs.id = j.id;
Tự điền

Viết hai đoạn TODO ra giấy, nhớ rằng tác giả từ T trở lên không được fan-out, rồi đối chiếu với lời giải dưới đây.

Lời giải. Cổng follower_count < :T (truyền bằng pgbench -D T=...) là chỗ bài 04 móc vào hàng đợi:

SQL
-- worker.sql (solution)
WITH j AS (
  SELECT id, post_id, author_id FROM fanout_jobs
  WHERE status = 'pending' ORDER BY id
  FOR UPDATE SKIP LOCKED LIMIT 1),
ins AS (
  INSERT INTO timeline (user_id, post_id, created_at)
  SELECT f.follower_id, j.post_id, now()
  FROM j JOIN users a ON a.id = j.author_id AND a.follower_count < :T
         JOIN follows f ON f.followee_id = j.author_id
  UNION ALL
  SELECT j.author_id, j.post_id, now() FROM j
  RETURNING 1)
UPDATE fanout_jobs SET status = 'done', done_at = clock_timestamp()
FROM j WHERE fanout_jobs.id = j.id;

Worker chết giữa chừng thì transaction rollback, job trở lại pending. Cái giá nằm ở chỗ khác: giờ có một khoảng thời gian bài đã đăng nhưng chưa có trong timeline của ai.

Trục thời gian vòng đời một bài đăng: request ghi posts và dòng job rồi trả về, job chờ, worker chèn timeline, job xong; trong khoảng từ lúc đăng đến lúc job xong người đăng và người theo dõi chưa thấy bài, và một lượt xoá bài có thể rơi vào giữa khoảng đó

2. Bao nhiêu worker thì hàng đợi không tồn?

Thêm worker rút khoảng trễ, nhưng rút bao nhiêu thì tuỳ hàng đợi có job nặng hay không. Thí nghiệm: đăng dồn 1.000 bài, trong đó 52 bài của 40 tài khoản từ 10.000 người theo dõi trở lên, rồi cho W client pgbench chạy worker.sql (-c W -j W -t $((1000/W))) vào bản sao rỗng của timeline. Đo hai lần: không cổng (-D T=1000000000, để thấy tác động của job nặng) và có cổng -D T=10000 như bài 04. Trên Apple M4, Postgres 16 trong Docker, median 3 lần chạy: 8 worker xả hết 1.000 job nhanh 3,5 lần so với 1 worker khi không cổng (3,09 s xuống 0,88 s), và nhanh 5,4 lần khi có cổng (1,61 s xuống 0,30 s).

Khi hàng đợi còn 52 job nặng, job nặng cuối cùng quyết định thời gian xả hết, nên thêm worker lợi giảm dần. Bỏ 52 job đó bằng cổng T thì cùng 8 worker nhanh hơn rõ. Vậy tách tài khoản lớn mới là đòn chính, thêm worker là đòn phụ: job nguyên khối 45 giây của tài khoản 5 triệu người theo dõi không chẻ nhỏ được bằng cách thêm worker.

Thời gian xả hết 1.000 job: không cổng là 3,09 giây với 1 worker và 0,88 giây với 8 worker; có cổng T bằng 10.000 là 1,61 giây và 0,30 giây; cả bốn đều dưới vạch mục tiêu 5 giây

3. Người đăng không thấy bài của chính mình

Cái giá thứ hai. Bạn đăng bài, job còn pending, feed đọc từ timeline dựng sẵn. Tác giả đã chạy kịch bản đó: đăng một bài, chưa chạy worker, đọc top 50 từ timeline.

Thử đoán trước

Chỉ đọc từ timeline và job chưa chạy: trong 50 dòng trả về, có bao nhiêu dòng là bài bạn vừa đăng?

Không dòng nào: kết quả đo là 0/50. Bài nằm an toàn trong posts, nhưng timeline của bạn chưa có nó. Đây là read-your-writes của bài replication lag, chỉ khác nguồn trễ: ở đó follower chưa áp log, ở đây worker chưa chèn timeline.

Người dùng phải thấy thứ mình vừa ghi, bằng cách đọc từ nơi chắc chắn đã có. Ở đây nơi đó là posts.

Áp vào đây: lúc đọc feed của chính người đăng, lấy thêm 50 bài gần nhất của chính họ từ posts, UNION với timeline (UNION loại trùng theo id, nên khi worker chạy xong thì bài không hiện hai lần), rồi cắt lại 50. Điền chỗ TODO trước khi xem lời giải:

SQL
-- own_merge.sql skeleton
SELECT id, created_at FROM (
  (SELECT p.id, p.created_at FROM timeline t JOIN posts p ON p.id = t.post_id
   WHERE t.user_id = :r AND p.deleted_at IS NULL ORDER BY t.created_at DESC LIMIT 50)
  UNION /* TODO: the reader's own posts, skip deleted ones */) u
ORDER BY created_at DESC LIMIT 50;

Lời giải: nhánh thứ hai là SELECT id, created_at FROM posts WHERE author_id = :r AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 50, đặt vào chỗ TODO. Cả hai nhánh đều lọc deleted_at IS NULL.

Chạy lại kịch bản: đọc có gộp cho 1/50. Sau khi worker chạy xong, đọc chỉ từ timeline cũng ra 1, vì worker.sql chèn cả vào timeline của chính tác giả (nhánh UNION ALL).

Cái giá, đo bằng pgbench trên người theo dõi 500 tài khoản (timeline đủ đầy, đo trước khi nhánh timeline có thêm điều kiện lọc xoá, thứ chỉ đổi p50 khoảng 0 đến 4%):

p50 (1 kết nối)p50 (20 kết nối)p99 (20 kết nối)
Chỉ timeline0,124 ms0,498 ms3,62 ms
Gộp bài của mình0,167 ms0,641 ms4,90 ms
Tỉ lệ1,35×1,29×1,35×

Chi phí đọc tăng khoảng 1,3 đến 1,35 lần cho mọi lượt mở feed, kể cả lúc người đó chưa đăng gì. Con số nhỏ so với 200 ms, nhưng nhân với 3.500 lượt mỗi giây thì phải tính.

4. Bài đã xoá: gỡ lúc đọc hay gỡ khỏi từng timeline?

Cái giá thứ ba là bài đã xoá. Tác giả xoá bài, nhưng bài đã nằm trong timeline của hàng trăm hay hàng chục nghìn người. Dưới ràng buộc đã nêu, có bốn cách gỡ, và mỗi cách trả một loại giá khác nhau.

Điểm quyết định

Ràng buộc: Timeline dựng sẵn 500 bài mỗi người · fan-out bất đồng bộ qua hàng đợi · bài đã xoá không được hiện trong feed · đỉnh 3.500 lượt mở feed/giây

Với ràng buộc này, bạn chấp nhận cái giá nào?

Chọn trước khi đọc tiếp — phần sau phân tích từng cái giá.

Nền: độ trễ giữa các bản và hệ quả cho người đọc

Phương án A rẻ vì truy vấn dựng feed vốn JOIN posts, nên p.deleted_at IS NULL chỉ là một điều kiện nữa. Đo hai người đọc (50 và 2.000 tài khoản đang theo dõi, 1% bài đã xoá), không lọc so với có lọc, cho p50 gần như trùng nhau: 0,102 ms với 0,102 ms ở người 50 theo dõi, 0,193 ms với 0,200 ms ở người 2.000 theo dõi. LIMIT 50 vẫn đủ 50 dòng nếu điều kiện nằm trong cùng WHERE của truy vấn đã JOIN. Nhánh tài khoản lớn ở bài 04 và nhánh bài của mình ở mục 3 cùng đọc posts, nên lọc deleted_at IS NULL tự nhiên theo cùng cách.

SQL
-- tl_read_del.sql
SELECT p.id, p.author_id, p.created_at
FROM timeline t JOIN posts p ON p.id = t.post_id
WHERE t.user_id = :r AND p.deleted_at IS NULL
ORDER BY t.created_at DESC LIMIT 50;

Phương án B là bẫy ngây thơ: DELETE FROM timeline WHERE post_id = :pid trên bảng chỉ có index theo user buộc Postgres quét cả bảng. Trên mẫu 9 triệu hàng, bài của tài khoản 101 người theo dõi tốn 217 ms, bài của tài khoản 95 nghìn người tốn 223 ms.

Phương án C sửa bằng một index trên timeline: 217 ms thành 0,05 ms ở bài nhỏ, 6 ms ở bài lớn.

SQL
CREATE INDEX timeline_post_idx ON timeline (post_id);

Index của C tính cho mọi bài đăng dù phần lớn không bao giờ bị xoá: +8,6% của 575 GB (bài 01) là khoảng 50 GB. Số fan-out chậm thêm 4% đến 20% có dải đo chồng nhau nên chưa chắc.

B và C còn một lỗi mà A không gặp: xoá chạy trước fan-out. Bài đăng rồi xoá khi job còn pending: DELETE không thấy hàng nào, rồi worker chèn đúng bài đã xoá. Đây là suy luận, không phải số đo; cách chặn là worker kiểm deleted_at trước khi chèn, hoặc giữ bộ lọc lúc đọc của A làm lưới an toàn. Hai trong ba cách sửa ở bài này đẩy việc về lúc đọc, phía bạn đã cố giữ rẻ từ bài 03.

Bốn cách gỡ bài đã xoá: A lọc lúc đọc không xoá hàng nào, B xoá không index mất 217 đến 270 ms mỗi bài, C xoá có index mất 0,05 đến 6 ms cộng 85 MB index, D lọc rồi dọn ở nền; B và C đều hở khi xoá chạy trước fan-out

5. Bẫy hay gặp

❌ Fan-out đồng bộ trong request đăng bài: tài khoản lớn làm request timeout, người đăng bấm lại và đăng trùng. ✅ Ghi posts và fanout_jobs trong cùng transaction rồi trả về; fan-out là việc của worker.

❌ DELETE FROM timeline WHERE post_id = ... trên bảng không có index post_id: mỗi bài xoá quét cả bảng, 217 ms trở lên. ✅ Lọc deleted_at IS NULL lúc đọc, hoặc chấp nhận trả 85 MB cho index và kiểm lại deleted_at trong worker fan-out.

Liên hệ các bài khác

Tóm tắt

  • Worker chỉ fan-out cho tác giả dưới T, và chèn cả vào timeline của chính tác giả; thêm worker có lợi giảm dần khi hàng đợi còn job nặng.
  • Mọi nhánh đọc (timeline, tài khoản lớn, bài của mình) lọc deleted_at IS NULL; DELETE thật chỉ là việc dọn, và worker kiểm deleted_at trước khi chèn.

6. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao đưa fan-out vào hàng đợi giải được request đăng bài treo, nhưng lại tạo ra bài toán người đăng không thấy bài mình?
  2. Q2
    Worker A đang giữ job 7 thì worker B chạy cùng câu lệnh lấy job. B làm gì với job 7, thiếu SKIP LOCKED thì sao, và nếu worker A chết giữa chừng thì job 7 ra sao?
  3. Q3
    Bạn gộp bài của chính mình lúc đọc bằng UNION. Vì sao dùng UNION thay vì UNION ALL, và cái giá của cách sửa này là gì?
  4. Q4
    Dưới ràng buộc timeline khoảng 575 GB, bạn gỡ bài đã xoá bằng cách nào: lọc lúc đọc, DELETE không index, hay DELETE có index? Nêu cái giá.
  5. Q5
    Tình huống mới: một tác giả đăng bài rồi xoá sau 1 giây, lúc job fan-out còn pending, và bạn đang dùng DELETE có index. Điều gì xảy ra với timeline người theo dõi, và bạn sửa thế nào? Ghi ít nhất một cách sửa trước khi mở đáp án.

Bài tiếp theo: Đối chiếu thực tế: các mạng xã hội thật xử lý fan-out thế nào

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Đối chiếu thực tế: các mạng xã hội đã dựng feed thế nào?