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):
- "Không like 2 lần" là việc của database, không phải của Redis. Bảng
post_likescó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.- Redis làm 2 việc: (a) trả lời nhanh "tôi đã like bài nào" bằng một
SETtheo user + khoá học; (b) làm write buffer (bộ đệm ghi) — Lua scriptSADD+XADDvà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 UPDATElike_counttranh nhau cùng 1 row — đo được 80 TPS, latency 398 ms, so với 1464 TPS khi không update counter.- 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 RETURNINGlà đủ, 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 KEY | Rà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 row | Mộ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 buffer | Ghi nhanh vào bộ nhớ trước, ghi xuống DB sau theo lô (batch). |
| Redis Stream | Kiể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. |
| idempotent | Chạy lại nhiều lần cho cùng kết quả như chạy một lần. |
| listpack / hashtable | Hai 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 / hashtag | Redis Cluster chia key vào 16384 slot. Phần trong {…} của tên key quyết định slot — gọi là hashtag. |
| eviction | Redis đầy RAM thì tự xoá key theo maxmemory-policy. |
| keyset pagination | Phâ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ầu | Ai chịu trách nhiệm | Vì sao |
|---|---|---|
| Không like trùng | PostgreSQL (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ài | PostgreSQL (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 xem | Redis 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ài | Redis Stream + worker | Gom N like thành 1 UPDATE counter mỗi batch. |
| Post, comment, reply | PostgreSQL | Tầ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 key | Có — không thể like vào id không tồn tại | Không có — FK không trỏ được tới 2 bảng |
| Kích thước khoá | 2 bigint | thê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ảng | Thêm giá trị type |
| Laravel | 2 relation thường | morphMany 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, vd0001.0007.0042) hoặc extensionltree. 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ản | TPS | Latency TB |
|---|---|---|
| Insert like + UPDATE counter, cùng 1 bài (bài hot) | 80 | 398 ms |
| Insert like + UPDATE counter, rải trên 10.000 bài | 700 | 46 ms |
| Chỉ insert like vào 1 bài, không UPDATE counter | 1464 | 22 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
| Key | Kiểu | Nội dung | TTL | Ai ghi |
|---|---|---|---|---|
forum:u:{userId}:c:{courseId}:liked | SET | Member p:{postId}, c:{commentId}, và _ (sentinel) | 7 ngày, gia hạn mỗi lần like/unlike | Lua toggle, Lua warm |
forum:like-events | STREAM | op, target, user, course | Không TTL | Lua toggle; worker XACK + XDEL |
(group) like-flusher | consumer group | Con 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:
| Key | Số member | Encoding | MEMORY USAGE |
|---|---|---|---|
| Theo user + course | 100 (p:100001…) | listpack | 1.031 byte |
| Theo user + course | 150 | hashtable | 7.228 byte |
| Theo post (bài hot) | 50.000 user id | hashtable | 2.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 post | SET theo user + course (chọn) |
|---|---|---|
| Kích thước key | Không giới hạn — bài hot thành big key, DEL/hết hạn làm Redis đứng | Bị 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ài | 20 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ẵn | Khô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
- Phải bọc
HINCRBYtrongif EXISTS(Lua). Đã test cách này: counter 500 → 501 đúng. - Nhưng khi nạp lại counter từ DB lúc stream còn event chưa flush, Redis thiếu đúng số event đó cho tới khi hết TTL. Phải để TTL ngắn (5–10 phút) để tự lành.
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ẫy | Chuyện gì xảy ra (đã test) | Cách chữa |
|---|---|---|---|
| 1 | Redis 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:20776Toà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. |
| 2 | SET 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 -> _. |
| 3 | Nạ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. |
| 4 | SET 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. |
| 5 | Lua 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. |
| 6 | Tin 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. |
| 7 | Chạ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. |
| 8 | Batch 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 constraintCả 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. |
| 9 | Redis 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. |
| 10 | Redis 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. |
| 11 | Prefix 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). |
| 12 | XACK 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
- Luật "không like trùng" không phụ thuộc Redis. Redis mất, sai, bị xoá — DB vẫn đúng.
- Bài hot không làm nghẽn DB: N like trên 1 bài = 1 UPDATE mỗi batch thay vì N UPDATE tranh row lock (80 TPS → giới hạn bởi Redis, hàng chục nghìn lệnh/giây).
- API like trả về dưới 1 ms phía Redis, không mở transaction DB trong request.
- Render trạng thái "đã like" cho cả trang = 1 lệnh
SMISMEMBER. - Worker idempotent nên crash ở bất kỳ bước nào đều chạy lại được.
7. Tác hại / hạn chế
- Thêm 1 hệ thống phải vận hành: Redis có persistence, worker dưới Supervisor, alert lag.
- Eventual consistency (nhất quán sau một lúc):
like_counttrễ ~1 giây; khi worker chết thì trễ tới khi nó sống lại. - Có thể mất like khi Redis chết (≤1 giây với AOF everysec) — người dùng thấy like biến mất.
- Hai nguồn trạng thái (SET và DB) có thể lệch trong các trường hợp bẫy 3, 4, 6. Lệch chỉ ở UI, không ở dữ liệu thật.
- Code nhiều hơn hẳn: 2 Lua script, worker, SQL batch, xử lý poison batch — so với 1 câu SQL ở mức đồng bộ.
8. Trade-off — chọn mức nào
| Mức 1: Đồng bộ, chỉ DB | Mức 2: Insert đồng bộ, counter bất đồng bộ | Mức 3: Redis write-behind (bài này) | |
|---|---|---|---|
| Cách làm | INSERT ON CONFLICT RETURNING + UPDATE like_count trong 1 transaction | Chỉ insert like trong request. Job mỗi vài giây tính lại like_count cho bài vừa có like | Lua SET + Stream, worker gom batch |
| Không like trùng | PK | PK | PK (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ông | Không | Có (≤1 giây) |
| Số hệ thống | 1 | 1 + scheduler | 2 + worker |
| Chọn khi | Khô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ệc | Làm gì |
|---|---|
| Theo dõi worker | XINFO 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 config | appendonly yes, appendfsync everysec, maxmemory-policy volatile-lru (hoặc Redis riêng + noeviction). Alert evicted_keys. |
| Deploy worker | Supervisor 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 counter | Job đố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 SET | Khô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.
pg_stat_activity: hàng chục sessionwait_event = transactionidcùng chạyUPDATE posts SET like_countvới cùngid→ xác nhận hot row.- 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 đó.
- 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"
XINFO GROUPS:lag= 0,pending= 0 → worker không kẹt.INFO stats:evicted_keystăng mạnh từ đêm Chủ nhật;INFO persistencebì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).- 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. - Sửa gốc: đổi policy sang
volatile-lru, thêm alertevicted_keys, tách Redis cho cache báo cáo.
Case 3 — Worker restart liên tục, lag tăng dần
- Log worker: cùng lỗi FK / check constraint lặp lại với cùng stream id → poison batch (bẫy 8).
XPENDING forum:like-events like-flusher - + 10xem id và số lần giao (delivery count).XRANGEđúng id đó để lấy event hỏng, lưu ra log,XACK+XDELnó. Worker chạy tiếp.- 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ỏi | Trả 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_likesvàcomment_likescó primary key(target_id, user_id), ghi bằngINSERT ... ON CONFLICT DO NOTHING RETURNINGvà chỉ tănglike_countkhi 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}:likedchứap:id,c:id— render trang bằng một lệnhSMISMEMBER. Khi like, một Lua scriptSADDvàXADDvà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 theoRETURNING.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."