Forum khoá học: post, reply, like không trùng — thiết kế database, key Redis và cơ chế xử lý

Đề bài phỏng vấn: mỗi khoá học có một forum. Sinh viên đăng bài (post), comment và reply comment của nhau, like bài và like comment. Một sinh viên không được like cùng một bài hoặc cùng một comment hai lần. Em thiết kế database và Redis thế nào?

Trả lời ngắn (30 giây):

  1. "Không like 2 lần" là việc của database, không phải của Redis. Bảng post_likes có PRIMARY KEY (post_id, user_id). Đây là chốt cuối, đúng cả khi Redis chết, cache sai hay code có bug. Đã test: 20 request like cùng lúc → đúng 1 dòng. Bỏ constraint, dùng "kiểm tra rồi insert" → 14 dòng.
  2. Redis làm 2 việc: (a) trả lời nhanh "tôi đã like bài nào" bằng một SET theo user + khoá học; (b) làm write buffer (bộ đệm ghi) — Lua script SADD + XADD vào Stream, worker gom batch rồi ghi PostgreSQL. Lý do cần (b): like dồn vào 1 bài hot làm UPDATE like_count tranh nhau cùng 1 row — đo được 80 TPS, latency 398 ms, so với 1464 TPS khi không update counter.
  3. Nếu không có bài hot thì không cần (b). Làm đồng bộ với INSERT ... ON CONFLICT DO NOTHING RETURNING là đủ, bớt hẳn một hệ thống.

Mọi output trong bài là chạy thật: SQL trên PostgreSQL 18.4 (my-test-pgdb), Redis 8.8.0 container tạm, code PHP chạy bằng Laravel 12.64 / phpredis nối vào cả hai. Chỗ nào chưa verify có ghi rõ.

Giả định quy mô (ví dụ minh hoạ, không phải số thật của hệ thống nào): 50.000 sinh viên, 2.000 khoá học, mỗi sinh viên học ~6 khoá. Lúc cao điểm ~200 like/giây toàn hệ thống. Có khoá đại trà vài nghìn người, thông báo của giảng viên có thể nhận hàng nghìn like trong vài phút — đó là "bài hot".

Thuật ngữ dùng trong bài

Thuật ngữNghĩa ngắn
unique constraint / PRIMARY KEYRàng buộc trong DB: không cho 2 dòng trùng giá trị khoá. Database tự khoá theo giá trị khi insert nên không có race.
check-then-insertĐọc xem có chưa rồi mới insert. Hai request cùng đọc "chưa có" → cả hai insert.
hot rowMột dòng bị rất nhiều transaction cùng UPDATE. Các transaction phải xếp hàng chờ row lock.
write-behind / write bufferGhi nhanh vào bộ nhớ trước, ghi xuống DB sau theo lô (batch).
Redis StreamKiểu dữ liệu log chỉ ghi thêm (append-only). Có consumer group (nhóm đọc), mỗi event phải được XACK (xác nhận đã xử lý).
PEL (Pending Entries List)Danh sách event đã giao cho worker nhưng chưa ACK. Worker chết thì event còn nằm đó để đọc lại.
idempotentChạy lại nhiều lần cho cùng kết quả như chạy một lần.
listpack / hashtableHai cách Redis lưu một SET. Listpack là mảng liền, rất gọn khi nhỏ. Vượt ngưỡng thì chuyển sang hashtable, tốn RAM hơn nhiều.
hash slot / hashtagRedis Cluster chia key vào 16384 slot. Phần trong {…} của tên key quyết định slot — gọi là hashtag.
evictionRedis đầy RAM thì tự xoá key theo maxmemory-policy.
keyset paginationPhân trang theo con trỏ (WHERE id > ?), không dùng OFFSET.

1. Tách đề bài thành các yêu cầu thật

Yêu cầuAi chịu trách nhiệmVì sao
Không like trùngPostgreSQL (PRIMARY KEY)Phải đúng tuyệt đối, kể cả khi Redis mất dữ liệu.
Hiện số like trên danh sách bàiPostgreSQL (cột like_count)Đọc cùng row bài viết, không cần query thêm. COUNT(*) mỗi lần xem là quá đắt.
Hiện nút "đã like" cho người đang xemRedis SET (cache, nạp từ DB)Mỗi trang cần biết 20–50 item, hỏi 1 lệnh SMISMEMBER.
Chịu được nhiều like dồn vào 1 bàiRedis Stream + workerGom N like thành 1 UPDATE counter mỗi batch.
Post, comment, replyPostgreSQLTần suất ghi thấp. Cache danh sách comment không đáng công (xem mục 4.5).

2. Thiết kế database (PostgreSQL)

