System Design cho Advanced Beginners: Giải phẫu hạ tầng từ monolith đơn giản đến hệ thống quy mô lớn

System Design cho Advanced Beginners: Giải phẫu hạ tầng từ monolith đơn giản đến hệ thống quy mô lớn

Mục lục

Bạn đã từng viết vài cái website nhỏ. Bạn biết dựng một CRUD app bằng Django, Laravel hay Gin trong một buổi chiều. Nhưng rồi bạn nhìn vào kiến trúc của một nền tảng thật như Stripe, GitHub hay một sàn thương mại điện tử, và tự hỏi: rốt cuộc phía sau có gì mà chúng nó chạy được cho hàng chục triệu người dùng?

Khoảng cách giữa “tôi biết viết web” và “tôi biết thiết kế hệ thống” không phải là một bước nhảy về kỹ năng code. Nó là một bước nhảy về mental model: hiểu được các thành phần độc lập ghép lại với nhau như thế nào, chúng hỏng ra sao, và mỗi lựa chọn kiến trúc đánh đổi cái gì.

Bài viết này dựa trên essay “Systems Design for Advanced Beginners” của Robert Heaton, nhưng mở rộng theo góc nhìn của một kỹ sư backend làm việc hằng ngày với Go, PostgreSQL và các hệ thống phân tán. Tôi sẽ dùng một ví dụ xuyên suốt - một sàn rao vặt tên Steveslist - để đi từ tầng giao tiếp ngoại vi, qua webhook, database, search engine, cho đến pub/sub và data warehouse.

Tổng quan bài toán: khi ứng dụng vượt khỏi quy mô pet project

Giả sử Steveslist đã thành công. Sau 5 năm, nó có hai sản phẩm hướng người dùng là web app và mobile app, cộng thêm một API công khai cho các lập trình viên bên thứ ba xây power-tool (ví dụ tạo hàng trăm listing bằng script).

graph TD
    subgraph "Client Layer"
        Web["Web Browser (SPA)"]
        Mobile["Smartphone App (iOS/Android)"]
        SDK["Client Libraries / Scripts"]
    end

    subgraph "Edge Layer"
        LB["Load Balancer / API Gateway"]
    end

    subgraph "Application Layer"
        API["Steveslist API Servers (Stateless)"]
    end

    Web -->|HTTPS| LB
    Mobile -->|HTTPS| LB
    SDK -->|HTTPS + API Key| LB
    LB --> API

Đằng sau tầng application đó là một loạt hệ thống con mà hầu hết người dùng không bao giờ nhìn thấy:

  • Webhooks - chủ động đẩy thông báo khi có sự kiện (ví dụ “đã bán được hàng”) cho người dùng.
  • Xác thực mật khẩu - đăng nhập an toàn.
  • SQL database - nguồn dữ liệu chính, cần scale và độ tin cậy cao.
  • Free-text search - ô tìm kiếm cho phép gõ “TV cũ” hay “xe máy”.
  • Internal tools - công cụ quản trị nội bộ.
  • Cron jobs - tác vụ định kỳ (xuất hoá đơn, đồng bộ dữ liệu).
  • Pub/Sub - xử lý bất đồng bộ khi có sự kiện.
  • Big data analytics - chạy truy vấn khổng lồ trên toàn bộ dữ liệu.

Hãy làm rõ một khái niệm trước khi đi tiếp: server thực chất là gì? Với mục đích của bài này, một server là một máy tính chạy trên mạng, lắng nghe các kết nối từ máy tính khác, thực thi một hành động và thường trả về dữ liệu. Web server lắng nghe HTTP request; database server lắng nghe query và đọc/ghi dữ liệu. Định nghĩa này bỏ qua hàng tá chi tiết, nhưng đủ để đi hết bài.

Tầng giao tiếp ngoại vi: từ SPA đến REST/JSON API

Single-Page App và cái bẫy “single”

Web app của Steveslist là một Single-Page App (SPA). Chữ “single” nghĩa là trình duyệt gần như không bao giờ reload toàn bộ trang khi người dùng click.

Trình duyệt thực hiện HTTP request đầu tiên, server trả về một trang HTML khung xương rỗng nghĩa (skeleton) và một bundle JavaScript lớn. JavaScript này chạy trong trình duyệt, cập nhật giao diện theo thao tác người dùng. Khi cần dữ liệu, nó gửi AJAX request nền tới một URL và cập nhật view từ response.

sequenceDiagram
    autonumber
    participant B as "User's Browser"
    participant S as "Steveslist Servers"

    B->>S: GET / (initial HTTP request)
    S-->>B: Skeleton HTML + JS file references
    B->>S: GET /static/app.bundle.js
    S-->>B: JavaScript bundle (~2MB)
    Note over B: JS boots, router mounts
    B->>S: GET /api/v1/listings (AJAX, JSON)
    S-->>B: JSON payload
    Note over B: UI updates in place, no full reload

