Phần 4 – Thực chiến | Chương trước: Chủ đề Principal | Chương sau: So sánh khách quan

Chương này ghép mọi mảnh của 24 chương trước thành một hệ thống hoàn chỉnh: dịch vụ đặt hàng orderly — REST API nhận đơn hàng, lưu PostgreSQL, cache Redis, phát sự kiện Kafka cho worker xử lý. Cấu trúc chương: hiện trạng “trước refactor” → kiến trúc đích → code đầy đủ → hạ tầng → pipeline → nhật ký refactor từng bước với lý do.


1. Điểm xuất phát: orderly phiên bản “tiền 12-Factor”

Hệ thống thật mà ta sẽ refactor — mọi vấn đề đều lấy từ các chương trước, giờ tập trung trong một chỗ:

// main.go — PHIÊN BẢN CŨ (đừng bắt chước)
package main

const dbDSN = "postgres://app:Prod@Pass99@10.0.3.17:5432/orders" // ① F3: secret trong code

var orderCache = map[string]*Order{}   // ② F6: cache không TTL, không giới hạn — state + memory leak
var cacheMu sync.Mutex

func main() {
	db, _ := sql.Open("postgres", dbDSN)
	runMigrations(db)                   // ③ F5/F12: N instance đua migration lúc khởi động

	f, _ := os.OpenFile("/var/log/orderly.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	log.SetOutput(f)                    // ④ F11: log ra file local

	http.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
		// ... tạo order, lưu DB ...
		go sendConfirmationEmail(order) // ⑤ F8/F9: job nền trong process web — deploy là mất email
		go exportToAccounting(order)    //    và không retry, không dấu vết
	})

	c := cron.New()
	c.AddFunc("0 3 * * *", cleanupExpired) // ⑥ F6/F12: in-process cron — scale là chạy N lần
	c.Start()

	http.ListenAndServe(":8080", nil)   // ⑦ F7/F9: port cứng; không graceful shutdown; không probe
}

Deploy hiện tại: build trên máy dev, scp binary lên một VM, chạy trong screen. Mỗi triệu chứng vận hành mà team đang chịu đều truy được về một con số ở trên: deploy nào cũng rơi vài request (⑦), muốn thêm máy thứ hai thì cron chạy đôi (⑥) và cache lệch (②), sự cố nửa đêm phải SSH đọc file log (④), bí mật lộ khi share code cho đối tác audit (①).

2. Kiến trúc đích

                        ┌─────────────── Kubernetes cluster ───────────────┐
  Internet ──▶ Ingress ─▶ Service ─▶ [web ×3..30 HPA]  ──▶ PostgreSQL (RDS)
                                        │        │     ──▶ Redis (ElastiCache)
                                        │ publish│
                                        ▼        │
                                     Kafka (MSK) │
                                        │        │
                                   [worker ×2..20 KEDA] ──▶ email, accounting...
                                                  │
              CronJob cleanup ────────────────────┘   Job migrate (release step)
              (mọi process type: CÙNG một image, khác args)
orderly/
├── go.mod / go.sum
├── cmd/app/main.go                    # server | worker | migrate | cleanup-expired
├── internal/
│   ├── config/config.go               # F3
│   ├── logging/logging.go             # F11 (slog JSON + trace_id)
│   ├── telemetry/otel.go              # ch22
│   ├── server/ (router, health, middleware)
│   ├── service/order.go               # business logic — chỉ interface
│   ├── repository/postgres/           # F4
│   ├── cache/redis.go                 # F4
│   ├── events/kafka.go                # F4 — producer + consumer
│   └── migrate/sql/                   # F12 — embed
├── deploy/
│   ├── Dockerfile
│   └── k8s/ (base + overlays)         # ch20, F10
├── docker-compose.yml                 # F10
└── .github/workflows/ci.yml           # ch21

3. Code — các mảnh ghép chính

(Các pattern nền — khung run(), health, middleware, DI — đã có đầy đủ ở chương 18; ở đây chỉ hiện phần đặc thù của bài toán.)

3.1. Config — bản đồ mọi backing service (F3, F4)

