#12 Bước 7 — Module `post`: bài kiểm tra thật của kiến trúc Phần 2
// fail ánh xạ lỗi nghiệp vụ sang HTTP status.
//
// Bảng này sống TRONG module, không ở platform/httpx — platform không
// được biết domain.ErrPostNotFound là gì (bước 4 §14.3).
func (h *Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, domain.ErrInvalidInput):
httpx.Fail(w, r, http.StatusUnprocessableEntity, "invalid_input", err.Error())
case errors.Is(err, domain.ErrPostNotFound):
// Cũng dùng cho "bài của người khác" — xem §8.3.
httpx.Fail(w, r, http.StatusNotFound, "not_found", "không tìm thấy bài viết")
default:
// Log ĐẦY ĐỦ, trả về CHUNG CHUNG (bước 4 §5).
h.log.Error("post: lỗi không lường trước",
"err", err, "method", r.Method, "path", r.URL.Path)
httpx.Fail(w, r, http.StatusInternalServerError, "internal", "lỗi hệ thống")
}
}
func subject(r *http.Request) (uuid.UUID, bool) {
raw, ok := middleware.Subject(r.Context())
if !ok {
return uuid.Nil, false
}
id, err := uuid.Parse(raw)
return id, err == nil
}
func toResponse(p domain.Post) PostResponse {
return PostResponse{
ID: p.ID.String(), AuthorID: p.AuthorID.String(),
Title: p.Title, Slug: p.Slug, Body: p.Body,
ReadTime: p.ReadTime, CreatedAt: p.CreatedAt,
}
}
func toSummary(p domain.Post) PostSummary {
return PostSummary{
ID: p.ID.String(), AuthorID: p.AuthorID.String(),
Title: p.Title, Slug: p.Slug,
ReadTime: p.ReadTime, CreatedAt: p.CreatedAt,
}
}
9.3 Con trỏ phân trang: mã hoá, không phơi ra
// encodeCursor gói (created_at, id) thành một chuỗi đục cho client.
//
// base64 KHÔNG phải để bảo mật — nó dễ giải mã. Mục đích là làm cho
// client KHÔNG THỂ tự dựng con trỏ, nhờ đó ta còn đổi được cấu trúc
// bên trong (thêm cột sắp xếp, đổi kiểu) mà không phá vỡ ai.
//
// Phơi ra "?after_created_at=...&after_id=..." là biến chi tiết cài đặt
// của phân trang thành một phần hợp đồng công khai.
func encodeCursor(c repository.Cursor) string {
raw := c.CreatedAt.UTC().Format(time.RFC3339Nano) + "|" + c.ID.String()
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}
func decodeCursor(s string) (*repository.Cursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("con trỏ không phải base64: %w", err)
}
parts := strings.SplitN(string(raw), "|", 2)
if len(parts) != 2 {
return nil, errors.New("con trỏ sai định dạng")
}
at, err := time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
return nil, fmt.Errorf("con trỏ có thời gian sai: %w", err)
}
id, err := uuid.Parse(parts[1])
if err != nil {
return nil, fmt.Errorf("con trỏ có id sai: %w", err)
}
return &repository.Cursor{CreatedAt: at, ID: id}, nil
}
RFC3339Nano, không phảiRFC3339. Hai bài viết tạo trong cùng một micro-giây là chuyện thường khi có nhập liệu hàng loạt.RFC3339cắt mất phần nano, nên con trỏ trỏ vào một thời điểm sớm hơn dòng cuối thật — và trang sau lặp lại những dòng có cùng giây. Mất độ chính xác trong con trỏ phân trang không làm nó chậm, nó làm nó sai.
9.4 router.go
package http
import (
"github.com/go-chi/chi/v5"
"github.com/chuongtd/community/internal/platform/httpx/middleware"
)
// Routes gắn endpoint của module post.
//
// Đọc hàm này là thấy ngay cái gì cần đăng nhập và cái gì không.
func (h *Handler) Routes(r chi.Router, verifier middleware.Verifier) {
r.Route("/posts", func(r chi.Router) {
// ── Công khai ──────────────────────────────
r.Get("/", h.handleList)
r.Get("/{slug}", h.handleGetBySlug)
// ── Cần đăng nhập ──────────────────────────
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(verifier))
r.Post("/", h.handleCreate)
r.Delete("/{id}", h.handleDelete)
r.Post("/{id}/view", h.handleView)
})
})
}
⚠️
GET /posts/{slug}vàDELETE /posts/{id}— hai tham số khác nhau trên cùng đường dẫnĐọc bằng slug (URL đẹp, chia sẻ được), xoá bằng id (ổn định, không đổi khi sửa tiêu đề). Với
chi, hai phương thức khác nhau trên cùng mẫu đường dẫn là hợp lệ và không xung đột.Nhưng có một hệ quả:
DELETE /posts/xin-chao-viet-nam-3f2a1b8csẽ trả 400 với thông báo "id không phải UUID" thay vì làm điều người gọi mong đợi. Đây là bất đối xứng có chủ đích, và nếu API của bạn dành cho người ngoài thì nó đáng được ghi vào tài liệu API — hoặc chấp nhận cả hai dạng ởhandleDelete(thửuuid.Parse, thất bại thì tra theo slug). Tôi để nguyên vì nó tường minh; điều quan trọng là biết mình đang chọn.
10. post/module.go và lắp vào cmd/api
internal/modules/post/module.go:
package post
import (
"log/slog"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
posthttp "github.com/chuongtd/community/internal/modules/post/transport/http"
"github.com/chuongtd/community/internal/modules/post/repository"
"github.com/chuongtd/community/internal/modules/post/service"
"github.com/chuongtd/community/internal/platform/eventbus"
"github.com/chuongtd/community/internal/platform/httpx/middleware"
)
type Module struct {
api *posthttp.Handler
verifier middleware.Verifier
}
func New(
pool *pgxpool.Pool,
tx service.Transactor,
events service.EventWriter,
verifier middleware.Verifier,
log *slog.Logger,
) *Module {
repo := repository.NewPostgres(pool)
svc := service.New(tx, repo, events, log)
return &Module{
api: posthttp.NewHandler(svc, log),
verifier: verifier,
}
}
func (m *Module) RegisterHTTP(r chi.Router) {
m.api.Routes(r, m.verifier)
}
// RegisterEvents — module này chưa NGHE event nào.
//
// Hàm rỗng có mặt để `post` thoả cùng interface với các module khác trong
// vòng lặp lắp ráp của cmd/worker. Ngày nó cần nghe (ví dụ xoá bài khi
// user bị xoá), chỗ thêm vào đã có sẵn và không phải sửa main.go.
func (m *Module) RegisterEvents(_ eventbus.Subscriber) {}
Trong cmd/api/main.go — thêm một dòng:
identity.New(pool, txm, tokens, tokens, log).RegisterHTTP(r)
stats.New(pool, txm, idempotency.NewGuard(), log).RegisterHTTP(r)
post.New(pool, txm, outbox.NewWriter(), tokens, log).RegisterHTTP(r) // ← THÊM
outbox.NewWriter()xuất hiện lần thứ hai (lần đầu nằm trongidentity.New). Hai module dùng chung mộtWriterkhông sao — nó không có trạng thái, và mọi thao tác đều đi quatxmà chỗ gọi truyền vào. Đó là lý do bước 4 §9 khai nó làstruct{}rỗng thay vì giữ một pool bên trong.
11. ★ stats — nơi guard cuối cùng có việc làm
Ở bước 6 §5.3 tôi đã thừa nhận: EnsureUserStats là ON CONFLICT DO NOTHING nên tự an toàn, và guard ở đó chỉ để bảo vệ những việc thêm vào sau. Bây giờ là "sau" đó.
UPDATE user_stats SET posts_count = posts_count + 1 WHERE user_id = $1;
Chạy hai lần cho hai kết quả khác nhau. Theo phép thử ARCHITECTURE.md §8.4, đây là loại thao tác bắt buộc phải có guard. Không có nó, mỗi lần Kafka giao lại một message — chuyện sẽ xảy ra, không phải có thể — là một con số sai vĩnh viễn trên hồ sơ của người dùng.
11.1 Mở rộng interface Repository
Trong internal/modules/stats/subscriber/subscriber.go, thêm ba phương thức vào interface đã có:
type Repository interface {
EnsureUserStats(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error
// ── MỚI ở bước 7 ──
IncrementPostsCount(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error
DecrementPostsCount(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error
IncrementTotalPostViews(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error
}
Ba query tương ứng đã sinh sẵn từ bước 3 §9.5 — không cần chạy lại sqlc generate cho phần này. Chỉ cần thêm ba hàm bọc vào stats/repository/postgres.go, đúng khuôn EnsureUserStats đã có:
func (r *Postgres) IncrementPostsCount(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error {
if err := gen.New(tx).IncrementPostsCount(ctx, userID); err != nil {
return fmt.Errorf("stats: tăng posts_count: %w", err)
}
return nil
}
func (r *Postgres) DecrementPostsCount(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error {
if err := gen.New(tx).DecrementPostsCount(ctx, userID); err != nil {
return fmt.Errorf("stats: giảm posts_count: %w", err)
}
return nil
}
func (r *Postgres) IncrementTotalPostViews(ctx context.Context, tx pgx.Tx, userID uuid.UUID) error {
if err := gen.New(tx).IncrementTotalPostViews(ctx, userID); err != nil {
return fmt.Errorf("stats: tăng total_post_views: %w", err)
}
return nil
}
11.2 on_post_created.go
internal/modules/stats/subscriber/on_post_created.go:
package subscriber
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/chuongtd/community/internal/contracts"
v1 "github.com/chuongtd/community/internal/contracts/v1"
"github.com/chuongtd/community/internal/platform/eventbus"
)
// OnPostCreated tăng posts_count của tác giả.
//
// ★ Guard ở đây là BẮT BUỘC, không phải phòng xa: posts_count + 1 chạy
// hai lần cho hai kết quả khác nhau. Xem bước 7 §11.
func (s *Subscriber) OnPostCreated(ctx context.Context, e contracts.Envelope) error {
p, err := contracts.DecodePayload[v1.PostCreated](e)
if err != nil {
return eventbus.Permanent(err)
}
authorID, err := uuid.Parse(p.AuthorID)
if err != nil {
return eventbus.Permanent(
fmt.Errorf("stats: author_id không phải UUID (%q): %w", p.AuthorID, err))
}
return s.tx.Do(ctx, func(tx pgx.Tx) error {
ok, err := s.guard.Mark(ctx, tx, e.EventID, ConsumerName)
if err != nil {
return err
}
if !ok {
s.log.Debug("stats: bỏ qua post.created trùng", "event_id", e.EventID)
return nil
}
// ★ CÙNG transaction với Mark. Tách ra là mất một lượt đếm
// vĩnh viễn (bước 6 §3.2).
return s.repo.IncrementPostsCount(ctx, tx, authorID)
})
}
11.3 on_post_deleted.go
package subscriber
// OnPostDeleted giảm posts_count của tác giả.
func (s *Subscriber) OnPostDeleted(ctx context.Context, e contracts.Envelope) error {
p, err := contracts.DecodePayload[v1.PostDeleted](e)
if err != nil {
return eventbus.Permanent(err)
}
// author_id đến TỪ PAYLOAD, không tra database — dòng posts đã bị
// xoá trước khi event này rời khỏi outbox (§5.1).
authorID, err := uuid.Parse(p.AuthorID)
if err != nil {
return eventbus.Permanent(
fmt.Errorf("stats: author_id không phải UUID (%q): %w", p.AuthorID, err))
}
return s.tx.Do(ctx, func(tx pgx.Tx) error {
ok, err := s.guard.Mark(ctx, tx, e.EventID, ConsumerName)
if err != nil {
return err
}
if !ok {
s.log.Debug("stats: bỏ qua post.deleted trùng", "event_id", e.EventID)
return nil
}
// Query dùng GREATEST(posts_count - 1, 0) — lớp phòng thủ thứ hai
// bên cạnh guard (bước 3 §4.2).
return s.repo.DecrementPostsCount(ctx, tx, authorID)
})
}
Vì sao
GREATESTvẫn cần dù đã có guardGuard chặn cùng một event bị xử lý hai lần. Nó không chặn được hai event khác nhau cùng nói "hãy giảm đi 1" — ví dụ khi số liệu đã lệch sẵn từ một sự cố trước, hoặc khi ai đó phát lại event từ DLQ mà counter đã được tính lại bằng
RecalculatePostsCount.Hai lớp bảo vệ hai chuyện khác nhau: guard giữ tính đúng đắn của luồng bình thường,
GREATEST+CHECK (>= 0)giữ giới hạn an toàn khi luồng đã sai. Bỏ một trong hai đều để lại một lỗ.
11.4 on_post_viewed.go — cố ý không có guard
package subscriber
// OnPostViewed tăng total_post_views của tác giả.
//
// ⚠️ KHÔNG dùng guard, và đó là quyết định có chủ đích — đọc bước 7 §13
// TRƯỚC KHI "sửa lỗi" này. Tóm lại: một lượt xem đếm trùng là một sai số
// trong hàng triệu; một dòng consumed_events cho mỗi lượt xem là hàng
// triệu dòng.
func (s *Subscriber) OnPostViewed(ctx context.Context, e contracts.Envelope) error {
p, err := contracts.DecodePayload[v1.PostViewed](e)
if err != nil {
return eventbus.Permanent(err)
}
authorID, err := uuid.Parse(p.AuthorID)
if err != nil {
return eventbus.Permanent(
fmt.Errorf("stats: author_id không phải UUID (%q): %w", p.AuthorID, err))
}
// Không tx.Do, không guard — một câu UPDATE là đủ.
return s.repo.IncrementTotalPostViewsPool(ctx, authorID)
}
Cần thêm một hàm dùng pool thay vì tx vào stats/repository/postgres.go:
// IncrementTotalPostViewsPool tăng lượt xem NGOÀI transaction.
//
// Tên có hậu tố Pool để khi đọc code thấy ngay đây là ngoại lệ, không
// phải một chỗ ai đó quên truyền tx (bước 7 §13).
func (r *Postgres) IncrementTotalPostViewsPool(ctx context.Context, userID uuid.UUID) error {
if err := gen.New(r.pool).IncrementTotalPostViews(ctx, userID); err != nil {
return fmt.Errorf("stats: tăng total_post_views: %w", err)
}
return nil
}
11.5 module.go — đăng ký topic thứ hai
Sửa internal/modules/stats/module.go:
func (m *Module) RegisterEvents(bus eventbus.Subscriber) {
const group = subscriber.ConsumerName
bus.Subscribe(contracts.TopicIdentity, group,
eventbus.Route(map[string]eventbus.Handler{
v1.TypeUserRegistered: m.sub.OnUserRegistered,
}))
// ── MỚI ở bước 7 ──
bus.Subscribe(contracts.TopicPost, group,
eventbus.Route(map[string]eventbus.Handler{
v1.TypePostCreated: m.sub.OnPostCreated,
v1.TypePostDeleted: m.sub.OnPostDeleted,
v1.TypePostViewed: m.sub.OnPostViewed,
}))
}
Cùng một group, hai topic. Đây là chỗ thiết kế Consumer ở bước 6 §4.1 trả cổ tức: nó gom các đăng ký theo group, tạo một kgo.Client cho stats-service, và truyền cả hai topic vào ConsumeTopics. Không phải sửa một dòng nào trong consumer.go.
Và cmd/worker/main.go cũng không đổi — nó chỉ gọi RegisterEvents trên danh sách module.
12. Mở lại RecalculatePostsCount
Bước 3 §9.5 đã comment query này lại vì bảng posts chưa tồn tại. Giờ nó tồn tại — bỏ dấu -- ở đầu mỗi dòng:
-- name: RecalculatePostsCount :exec
-- Dùng khi số liệu lệch (ARCHITECTURE.md §15.3). Nguồn sự thật là bảng gốc.
UPDATE user_stats us
SET posts_count = (SELECT count(*) FROM posts WHERE author_id = us.user_id),
updated_at = now()
WHERE us.user_id = $1;
sqlc generate
Nếu sqlc chạy sạch, đó là một tín hiệu đáng chú ý: nó vừa kiểm chứng rằng bảng posts có đúng cột author_id mà query này cần — lúc sinh code, không phải lúc chạy. Đây chính là điều bước 3 nói trước sẽ xảy ra, và giờ nó xảy ra.
Thêm một endpoint quản trị để dùng khi cần:
// Trong stats/module.go — RegisterHTTP
// ⚠️ Endpoint này PHẢI được bảo vệ trước khi lên production. Nó hiện
// chưa có xác thực vì hệ thống chưa có khái niệm quyền quản trị.
r.Post("/admin/users/{id}/stats/recalculate", m.api.Recalculate)
⚠️ Ba điều phải biết trước khi tin vào
Recalculate
- Nó chỉ sửa được
posts_count.total_post_viewskhông có bảng gốc nào để đếm lại — không ai lưu từng lượt xem. Counter nào không tính lại được thì sai là sai vĩnh viễn, và đó là một lý do nữa để §13 chấp nhận sai số cho nó.- Nó tranh chấp với consumer. Nếu chạy đúng lúc có event
post.createdđang được xử lý, kết quả phụ thuộc thứ tự commit. Với một user thì rủi ro nhỏ; nếu chạy cho toàn bộ user, hãy dừng worker trước.- Nó không xoá
consumed_events. Cố ý: những event đã xử lý vẫn phải được coi là đã xử lý, nếu không lần giao lại tiếp theo sẽ cộng thêm lên con số vừa tính lại.
13. ⚠️ total_post_views là ngoại lệ — và vì sao
Đây là mục mà tôi khuyên đọc kỹ ngang với §6.2, vì nó nói một điều mà phần lớn tài liệu về event-driven không nói: áp cùng một bộ máy cho mọi event là một sai lầm về thiết kế, không phải một biểu hiện của tính nhất quán.
13.1 Ba con số
Giả sử nền tảng có 1.000 lượt xem mỗi giây — không phải quy mô lớn, một bài viết lên trang chủ Hacker News là đủ.
| Thứ | Mỗi lượt xem | Mỗi ngày |
|---|---|---|
Dòng outbox |
1 | 86,4 triệu |
| Message Kafka | 1 | 86,4 triệu |
Dòng consumed_events (nếu dùng guard) |
1 | 86,4 triệu × 30 ngày giữ |
UPDATE user_stats |
1 | 86,4 triệu |
Dòng thứ ba là dòng giết thiết kế. Với retention 30 ngày (bước 6 §7), bảng consumed_events sẽ giữ 2,6 tỷ dòng — chỉ để đảm bảo rằng một con số hiển thị "1.234.567 lượt xem" không bị lệch thành "1.234.568".
13.2 Phép thử đúng cho từng counter
Câu hỏi không phải "thao tác này có idempotent không" mà là hai câu hỏi ghép lại:
| Counter | Đếm sai 1 đơn vị có ai thấy? | Số event mỗi ngày | Cần guard? |
|---|---|---|---|
posts_count |
Có. Hồ sơ hiện "4 bài viết" nhưng chỉ có 3 — người dùng đếm được bằng mắt | ~hàng nghìn | Có |
followers_count |
Có. Cùng lý do | ~hàng nghìn | Có |
total_post_views |
Không. Không ai đếm tay được 1,2 triệu lượt xem | ~hàng triệu | Không |
Nguyên tắc: guard đáng dùng khi cái giá của một con số sai lớn hơn cái giá của việc lưu một dòng cho mỗi event. Với posts_count, một con số sai là một lỗi người dùng nhìn thấy và báo lại. Với total_post_views, một con số sai là nhiễu dưới mức cảm nhận, còn cái giá lưu trữ là hàng tỷ dòng.
13.3 Nhưng bước này vẫn đi qua outbox — và đó vẫn là vấn đề
Bỏ guard giải quyết bảng consumed_events. Nó không giải quyết 86 triệu dòng outbox mỗi ngày.
Tôi vẫn để RecordView ghi outbox ở bước này, có chủ đích: nó giữ mẫu code thống nhất để bạn thấy rõ chỗ nào cần đổi, thay vì phải tưởng tượng. Nhưng đây là món nợ có thật, và ba đường nâng cấp theo thứ tự công sức tăng dần:
| Cách | Cơ chế | Đánh đổi |
|---|---|---|
| Gộp trong bộ nhớ | api đếm trong map[postID]int, mỗi 10 giây phát một event mang số cộng dồn |
Đơn giản nhất. Mất số liệu của 10 giây cuối nếu process chết — chấp nhận được với lượt xem |
| Bỏ qua outbox | api publish thẳng Kafka, không transaction |
Nhanh, nhưng phá Luật 3. Chỉ đúng vì lượt xem không có dữ liệu nghiệp vụ đi kèm cần nhất quán |
| Counter ở Redis | INCR mỗi lượt xem, một job đẩy về Postgres định kỳ |
Nhanh nhất, thêm một thành phần hạ tầng vào vận hành |
Cách thứ nhất thường là đủ, và nó đáng chú ý vì đổi event PostViewed thành PostViewsBatched{PostID, AuthorID, Count} — tức là đổi hạt của event chứ không chỉ đổi cách vận chuyển. Đó là bài học nhỏ đi kèm: khi một event quá nhiều, đôi khi câu trả lời là định nghĩa lại nó, không phải tối ưu đường đi của nó.
Vì sao không làm ngay bây giờ
Vì
ARCHITECTURE.md§11.4 đã có chỉ số để biết khi nào cần: số dòng outbox chưa publish. Nếu con số đó tăng đều mà không có sự cố Kafka,PostViewedlà nghi phạm đầu tiên và bạn sẽ thấy nó trongSELECT event_type, count(*) FROM outbox GROUP BY 1.Tối ưu trước khi có số đo là đoán. Nhưng biết trước mình sẽ phải tối ưu chỗ nào thì không phải đoán — đó là lý do mục này tồn tại thay vì để bạn tự phát hiện lúc đĩa đầy.
14. Chạy thử đầu-cuối
14.1 Chuẩn bị
migrate -path db/migrations -database "postgres://app:secret@localhost:5432/community?sslmode=disable" up
sqlc generate
go build ./...
.\dev.ps1 up
.\dev.ps1 topics
Bốn terminal: .\dev.ps1 api, .\dev.ps1 relay, .\dev.ps1 worker, và một terminal gọi HTTP.
Terminal worker phải in hai dòng nhận partition — một cho mỗi topic:
{"level":"INFO","msg":"kafka: consumer bắt đầu","group":"stats-service","topics":["identity","post"]}
{"level":"INFO","msg":"kafka: nhận partition","group":"stats-service","parts":{"identity":[0,1,2],"post":[0,1,2]}}
14.2 Đăng nhập lấy token
$body = @{ email="hoangnh6@example.com"; password="matkhau-rat-dai-va-an-toan" } | ConvertTo-Json
$auth = Invoke-RestMethod -Uri http://localhost:8000/auth/login -Method Post `
-ContentType "application/json; charset=utf-8" -Body ([Text.Encoding]::UTF8.GetBytes($body))
$headers = @{ Authorization = "Bearer $($auth.token)"; "X-Request-ID" = "thu-nghiem-post-001" }
$userId = $auth.user.id
14.3 ★ Tạo bài viết có đ trong tiêu đề
Chọn tiêu đề này có mục đích — nó là bài kiểm tra §6.2 ngay trên hệ thống thật:
$body = @{
title = "Đường đến với Golang: hướng dẫn cho người mới"
body = "Go là một ngôn ngữ đơn giản. " * 60
} | ConvertTo-Json
$post = Invoke-RestMethod -Uri http://localhost:8000/posts -Method Post `
-ContentType "application/json; charset=utf-8" `
-Body ([Text.Encoding]::UTF8.GetBytes($body)) -Headers $headers
$post | Format-List slug, read_time, id
slug : duong-den-voi-golang-huong-dan-cho-nguoi-moi-a3f21b8c
read_time : 2
id : a3f21b8c-...
Kiểm hai chỗ:
- Slug bắt đầu bằng
duong-den, không phảiuong-en. Bước 1 củaslugBodyđang làm việc. read_time = 2— 360 từ ÷ 200 = 1,8 → làm tròn lên 2.
14.4 Số liệu tự cập nhật qua ba tiến trình
Relay đẩy event trong ~1 giây, worker xử lý ngay sau đó:
Start-Sleep -Seconds 2
Invoke-RestMethod -Uri "http://localhost:8000/users/$userId/stats" | Format-List posts_count, total_post_views
posts_count : 1
total_post_views : 0
posts_count vừa tăng từ 0 lên 1 mà không một dòng code nào trong module post biết đến bảng user_stats. Đó là toàn bộ điều bảy bài tài liệu này xây tới.
14.5 Xoá bài và xem counter giảm
Invoke-RestMethod -Uri "http://localhost:8000/posts/$($post.id)" -Method Delete -Headers $headers
Start-Sleep -Seconds 2
Invoke-RestMethod -Uri "http://localhost:8000/users/$userId/stats" | Format-List posts_count
posts_count : 0
Và kiểm rằng event xoá mang theo author_id — thứ mà không có nó thì handler đã không làm được gì (§5.1):
docker compose -f deployments/docker-compose.yml exec postgres psql -U app -d community -c "SELECT event_type, payload FROM outbox WHERE event_type = 'post.post.deleted.v1' ORDER BY id DESC LIMIT 1;"
Và xác nhận bài viết thật sự đã biến mất khỏi bảng posts — tức là consumer đã không thể tra ngược dù muốn:
docker compose -f deployments/docker-compose.yml exec postgres psql -U app -d community -c "SELECT count(*) FROM posts;"
14.6 Kiểm lại ba lời hứa của kiến trúc
Đây là phần đánh giá cả bước, và nó chạy bằng trình biên dịch chứ không bằng cảm nhận:
# 1. Ba module không import nhau.
"identity->post"; go list -deps ./internal/modules/identity/... | Select-String "modules/post"
"post->identity"; go list -deps ./internal/modules/post/... | Select-String "modules/identity"
"post->stats"; go list -deps ./internal/modules/post/... | Select-String "modules/stats"
"stats->post"; go list -deps ./internal/modules/stats/... | Select-String "modules/post"
# 2. platform/ không biết gì về nghiệp vụ.
Select-String -Path internal/platform/*.go, internal/platform/**/*.go -Pattern "modules/" -SimpleMatch
Cả năm lệnh không ra dòng nào. Bốn module hoàn toàn không biết nhau, hạ tầng không biết nghiệp vụ, và dữ liệu vẫn chảy được — qua contracts, thứ mà mọi module đều phụ thuộc và không ai sở hữu.
# 3. Thêm module không sửa hạ tầng: xem chính diff của bạn.
git diff --stat HEAD -- internal/platform/ cmd/relay/ cmd/worker/
Phải ra rỗng. Nếu không rỗng, đọc lại §1.2 để phân loại: tiện ích kỹ thuật thật thì được, bất cứ gì biết chữ "post" thì không.
15. Ba bài diễn tập sự cố
15.1 ★★ Giao trùng với một counter — bài quan trọng nhất của cả bảy bài
Bài diễn tập ở bước 6 §9.1 chưa chứng minh được guard cứu gì, vì EnsureUserStats tự an toàn. Bài này chứng minh.
Bước 1 — dựng trạng thái đã biết. Tạo ba bài viết rồi ghi lại con số:
1..3 | ForEach-Object {
$b = @{ title = "Bài viết thử nghiệm số $_"; body = "Nội dung ngắn." } | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:8000/posts -Method Post `
-ContentType "application/json; charset=utf-8" `
-Body ([Text.Encoding]::UTF8.GetBytes($b)) -Headers $headers | Out-Null
}
Start-Sleep -Seconds 3
Invoke-RestMethod -Uri "http://localhost:8000/users/$userId/stats" | Select-Object posts_count
posts_count : 3
Bước 2 — buộc Kafka giao lại toàn bộ. Dừng worker (Ctrl+C) rồi:
docker compose -f deployments/docker-compose.yml exec kafka /opt/kafka/bin/kafka-consumer-groups.sh `
--bootstrap-server localhost:9092 --group stats-service `
--reset-offsets --to-earliest --all-topics --execute
Bước 3 — chạy lại worker với LOG_LEVEL=debug:
$env:LOG_LEVEL = "debug"; go run ./cmd/worker
Log đầy dòng bỏ qua:
{"level":"DEBUG","msg":"stats: bỏ qua post.created trùng","event_id":"..."}
Bước 4 — con số không đổi:
Invoke-RestMethod -Uri "http://localhost:8000/users/$userId/stats" | Select-Object posts_count
posts_count : 3
Bước 5 — và giờ xem hậu quả khi không có guard. Đây là nửa quan trọng nhất, vì nó cho bạn thấy cái gì đã được ngăn chặn.
Trong on_post_created.go, tạm vô hiệu hoá guard:
ok, err := s.guard.Mark(ctx, tx, e.EventID, ConsumerName)
if err != nil {
return err
}
_ = ok // ← TẠM THỜI: bỏ qua kết quả guard
// if !ok { ... return nil }
Đặt lại số liệu, reset offset, chạy lại:
docker compose -f deployments/docker-compose.yml exec postgres psql -U app -d community -c "
UPDATE user_stats SET posts_count = 3 WHERE user_id = '$userId';
DELETE FROM consumed_events WHERE consumer = 'stats-service';"
docker compose -f deployments/docker-compose.yml exec kafka /opt/kafka/bin/kafka-consumer-groups.sh `
--bootstrap-server localhost:9092 --group stats-service `
--reset-offsets --to-earliest --all-topics --execute
go run ./cmd/worker
Invoke-RestMethod -Uri "http://localhost:8000/users/$userId/stats" | Select-Object posts_count
posts_count : 6 ← ★ SAI. Ba bài viết, sáu lần đếm.
Đây là con số mà guard đã ngăn. Ba điều đáng ghi nhận về nó:
- Không có lỗi nào xảy ra. Không exception, không log ERROR, không cảnh báo. Mọi tiến trình đều báo thành công.
- Không có cách nào phát hiện tự động. Chỉ có người dùng nhìn thấy "6 bài viết" trên hồ sơ trong khi họ chỉ viết 3.
- Sai vĩnh viễn. Con số đó ở lại tới khi có người chạy
RecalculatePostsCount— và để chạy nó, phải có ai đó biết là nó sai.
Nhớ hoàn nguyên đoạn code, chạy lại RecalculatePostsCount (hoặc UPDATE ... SET posts_count = 3), và đưa LOG_LEVEL về info.
Nếu chỉ làm một bài diễn tập trong toàn bộ bảy tài liệu, hãy làm bài này. Nó là lý do bảng
consumed_eventstồn tại, và nó biến một câu lý thuyết được nhắc năm lần thành một con số bạn thấy bằng mắt.
15.2 Slug với tiêu đề chỉ có emoji
$b = @{ title = "🎉🎉🎉"; body = "Ăn mừng." } | ConvertTo-Json
$p = Invoke-RestMethod -Uri http://localhost:8000/posts -Method Post `
-ContentType "application/json; charset=utf-8" `
-Body ([Text.Encoding]::UTF8.GetBytes($b)) -Headers $headers
$p.slug
bai-viet-7c4e1a92
Không phải -7c4e1a92. Bước 5 của slugBody đang làm việc — và URL vẫn mở được:
Invoke-RestMethod -Uri "http://localhost:8000/posts/$($p.slug)" | Select-Object title
Đáng thử thêm hai tiêu đề nữa, vì cả hai đều cho slug rỗng ở bước 3: "---" và "中文标题".
15.3 Phân trang trong lúc có bài mới được thêm
Bài này chứng minh vì sao Phụ lục A chọn keyset.
# Tạo 25 bài để có nhiều hơn một trang.
1..25 | ForEach-Object {
$b = @{ title = "Bài số $_"; body = "Nội dung $_." } | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:8000/posts -Method Post `
-ContentType "application/json; charset=utf-8" `
-Body ([Text.Encoding]::UTF8.GetBytes($b)) -Headers $headers | Out-Null
}
# Trang 1.
$p1 = Invoke-RestMethod -Uri "http://localhost:8000/posts?size=10"
$p1.items.Count; $p1.next_cursor
# Chen một bài MỚI vào giữa hai lần gọi — đây là tình huống gây lỗi
# cho phân trang OFFSET.
$b = @{ title = "Bài chen ngang"; body = "Xuất hiện giữa hai trang." } | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:8000/posts -Method Post `
-ContentType "application/json; charset=utf-8" `
-Body ([Text.Encoding]::UTF8.GetBytes($b)) -Headers $headers | Out-Null
# Trang 2.
$p2 = Invoke-RestMethod -Uri "http://localhost:8000/posts?size=10&after=$($p1.next_cursor)"
# Không có bài nào xuất hiện ở CẢ HAI trang.
$trung = Compare-Object $p1.items.id $p2.items.id -IncludeEqual -ExcludeDifferent
"So bai trung: $($trung.Count)"
So bai trung: 0
Với OFFSET 10, bài chen ngang đẩy mọi thứ xuống một bậc và bài thứ 10 của trang 1 xuất hiện lại ở đầu trang 2. Keyset không có vấn đề đó vì con trỏ trỏ vào một hàng cụ thể, không phải một vị trí đếm được.
16. Kiểm chứng bằng test
16.1 slug — test quan trọng nhất của bước này
internal/modules/post/domain/slug_test.go:
package domain
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// ★ Test này khoá lại cạm bẫy §6.2.1. Nếu ai đó gỡ vietnameseSpecial
// vì "NFD đã xử lý hết dấu rồi", đúng test này đỏ.
func TestSlugXuLyChuDTiengViet(t *testing.T) {
cases := []struct{ title, want string }{
{"Đường đến với Golang", "duong-den-voi-golang"},
{"đ Đ đơn giản", "d-d-don-gian"},
{"Đọc kỹ hướng dẫn", "doc-ky-huong-dan"},
}
for _, c := range cases {
require.Equal(t, c.want, slugBody(c.title),
"chữ đ phải thành d, KHÔNG được bị xoá (xem docs/07 §6.2.1)")
}
}
func TestSlugBoDauTiengViet(t *testing.T) {
cases := []struct{ title, want string }{
{"Xin chào Việt Nam", "xin-chao-viet-nam"},
{"Nguyễn Huy Hoàng viết blog", "nguyen-huy-hoang-viet-blog"},
{"Tại sao PostgreSQL tuyệt vời?", "tai-sao-postgresql-tuyet-voi"},
{"Học Go với nhiều khoảng trắng", "hoc-go-voi-nhieu-khoang-trang"},
{"C++ vs Rust: 100% khách quan", "c-vs-rust-100-khach-quan"},
}
for _, c := range cases {
require.Equal(t, c.want, slugBody(c.title))
}
}
// Tiêu đề không còn ký tự ASCII nào sau khi lọc.
func TestSlugTieuDeKhongCoChuLatin(t *testing.T) {
for _, title := range []string{"🎉🎉🎉", "中文标题", "---", " ", ""} {
require.Equal(t, "bai-viet", slugBody(title),
"slug rỗng phải có giá trị thay thế, nếu không URL thành \"-abc12345\"")
}
}
func TestSlugKhongVuotDoDaiCot(t *testing.T) {
id := uuid.New()
// 400 từ, mỗi từ 10 ký tự.
long := strings.TrimSpace(strings.Repeat("chuyenmuc ", 400))
s := Slugify(long, id)
require.LessOrEqual(t, len(s), 255, "phải vừa VARCHAR(255)")
require.False(t, strings.HasSuffix(slugBody(long), "-"), "không kết thúc bằng gạch nối")
// Cắt ở ranh giới từ, không chẻ đôi một từ.
require.True(t, strings.HasSuffix(slugBody(long), "chuyenmuc"))
}
func TestSlugLuonCoHauToVaKhongTrung(t *testing.T) {
a := Slugify("Học Go từ đầu", uuid.New())
b := Slugify("Học Go từ đầu", uuid.New())
require.NotEqual(t, a, b, "cùng tiêu đề, khác id ⇒ phải khác slug")
require.True(t, strings.HasPrefix(a, "hoc-go-tu-dau-"))
require.Len(t, strings.TrimPrefix(a, "hoc-go-tu-dau-"), 8)
}
func TestReadTimeLamTronLen(t *testing.T) {
require.Equal(t, 1, ReadTime(""), "bài rỗng vẫn phải là 1, vì CHECK (read_time >= 1)")
require.Equal(t, 1, ReadTime("ba tu thoi"))
require.Equal(t, 1, ReadTime(words(200)))
require.Equal(t, 2, ReadTime(words(201)), "201 từ là 2 phút, không phải 1")
require.Equal(t, 5, ReadTime(words(1000)))
}
func words(n int) string { return strings.TrimSpace(strings.Repeat("tu ", n)) }
TestSlugXuLyChuDTiengVietlà test tôi khuyên đừng bao giờ xoá. Lỗi nó canh giữ có ba tính chất khiến nó cực khó phát hiện bằng cách khác: nó chỉ xảy ra với một ký tự trong bảng chữ cái, nó không gây lỗi nào, và nó qua được mọi test viết bằng ví dụ không cóđ. Xem lại bảng ở §6.2.1: dòngXin chào Việt Namra kết quả đúng trong cả hai cột.
16.2 Service — không cần database
Dùng cùng khuôn fake ở bước 4 §17.3:
func TestTaoBaiVietGhiCaPostVaOutbox(t *testing.T) {
repo := &fakeRepo{}
events := &fakeEvents{}
svc := service.New(fakeTx{}, repo, events, testLogger())
post, err := svc.Create(context.Background(), domain.Draft{
AuthorID: uuid.New(),
Title: "Đường đến với Golang",
Body: "Nội dung bài viết.",
})
require.NoError(t, err)
require.Len(t, repo.inserted, 1)
require.Len(t, events.written, 1, "phải phát đúng một event")
e := events.written[0]
require.Equal(t, contracts.TopicPost, e.topic)
require.Equal(t, v1.TypePostCreated, e.env.EventType)
require.Equal(t, post.ID.String(), e.env.AggregateID,
"AggregateID phải là post_id — nó quyết định partition key")
}
// ★ Test này canh giữ §5.1 — thiếu AuthorID là handler stats không làm
// được việc, và phát hiện muộn thì phải lên v2.
func TestEventXoaBaiPhaiMangAuthorID(t *testing.T) {
authorID := uuid.New()
repo := &fakeRepo{deleted: domain.Post{ID: uuid.New(), AuthorID: authorID}}
events := &fakeEvents{}
svc := service.New(fakeTx{}, repo, events, testLogger())
require.NoError(t, svc.Delete(context.Background(), repo.deleted.ID, authorID))
require.Len(t, events.written, 1)
p, err := contracts.DecodePayload[v1.PostDeleted](events.written[0].env)
require.NoError(t, err)
require.Equal(t, authorID.String(), p.AuthorID,
"consumer KHÔNG tra được author_id sau khi dòng posts bị xoá (docs/07 §5.1)")
}
func TestXoaBaiCuaNguoiKhacTraErrPostNotFound(t *testing.T) {
repo := &fakeRepo{deleteErr: domain.ErrPostNotFound}
events := &fakeEvents{}
svc := service.New(fakeTx{}, repo, events, testLogger())
err := svc.Delete(context.Background(), uuid.New(), uuid.New())
require.ErrorIs(t, err, domain.ErrPostNotFound,
"không được có lỗi riêng cho 'không phải bài của bạn' — xem §8.3")
require.Empty(t, events.written, "xoá thất bại thì KHÔNG phát event")
}
func TestTieuDeToanKhoangTrangBiChan(t *testing.T) {
svc := service.New(fakeTx{}, &fakeRepo{}, &fakeEvents{}, testLogger())
_, err := svc.Create(context.Background(), domain.Draft{
AuthorID: uuid.New(), Title: " ", Body: "Nội dung.",
})
require.ErrorIs(t, err, domain.ErrInvalidInput)
}
16.3 Consumer — chứng minh guard cứu counter
Test này là bản tự động của bài diễn tập §15.1:
func TestPostCreatedTrungChiTangCounterMotLan(t *testing.T) {
repo := &fakeStatsRepo{}
sub := subscriber.New(fakeTx{}, &fakeGuard{}, repo, testLogger())
authorID := uuid.New()
env := mustEnvelope(t, v1.TypePostCreated, uuid.NewString(), v1.PostCreated{
PostID: uuid.NewString(), AuthorID: authorID.String(), Title: "x", Slug: "x",
})
require.NoError(t, sub.OnPostCreated(context.Background(), env))
require.NoError(t, sub.OnPostCreated(context.Background(), env)) // giao lại
require.Equal(t, 1, repo.incrementPostsCalls,
"posts_count + 1 chạy hai lần là một con số sai vĩnh viễn")
}
// Hai event KHÁC NHAU phải được đếm cả hai — guard không được chặn quá tay.
func TestHaiBaiVietKhacNhauDeuDuocDem(t *testing.T) {
repo := &fakeStatsRepo{}
sub := subscriber.New(fakeTx{}, &fakeGuard{}, repo, testLogger())
authorID := uuid.New()
for i := 0; i < 2; i++ {
env := mustEnvelope(t, v1.TypePostCreated, uuid.NewString(), v1.PostCreated{
PostID: uuid.NewString(), AuthorID: authorID.String(),
})
require.NoError(t, sub.OnPostCreated(context.Background(), env))
}
require.Equal(t, 2, repo.incrementPostsCalls)
}
Test thứ hai quan trọng ngang test thứ nhất. Một guard chặn mọi thứ cũng "vượt" test thứ nhất — ví dụ nếu ai đó lỡ dùng
aggregate_idlàm khoá thay vìevent_id, thì hai event khác nhau của cùng một bài viết sẽ bị coi là trùng. Cặp test này khoá cả hai chiều: đúng cái trùng thì chặn, đúng cái khác thì cho qua.
16.4 Chạy
go test ./internal/... -race -count=1
17. Những thứ cố ý chưa có
| Chưa có | Vì sao | Sẽ làm ở |
|---|---|---|
Sửa bài (PUT /posts/{id}) |
Cần quyết định: sửa tiêu đề có đổi slug không? Đổi thì hỏng link cũ, không đổi thì slug lệch tiêu đề. Câu trả lời thường là không đổi + bảng post_slugs lưu slug cũ để redirect — một thiết kế riêng, không nên nhét vào bước này |
Khi cần |
| Tag / chuyên mục | Bảng post_tags nhiều-nhiều + following_tags_count trong user_stats. Là một module nhỏ riêng |
Bước 8 |
| Bookmark, follow, upvote | Đó là module social, phát lên topic social đã tạo từ bước 1. bookmarks_count, followers_count chờ ở đó |
Bước 8 |
Bỏ khối code khi tính read_time |
Cần một trình phân tích Markdown (§6.3) — một phụ thuộc đáng cân nhắc riêng | Khi cần |
Gộp PostViewed theo lô |
Chưa có số đo cho thấy outbox là nút thắt. §13.3 có sẵn ba đường | Khi chỉ số outbox tăng |
| Search (full-text) | Phụ lục B bàn hai nhánh thiết kế | Khi cần |
Xác thực cho endpoint /admin/... |
Hệ thống chưa có khái niệm quyền quản trị. Phải làm trước khi lên production (§12) | Trước khi triển khai |
18. Checklist hoàn thành
- [ ]
migrate upsạch;\d postscóidx_posts_createdvàidx_posts_author_created - [ ]
sqlc generatesạch, kể cảRecalculatePostsCountvừa mở lại - [ ]
go build ./...vàgo vet ./...sạch - [ ]
go test ./internal/... -race -count=1xanh - [ ]
TestSlugXuLyChuDTiengVietxanh - [ ] Tạo bài
"Đường đến với Golang"→ slug bắt đầuduong-den, không phảiuong-en - [ ] Tiêu đề
"🎉🎉🎉"→ slug làbai-viet-xxxxxxxx, URL mở được - [ ]
read_timecủa bài 360 từ = 2 - [ ] Worker in
topics: ["identity","post"]và nhận partition của cả hai - [ ] Tạo bài →
posts_counttăng 1 trong vòng ~2 giây - [ ] Xoá bài →
posts_countgiảm 1 - [ ] Payload
post.post.deleted.v1cóauthor_id - [ ] Diễn tập §15.1: reset offset →
posts_countkhông đổi - [ ] Diễn tập §15.1 bước 5: gỡ guard →
posts_countnhân đôi; đã hoàn nguyên code - [ ] Diễn tập §15.3: không bài nào ở cả hai trang
- [ ]
go list -deps— bốn module không import nhau - [ ]
git diff --stat HEAD -- internal/platform/ cmd/relay/ cmd/worker/— rỗng
Dòng cuối là dòng đánh giá cả bước. Nếu nó rỗng, kiến trúc đã làm đúng việc nó hứa.
Commit:
git add .
git commit -m "feat(post): module bài viết, slug tiếng Việt, và stats đếm qua event"
Phụ lục A — Phân trang: keyset chứ không OFFSET
OFFSET là cách ai cũng viết đầu tiên, và nó sai theo hai cách khác nhau. Cách thứ hai là lỗi đúng nghĩa, không phải chuyện hiệu năng.
Vấn đề 1 — chậm dần theo số trang
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
PostgreSQL phải đọc và bỏ đi 10.000 dòng trước khi trả về 20 dòng. Không có index nào giúp được, vì OFFSET là "đếm rồi bỏ", không phải "tìm".
| Trang | OFFSET |
Keyset |
|---|---|---|
| 1 | 2 ms | 2 ms |
| 50 | 30 ms | 2 ms |
| 500 | 400 ms | 2 ms |
| 5000 | 4 s | 2 ms |
Keyset không đổi vì nó luôn là một Index Scan bắt đầu từ một điểm cụ thể.
Vấn đề 2 — ⚠️ lặp và bỏ sót dòng khi dữ liệu đang thay đổi
Đây là vấn đề nghiêm trọng hơn, và nó là lỗi đúng đắn, không phải hiệu năng.
Trang 1: OFFSET 0 LIMIT 3 → [E, D, C] (mới nhất trước)
← một bài MỚI (F) được tạo ngay lúc này
Trang 2: OFFSET 3 LIMIT 3 → [C, B, A]
↑ C xuất hiện LẦN THỨ HAI
Bài F chen vào đầu danh sách, đẩy mọi thứ xuống một bậc, nên vị trí thứ 4 giờ là C — dòng mà người dùng đã thấy ở trang 1. Với hướng xoá thì ngược lại: một bài bị xoá làm một dòng bị bỏ qua hoàn toàn và không ai bao giờ thấy nó.
Trên một nền tảng có bài viết mới liên tục, đây không phải trường hợp hiếm — đó là hành vi thường xuyên. Người dùng thấy bài trùng khi cuộn, và không ai báo lỗi vì nó trông giống một lỗi giao diện.
Keyset không có vấn đề đó vì con trỏ trỏ vào một hàng cụ thể: "cho tôi các bài cũ hơn (đúng bài này)". Bài mới xuất hiện ở trên không ảnh hưởng gì.
Vì sao phải là so sánh hàng
-- ✅ ĐÚNG: so sánh hàng
WHERE (created_at, id) < ($1, $2)
-- ❌ SAI: bỏ sót dòng
WHERE created_at < $1
-- ❌ SAI: cú pháp đúng, logic sai
WHERE created_at < $1 OR (created_at = $1 AND id < $2) -- ← thật ra đúng, nhưng xem dưới
Cách thứ hai bỏ mất mọi dòng có cùng created_at với dòng cuối trang trước. Khi nhập liệu hàng loạt, chuyện nhiều bài cùng một micro-giây là bình thường — và những bài đó biến mất khỏi mọi trang.
Cách thứ ba đúng về mặt logic và tương đương cách thứ nhất, nhưng dài hơn và planner khó dùng index hơn với biểu thức OR. So sánh hàng (a, b) < (x, y) là cú pháp SQL chuẩn cho đúng phép so sánh từ điển này, và PostgreSQL khớp nó trực tiếp với index nhiều cột.
Đó cũng là lý do id phải có mặt trong cả ORDER BY và con trỏ: created_at một mình không phải khoá duy nhất, và phân trang keyset đòi hỏi một thứ tự toàn phần. Thiếu id, thứ tự giữa các dòng cùng thời điểm là không xác định, và con trỏ không trỏ được vào đâu cả.
Cái giá của keyset
Nói cho công bằng, keyset không miễn phí:
| Mất | Chi tiết |
|---|---|
| Nhảy tới trang N | Không có "trang 47". Chỉ có "trang sau". Với cuộn vô hạn thì không thành vấn đề; với bảng dữ liệu có số trang thì phải đổi giao diện |
| Tổng số trang | Cần một count(*) riêng, và trên bảng lớn thì nó cũng đắt |
| Đi ngược | Cần một query đối xứng với > và ORDER BY ASC, rồi đảo kết quả trong Go |
Với dòng thời gian bài viết — luôn cuộn xuống, luôn mới nhất trước — không mất gì cả. Đó là lý do mọi mạng xã hội dùng keyset.
Phụ lục B — Ngày cần một search indexer
§5.2 bỏ Body khỏi payload. Ngày bạn thêm tìm kiếm full-text, consumer mới sẽ cần Body, và đó là một ngã ba thiết kế thật.
Nhánh 1 — thêm Body vào payload |
Nhánh 2 — indexer gọi API đọc lại | |
|---|---|---|
| Tên gọi | Event-carried state transfer | Query-back / claim check |
| Kích thước message | 50 KB × mọi bài viết, mọi consumer | Nhỏ như hiện tại |
| Indexer có phụ thuộc runtime? | Không — tự chứa | Có — cần post API còn sống |
| Xử lý lại được lịch sử? | Được — event có đủ dữ liệu | Chỉ với bài còn tồn tại. Bài đã xoá thì mất |
| Vi phạm Luật 1? | Không | Không, nếu gọi qua HTTP API. Có, nếu import post/repository |
Nhánh 2 có một cái bẫy đáng biết trước: nó hoạt động tốt cho tới lần đầu bạn cần dựng lại index từ đầu. Lúc đó indexer phát lại 7 ngày event, gọi API cho từng bài, và nhận 404 cho mọi bài đã bị xoá trong khoảng đó — index dựng lại sẽ thiếu, mà không có lỗi nào chỉ ra tại sao.
Cách thường được chọn trong thực tế là nhánh 3: một topic riêng.
post ← event nhỏ, nhiều consumer (PostCreated không có Body)
post.content ← event lớn, ít consumer (PostContent CÓ Body)
stats chỉ nghe post; indexer nghe cả hai. Consumer nào cần dữ liệu lớn thì trả giá cho dữ liệu lớn, những consumer khác không phải trả. retention.ms của hai topic cũng đặt khác nhau được — topic nội dung giữ lâu hơn để còn dựng lại index.
Điều đáng rút ra: khi một event bị kéo về hai hướng — nhỏ để rẻ, lớn để tự chứa — câu trả lời thường không phải chọn một bên mà là tách thành hai event. Cùng bài học với PostViewed ở §13.3, nơi câu trả lời cũng là định nghĩa lại event chứ không phải tối ưu đường đi.
Bước tiếp theo
Bảy bài đã dựng xong một modular monolith event-driven hoàn chỉnh, và bước này vừa chứng minh nó chịu được việc thêm module.
Bước 8 — module social là bước lặp lại mẫu này lần thứ ba, và nó có hai thứ mới đáng làm:
- Quan hệ nhiều-nhiều (
follows,bookmarks,post_tags) — khoá chính tổ hợp, và câu hỏi "ai sở hữu bảng này khi nó nối hai module?" - Một event, hai counter:
social.user.followed.v1phải tăngfollowers_countcủa người được theo dõi vàfollowing_users_countcủa người đi theo dõi — haiUPDATEtrên hai dòng khác nhau, trong cùng một transaction, dưới cùng một guard
Cái thứ hai là bài kiểm tra tiếp theo của guard: một event mà handler làm hai việc, nên "xử lý một nửa" trở thành một trạng thái có thể xảy ra nếu transaction bị tách. Bạn đã có sẵn mọi thứ cần để làm đúng.
Ngoài ra, nếu muốn đổi hướng thay vì thêm module, hai việc này giờ đã đủ chín để làm:
| Việc | Vì sao bây giờ mới hợp lý |
|---|---|
| Dockerfile + docker-compose cho cả ba binary | Đã biết chính xác mỗi tiến trình cần biến môi trường nào (bước 5 §8) |
CI: go test + hai lệnh go list -deps của §14.6 |
Ranh giới module giờ đã có bốn module để mà kiểm. Kiểm tự động là cách duy nhất giữ nó không rò dần theo thời gian |
All Rights Reserved