SPA rất tốn công build và maintain - bạn phải quản lý routing phía client, state management, cache, và giờ là cả server-side rendering. Nhưng trải nghiệm người dùng mượt, và đó là lý do gần như mọi sản phẩm consumer lớn đều theo mô hình này.

REST/JSON API và tái sử dụng endpoint

Mobile app (iOS/Android) về mặt kiến trúc giống hệt web app: cũng gửi HTTP request, server xử lý và trả HTTP response, client cập nhật UI. Vì mobile app thực hiện cùng nghiệp vụ (tạo listing, gửi tin nhắn), chúng gửi request tới đúng những URL giống web app. Bạn chỉ cần viết thêm phần frontend native, còn backend dùng chung.

Các lập trình viên bên thứ ba tương tác qua API endpoints. Ví dụ để lấy danh sách listing:

GET https://api.steveslist.com/v1/listings

Server trả về JSON - một format có cấu trúc, dễ parse bằng bất kỳ ngôn ngữ nào:

{
  "listings": [
    {
      "id": 2178123867,
      "name": "TV cũ",
      "country": "VN",
      "city": "Hà Nội",
      "price_amount": 1000,
      "price_currency": "usd"
    }
  ]
}

Xác thực API: API key như một “mật khẩu lập trình”

Người dùng tự định danh (authenticate) với API bằng API key - một chuỗi dài, ngẫu nhiên, về bản chất là “mật khẩu” nhưng dành cho code. API key được hiển thị ở trang Settings và gắn vào HTTP header trong mọi request. Khi nhận request, server kiểm tra key có khớp với user nào không, nếu có thì thực thi nghiệp vụ thay mặt user đó.

  • Go
  • Python
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
)

type Listing struct {
	Name          string `json:"name"`
	Country       string `json:"country"`
	City          string `json:"city"`
	PriceAmount   int    `json:"price_amount"`
	PriceCurrency string `json:"price_currency"`
}

