0

#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ải RFC3339. 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. RFC3339 cắ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}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-3f2a1b8c sẽ 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 trong identity.New). Hai module dùng chung một Writer không sao — nó không có trạng thái, và mọi thao tác đều đi qua tx mà 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: EnsureUserStatsON 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.5khô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 GREATEST vẫn cần dù đã có guard

Guard 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ế Consumerbướ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.

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

  1. Nó chỉ sửa được posts_count. total_post_views khô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ó.
  2. 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.
  3. 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
followers_count Có. Cùng lý do ~hàng nghìn
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ờ

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, PostViewed là nghi phạm đầu tiên và bạn sẽ thấy nó trong SELECT 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ải uong-en. Bước 1 của slugBody đ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ó:

  1. 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.
  2. 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.
  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_events tồ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: "---""中文标题".

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)) }

TestSlugXuLyChuDTiengViet là 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òng Xin chào Việt Nam ra 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_id là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 up sạch; \d postsidx_posts_createdidx_posts_author_created
  • [ ] sqlc generate sạch, kể cả RecalculatePostsCount vừa mở lại
  • [ ] go build ./...go vet ./... sạch
  • [ ] go test ./internal/... -race -count=1 xanh
  • [ ] TestSlugXuLyChuDTiengViet xanh
  • [ ] Tạo bài "Đường đến với Golang" → slug bắt đầu duong-den, không phải uong-en
  • [ ] Tiêu đề "🎉🎉🎉" → slug là bai-viet-xxxxxxxx, URL mở được
  • [ ] read_time của bài 360 từ = 2
  • [ ] Worker in topics: ["identity","post"] và nhận partition của cả hai
  • [ ] Tạo bài → posts_count tăng 1 trong vòng ~2 giây
  • [ ] Xoá bài → posts_count giảm 1
  • [ ] Payload post.post.deleted.v1 author_id
  • [ ] Diễn tập §15.1: reset offset → posts_count không đổi
  • [ ] Diễn tập §15.1 bước 5: gỡ guard → posts_count nhâ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 >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ầ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. , 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.v1 phải tăng followers_count của người được theo dõi following_users_count của người đi theo dõi — hai UPDATE trê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

Viblo
Let's register a Viblo Account to get more interesting posts.