flowchart LR
  C["courses"] -->|"1 - n"| P["posts
course_id, like_count, comment_count"] P -->|"1 - n"| CM["comments
post_id, parent_id, like_count, reply_count"] CM -->|"parent_id: reply 1 cấp"| CM P -->|"1 - n"| PL["post_likes
PK (post_id, user_id)"] CM -->|"1 - n"| CL["comment_likes
PK (comment_id, user_id)"] U["users"] -.-> PL U -.-> CL
CREATE TABLE posts (
  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  course_id     bigint NOT NULL,          -- mỗi course 1 forum: không cần bảng forums
  user_id       bigint NOT NULL,
  title         text   NOT NULL,
  body          text   NOT NULL,
  like_count    int    NOT NULL DEFAULT 0 CHECK (like_count >= 0),
  comment_count int    NOT NULL DEFAULT 0,
  created_at    timestamptz NOT NULL DEFAULT now(),
  deleted_at    timestamptz
);
CREATE INDEX posts_course_feed ON posts (course_id, id DESC) WHERE deleted_at IS NULL;

CREATE TABLE comments (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  post_id     bigint NOT NULL REFERENCES posts(id),
  parent_id   bigint REFERENCES comments(id),   -- NULL = comment gốc, có giá trị = reply
  user_id     bigint NOT NULL,
  body        text   NOT NULL,
  like_count  int    NOT NULL DEFAULT 0 CHECK (like_count >= 0),
  reply_count int    NOT NULL DEFAULT 0,
  created_at  timestamptz NOT NULL DEFAULT now(),
  deleted_at  timestamptz
);
CREATE INDEX comments_top   ON comments (post_id, id)   WHERE parent_id IS NULL;
CREATE INDEX comments_reply ON comments (parent_id, id) WHERE parent_id IS NOT NULL;

CREATE TABLE post_likes (
  post_id    bigint NOT NULL REFERENCES posts(id),
  user_id    bigint NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (post_id, user_id)                  -- ← luật "không like 2 lần" nằm ở đây
);
CREATE INDEX post_likes_user ON post_likes (user_id, post_id);   -- để nạp cache "tôi đã like gì"

CREATE TABLE comment_likes (
  comment_id bigint NOT NULL REFERENCES comments(id),
  user_id    bigint NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX comment_likes_user ON comment_likes (user_id, comment_id);

2.1. Vì sao PRIMARY KEY là bắt buộc — test thật

Không có constraint, dùng một câu duy nhất INSERT ... WHERE NOT EXISTS (nhiều người tưởng một câu SQL là an toàn). 20 session xuất phát cùng lúc bằng pg_sleep_until:

INSERT INTO tmp_f_likes_nopk SELECT 1, 7
WHERE NOT EXISTS (SELECT 1 FROM tmp_f_likes_nopk WHERE post_id = 1 AND user_id = 7);

     14 INSERT 0 1
      6 INSERT 0 0
 so_dong_like
--------------
           14        ← 1 user like 1 bài 14 lần

Lý do: ở READ COMMITTED, mỗi session chỉ thấy dữ liệu đã commit. 14 session cùng thấy "chưa có" trước khi ai kịp commit. Có PRIMARY KEY, cùng 20 session:

WITH ins AS (
  INSERT INTO tmp_f_post_likes (post_id, user_id) VALUES (2, 7)
  ON CONFLICT DO NOTHING
  RETURNING post_id)
UPDATE tmp_f_posts SET like_count = like_count + 1
 WHERE id IN (SELECT post_id FROM ins);

-- 19 session: UPDATE 0     1 session: UPDATE 1
 id | like_count | rows
----+------------+------
  2 |          1 |    1

Unique index khoá theo giá trị khoá lúc insert: session thứ hai phải chờ session đầu commit rồi mới biết là trùng. Mẹo quan trọng là RETURNING: chỉ tăng counter khi insert thật sự xảy ra. Unlike cũng vậy:

WITH del AS (DELETE FROM tmp_f_post_likes WHERE post_id=1 AND user_id=7 RETURNING post_id)
UPDATE tmp_f_posts SET like_count = like_count - 1 WHERE id IN (SELECT post_id FROM del);
-- lần 1: like_count 1 → 0, UPDATE 1
-- lần 2: UPDATE 0   (không có gì để xoá → counter không bị trừ thành âm)

Nếu quên ON CONFLICT thì DB vẫn chặn, chỉ là ném lỗi:

ERROR:  duplicate key value violates unique constraint "tmp_f_post_likes_pkey"
DETAIL:  Key (post_id, user_id)=(1, 7) already exists.

2.2. Hai bảng like hay một bảng polymorphic?

post_likes + comment_likes (chọn)likes(likeable_type, likeable_id, user_id)
Foreign keyCó — không thể like vào id không tồn tạiKhông có — FK không trỏ được tới 2 bảng
Kích thước khoá2 bigintthêm cột type (text) trong mọi index
Thêm loại mới (like reply riêng, like tài liệu)Thêm bảngThêm giá trị type
Laravel2 relation thườngmorphMany sẵn có

Forum chỉ có 2 loại, nên chọn 2 bảng. Chuyển sang polymorphic khi có từ 4–5 loại được like trở lên.

2.3. Reply: 2 cấp, không làm cây vô hạn

Chỉ cho comment gốc và reply. Ai reply vào một reply thì app gắn parent_id về comment gốc (kiểu Facebook), kèm tag tên người được trả lời. Được gì: 2 query đơn giản, dùng 2 partial index, không cần recursive CTE.

-- Trang comment gốc (keyset pagination)
SELECT id, user_id, body, reply_count FROM comments
 WHERE post_id = 7 AND parent_id IS NULL AND id > 1000 ORDER BY id LIMIT 20;
 Limit (actual rows=20.00 loops=1)
   ->  Index Scan using comments_top on comments
         Index Cond: ((post_id = 7) AND (id > 1000))
         Buffers: shared hit=26

-- Reply của 1 comment (bấm "xem reply")
SELECT id, user_id, body FROM comments WHERE parent_id = 3 ORDER BY id LIMIT 10;
   ->  Index Scan using comments_reply on comments
         Index Cond: (parent_id = 3)
         Buffers: shared hit=3

(Bảng 266.666 comment, 2.000 post.) Thêm reply thì tăng 2 counter trong cùng transaction, chỉ nhận parent là comment gốc:

WITH r AS (
  INSERT INTO comments (post_id, parent_id, user_id, body)
  SELECT post_id, id, 11, 'rep' FROM comments WHERE id = 3 AND parent_id IS NULL
  RETURNING post_id, parent_id),
u1 AS (UPDATE comments SET reply_count = reply_count + 1 WHERE id IN (SELECT parent_id FROM r))
UPDATE posts SET comment_count = comment_count + 1 WHERE id IN (SELECT post_id FROM r);
-- parent là 1 reply → insert 0 dòng → app trả 422 (hoặc đổi parent về gốc trước khi insert)

Cần cây sâu vô hạn (kiểu Reddit)? Dùng thêm cột path (materialized path, vd 0001.0007.0042) hoặc extension ltree. Forum khoá học không cần — đừng làm trước.

3. Vì sao cần Redis — đo chỗ database bị nghẽn

pgbench, 32 client, 15 giây, user id ngẫu nhiên (không trùng). Máy lab 4 core dùng chung — chỉ dùng để so sánh tương đối, không phải số production:

Kịch bảnTPSLatency TB
Insert like + UPDATE counter, cùng 1 bài (bài hot)80398 ms
Insert like + UPDATE counter, rải trên 10.000 bài70046 ms
Chỉ insert like vào 1 bài, không UPDATE counter146422 ms

Kết luận từ số đo: thứ chậm không phải là insert like, mà là UPDATE like_count trên cùng một row. 32 transaction xếp hàng chờ row lock của posts.id = 3, mỗi cái giữ lock tới khi commit xong. Bài hot = 1 hàng đợi duy nhất.

sequenceDiagram
  participant T1 as Like tu user A
  participant T2 as Like tu user B
  participant T3 as Like tu user C
  participant DB as PostgreSQL row posts id=3

  T1->>DB: INSERT like + UPDATE like_count
  Note over DB: T1 giu row lock toi khi COMMIT
  T2->>DB: UPDATE like_count
  Note over T2: CHO row lock
  T3->>DB: UPDATE like_count
  Note over T3: CHO row lock
  T1->>DB: COMMIT
  DB-->>T2: duoc chay
  T2->>DB: COMMIT
  DB-->>T3: duoc chay

Redis giải đúng chỗ này: thay vì 1000 like = 1000 UPDATE lên row hot, worker gom thành 1 UPDATE mỗi batch (like_count = like_count + 1000).

4. Thiết kế Redis

4.1. Bảng key

KeyKiểuNội dungTTLAi ghi
forum:u:{userId}:c:{courseId}:likedSETMember p:{postId}, c:{commentId}, và _ (sentinel)7 ngày, gia hạn mỗi lần like/unlikeLua toggle, Lua warm
forum:like-eventsSTREAMop, target, user, courseKhông TTLLua toggle; worker XACK + XDEL
(group) like-flusherconsumer groupCon trỏ đọc + PEL—Tạo 1 lần khi deploy

Quy tắc đặt tên: app:thực thể:id:…:mục đích, dấu : phân tầng. Tiền tố forum: để SCAN MATCH forum:* hay đo RAM theo nhóm được. Member ngắn (p:123) vì một user có thể có hàng trăm member.

Cố ý KHÔNG có: key counter forum:p:{id}:likes trong Redis (lý do ở mục 4.6), key danh sách post / comment (mục 4.5).

4.2. Vì sao SET theo user + course, không theo post?

Cách hay gặp là post:{id}:likers = SET các user id. Nghe tự nhiên, nhưng có 3 vấn đề. Đo trên Redis 8.8:

KeySố memberEncodingMEMORY USAGE
Theo user + course100 (p:100001…)listpack1.031 byte
Theo user + course150hashtable7.228 byte
Theo post (bài hot)50.000 user idhashtable2.325.406 byte (~2,3 MB, 1 key)

(set-max-listpack-entries 128, set-max-intset-entries 512 — giá trị mặc định.)

Tiêu chíSET theo postSET theo user + course (chọn)
Kích thước keyKhông giới hạn — bài hot thành big key, DEL/hết hạn làm Redis đứngBị chặn bởi số like của 1 người trong 1 khoá — vài chục tới vài trăm
Render 1 trang 20 bài20 lệnh SISMEMBER (pipeline)1 lệnh SMISMEMBER key p:1 … p:20
Nạp lại khi cache mấtĐọc 50.000 dòng cho 1 bàiĐọc vài trăm dòng của 1 người
"Ai đã like bài này?"Có sẵnKhông có — query DB (post_likes theo PK), tính năng ít dùng

Điểm thứ 3 quyết định: Lua script dùng SADD để biết "like lần đầu hay trùng". Kết quả chỉ đúng khi SET đã được nạp đủ từ DB. Nạp đủ một SET nhỏ thì rẻ, nạp đủ SET 50.000 member thì không.

4.3. Luồng like / unlike

sequenceDiagram
  participant B as Browser
  participant API as Laravel API
  participant R as Redis
  participant W as Worker flush
  participant DB as PostgreSQL

  B->>API: POST like p:100
  API->>DB: post 100 co thuoc course 42 khong - PK lookup
  API->>R: EVAL toggle - SET liked + STREAM
  alt SET chua co trong Redis
    R-->>API: -1
    API->>DB: SELECT cac like cua user trong course
    API->>R: EVAL warm - chi nap neu key chua ton tai
    API->>R: EVAL toggle lan 2
  end
  R-->>API: 1 moi like hoac 0 trung
  API-->>B: 200 liked true
  Note over R,W: vai tram ms sau
  W->>R: XREADGROUP COUNT 500
  W->>W: gop theo cap target-user, giu thao tac cuoi
  W->>DB: 1 transaction - INSERT ON CONFLICT RETURNING + UPDATE counter theo so dong that
  W->>R: XACK + XDEL sau khi COMMIT

4.4. Code — đã chạy thật với Laravel 12.64 + phpredis

Lua toggle — kiểm tra, đổi SET, ghi event trong một bước nguyên tử. Không thể có trạng thái "SET đã đổi mà event chưa ghi".

-- KEYS[1] set "đã like", KEYS[2] stream. ARGV: member, op, user_id, course_id, ttl
if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end   -- chưa nạp: KHÔNG được đoán
local changed
if ARGV[2] == 'like' then
  changed = redis.call('SADD', KEYS[1], ARGV[1])
else
  changed = redis.call('SREM', KEYS[1], ARGV[1])
end
if changed == 0 then return 0 end                           -- like trùng / unlike khi chưa like
redis.call('EXPIRE', KEYS[1], ARGV[5])
redis.call('XADD', KEYS[2], '*', 'op', ARGV[2], 'target', ARGV[1], 'user', ARGV[3], 'course', ARGV[4])
return 1

Lua warm — nạp SET từ DB. Hai điểm then chốt: chỉ nạp khi key chưa tồn tại, và luôn có member _:

-- KEYS[1] set "đã like". ARGV: ttl, member...
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
redis.call('SADD', KEYS[1], '_')
for i = 2, #ARGV, 5000 do          -- unpack() quá ~8000 phần tử sẽ lỗi, chia nhỏ
  redis.call('SADD', KEYS[1], unpack(ARGV, i, math.min(i + 4999, #ARGV)))
end
redis.call('EXPIRE', KEYS[1], ARGV[1])
return 1

Service (trong bài để Lua inline cho dễ đọc):

final class ForumLikeService
{
    public const STREAM = 'forum:like-events';
    public const GROUP  = 'like-flusher';
    private const SET_TTL = 604800; // 7 ngày

    private const TOGGLE = <<<'LUA'
    ... script toggle ở trên ...
    LUA;
    private const WARM = <<<'LUA'
    ... script warm ở trên ...
    LUA;

    /** @return bool true nếu trạng thái thật sự đổi */
    public function toggle(int $userId, int $courseId, string $member, bool $like): bool
    {
        $args = [$this->setKey($userId, $courseId), self::STREAM,
                 $member, $like ? 'like' : 'unlike', $userId, $courseId, self::SET_TTL];

        $changed = Redis::eval(self::TOGGLE, 2, ...$args);
        if ($changed === -1) {
            $this->warm($userId, $courseId);
            $changed = Redis::eval(self::TOGGLE, 2, ...$args);
        }

        return $changed === 1;
    }

    /** @param string[] $members vd ['p:1','p:2','c:9'] @return array<string,bool> */
    public function likedMap(int $userId, int $courseId, array $members): array
    {
        $key = $this->setKey($userId, $courseId);
        if (! Redis::exists($key)) {
            $this->warm($userId, $courseId);
        }

        return array_combine($members, array_map('boolval', Redis::smismember($key, ...$members)));
    }

    private function warm(int $userId, int $courseId): void
    {
        $members = DB::table('post_likes as l')
            ->join('posts as p', 'p.id', '=', 'l.post_id')
            ->where('l.user_id', $userId)->where('p.course_id', $courseId)
            ->selectRaw("'p:' || l.post_id AS m")
            ->unionAll(DB::table('comment_likes as l')
                ->join('comments as c', 'c.id', '=', 'l.comment_id')
                ->join('posts as p', 'p.id', '=', 'c.post_id')
                ->where('l.user_id', $userId)->where('p.course_id', $courseId)
                ->selectRaw("'c:' || l.comment_id AS m"))
            ->pluck('m')->all();

        Redis::eval(self::WARM, 1, $this->setKey($userId, $courseId), self::SET_TTL, ...$members);
    }

    private function setKey(int $userId, int $courseId): string
    {
        return "forum:u:{$userId}:c:{$courseId}:liked";
    }
}

Controller — kiểm tra quyền ở trust boundary (ranh giới tin cậy) trước khi chạm Redis:

public function like(Request $request, Course $course, Post $post, ForumLikeService $likes)
{
    abort_unless($post->course_id === $course->id, 404);          // chặn like chéo khoá học
    $this->authorize('participate', $course);                      // sinh viên có học khoá này
    RateLimiter::attempt("like:{$request->user()->id}", 60, fn () => true)
        || abort(429);                                             // chống spam bật/tắt

    $likes->toggle($request->user()->id, $course->id, "p:{$post->id}", $request->boolean('like'));

    return response()->json(['liked' => $request->boolean('like')]);
}

(Controller là phác thảo, chưa chạy trong lab; service và worker thì đã chạy.)

Worker flush — chạy bằng một command while (true) $flush->once(); dưới Supervisor, 1 process duy nhất (lý do ở bẫy số 7).

final class FlushLikes
{
    private const SQL = [
        'p:like' => <<<'SQL'
WITH ev(tid, uid, cid) AS (SELECT * FROM unnest(?::bigint[], ?::bigint[], ?::bigint[])),
ins AS (
  INSERT INTO post_likes (post_id, user_id)
  SELECT ev.tid, ev.uid FROM ev
  JOIN posts p ON p.id = ev.tid AND p.course_id = ev.cid AND p.deleted_at IS NULL
  ON CONFLICT DO NOTHING
  RETURNING post_id),
agg AS (SELECT post_id, count(*) AS n FROM ins GROUP BY post_id)
UPDATE posts p SET like_count = p.like_count + agg.n FROM agg WHERE p.id = agg.post_id
SQL,
        'p:unlike' => <<<'SQL'
WITH ev(tid, uid, cid) AS (SELECT * FROM unnest(?::bigint[], ?::bigint[], ?::bigint[])),
del AS (
  DELETE FROM post_likes l USING ev
  WHERE l.post_id = ev.tid AND l.user_id = ev.uid
  RETURNING l.post_id),
agg AS (SELECT post_id, count(*) AS n FROM del GROUP BY post_id)
UPDATE posts p SET like_count = p.like_count - agg.n FROM agg WHERE p.id = agg.post_id
SQL,
        // 'c:like', 'c:unlike': giống hệt, đổi sang comment_likes / comments
        // (JOIN comments c ... JOIN posts p ON p.id = c.post_id AND p.course_id = ev.cid)
    ];

    /** Xử lý 1 batch. Trả về số event đã xử lý (0 = stream rỗng). */
    public function once(int $count = 500, int $blockMs = 2000): int
    {
        // '0' = event đã giao cho worker này nhưng chưa ACK (lần chạy trước chết giữa chừng)
        $entries = $this->read('0', $count, null);
        if ($entries === []) {
            $entries = $this->read('>', $count, $blockMs);
        }
        if ($entries === []) {
            return 0;
        }

        // Gộp theo cặp (target, user): chỉ giữ thao tác CUỐI CÙNG trong batch
        $final = [];
        foreach ($entries as $e) {
            $final["{$e['target']}|{$e['user']}|{$e['course']}"] = $e['op'];
        }

        $groups = [];
        foreach ($final as $pair => $op) {
            [$target, $user, $course] = explode('|', $pair);
            [$type, $id] = explode(':', $target);
            $groups["$type:$op"][] = [(int) $id, (int) $user, (int) $course];
        }

        DB::transaction(function () use ($groups) {
            foreach ($groups as $sqlKey => $rows) {
                DB::update(self::SQL[$sqlKey], [
                    '{'.implode(',', array_column($rows, 0)).'}',
                    '{'.implode(',', array_column($rows, 1)).'}',
                    '{'.implode(',', array_column($rows, 2)).'}',
                ]);
            }
        });

        // ACK sau khi COMMIT. Chết giữa COMMIT và ACK → lần sau đọc lại '0' → chạy lại, vẫn đúng
        $ids = array_keys($entries);
        Redis::xack(ForumLikeService::STREAM, ForumLikeService::GROUP, $ids);
        Redis::xdel(ForumLikeService::STREAM, $ids);

        return count($entries);
    }

    private function read(string $from, int $count, ?int $blockMs): array
    {
        $res = Redis::xreadgroup(ForumLikeService::GROUP, 'flusher-1',
            [ForumLikeService::STREAM => $from], $count, $blockMs);

        return $res ? (array) reset($res) : [];   // reset(): key trả về có kèm prefix
    }
}

// Tạo group 1 lần (migration / deploy):
Redis::xgroup('CREATE', 'forum:like-events', 'like-flusher', '0', true);  // true = MKSTREAM

Output chạy thật

DB ban đầu: post 1, 2 thuộc course 42; post 3 thuộc course 99; user 7 đã like post 2 (like_count = 1). Redis prefix lab:.

likedMap trước (user 7 đã like p:2 trong DB): {"p:1":false,"p:2":true}
keys: ["lab:forum:u:7:c:42:liked","lab:forum:like-events"]
like p:1 -> true
like p:1 lần 2 -> false
user 8 (set lạnh) like p:1 -> true                                   ← -1, warm, gọi lại
user 8 like p:3 (post của course 99, qua set course 42) -> true      ← Redis không biết
user 9 like rồi unlike p:2 trong cùng batch
user 7 like comment c:2 -> true
user 7 unlike p:2 (like cũ đã có trong DB) -> true
DB trước flush:   [{"id":1,"like_count":0},{"id":2,"like_count":1},{"id":3,"like_count":0}]
XINFO GROUPS lag: [{"name":"like-flusher", ..., "pending":0, "lag":7}]
flush -> 7 event
DB sau flush:     [{"id":1,"like_count":2},{"id":2,"like_count":0},{"id":3,"like_count":0}]
comments:         [{"id":1,"like_count":0},{"id":2,"like_count":1}]
post_likes:       [{"post_id":1,"user_id":7},{"post_id":1,"user_id":8}]
XLEN sau flush: 0

Đọc kết quả: post 1 = 2 (user 7, 8). Post 2 = 0 (user 7 unlike, user 9 like-rồi-unlike bị gộp thành không làm gì). Post 3 = 0 — like chéo khoá học bị worker lọc bằng JOIN ... p.course_id = ev.cid. Nhưng SET của user 8 vẫn chứa p:3 → đó là lý do controller phải kiểm tra trước (bẫy số 6).

Worker chết sau COMMIT, trước ACK — giả lập bằng cách đọc event, tự ghi DB, không ACK:

DB giả lập đã commit:  [{"id":1,"like_count":3}, ...]
XPENDING: [1,"1789626261926-0","1789626261926-0",[["flusher-1","1"]]]
flush chạy lại -> 1 event
DB sau flush lại:      [{"id":1,"like_count":3}, ...]      ← không cộng 2 lần

Đây là tính chất quan trọng nhất của thiết kế: worker idempotent. Counter chỉ cộng theo số dòng RETURNING thật, nên chạy lại batch bao nhiêu lần cũng không đếm trùng. Nhờ vậy dùng được at-least-once (giao ít nhất một lần) của Stream mà không cần exactly-once.

4.5. Có nên cache danh sách post / comment trong Redis?

Không, ở quy mô này. Query danh sách đã đi index, 26 buffer. Cache danh sách phải xoá mỗi khi có post mới, comment mới, sửa, xoá, đổi counter — rất nhiều chỗ để quên. Thêm khi đo thấy query danh sách nằm trong top pg_stat_statements, không thêm trước.

4.6. Có nên để counter trong Redis (HINCRBY)?

Thiết kế này không để. Số like lấy từ posts.like_count, trễ bằng chu kỳ flush (dưới 1–2 giây). Người vừa bấm like thì UI tự +1 ngay (optimistic UI), người khác thấy trễ 1 giây — không ai để ý. Để counter trong Redis thì dính 2 bẫy nổ trễ:

DEL forum:p:200:cnt              # key counter hết hạn
HINCRBY forum:p:200:cnt likes 1  # 1 like mới tới
HGETALL forum:p:200:cnt
likes
1                                # ← bài có 500 like giờ hiện 1

Chỉ thêm khi trang chi tiết bài có lượng đọc cực lớn và cần số chính xác theo thời gian thực.

5. Bẫy — xếp theo mức hay dính

#BẫyChuyện gì xảy ra (đã test)Cách chữa
1Redis dùng chung với cache, maxmemory-policy allkeys-lru Nổ trễ, im lặng. Stream đứng yên 5 giây, sau đó ghi cache đầy RAM:
round 1: EXISTS stream=0 evicted_keys:20776
Toàn bộ like chưa flush mất hết, không có log lỗi.
Stream và cache không được cùng một policy xoá tất cả. Dùng volatile-lru (chỉ xoá key có TTL — stream không có TTL), hoặc tách Redis riêng cho stream với noeviction. Alert evicted_keys tăng.
2SET rỗng bị Redis tự xoá SREM member cuối → EXISTS = 0. User chưa like gì (hoặc unlike hết) thì mỗi request đều thành "chưa nạp" → query DB mỗi lần. Luôn có member _. Test: unlike hết vẫn EXISTS -> 1, members -> _.
3Nạp SET không nguyên tử / ghi đè SET đang có Tab A unlike (SREM, event chưa flush). Tab B đọc DB cũ (vẫn thấy like) rồi SADD lại → SET nói "đã like", DB sau flush nói "chưa like". Script warm chỉ ghi khi key chưa tồn tại, và nạp trong 1 lần gọi. Test: load again -> 0.
4SET hết hạn khi worker đang chết lâu Like chưa xuống DB. SET hết TTL → nạp lại từ DB → không thấy like đó → user like lại được. DB vẫn đúng (PK), UI nhảy. TTL (7 ngày) rất dài so với độ trễ flush bình thường. Alert khi lag của group > 10.000 hoặc event cũ nhất > 1 phút.
5Lua unpack quá nhiều phần tử ERR user_script:5: too many results to unpack ở 8.000 member (7.000 thì qua). Chia nhỏ 5.000/lần trong script. Test 20.001 member: SCARD=20002.
6Tin member từ client Like p:3 (bài course khác) qua SET course 42 → Redis trả true, SET bị bẩn. Worker lọc được, UI thì sai. Kẻ xấu gửi 1 triệu id giả → SET thành big key. Controller kiểm tra post.course_id + quyền tham gia + rate limit trước Redis. Worker vẫn JOIN lọc — 2 lớp.
7Chạy nhiều worker trong cùng group Like ở worker 1, unlike ở worker 2 → thứ tự áp xuống DB có thể bị đảo → DB "đã like", Redis "chưa like". (Suy ra từ cơ chế, chưa dựng lại race trong lab.) 1 worker. 500 event/batch × vài batch/giây là thừa cho 200 like/giây. Cần nhiều hơn thì chia stream theo user_id % N, mỗi stream 1 worker — cùng user luôn về cùng worker.
8Batch fail cả lô vì 1 event hỏng Bài bị xoá cứng trước khi flush:
ERROR: insert or update on table "tmp_f_post_likes" violates foreign key constraint
Cả batch rollback, worker đọc lại pending, fail mãi — poison batch.
INSERT ... SELECT ... JOIN posts để lọc bài không còn. Dùng soft delete. Đếm số lần retry, quá N lần thì log batch ra rồi ACK.
9Redis Cluster: 2 key khác slot trong 1 script CROSSSLOT Keys in request don't hash to the same slot Standalone + replica là đủ cho bài toán này. Nếu buộc phải Cluster: hashtag theo course forum:{c42}:u:7:liked + stream theo course forum:{c42}:like-events (test: cùng slot 7547) — đổi lại worker phải đọc nhiều stream.
10Redis chết / failover AOF everysec mất tới ~1 giây ghi. Failover sang replica mất phần chưa replicate. Like "biến mất". Do SET và stream đổi trong cùng 1 script nên mất cùng nhau — không lệch nhau. (Chưa dựng failover cho bài này, xem Redis — những thứ cần biết.) Chấp nhận được với like, không chấp nhận được với tiền. Nói rõ trade-off này khi phỏng vấn.
11Prefix của Laravel phpredis tự thêm prefix vào KEYS[] của eval (test: lab:forum:…). Key tự ghép trong Lua thì không có prefix. Kết quả XREADGROUP trả tên stream có prefix → $res['forum:like-events'] là null. Mọi key đưa qua KEYS, không ghép trong Lua. Lấy phần tử đầu bằng reset($res).
12XACK rồi quên XDEL Stream giữ mọi event đã xử lý, RAM tăng mãi. XACK + XDEL như code trên (Redis 8.2+ có XACKDEL gộp 1 lệnh, đã test, nhưng phpredis 6.3.0 chưa có method xackdel, phải gọi rawCommand — và test cho thấy rawCommand không thêm prefix: key ra là rawkey thay vì lab:rawkey).

6. Lợi ích

7. Tác hại / hạn chế

8. Trade-off — chọn mức nào

Mức 1: Đồng bộ, chỉ DBMức 2: Insert đồng bộ, counter bất đồng bộMức 3: Redis write-behind (bài này)
Cách làmINSERT ON CONFLICT RETURNING + UPDATE like_count trong 1 transactionChỉ insert like trong request. Job mỗi vài giây tính lại like_count cho bài vừa có likeLua SET + Stream, worker gom batch
Không like trùngPKPKPK (Redis chỉ lọc sớm)
Bài hot (đo)80 TPS, 398 ms~1464 TPS (không UPDATE counter)Giới hạn bởi Redis
Mất like khi sự cốKhôngKhôngCó (≤1 giây)
Số hệ thống11 + scheduler2 + worker
Chọn khiKhông có bài nào nhận > ~50 like/giây. Phần lớn forum khoá học ở đây.Có bài hot nhưng không muốn thêm Redis vào đường ghiĐã có Redis vận hành tốt, like dồn rất mạnh (khoá MOOC, livestream), chấp nhận mất ít like
flowchart TD
  A["Có bài nhận hàng chục like/giây trở lên?"] -->|"Không"| M1["Mức 1: đồng bộ, chỉ DB"]
  A -->|"Có"| B["Chấp nhận mất vài like khi Redis sự cố?"]
  B -->|"Không"| M2["Mức 2: insert đồng bộ, counter tính lại theo lô"]
  B -->|"Có"| C["Đã có Redis có persistence + người trực?"]
  C -->|"Chưa"| M2
  C -->|"Rồi"| M3["Mức 3: Redis SET + Stream + worker"]

Nói thẳng khi phỏng vấn: nếu đề bài không nói gì về lưu lượng, câu trả lời tốt là trình bày Mức 1 trước, chỉ ra điểm nghẽn là hot row (có số đo), rồi mới nâng lên Mức 3. Nhảy thẳng vào "dùng Redis SET để chống like trùng" mà không có unique constraint là câu trả lời trượt.

Mức 2 — job tính lại counter (đã test, sửa đúng 2 bài bị lệch, không đụng bài đúng):

UPDATE posts p SET like_count = x.n
FROM (SELECT post_id, count(*)::int AS n FROM post_likes
       WHERE post_id = ANY('{4,5,6}') GROUP BY post_id) x
WHERE p.id = x.post_id AND p.like_count <> x.n
RETURNING p.id, p.like_count;
 id | like_count
----+------------
  5 |         25
  6 |         25
UPDATE 2

Danh sách bài "vừa có like" lấy từ một Redis SET forum:dirty-posts (SPOP theo lô) hoặc WHERE created_at > now() - interval '10 seconds' trên post_likes (cần index created_at). Câu này cũng dùng làm job đối soát hằng đêm cho Mức 3.

9. Khó khăn khi vận hành thực tế

ViệcLàm gì
Theo dõi workerXINFO GROUPS forum:like-events → trường lag (event chưa đọc) và pending (đọc rồi chưa ACK). Export qua redis_exporter, alert lag > 10.000 hoặc pending không giảm trong 5 phút.
Redis configappendonly yes, appendfsync everysec, maxmemory-policy volatile-lru (hoặc Redis riêng + noeviction). Alert evicted_keys.
Deploy workerSupervisor numprocs=1. Worker dài hạn giữ connection DB — restart sau mỗi deploy (supervisorctl restart), nếu không nó chạy code cũ.
Lệch counterJob đối soát hằng đêm (SQL ở mục 8) cho bài có like trong 24h. Log số bài bị sửa — số này tăng là có bug.
Debug "tôi like mà không thấy"Theo thứ tự: SMISMEMBER forum:u:{id}:c:{cid}:liked p:{pid} → XRANGE forum:like-events - + COUNT 10 xem event còn kẹt không → XPENDING → SELECT FROM post_likes WHERE post_id=? AND user_id=?.
Redis restart mất SETKhông cần làm gì — SET tự nạp lại lúc cần. Đỉnh query post_likes_user ngay sau restart là bình thường.

10. Case thực tế và cách xử lý

Case 1 — Giảng viên đăng thông báo trong khoá 8.000 người, DB CPU 100%

(Tình huống minh hoạ.) Đang ở Mức 1. 3.000 sinh viên like trong 2 phút, API like p95 lên 4 giây, các trang khác cũng chậm vì connection pool bị chiếm hết.

  1. pg_stat_activity: hàng chục session wait_event = transactionid cùng chạy UPDATE posts SET like_count với cùng id → xác nhận hot row.
  2. Chữa cháy ngay: tắt UPDATE counter trong request (feature flag), chỉ insert like → về Mức 2. Job đối soát chạy mỗi 5 giây cho bài đó.
  3. Sau sự cố: đánh giá có cần Mức 3 không, dựa trên tần suất (1 lần/học kỳ thì Mức 2 là đủ).

Case 2 — Sáng thứ Hai nhiều sinh viên báo "like tự mất"

  1. XINFO GROUPS: lag = 0, pending = 0 → worker không kẹt.
  2. INFO stats: evicted_keys tăng mạnh từ đêm Chủ nhật; INFO persistence bình thường. → Redis đầy RAM (job cache báo cáo chạy đêm), policy là allkeys-lru, stream bị xoá (bẫy 1).
  3. Không khôi phục được event đã mất. Dọn SET để UI khớp DB: SCAN MATCH forum:u:*:liked + UNLINK → mọi SET nạp lại từ DB.
  4. Sửa gốc: đổi policy sang volatile-lru, thêm alert evicted_keys, tách Redis cho cache báo cáo.

Case 3 — Worker restart liên tục, lag tăng dần

  1. Log worker: cùng lỗi FK / check constraint lặp lại với cùng stream id → poison batch (bẫy 8).
  2. XPENDING forum:like-events like-flusher - + 10 xem id và số lần giao (delivery count).
  3. XRANGE đúng id đó để lấy event hỏng, lưu ra log, XACK + XDEL nó. Worker chạy tiếp.
  4. Sửa code: SQL có JOIN lọc, và logic "retry quá 5 lần thì tách event ra xử lý từng cái".

11. Câu hỏi phụ interviewer hay hỏi tiếp

Câu hỏiTrả lời ngắn
Chỉ dùng Redis SET (SADD trả 0/1) để chặn like trùng được không, bỏ unique constraint?Không. Redis có thể mất dữ liệu (failover, eviction, flush nhầm). Mất SET → SADD trả 1 lần nữa → like trùng. Redis chỉ là lớp lọc sớm, PK mới là luật.
Sao không dùng SETNX like:p:1:u:7 mỗi cặp 1 key?Đo: 100.000 key like:p:…:u:… tốn 6,1 MB, tức ~61 byte/key → 10 triệu like ≈ 600 MB, trong khi SET theo user+course dạng listpack chỉ ~10 byte/member. Và không trả lời được "20 bài này tôi like cái nào" bằng 1 lệnh.
Sao không dùng Bloom filter?Bloom filter có false positive — sẽ nói "đã like" với bài chưa like, chặn nhầm người dùng. Không xoá được (unlike). Chỉ hợp với "chắc chắn chưa có" ở quy mô rất lớn.
Sao dùng Stream mà không dùng Laravel queue (dispatch job mỗi like)?Mỗi like 1 job thì worker vẫn làm N UPDATE lên row hot — không gom được. Stream đọc 500 event 1 lần, gộp theo bài. Dùng Laravel queue cũng được nếu job chỉ đánh dấu "bài bẩn" rồi tính lại theo lô (Mức 2).
Sao không dùng Pub/Sub?Pub/Sub không lưu: worker không nghe lúc đó là mất. Stream có PEL và đọc lại được.
Like xong unlike ngay trong 100 ms thì sao?2 event vào stream, worker gộp theo cặp, giữ thao tác cuối (unlike) → không ghi gì. Đã test với user 9.
Làm sao biết counter có lệch không?Job đối soát so like_count với count(*) từ post_likes. Số bài bị sửa là metric; khác 0 thường xuyên là có bug.
User bị rút khỏi khoá học thì like cũ?Giữ nguyên (dữ liệu lịch sử). Chặn like mới ở controller. SET cũ tự hết hạn sau 7 ngày, không cần xoá.
Xoá bài có 50.000 like?Soft delete. Không xoá cứng post_likes trong request — job xoá theo lô 5.000 dòng nếu thật sự cần.
Scale Redis thế nào?Standalone + replica + Sentinel đủ cho hàng trăm nghìn lệnh/giây. Cluster thì phải thêm hashtag theo course và stream theo course (bẫy 9) — chỉ làm khi 1 node không đủ RAM.
Hiển thị "Nam, Lan và 23 người khác đã like"?Query post_likes WHERE post_id=? ORDER BY created_at DESC LIMIT 2 (đi PK prefix) + like_count. Không cần Redis.

Tóm tắt — trả lời trong 60 giây

"Em tách làm 2 lớp. PostgreSQL giữ luật: bảng post_likes và comment_likes có primary key (target_id, user_id), ghi bằng INSERT ... ON CONFLICT DO NOTHING RETURNING và chỉ tăng like_count khi có dòng được insert thật. Em đã test: không có constraint thì 20 request đồng thời tạo 14 like trùng.

Redis làm nhanh: mỗi sinh viên mỗi khoá có một SET forum:u:{user}:c:{course}:liked chứa p:id, c:id — render trang bằng một lệnh SMISMEMBER. Khi like, một Lua script SADD và XADD vào stream cùng lúc. Một worker đọc 500 event, gộp theo cặp, ghi DB trong một transaction rồi mới ACK — chạy lại vẫn đúng vì counter đếm theo RETURNING.

Lý do cần Redis là hot row: đo được 80 TPS khi mọi like cùng UPDATE một bài. Nếu forum không có bài hot thì em bỏ phần Redis, làm đồng bộ là đủ. Đổi lại khi dùng Redis là có thể mất like dưới 1 giây khi Redis sự cố, và phải để stream ở Redis không bị eviction."