// internal/config/config.go
type Config struct {
	Port            string        `env:"PORT" envDefault:"8080"`
	LogLevel        string        `env:"LOG_LEVEL" envDefault:"info"`
	DrainDelay      time.Duration `env:"DRAIN_DELAY" envDefault:"5s"`
	ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`

	DatabaseURL  string   `env:"DATABASE_URL,required"`
	RedisURL     string   `env:"REDIS_URL,required"`
	KafkaBrokers []string `env:"KAFKA_BROKERS,required"`

	CacheTTL          time.Duration `env:"CACHE_TTL" envDefault:"5m"`
	WorkerConcurrency int           `env:"WORKER_CONCURRENCY" envDefault:"8"`
}

3.2. Service — nghiệp vụ với transactional outbox

Điểm thiết kế đáng giá nhất của case study. Bài toán: lưu order vào Postgres phát event Kafka — hai hệ thống, không có transaction chung. Ghi DB xong mới publish? Crash giữa chừng (F9 nói: sẽ xảy ra) → order có mà event mất, hệ thống downstream không bao giờ biết. Publish trước? Event có mà order fail. Giải pháp chuẩn ngành: outbox pattern — event ghi vào bảng outbox trong cùng transaction với order; một relay đọc outbox và đẩy sang Kafka, đánh dấu đã gửi. Crash ở bất kỳ điểm nào cũng không mất event (chỉ có thể gửi trùng → consumer idempotent, vốn đã là yêu cầu của F9).

// internal/service/order.go (trích)
func (s *OrderService) Create(ctx context.Context, req CreateOrderRequest) (*Order, error) {
	if err := req.Validate(); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalid, err)
	}
	order := NewOrder(req)

	// Order + event: MỘT transaction — nguyên tử, chịu được crash bất kỳ lúc nào
	err := s.repo.CreateWithOutbox(ctx, order, OutboxEvent{
		Topic:   "orders.created",
		Key:     order.ID,
		Payload: mustJSON(OrderCreatedEvent{ID: order.ID, Amount: order.Amount}),
	})
	if err != nil {
		return nil, fmt.Errorf("create order: %w", err)
	}
	s.cache.Invalidate(ctx, order.UserID) // cache lỗi không fail request — chỉ log
	return order, nil
}
// internal/repository/postgres/order.go (trích)
func (r *OrderRepo) CreateWithOutbox(ctx context.Context, o *service.Order, ev service.OutboxEvent) error {
	tx, err := r.pool.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)

	if _, err := tx.Exec(ctx,
		`INSERT INTO orders (id, user_id, amount, status, created_at)
		 VALUES ($1,$2,$3,$4,$5)`,
		o.ID, o.UserID, o.Amount, o.Status, o.CreatedAt); err != nil {
		return err
	}
	if _, err := tx.Exec(ctx,
		`INSERT INTO outbox (topic, key, payload) VALUES ($1,$2,$3)`,
		ev.Topic, ev.Key, ev.Payload); err != nil {
		return err
	}
	return tx.Commit(ctx)
}

3.3. Cache Redis — cache-aside có kỷ luật (F4, F6)

// internal/cache/redis.go (trích) — sửa lỗi ② của bản cũ:
// TTL bắt buộc, tập trung (mọi instance nhìn cùng cache), fail-open
func (c *Cache) GetOrder(ctx context.Context, id string) (*service.Order, bool) {
	b, err := c.rdb.Get(ctx, "order:"+id).Bytes()
	if err != nil {
		return nil, false // Redis chết → miss → đọc DB: cache là TỐI ƯU, không phải sự thật
	}
	var o service.Order
	if json.Unmarshal(b, &o) != nil {
		return nil, false
	}
	return &o, true
}

func (c *Cache) SetOrder(ctx context.Context, o *service.Order) {
	b, _ := json.Marshal(o)
	if err := c.rdb.Set(ctx, "order:"+o.ID, b, c.ttl).Err(); err != nil {
		c.log.Warn("cache set failed", "err", err) // không bao giờ fail request vì cache
	}
}

3.4. Worker — consumer Kafka idempotent (F8, F9)

// cmd/app: case "worker" → events.RunConsumer (trích)
func (c *Consumer) handle(ctx context.Context, msg kafka.Message) error {
	var ev OrderCreatedEvent
	if err := json.Unmarshal(msg.Value, &ev); err != nil {
		c.log.Error("poison message", "offset", msg.Offset, "err", err)
		return nil // commit bỏ qua + đẩy DLQ — đừng chặn partition vì một message hỏng
	}
	// Idempotency: outbox có thể gửi trùng, Kafka giao at-least-once —
	// khóa duy nhất chặn xử lý lặp
	done, err := c.markProcessed(ctx, ev.ID) // INSERT ... ON CONFLICT DO NOTHING
	if err != nil {
		return err // lỗi hạ tầng → không commit → thử lại
	}
	if !done {
		return nil // đã xử lý rồi — bỏ qua êm
	}
	return c.sendConfirmationEmail(ctx, ev) // sửa lỗi ⑤: có retry, có dấu vết, sống sót qua deploy
}

3.5. Migration SQL

-- internal/migrate/sql/0001_init.up.sql
CREATE TABLE orders (
    id         TEXT PRIMARY KEY,
    user_id    TEXT NOT NULL,
    amount     BIGINT NOT NULL CHECK (amount > 0),
    status     TEXT NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_user ON orders (user_id, created_at DESC);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    topic      TEXT NOT NULL,
    key        TEXT NOT NULL,
    payload    JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    sent_at    TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unsent ON outbox (id) WHERE sent_at IS NULL;

CREATE TABLE processed_events (          -- idempotency của worker
    event_id     TEXT PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

4. Hạ tầng

4.1. docker-compose — toàn hệ trên laptop trong một lệnh (F10)

services:
  app:
    build: .
    command: ["server"]
    ports: ["8080:8080"]
    environment: &appenv
      DATABASE_URL: postgres://app:dev@db:5432/orderly?sslmode=disable
      REDIS_URL: redis://redis:6379/0
      KAFKA_BROKERS: kafka:9092
      LOG_LEVEL: debug
    depends_on:
      db: { condition: service_healthy }
      kafka: { condition: service_started }
  worker:
    build: .
    command: ["worker"]
    environment: *appenv
    depends_on: [app]
  migrate:
    build: .
    command: ["migrate"]
    environment: *appenv
    depends_on:
      db: { condition: service_healthy }
  db:
    image: postgres:17.4-alpine        # đúng version RDS (F10)
    environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: dev, POSTGRES_DB: orderly }
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U app"], interval: 2s, retries: 15 }
  redis:
    image: redis:7.2-alpine
  kafka:
    image: apache/kafka:3.9.0          # KRaft — không cần ZooKeeper
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      # ... (cấu hình single-node chuẩn)

docker compose up → dev mới có toàn bộ hệ thống chạy trong 5 phút — chính là thước đo parity (F10) và onboarding tốt nhất.

4.2. Kubernetes & Dockerfile

Dockerfile: đúng bản chương 19 (multi-stage, distroless, exec form, CMD ["server"]). K8s: web Deployment + HPA như chương 20; worker Deployment + KEDA ScaledObject theo Kafka lag (chương 13); migrate là Job release-step (chương 10); cleanup-expired là CronJob concurrencyPolicy: Forbid (chương 17) — sửa dứt điểm lỗi ⑥. Tất cả trỏ cùng một image digest, khác args.

4.3. Pipeline

Đúng khung chương 21: verify (test với Postgres/Redis/Kafka service containers) → build/push/sign theo SHA → staging tự động → production sau phê duyệt, qua repo GitOps + Argo Rollouts canary (chương 23).

5. Nhật ký refactor — thứ tự và lý do

Bài học lớn nhất của case study không phải code đích, mà là thứ tự đường đi — refactor thật không được dừng hệ thống, và mỗi bước phải tự đứng vững, giao giá trị ngay:

BướcViệcSửa lỗiVì sao ở vị trí này
1Config ra env + slog ra stdout①④Rẻ nhất, không đổi hành vi, mở khóa mọi bước sau; secret lộ là rủi ro đang chảy máu — cầm máu trước. (Kèm rotate toàn bộ credential đã từng vào Git!)
2Dockerfile + compose + CI verify/buildF2/F5/F10Có artifact bất biến và môi trường tái tạo được rồi mới dám sửa tiếp — lưới an toàn cho các bước rủi ro hơn
3Graceful shutdown + health probesNhỏ (100 dòng), chấm dứt rơi request mỗi deploy — quick win thấy được ngay, mua niềm tin cho dự án refactor
4Migration tách thành subcommand + JobChuẩn bị điều kiện chạy nhiều instance
5Cache local → RedisĐiều kiện chạy nhiều instance (cùng bước 4 mở khóa scale out)
6go func email/export → outbox + Kafka + workerNặng nhất, đụng nghiệp vụ — làm khi đã có CI, môi trường parity và kinh nghiệm từ 5 bước trước; bật song song với đường cũ, so sánh, rồi cắt
7In-process cron → CronJobSau khi image đa vai đã tồn tại (bước 4) thì gần như miễn phí
8Lên K8s + HPA/KEDA + GitOps + canaryChỉ bây giờ — vì app đã giữ hợp đồng; lên K8s trước bước 3–5 là mang mọi bệnh cũ lên nền tảng mới với chi phí nhân đôi

Điểm đáng suy ngẫm cuối: bước 1–5 chiếm ~20% công sức nhưng giao ~80% giá trị vận hành (không lộ secret, không rơi request, log tập trung, scale được). Nếu tổ chức chỉ cho bạn một quý — làm 5 bước đầu, phần còn lại để quý sau. 12-Factor là một cái thang, không phải một cánh cổng.


Tóm tắt

  • Case study hội tụ mọi chương: config/env, attached resources, outbox pattern cho ranh giới DB–Kafka, consumer idempotent, cache fail-open, một image bốn vai (server/worker/migrate/cleanup), compose parity, pipeline ký + canary.
  • Mẫu thiết kế đáng mang theo: transactional outbox (sự kiện không bao giờ mất, chỉ có thể trùng) + idempotent consumer (trùng thì vô hại) — cặp đôi chịu được mọi kiểu chết của F9.
  • Refactor theo thang giá trị: cầm máu (secret, log) → lưới an toàn (artifact, CI, parity) → quick win (shutdown, probe) → mở khóa scale (Redis, migration) → nghiệp vụ (queue) → nền tảng (K8s). Không nhảy cóc.