func createListing(apiKey string) error {
	body, _ := json.Marshal(Listing{
		Name:          "TV cũ",
		Country:       "VN",
		City:          "Hà Nội",
		PriceAmount:   1000,
		PriceCurrency: "usd",
	})

	req, _ := http.NewRequest("POST",
		"https://api.steveslist.com/v1/listings",
		bytes.NewReader(body))
	// API key travels in a custom header, never in the URL query string.
	req.Header.Set("X-Steveslist-API-Key", apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	fmt.Println("status:", resp.StatusCode)
	return nil
}
import requests

url = "https://api.steveslist.com/v1/listings"
payload = {
    "name": "TV cũ",
    "country": "VN",
    "city": "Hà Nội",
    "price_amount": 1000,
    "price_currency": "usd",
}
api_key = "YOUR_API_KEY_GOES_HERE"

resp = requests.post(
    url,
    json=payload,
    # Custom header keeps the key out of logs, browser history and proxies.
    headers={"X-Steveslist-API-Key": api_key},
    timeout=10,
)
print(resp.status_code, resp.json())

Cuối cùng là client library - thư viện “bọc” API để lập trình viên không cần biết chi tiết HTTP. Họ viết steveslist.Listing.create(...), thư viện tự dựng HTTP request đúng chuẩn. Đây là cách phổ biến nhất người ta dùng API, và cũng là lý do bạn nên viết SDK cho mọi ngôn ngữ bạn có thể nghĩ tới.

Webhook: đẩy sự kiện thay vì bắt client poll

Push vs Pull

Giả sử một seller muốn tự động hoá hoàn toàn: mỗi khi có đơn, gửi email cảm ơn và lệnh cho kho xuất hàng. Cách ngây thơ là seller poll API liên tục: “Có đơn mới chưa? Có đơn mới chưa?”. Cách này cực kém hiệu quả - nó tạo hàng loạt request rỗng và đè nặng lên server.

Giải pháp chuẩn ngành là webhook: một HTTP request mà chúng ta gửi tới server của người dùng mỗi khi có sự kiện. Người dùng đăng ký một URL, và dựng một web server để hứng các thông báo đó.

sequenceDiagram
    autonumber
    actor Buyer as "Buyer"
    participant SL as "Steveslist Server"
    participant WS as "Seller's Webhook Server"

    Buyer->>SL: POST /checkout (mua hàng)
    Note over SL: Giao dịch hoàn tất,
tra cứu webhook URL của seller SL->>WS: POST /hooks (JSON + chữ ký HMAC) Note over WS: Xác minh chữ ký,
xử lý đơn hàng WS-->>SL: 200 OK

Tip

Webhook không chỉ bắn khi có đơn hàng. Steveslist bắn webhook cả khi người dùng nhận tin nhắn, khi một listing bị admin gỡ, hay khi buyer khiếu nại. Nhờ đó seller tự động hoá được cả khâu bán và giao hàng.

Bảo mật: ký chữ ký bằng HMAC-SHA256

Webhook endpoint nằm công khai trên internet. Bất kỳ ai biết URL đều có thể bắn webhook giả - và nếu seller không cẩn thận, kẻ tấn công có thể lừa họ giao hàng miễn phí. URL khó đoán không phải là bảo mật (obscurity is not security).

Để seller xác minh webhook thật sự đến từ chúng ta, ta ký mã hoá (cryptographically sign) nội dung webhook bằng HMAC. Khi bật webhook, ta sinh một shared secret key ngẫu nhiên và gửi cho seller. Mỗi lần bắn, ta dùng secret + nội dung webhook, chạy qua HMAC-SHA256 để ra một chữ ký xác định.

graph LR
    Payload["Webhook payload
{\"id\": 123, \"action\": \"item_sold\"}"] --> HMAC["HMAC-SHA256"] Secret["Shared secret key
(chỉ ta và seller biết)"] --> HMAC HMAC --> Sig["Signature
234gj98d49j8..."]

Server nhận webhook tính lại chữ ký theo đúng cách đó và so sánh. Khớp thì chấp nhận, lệch thì từ chối. Vì chỉ ta và seller biết secret, chữ ký hợp lệ chứng minh webhook đến từ ta.

Warning

Toàn bộ code xác minh chữ ký do seller viết và bảo trì. Ta có thể cung cấp ví dụ và tài liệu, nhưng không thể ép họ xác minh đúng - hay thậm chí xác minh. Đây là lý do các bên như Stripe và GitHub đều có tài liệu signature verification rất kỹ.

  • Go
  • Python
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"net/http"
)

var sharedSecret = []byte("123mhu23jy8xdwgmd...")

// VerifySignature recomputes the HMAC over the raw body and compares
// it against the signature header using a constant-time comparison.
func VerifySignature(r *http.Request, body []byte) bool {
	got, err := hex.DecodeString(r.Header.Get("X-Steveslist-Signature"))
	if err != nil {
		return false
	}

	mac := hmac.New(sha256.New, sharedSecret)
	mac.Write(body)
	want := mac.Sum(nil)

	// Constant-time compare prevents timing side-channels.
	return hmac.Equal(got, want)
}
import hmac
import hashlib

SHARED_SECRET = b"123mhu23jy8xdwgmd..."

def verify_signature(raw_body: bytes, signature_hex: str) -> bool:
    expected = hmac.new(
        SHARED_SECRET,
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    # compare_digest avoids timing side-channels.
    return hmac.compare_digest(expected, signature_hex)

Độ tin cậy: at-least-once, retry và DLQ

Câu hỏi hóc búa hơn: khi webhook gửi thất bại thì sao? Không kết nối được server? Server trả lỗi? Server treo 20 giây rồi ngắt kết nối mà không nói gì?

Steveslist chọn đảm bảo at-least-once (ít nhất một lần): nếu gửi thất bại, ta thử lại (nhiều nhưng không vô hạn lần) cho đến khi chắc chắn thành công. Điều này có thể khiến cùng một webhook bị gửi hai lần - và trách nhiệm của seller là viết code chịu được sự trùng lặp (idempotent), thay vì giao 5 cái TV cho một đơn hàng.

Retry chuẩn mực dùng exponential backoff với jitter, và sau khi hết số lần thử thì đẩy vào Dead Letter Queue (DLQ) để điều tra thủ công.

graph TD
    Event["Sự kiện phát sinh"] --> Send["Gửi webhook HTTP"]
    Send --> Check{Kết nối OK
và status 2xx?} Check -->|Có| Done["Đánh dấu thành công"] Check -->|Không| Count{Còn lượt retry?} Count -->|Còn| Backoff["Chờ exponential backoff
(1s, 2s, 4s, 8s...+jitter)"] Backoff --> Send Count -->|Hết| DLQ["Đẩy vào Dead Letter Queue"] DLQ --> Alert["Cảnh báo & xử lý thủ công"]
// Exponential backoff with jitter for webhook delivery attempts.
func backoffDelay(attempt int) time.Duration {
	base := time.Second << uint(attempt) // 1s, 2s, 4s, 8s...
	if base > 5*time.Minute {
		base = 5 * time.Minute // cap the ceiling
	}
	// Full jitter spreads retries so we don't hammer a recovering server.
	jitter := time.Duration(rand.Int63n(int64(base / 2)))
	return base/2 + jitter
}

Quản lý trạng thái và dữ liệu: nguồn sự thật duy nhất

RDBMS là single source of truth

Database chính của Steveslist là một RDBMS (PostgreSQL/MySQL). Mọi dữ liệu mới được ghi vào đây trước tiên, trước khi ghi đi bất kỳ đâu khác. Ta gọi nó là source of truth. Các truy vấn đòi hỏi độ chính xác và tính tức thời - “cho tôi danh sách listing đang mở của user”, “kiểm tra mật khẩu user” - đều đọc từ đây.

Điều làm RDBMS mạnh là ACID transactions: một chuỗi thao tác hoặc thành công toàn bộ, hoặc bị rollback toàn bộ. Chuyển tiền phải trừ tài khoản A và cộng tài khoản B trong cùng một transaction - không thể có trạng thái nửa vời.

Database chạy trên máy tính như mọi chương trình khác. Khi dữ liệu phình to, ổ cứng và RAM đầy lên, database chậm dần vì phải quét qua ngày càng nhiều bản ghi. Chạy trên một máy với ổ cứng khổng lồ chỉ trì hoãn vấn đề. Bạn bắt buộc phải nghĩ tới scale ngang.

Connection pooling: PgBouncer

Trước khi nói về sharding, có một nút thắt bị hiểu nhầm phổ biến: connection limit. PostgreSQL chạy mỗi kết nối như một process riêng (fork-on-connect), tốn ~5-10MB RAM và context switch đắt đỏ. Với 200 app server, mỗi cái mở pool 20 connection, bạn đã đập thẳng 4000 connection vào DB - nó sẽ chết.

Giải pháp là connection pooler như PgBouncer đứng giữa app và DB. Nó giữ một pool connection nhỏ, gọn tới DB, và hàng nghìn client multiplex qua đó.

Tip

Trong chế độ transaction pooling, PgBouncer chỉ gán một server connection cho client trong suốt một transaction, rồi thu hồi. Nhược điểm: bạn không dùng được prepared statement có session state, LISTEN/NOTIFY, hay advisory lock xuyên transaction. Hãy kiểm tra app trước khi bật.

Read Replica và replica lag

Database chỉ đọc tốn kém thì đa phần là đọc. Ta replicate dữ liệu sang nhiều máy để vừa chịu lỗi vừa chia tải đọc.

graph TD
    App["Application Servers"] -->|Writes| Primary[("`PostgreSQL
    Primary (RW)`")]
    App -->|Reads| R1[("`Replica 1
    (RO)`")]
    App -->|Reads| R2[("`Replica 2
    (RO)`")]
    Primary -->|Streaming replication| R1
    Primary -->|Streaming replication| R2

Replication lag là vấn đề lớn nhất ở đây. Đọc từ replica không đảm bảo thấy dữ liệu vừa ghi vào primary - dữ liệu có thể trễ vài mili-giây đến vài giây (thậm chí lâu hơn khi primary quá tải). Hệ quả là trải nghiệm kinh điển “tôi vừa đổi avatar nhưng nó vẫn là ảnh cũ” - read-your-own-writes violation.

Một số chiến lược xử lý:

  • Read-your-writes routing: sau khi user ghi, ghim họ vào primary (hoặc đọc từ primary trong một cửa sổ thời gian ngắn).
  • Sticky session / session pinning: giữ một client trên cùng một replica.
  • Synchronous replication: primary chỉ trả về khi replica đã nhận dữ liệu - an toàn hơn nhưng chậm hơn, và tăng rủi ro write stall nếu replica “đuối”.

Sharding và write bottleneck

Khi khối lượng ghi vượt qua một máy - hoặc dữ liệu quá lớn cho một máy - replication không giúp được (mọi replica đều nhận cùng workload ghi). Lúc này bạn sharding: chia dữ liệu thành các mảnh (shard) nằm trên các máy khác nhau. Với Steveslist, ta chia theo user, nghĩa là toàn bộ dữ liệu của một user nằm trên cùng một máy.

Điều này cần một routing layer: hoặc app server tự biết mapping shard, hoặc có một database router trung tâm. App tự biết thì ít hop hơn (nhanh hơn), nhưng router trung tâm dễ cập nhật mapping hơn.

Warning

Sharding là một trong những quyết định tốn kém nhất về vận hành. Bạn mua lại nó bằng: cross-shard query khó, distributed transaction gần như bất khả thi, rebalancing phức tạp, và gánh nặng vận hành khổng lồ. Rất nhiều hệ thống không bao giờ cần sharding. Hãy cạn kiệt vertical scaling, indexing, caching và partitioning của PostgreSQL trước đã.

Việc migrate dữ liệu giữa các shard (khi một shard đầy) thường theo quy trình double-write an toàn: ghi song song cả shard cũ và mới, copy dữ liệu cũ, đối chiếu, đọc từ shard mới, rồi mới ngừng double-write và xoá shard cũ. Chi tiết nhưng hoàn toàn logic và làm được.

Tìm kiếm toàn văn: tại sao LIKE làm chết database

SQL database rất giỏi trả lời các truy vấn cụ thể, chính xác: liệt kê listing của user #145122 trong 90 ngày qua, hay đếm số listing mới mỗi ngày ở Hà Nội. Chúng dùng toán tử rõ ràng như =, >, GROUP BY.

Nhưng một tìm kiếm kiểu Google - “TV cũ” - thì lại là thảm hoạ. Cách ngây thơ là dùng LIKE:

SELECT * FROM items
WHERE description LIKE '%TV cũ%';

Truy vấn này khớp chỉ đúng chuỗi con đó. Nó không khớp “TV second-hand”, “TV cũ giá rẻ”, hay khi user gõ sai chính tả. Tệ hơn:

Warning

LIKE '%keyword%' với dấu % ở đầu chuỗi không dùng được B-tree index. Database buộc phải full table scan - đọc từng dòng, từng cột text. Trên bảng vài chục triệu dòng, một truy vấn như vậy có thể quét hàng GB dữ liệu, làm nóng buffer pool, và kéo CPU của DB lên 100% cho tới khi hàng đợi connection tràn. Đây là cách kinh điển để một analyst giết production DB.

Sau đó bạn có thể cố viết một query tổ hợp OR khổng lồ cho mọi hoán vị từ - nhưng nó vẫn chậm, dễ hỏng, và bỏ sót edge case. Kể cả khi trả về kết quả, bạn vẫn chưa biết xếp hạng theo độ liên quan thế nào - search engine cần “kết quả tốt nhất trước”, không phải “theo bảng chữ cái”.

Inverted index: cơ chế cốt lõi của search engine

Cái SQL thiếu là inverted index (chỉ mục nghịch đảo): một cấu trúc map mỗi từ (đã qua tokenize, stem, lowercase) tới danh sách các document chứa nó.

graph LR
    subgraph "Inverted Index"
        T1["tv"] --> D1["doc 1, doc 7"]
        T2["cũ"] --> D2["doc 1, doc 4"]
        T3["xe"] --> D3["doc 4, doc 9"]
    end
    Query["Query: 'TV cũ'"] --> T1
    Query --> T2
    T1 --> Merge["Giao/merge posting lists
+ tính relevance score"] T2 --> Merge

Với cấu trúc này, truy vấn “TV cũ” chỉ cần tra hai posting list rồi giao chúng lại - độ phức tạp gần như hằng số theo kích thước corpus, không phải tuyến tính như full scan. Đây là nền tảng của Elasticsearch, Meilisearch, và Lucene.

Điều quan trọng: Elasticsearch không thay thế được PostgreSQL. Nó kém tin cậy hơn (dễ mất dữ liệu ở quy mô lớn), ghi chậm hơn, và semantics không transactional. Đó là lý do ta dùng cả hai.

Đồng bộ hoá: dual-write anti-pattern vs Transactional Outbox

Giờ đến phần khó nhất: làm sao đồng bộ dữ liệu từ PostgreSQL sang Elasticsearch?

Cách nhiều người làm đầu tiên là dual-write: trong service, ghi vào Postgres, rồi ghi luôn vào Elasticsearch.

// ANTI-PATTERN: dual-write without atomicity.
func CreateListing(ctx context.Context, l Listing) error {
	if err := pg.Insert(ctx, l); err != nil {
		return err
	}
	// Nếu bước này fail hoặc crash giữa hai bước -> Postgres và ES
	// vĩnh viễn lệch nhau. Không có rollback xuyên hệ thống.
	return es.Index(ctx, l)
}

Dual-write là anti-pattern kinh điển: hai hệ thống không share transaction, nên không có atomicity. Nếu tiến trình crash giữa hai bước, hoặc ES tạm thời down, dữ liệu lệch vĩnh viễn mà không có cách nào biết chắc. Bạn có thể “gỡ” bằng cách ghi ES trước, nhưng rồi bạn lệch ngược lại.

Giải pháp đúng là Transactional Outbox. Ta ghi domain data và một bản ghi event vào cùng một transaction trong PostgreSQL. Sau đó một tiến trình riêng đọc bảng outbox và đẩy sang ES. Bởi vì ghi outbox nằm chung transaction với domain data, hai thứ luôn nhất quán.

graph TD
    Client["Client request"] --> Tx["BEGIN TX"]
    Tx --> InsertOrder["INSERT INTO orders"]
    Tx --> InsertOutbox["INSERT INTO outbox_events"]
    InsertOrder --> Commit["COMMIT (atomic)"]
    InsertOutbox --> Commit
    Commit --> Relay["Outbox Relay Worker
(poll hoặc CDC)"] Relay --> ES["Elasticsearch / Kafka"] Relay --> MarkSent["Đánh dấu event đã xử lý"]
BEGIN;
INSERT INTO orders (id, user_id, name) VALUES ($1, $2, $3);
-- Same transaction guarantees consistency between the two.
INSERT INTO outbox_events (aggregate_id, event_type, payload)
VALUES ($1, 'listing.created', $2);
COMMIT;

Change Data Capture (CDC) - dùng Debezium đọc WAL của PostgreSQL - là một biến thể tinh tế hơn: thay vì app ghi outbox table, một connector đọc write-ahead log ở tầng thấp và phát event ra. Nó gần như không ảnh hưởng hiệu năng app, nhưng phức tạp về vận hành và phụ thuộc vào format WAL.

Tip

Với dataset nhỏ và yêu cầu freshness thấp, reindex định kỳ (mỗi 15 phút) là giải pháp thoả hiệp hoàn toàn hợp lý. Craigslist từng công khai ghi “listing của bạn sẽ hiển thị trong search sau 15 phút” - dấu hiệu điển hình của pipeline batch sync từ SQL sang search engine.

Xử lý bất đồng bộ: Pub/Sub và event-driven

Vì sao không chạy đồng bộ?

“Một hành động kéo theo hệ quả” - khi user đăng ký, gửi email chào mừng; khi có listing mới, thông báo cho mọi người có search alert; khi thẻ bị từ chối, email nhắc nhở. Về mặt kỹ thuật, tất cả đều có thể chạy đồng bộ (synchronously) bởi server xử lý hành động khởi phát.

Nhưng đó thường là ý tồi. Phản ứng phụ (ví dụ tìm và thông báo cho hàng nghìn user quan tâm listing mới) có thể rất chậm. Chạy đồng bộ nghĩa là user phải chờ mọi tác vụ phụ hoàn tất trước khi nhận response. Response time phình ra, trải nghiệm tệ, và một tác vụ phụ lỗi có thể kéo sập luôn request chính.

Pub/Sub ở tầng kiến trúc

Ta tách phần khởi phát khỏi phần phản ứng bằng pub/sub. Khi hành động xảy ra, code publish một event (NewListingCreated, SubscriptionCardDeclined). Bất kỳ ai muốn phản ứng viết một consumer subscribe vào loại event đó.

graph LR
    Server["Steveslist Server"] -->|publish NewUserSignup| Broker["Message Broker
(Kafka / RabbitMQ / Redis Streams)"] Broker -->|push/pull| C1["SendWelcomeEmailConsumer"] Broker -->|push/pull| C2["NewUserSpamCheckConsumer"] Broker -->|push/pull| C3["AnalyticsIngestConsumer"]

Lợi ích:

  • Hành động không quan trọng chạy bất đồng bộ, giữ trải nghiệm người dùng nhanh nhạy.
  • Code tách bạch: nơi publish không cần biết ai subscribe và làm gì.
  • Nếu consumer lỗi (ví dụ hệ thống email hiccup), broker ghi nhận và retry sau.

Hai cơ chế chuyển giao phổ biến: push (broker đẩy event tới consumer) và pull (consumer poll broker). Redis Streams, Kafka, RabbitMQ khác nhau ở semantics, throughput, ordering guarantee và độ bền - chọn theo nhu cầu, không theo trend.

Idempotency khi xử lý at-least-once

Đây là phần mà 90% hệ thống gặp bug. Hầu hết message queue bảo at-least-once: nếu consumer crash sau khi xử lý nhưng trước khi ack, broker sẽ gửi lại message. Nghĩa là consumer phải idempotent - xử lý cùng message hai lần không gây tác dụng phụ trùng lặp.

Cách chuẩn là dùng event ID làm idempotency key, và ghi nhận việc đã xử lý trong cùng transaction với tác dụng phụ.

  • Go
  • Python
// Idempotent consumer: dedupe by event ID inside the same transaction
// as the side effect, so a redelivered message is a no-op.
func (h *Handler) Handle(ctx context.Context, evt Event) error {
	tx, _ := h.db.Begin(ctx)
	defer tx.Rollback(ctx)

	// INSERT ... ON CONFLICT DO NOTHING returns 0 rows if already processed.
	tag, err := tx.Exec(ctx,
		`INSERT INTO processed_events (event_id) VALUES ($1)
		 ON CONFLICT (event_id) DO NOTHING`, evt.ID)
	if err != nil {
		return err
	}
	if tag.RowsAffected() == 0 {
		return nil // already handled, ack and move on
	}

	if err := applySideEffect(ctx, tx, evt); err != nil {
		return err
	}
	return tx.Commit(ctx)
}
def handle(event, db):
    """Idempotent consumer: dedupe by event ID in the same transaction
    as the side effect, so a redelivered message is a no-op."""
    with db.transaction() as tx:
        inserted = tx.execute(
            """
            INSERT INTO processed_events (event_id)
            VALUES (%s)
            ON CONFLICT (event_id) DO NOTHING
            """,
            (event.id,),
        ).rowcount

        if inserted == 0:
            return  # already handled, ack and move on

        apply_side_effect(tx, event)
    # Transaction commits here; event id + effect are atomic.

Warning

Idempotency key không chỉ là “chống trùng tại chỗ”. Với webhook ra ngoài (gọi API bên thứ ba), hãy gửi kèm một Idempotency-Key header để bên nhận cũng dedupe được. Đây là cách Stripe chống tính tiền hai lần khi client retry.

Tách biệt OLTP và OLAP: đừng chạy báo cáo trên production DB

Để hiểu và tối ưu business, ta cần tính các thống kê phức tạp trên toàn bộ dữ liệu: “mỗi ngày có bao nhiêu listing, chia theo quốc gia và thành phố?” hay “bao nhiêu user đăng ký mỗi tháng đã tạo listing trong 90 ngày?”.

Đây là truy vấn aggregation trên toàn bộ dataset - và bạn tuyệt đối không muốn chạy nó trên DB production.

Warning

Truy vấn analytics quét hàng trăm triệu dòng có thể giết production DB theo nhiều cách: (1) giữ lock/buffer pool lâu khiến các truy vấn OLTP bình thường phải chờ; (2) làm buffer pool thrashing - xô đẩy các page nóng của OLTP ra khỏi RAM; (3) chiếm hết I/O và CPU. Hệ quả: toàn bộ user-facing app chậm hoặc sập chỉ vì một analyst chạy báo cáo.

Vấn đề gốc sâu hơn: engine giỏi truy vấn nhỏ (trả về listing của một user) thường rất kém ở truy vấn khổng lồ. Hai workload này có hình dạng vật lý khác nhau:

  • OLTP (row-oriented): truy cập vài dòng, nhiều cột, độ chọn lọc cao, ghi liên tục. Tối ưu cho point lookup.
  • OLAP (column-oriented): quét vài cột trên toàn bộ bảng, aggregate lớn, ghi theo batch. Tối ưu cho scan cột.

Giải pháp là data warehouse dùng lưu trữ dạng cột (columnar storage): ClickHouse, Snowflake, BigQuery, Redshift. Ta replicate dữ liệu từ production SQL sang warehouse theo lịch (ví dụ mỗi đêm), qua pipeline ETL/ELT (Extract-Transform-Load hoặc Extract-Load-Transform).

graph LR
    subgraph "OLTP (Row-oriented)"
        App["App Servers"] -->|"Point reads/writes
high selectivity"| PG[("`PostgreSQL Production`")] end subgraph "Pipeline" PG -->|"ETL/ELT
(nightly batch hoặc CDC)"| DW end subgraph "OLAP (Column-oriented)" DW[("`ClickHouse / BigQuery Columnar Storage`")] -->|"Full-table scans
aggregations"| BI["BI / Analysts"] end

Kết quả: analyst query trên warehouse với dataset khổng lồ mà production vẫn mượt. Đây chính là định nghĩa của việc “dùng đúng tool cho đúng việc” - điều mà không có một database nào là “tốt nhất” cho mọi thứ.

Tổng kết: bài học kiến trúc và những đánh đổi

Kate (nhân vật trong essay gốc) nói một câu quan trọng: không có thiết kế nào là “đúng” tuyệt đối. Lý do thật sự cho nhiều quyết định công nghệ thường là “chúng tôi chọn X vì Sara biết nhiều về X” và “chúng tôi chọn Y trong lúc vội vàng, khi nó chưa có vẻ là quyết định lớn, rồi không bao giờ có thời gian đánh giá lại”. Thừa nhận điều này giúp bạn khiêm tốn trước mọi kiến trúc bạn gặp.

CAP theorem trong thực tế

Định lý CAP nói một hệ thống phân tán chỉ có thể đảm bảo hai trong ba: Consistency, Availability, Partition tolerance. Nhưng trong hệ thống thật, partition (mạng bị chia cắt) là thuộc tính không thể tránh của mọi hệ phân tán - dây cáp đứt, switch hỏng, data center mất kết nối. Nên lựa chọn thực tế không phải “hai trong ba”, mà là CP hay AP khi có partition: bạn chọn từ chối phục vụ (giữ consistency) hay phục vụ dữ liệu có thể cũ (giữ availability).

Hệ quả thực tiễn: cả hệ thống không cần một câu trả lời duy nhất. Một webhook delivery nên là AP (thà gửi trùng còn hơn mất). Một giao dịch thanh toán nên là CP (thà từ chối còn hơn tính tiền sai). Đây là lý do bạn cần nhận thức rõ hệ thống con nào cần gì.

Conway’s Law

“Organizations design systems that mirror their own communication structure.” (Melvin Conway, 1967)

Đây không phải một câu triết lý suông. Nó là công cụ dự đoán: nếu bạn có 4 team, bạn sẽ có 4 service boundary - dù bạn muốn hay không. Một monolith do 20 team cùng sửa sẽ tự nhiên vỡ ra thành nhiều module chồng chéo. Ngược lại, nếu bạn muốn tách một service, cách nhanh nhất là dựng một team sở hữu nó end-to-end.

Hệ quả: kiến trúc và tổ chức nhân sự là hai mặt của một đồng xu. Bạn không thể thiết kế microservices với một team duy nhất 6 người vận hành 40 service - bạn sẽ chết vì cognitive load và on-call rotation.

Trade-off cuối cùng: operational complexity vs nhu cầu mở rộng thực tế

Mỗi thành phần kiến trúc bạn thêm vào đều mua bằng độ phức tạp vận hành: thêm một thứ có thể hỏng lúc 3 giờ sáng, thêm một dashboard phải dựng, thêm một runbook phải viết, thêm một loại sự cố phải học. Đổi lại, bạn mua khả năng mở rộng và tách biệt lỗi.

Câu hỏi đúng không phải “kiến trúc nào hiện đại nhất?” mà là “vấn đề thực tế nào tôi đang gặp, và thành phần này giải quyết nó?”

  • Nếu bạn chưa có vài triệu dòng, sharding là over-engineering.
  • Nếu bạn chưa có nhu cầu full-text search, chưa cần Elasticsearch.
  • Nếu bạn chưa có workload analytics nặng, chưa cần warehouse.
  • Nếu team bạn nhỏ, modular monolith thắng microservices.

Các hệ thống vĩ đại tiến hoá từ những hệ thống đơn giản hoạt động được. Một complex system thiết kế từ đầu không bao giờ chạy đúng, và không thể vá để chạy đúng (Gall’s Law). Hãy bắt đầu với một monolith đơn giản, đo lường thật, và chỉ tách ra khi có bằng chứng cụ thể về nút thắt. Nghe nhàm chán? Đúng - hệ thống tốt trên giấy thường trông nhàm chán, nhưng chạy ổn định trong production.

Chia sẻ :

Bài viết liên quan

Lore: Epic Games mở mã nguồn VCS sinh ra để thay Git ở thế giới binary khổng lồ

Lore: Epic Games mở mã nguồn VCS sinh ra để thay Git ở thế giới binary khổng lồ

Repo game không giống repo web. Trong engine cỡ Unreal, phần lớn dung lượng là asset binary: texture, mesh, world partition nặng hàng trăm MB đến vài GB, đổi liên tục bởi cả nghìn artist lẫn developer. Đẩy vào Git là chuỗi ngày đau khổ: Git LFS là thứ bolt-on, artist export texture 2 GB là cả pipeline kéo full file, dedup binary bị giới hạn bởi pack-delta heuristic, partial clone cộng sparse checkout còn experimental, mất mạng là fail.

Đọc thêm
Kiến trúc phần mềm: Monolith, Microservices và cái bẫy Distributed Monolith

Kiến trúc phần mềm: Monolith, Microservices và cái bẫy Distributed Monolith

Nhiều lập trình viên Backend trẻ thường có xu hướng xem các mô hình kiến trúc phần mềm như một tôn giáo hoặc thước đo trình độ. Microservices thường được tôn sùng như đỉnh cao công nghệ, Monolith bị gán nhãn lạc hậu, còn Distributed Monolith - trạng thái tệ hại nhất - lại thường bị nhầm lẫn với vi dịch vụ thực thụ.

Đọc thêm
Thiết kế hệ thống thực chiến: Sự bình dị của những kiến trúc vận hành trơn tru

Thiết kế hệ thống thực chiến: Sự bình dị của những kiến trúc vận hành trơn tru

Nhiều kỹ sư mới vào nghề thường lầm tưởng rằng một thiết kế hệ thống (System Design) ấn tượng phải là một mạng lưới chằng chịt các microservices, sử dụng Apache Kafka để stream event, áp dụng pattern CQRS phức tạp và chạy distributed consensus khắp nơi.

Đọc thêm