Phần 1 · Chương 1.2
Một câu lệnh, cả thế giới
Bạn sẽ tạo ra: Hạ tầng local một câu lệnh — bốn store, có healthcheck và đã kiểm chứng · khoảng 60 phút, bao gồm bài tập
Tài liệu gốc: SRS — Đặc tả yêu cầu phần mềm · SAD — Tài liệu kiến trúc phần mềm (tiếng Anh)
Chương 1.1 để lại cho chúng ta một workspace vượt qua ba câu lệnh mà chưa tính toán được gì. Đó chính là chủ đích — cửa ải phải dựng trước khi hàng hóa đi qua. Nhưng mọi chương phía trước đều cần một thế giới bên dưới: tin nhắn cần một nơi lưu trữ gốc, sự kiện cần một xương sống bền vững, việc phát tán trực tiếp cần một tấm lưới fan-out, còn analytics cần một ngôi nhà riêng. Hôm nay chúng ta dựng cả thế giới ấy trên máy của bạn — bốn store, một file, một câu lệnh — và làm cho câu lệnh ấy trung thực: nó chỉ trả về khi mọi thứ thực sự sẵn sàng nhận việc.
Yêu cầu nằm sờ sờ trước mắt
Nằm khuất trong mục maintainability của bản SRS, ở mức ưu tiên P1 — cùng hạng với thứ tự tin nhắn — là NFR-MNT-03: "The full stack shall be startable locally with a single command" (toàn bộ hệ thống phải khởi động được tại chỗ bằng một câu lệnh duy nhất). Rất dễ đọc dòng đó như một tiện nghi cho dev. Không phải. Nó là một yêu cầu của sản phẩm, kiểm chứng bằng trình diễn, và kế hoạch loạt bài gọi thẳng tên góc nhìn của chương này: một yêu cầu từ ngày đầu, không phải thứ nghĩ ra sau.
Vì sao là bốn store này
File compose chúng ta sắp viết gọi tên bốn store. Không cái nào ở đó theo thói quen — từng chỗ ngồi đều được biện luận, thành văn, trong các bản ghi quyết định của SAD. Trước YAML, hãy nghe lập luận.
Postgres là system of record — nguồn dữ liệu gốc. Mọi cam kết mà Phần 2 sẽ
đổ mồ hôi vì nó đều sống ở đây: thứ tự trong kênh là một row lock của Postgres
(ADR-03 cấp sequence bằng cách tăng channels.last_sequence dưới
SELECT … FOR UPDATE), và ADR-04 chỉ cho phép đúng một writer — "only the API
service touches PostgreSQL" (chỉ API service được chạm vào PostgreSQL) — để
các bất biến sống trong một codebase thay vì bị nhân đôi và phải test hai lần.
NATS JetStream là xương sống sự kiện bền vững. ADR-02 chọn nó thay vì Kafka vì nó mang lại durable stream, consumer group và redelivery "at a fraction of Kafka's operational mass" (với một phần nhỏ khối lượng vận hành của Kafka) — và danh sách phương án bị bác bỏ rất đáng đọc. Kafka: cồng kềnh quá mức cần thiết ở quy mô này. Redis Streams: bị bác vì nó "trói xương sống bền vững vào kho trạng thái phù du — một sự cố Redis khi đó sẽ có hai vùng nổ." Các store tách bạch nhau là có chủ đích, và sự tách bạch ấy bắt đầu ngay trong file compose này.
Redis là tấm lưới fan-out kiêm bảng presence — và nó được phép làm rơi. ADR-07 để việc phát tán trực tiếp chạy kiểu bắn-rồi-quên một cách có chủ đích: một frame pub/sub bị rơi không phải là một tin nhắn bị mất, vì độ bền nằm ở Postgres và cursor phía client sẽ vớt lại bất cứ thứ gì lỡ nhịp. ADR-10 đặt presence vào Redis với TTL cũng vì lẽ đó — nói theo đúng lời nó, lượng độ bền đúng mức cho dấu ba chấm đang gõ và chấm xanh online là không có gì cả.
ClickHouse là kho phân tích, đi đường riêng. CON-01 của bản SRS cấm analytics truy vấn vào database vận hành — nói theo lời ADR-08, phương án tái dùng Postgres cho analytics bị bác vì "CON-01 exists precisely to forbid this" (CON-01 tồn tại chính xác để cấm điều này). Một node ClickHouse là đủ cho v1 ("một node kèm backup đáp ứng 10k insert/giây và ngưỡng truy vấn 2 s trên 90 ngày, còn dư dả"), và schema sẽ mang dáng cluster ngay từ đầu, để việc mở rộng sau này "là một lần migrate dữ liệu, không phải một lần thiết kế lại."
flowchart TB
svcs["Các service của Relay<br/>(đến ở chương 1.4 →)"]
pg[("Postgres<br/>system of record — thứ tự tin nhắn<br/>sống ở đây (ADR-03, ADR-04)")]
nats[("NATS JetStream<br/>xương sống sự kiện bền vững (ADR-02)<br/>được outbox bơm vào (ADR-06)")]
redis[("Redis<br/>fan-out + presence —<br/>chấp nhận mất, có chủ đích (ADR-07, ADR-10)")]
ch[("ClickHouse<br/>kho phân tích, đi đường riêng<br/>(ADR-08, CON-01)")]
svcs -.-> pg
svcs -.-> nats
svcs -.-> redis
svcs -.-> chFile compose
Kiểm tra công cụ trước — nếp quen từ 1.1. Chương này cần Docker Engine với
Compose v2; chúng ta dựa vào --wait, thứ mọi bản phát hành gần đây đều có.
Nếu docker compose version in ra một số phiên bản v2, bạn đã đủ đồ nghề; nếu
câu lệnh không tồn tại, hãy cài Docker theo tài liệu chính thức
(docs.docker.com/engine/install) —
chương này sẽ không kể lại chuyện cài đặt.
docker compose versionGiờ thì khai báo cả thế giới. Một file duy nhất ở gốc repository:
# The whole local world, one command: docker compose up -d --wait
# Four stores, each here by a recorded decision (SAD §9). Host ports are env
# knobs with the standard defaults; the container side never changes.
name: relay
services:
postgres:
image: postgres:18-alpine
environment:
# Dev-only credentials — this file never ships anywhere.
POSTGRES_USER: relay
POSTGRES_PASSWORD: relay
POSTGRES_DB: relay
ports:
- "${RELAY_POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "relay", "-d", "relay"]
interval: 5s
timeout: 3s
retries: 5
start_period: 30s
redis:
image: redis:8-alpine
# No volume, on purpose: nothing in Redis is a source of truth.
# Fan-out is lossy by design (ADR-07); presence self-heals (ADR-10).
ports:
- "${RELAY_REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
nats:
image: nats:2.12-alpine
# JetStream on (-js) with file storage (-sd): the durable event spine
# (ADR-02) must survive a restart. -m exposes the monitoring endpoint.
command: ["-js", "-sd", "/data", "-m", "8222"]
ports:
- "${RELAY_NATS_PORT:-4222}:4222"
- "${RELAY_NATS_MONITOR_PORT:-8222}:8222"
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8222/healthz"]
interval: 5s
timeout: 3s
retries: 5
clickhouse:
image: clickhouse/clickhouse-server:25.3
# The analytical store rides its own path (CON-01): single node in v1,
# schema already cluster-shaped (ADR-08).
ports:
- "${RELAY_CLICKHOUSE_HTTP_PORT:-8123}:8123"
- "${RELAY_CLICKHOUSE_NATIVE_PORT:-9000}:9000"
volumes:
- clickhouse-data:/var/lib/clickhouse
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8123/ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
volumes:
postgres-data:
nats-data:
clickhouse-data:Hãy đọc nó thành ba lớp. Các image được ghim vào phiên bản tường minh —
postgres:18-alpine, không phải postgres:latest — vì một tag thả nổi là một
thế giới khác nhau mỗi tháng, trong khi toàn bộ ý nghĩa của file này là cùng
một thế giới ở mọi lần chạy. (Các con số phiên bản trong file sẽ già đi theo
loạt bài; tag của chương, chứ không phải phần chữ, giữ đúng trạng thái — lời
dặn từ 1.1, vẫn còn nguyên giá trị.) Các healthcheck là tín hiệu sẵn sàng
bằng ngôn ngữ riêng của từng store: Postgres trả lời pg_isready, Redis đáp
PONG, NATS và ClickHouse mỗi bên phơi ra một endpoint HTTP bé xíu. Trông
chúng như đồ trang trí. Cái BẪY bên dưới nói về lý do chúng là kết cấu chịu
lực.
Còn các volume chính là kiến trúc, được phát biểu lại. Postgres lưu bền vì
nó là system of record. NATS lưu bền (-sd /data) vì ADR-02 đã định JetStream
là xương sống bền vững — một hàng đợi quên sạch sau restart thì không phải
xương sống. ClickHouse lưu bền vì analytics là thứ tích lũy. Còn Redis không
nhận gì cả — một cách có chủ đích. Sự bất đối xứng ấy là bản SAD thu vào một
ánh nhìn.
flowchart LR
subgraph started["những gì up -d trao cho bạn"]
s1["postgres: container đã start"]
s2["initdb vẫn đang chạy…"]
s3["kết nối bị từ chối"]
s1 --> s2 --> s3
end
subgraph ready["những gì up -d --wait trao cho bạn"]
r1["healthcheck: pg_isready"]
r2["thử lại đến khi có hồi đáp"]
r3["(healthy) — sẵn sàng nhận kết nối"]
r1 --> r2 --> r3
end
started -- "khoảng trống nơi các service của 1.4<br/>sẽ crash-loop" --> readyMột câu lệnh, có kiểm chứng
Câu lệnh mà chương này sinh ra để phục vụ:
docker compose up -d --waitLần chạy đầu, Docker tải bốn image (tầm một ly cà phê); từ đó trở đi, khởi
động nguội đến lúc tất-cả-healthy mất chưa đầy một phút. --wait chỉ thoát
với mã 0 khi mọi healthcheck đều báo khỏe — nó đã trả về nghĩa là thế giới
đã sẵn sàng. Hãy kiểm chứng theo đúng cách bạn sẽ làm trong mọi phiên debug từ
nay về sau:
docker compose psBốn dòng, mỗi dòng kết thúc bằng (healthy). Chữ ấy là healthcheck của bạn
đang lên tiếng, không phải sự lạc quan.
Một điểm gợn cần nói thật: các cổng host mặc định (5432, 6379, 4222, 8123) là cổng tiêu chuẩn, nghĩa là bất cứ thứ gì đang chiếm chúng trên máy bạn sẽ thắng cuộc đua. Chuyện này chẳng hề lý thuyết — chính chiếc máy viết ra chương này đang chạy Postgres và Redis riêng của nó. File compose biến mọi cổng host thành một biến môi trường với giá trị mặc định tiêu chuẩn, nên một vụ va chạm chỉ tốn một biến, không tốn một lần sửa file:
RELAY_POSTGRES_PORT=15432 RELAY_REDIS_PORT=16379 docker compose up -d --waitPhía container không bao giờ đổi — khi các service của Relay xuất hiện, chúng nói chuyện với các store qua mạng nội bộ của compose và chẳng bận tâm host của bạn map cổng nào.
Dọn dẹp là hai câu lệnh mang hai ý nghĩa rất khác nhau:
docker compose down # stop the world; the volumes survive
docker compose down -v # stop the world AND erase every store's datadown là một quãng nghỉ: Postgres, NATS và ClickHouse giữ nguyên trạng thái
trong các named volume và nhặt lại nó ở lần up sau. down -v là nút reset —
mọi thứ bền vững chết theo nó. Biết mình đang muốn lệnh nào là khác biệt giữa
"khởi động lại" và "làm lại từ đầu"; cả hai đều chính đáng, và lời hứa
một-câu-lệnh của NFR-MNT-03 phủ cả hai chiều.
Cửa ải học biết về thế giới
Luật của chương 1.1 là mỗi chương kết thúc ở một trạng thái chạy được và có test — vậy mà chúng ta vừa thêm nguyên một bản khai báo hạ tầng không ai canh gác. Cửa ải phải giữ được sự trung thực mà không đòi hỏi Docker (CI runner của bạn, và đôi khi cả laptop của bạn, sẽ không có daemon) — nên bài test khẳng định về bản khai báo, không phải về các container đang chạy. Trước tiên, các hằng số:
// The local infrastructure, named. The compose file at the repository root
// (compose.yaml) is the source of truth; these constants let the rest of the
// workspace — and the smoke test beside this file — refer to it without
// parsing YAML. This is a new file on purpose: files fenced by earlier
// chapters are read-only from then on (chapter 1.2's additive-only rule).
export const COMPOSE_FILE = "compose.yaml";
export const INFRA_SERVICES = ["postgres", "redis", "nats", "clickhouse"] as const;
export const DURABLE_VOLUMES = [
"postgres-data",
"nats-data",
"clickhouse-data",
] as const;Để ý phần comment về file mới. Chương 1.1 đã công bố nội dung chính xác của
mười file, và loạt bài này hứa rằng các khối code ấy khớp với repository ở tag
của mọi chương. Cách rẻ nhất để giữ lời là một kỷ luật: một chương không bao
giờ sửa file mà chương trước đã trưng ra — hành vi mới nằm trong file mới.
Đó là lý do các hằng số phía trên sống trong infra.ts thay vì được nối thêm
vào index.ts của 1.1. Khi nào một chương tương lai thật sự phải đổi một file
cũ, thay đổi ấy sẽ được trưng ra thành một bản diff tường minh, không bao giờ
tuồn lén.
Và bài test — đúng khuôn của bài smoke test 1.1, đọc file thật:
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { COMPOSE_FILE, DURABLE_VOLUMES, INFRA_SERVICES } from "./infra.js";
// The gate stays Docker-free: these assertions read the compose declaration as
// text — no daemon, no containers. They fail if a store is renamed or dropped,
// if a healthcheck disappears (docker compose up -d --wait would silently stop
// meaning "ready"), or if Redis quietly gains a volume it must never need.
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
describe("the compose declaration agrees with @relay/config", () => {
const compose = readFileSync(join(repoRoot, COMPOSE_FILE), "utf8");
it("declares every infra service", () => {
for (const service of INFRA_SERVICES) {
expect(compose).toMatch(new RegExp(`^ ${service}:$`, "m"));
}
});
it("gives every service a healthcheck — up --wait must mean ready", () => {
const healthchecks = compose.match(/^ {4}healthcheck:$/gm) ?? [];
expect(healthchecks.length).toBeGreaterThanOrEqual(INFRA_SERVICES.length);
});
it("persists exactly the durable stores — and never Redis", () => {
for (const volume of DURABLE_VOLUMES) {
expect(compose).toContain(`${volume}:`);
}
expect(compose).not.toContain("redis-data");
});
});Ba phép khẳng định, không cái nào mang tính trang trí: đổi tên một store, xóa
một healthcheck, hay cấp cho Redis chỗ lưu bền — pnpm test sẽ trượt trước
khi con người kịp nhận ra. Giờ hãy chạy trọn cửa ải — tắt Docker đi nếu bạn
muốn có bằng chứng:
pnpm lint
pnpm typecheck
pnpm testSáu bài test, vẫn xanh, vẫn không cần Docker. Cửa ải của 1.1 đi xuyên qua chương này nguyên vẹn — nó chỉ hiểu biết thêm về thế giới.
flowchart LR
up["docker compose<br/>up -d --wait"]
healthy["4 × (healthy)<br/>postgres · redis · nats · clickhouse"]
gate["pnpm lint<br/>pnpm typecheck<br/>pnpm test<br/>(không cần Docker)"]
tag["tag của chương<br/>part1-ch2"]
up --> healthy --> gate --> tagĐến lượt bạn
Bài tập chính là công trình: thêm compose.yaml, infra.ts và
infra.test.ts vào workspace của bạn theo chương này, tự tay gõ phần YAML —
căn lề thụt của compose là một nghi thức trưởng thành. Rồi thử độ hiểu của
mình trước thế giới bạn vừa dựng:
- Chạy
docker compose up -d(không có--wait) rồi lập tứcdocker compose ps. Bắt quả tang một store đang ở(health: starting)— đó chính là khoảng trống của cái BẪY, sống động trên máy bạn. - Xóa khối
healthcheck:củapostgresrồi chạy lạidocker compose up -d --wait. Nó trả về gần như tức thì — và chẳng chứng minh điều gì. Cảm nhận xem--waitchỉ trung thực ngang với những phép kiểm nuôi nó. (Đặt lại nhé; đằng nàopnpm testcũng sẽ đòi.) - Chạy
docker compose down, rồiup -d --wait, rồi xemdocker volume ls— thế giới quay lại cùng ký ức của nó. Giờ làm y vậy vớidown -vvà nhìn các volume biến mất. Nói thành lời: store nào mất dữ liệu, và vì sao Redis chẳng bận lòng.
Nếu bạn kẹt, tag của chương đang giữ sẵn đáp án: part1-ch2.
Những điều đọng lại
Nếu không đọc gì khác trong chương này, hãy giữ lấy những điều sau:
- Khởi động tại chỗ bằng một câu lệnh là yêu cầu, không phải tiện nghi — NFR-MNT-03 đứng ở P1 vì người kỹ sư duy nhất của D8 cần một thế giới cầm được trong tay: khởi động, reset (yêu cầu này phủ cả chiều reset).
- Mỗi store đều giành được chỗ ngồi bằng văn bản: Postgres là nguồn gốc, JetStream là xương sống bền vững, Redis là tấm lưới chấp nhận mất có chủ đích, ClickHouse là đường phân tích tách riêng — file compose là bản SAD, phát biểu lại.
- Start xong chưa phải là sẵn sàng. Healthcheck định nghĩa sẵn sàng;
--waitbuộc nó phải đúng;depends_onmột mình chẳng xếp thứ tự được điều gì đáng kể. - Volume cũng là tài liệu: lưu bền ở nơi độ bền là nhiệm vụ, không cấp gì ở nơi mất dữ liệu là thiết kế — và có một bài test khẳng định sự bất đối xứng ấy.
- Cửa ải không bao giờ cần Docker: nó kiểm bản khai báo, không kiểm daemon — và kỷ luật chỉ-thêm-file-mới là thứ giữ cho khối code của mọi chương trước vẫn đúng ở mọi tag về sau.