▶ Tiếng Việt · English · 2:06 · Kịch bản: demo-video-series.md · Bản tiếng Anh
Tóm tắt một câu: Astra chạy thành hai replica kernel không trạng thái ngay từ ngày đầu; số chứng từ luôn liên tục và duy nhất giữa các pod vì bộ đếm là một lệnh upsert PostgreSQL dưới khoá dòng, nằm bên trong chính transaction của chứng từ, và một lần rolling restart giữ cho dịch vụ gần như, nhưng chưa hoàn toàn, không gián đoạn.
Nên đọc nếu: bạn muốn biết vì sao
ORD-20261021-00007không bao giờ bị cấp hai lần kể cả khi hai pod ghi cùng lúc, và cần thay đổi gì trước khi dựa vào rolling restart (góc nhìn developer) / bạn đang định hình mô hình vận hành: phần nào không trạng thái, phần nào có, và một lần deploy hôm nay khiến người dùng chịu gì (góc nhìn tech lead).
Vấn đề
Astra Express tạo đơn hàng từ Desk, từ API và từ một agent AI. Mỗi đơn có một số dễ đọc như
ORD-20261021-00007: tiền tố, ngày, rồi một bộ đếm năm chữ số theo tenant theo ngày. Nhân viên đọc các số này qua
điện thoại, in chúng lên vận đơn và đối soát COD theo chúng. Có hai tính chất quan trọng: không trùng (hai
bưu kiện cùng số đơn là một sự cố vận hành) và không hổng số (một số bị thiếu khiến kiểm toán viên hỏi cái gì
đã bị xoá).
Hệ thống một máy chủ có cả hai mà không tốn gì: một tiến trình, một bộ đếm. Ngay khi chạy hai replica, mỗi cách làm thông thường lại phá vỡ một tính chất:
- Bộ đếm trong bộ nhớ của từng tiến trình sinh ra số trùng.
SEQUENCEcủa database duy nhất và nhanh, nhưng không mang tính giao dịch: một insert bị rollback vẫn tiêu một giá trị, nên xuất hiện lỗ hổng. Nó cũng là toàn cục, không theo tenant và theo ngày.INCRcủa Redis duy nhất giữa các pod nhưng nằm ngoài transaction của chứng từ, nên một lần lưu thất bại để lại lỗ hổng, và Redis trở thành một phần của tính đúng đắn của việc đánh số.- Frappe/ERPNext giữ bộ đếm naming series trong một bảng database, cập nhật bên trong transaction của request, cùng họ giải pháp với Astra. Khác biệt là kernel của Astra được thiết kế để chạy nhiều replica ngay từ đầu, nên cơ chế phải được chứng minh khi có nhiều bên ghi đồng thời.
Nửa sau của vấn đề là triển khai. Một lần rolling restart khi người dùng đang online không được đăng xuất ai hay làm mất một cú click nào. Video đo xem manifest hiện tại tiến gần đến mức nào.
Video cho thấy gì
| Cảnh | Bạn thấy gì | Điều được chứng minh |
|---|---|---|
| 1 · Tiêu đề | "Hai pod, một dãy số liên tục." | Đặt vấn đề. |
| 2 · Triển khai | kubectl cho thấy astra-server 2/2 ready; manifest có replicas: 2, RollingUpdate với maxUnavailable: 0, readiness probe và liveness probe. Các pod khác: astra-cdc, keycloak, postgres-0, redis, seata-server. |
Hai replica ngay từ đầu, vì đa thuê bao và đánh số phải an toàn trên cụm. |
| 3 · Song song | 12 đơn được tạo cùng lúc, 6 đơn gửi thẳng vào mỗi pod. Tất cả trả về 201. Các số chạy từ ORD-20261021-00007 đến ORD-20261021-00018, không trùng, không bỏ số; pod nào nhận số nào thì xen kẽ nhau. |
Bộ đếm an toàn khi có nhiều bên ghi đồng thời trên các pod khác nhau. |
| 4 · Desk | Danh sách Customer Order trong Desk hiện cùng dãy số liên tục đó. | Khuôn mẫu đếm theo tenant và theo ngày. |
| 5 · Code | SeriesService: một upsert, một khoá dòng. |
Pod thứ hai chờ khoá của pod thứ nhất; rollback cũng rollback luôn con số. Không có sequence riêng, không có Redis. |
| 6 · Rollout | kubectl -n astra rollout restart deployment/astra-server trong khi một client đọc đơn hàng mỗi 0,25 s qua ingress. 47 request: 45 trả về 200, 2 không nhận được phản hồi khi một pod cũ dừng. |
Gần như không gián đoạn. |
| 7 · Nói đúng | Không có preStop hook, không có thời gian drain. | Chưa phải zero downtime: một thao tác trên Desk có thể phải thử lại. |
Ánh xạ pod với số đơn được ghi lại (docs/video/v10-k8s/assets/parallel-sorted.txt):
astra-server-c6966f6f6-p56xb 201 ORD-20261021-00007
astra-server-c6966f6f6-th87l 201 ORD-20261021-00008
astra-server-c6966f6f6-th87l 201 ORD-20261021-00009
astra-server-c6966f6f6-p56xb 201 ORD-20261021-00010
astra-server-c6966f6f6-p56xb 201 ORD-20261021-00011
astra-server-c6966f6f6-th87l 201 ORD-20261021-00012
astra-server-c6966f6f6-p56xb 201 ORD-20261021-00013
astra-server-c6966f6f6-p56xb 201 ORD-20261021-00014
astra-server-c6966f6f6-th87l 201 ORD-20261021-00015
astra-server-c6966f6f6-p56xb 201 ORD-20261021-00016
astra-server-c6966f6f6-th87l 201 ORD-20261021-00017
astra-server-c6966f6f6-th87l 201 ORD-20261021-00018Dãy số bắt đầu từ 7 vì make demo-seed đã tạo sáu đơn trong ngày hôm đó.
Cách hoạt động
Bản triển khai
deploy/k8s/base/astra-server.yaml
spec:
replicas: 2 # two pods from the start: tenancy and naming series must be cluster-safe
selector:
matchLabels:
app: astra-server
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
# …
readinessProbe:
httpGet: { path: /q/health/ready, port: 8080 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /q/health/live, port: 8080 }
periodSeconds: 10
resources:
requests: { cpu: 250m, memory: 512Mi }
limits: { memory: 1536Mi } # ZGC heap is 75 % of this; the Timefold solver needs headroomCùng file này còn định nghĩa một Service, một Ingress (đường dẫn /auth tới Keycloak, / tới kernel) và
một PodDisruptionBudget với minAvailable: 1.
Vì sao hai replica ngay từ đầu thay vì "để sau rồi scale": bất cứ thứ gì chỉ chạy được trên một pod (một bộ đếm trong bộ nhớ, một cache cục bộ bị coi là sự thật, một sticky session) sẽ ẩn mình cho đến lần scale-out đầu tiên, thường vào lúc tệ nhất. Chạy hai pod trong demo buộc mọi tính năng phải an toàn trên cụm ngay bây giờ. Kernel không giữ trạng thái phiên: danh tính đến từ bearer token trên mỗi request, tenant từ request (xem Video 7), và chứng từ từ PostgreSQL.
maxUnavailable: 0 với maxSurge: 1 nghĩa là Kubernetes khởi động một pod mới, chờ nó qua readiness, rồi mới
dừng một pod cũ. Theo thiết kế, năng lực phục vụ không bao giờ xuống dưới hai pod ready. Khoảng hở còn lại được
nói ở phần "Nói đúng".
Bộ đếm
astra-doctype/src/main/java/io/astra/doctype/store/SeriesService.java
/**
* Naming series such as {@code ART-.#####} or {@code ORD-.YYYYMMDD.-.#####}: literal parts and date
* tokens ({@code YYYY}, {@code YY}, {@code MM}, {@code DD}) separated by dots, then a zero-padded
* counter whose width is the number of {@code #}. Counters live per tenant in {@code astra_series},
* keyed by the pattern with its date tokens resolved, so dated series restart every period. The row
* lock taken by the upsert makes the sequence gap-free within the caller's transaction and safe across pods.
*/
// …
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO astra_series (tenant_id, series_key, current) VALUES (?, ?, 1) "
+ "ON CONFLICT (tenant_id, series_key) DO UPDATE SET current = astra_series.current + 1 "
+ "RETURNING current")) {
ps.setString(1, TenantContext.require());
ps.setString(2, key);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return prefix + String.format("%0" + width + "d", rs.getLong(1));
}
}Khuôn mẫu đến từ DocType:
astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml
naming: { series: "ORD-.YYYYMMDD.-.#####" }Và bảng bộ đếm, được tạo trong migration đầu tiên với cùng policy tenant như chứng từ:
astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql
-- Cluster-safe naming series, gap-free inside the enclosing transaction.
CREATE TABLE astra_series (
tenant_id text NOT NULL,
series_key text NOT NULL,
current bigint NOT NULL DEFAULT 0,
PRIMARY KEY (tenant_id, series_key)
);Chi tiết then chốt là next chạy trên connection nào. DocumentStore gọi nó bằng chính connection của chứng
từ, bên trong cùng transaction insert chứng từ:
astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java
private Document persistNew(Connection conn, DocTypeDefinition def, Document doc) throws SQLException {
DocumentValidator.applyDefaultsAndValidate(def, doc);
hooks.beforeSave(doc);
validateLinks(conn, def, doc);
if (doc.name() == null) doc.name(assignName(conn, def, doc));
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO astra_doc (id, tenant_id, doctype, name, owner, data, amended_from) "
+ "VALUES (?, ?, ?, ?, ?, ?::jsonb, ?) RETURNING *")) {
// …Xem nguồn
sequenceDiagram
participant A as Pod p56xb
participant B as Pod th87l
participant P as PostgreSQL astra_series
A->>P: BEGIN; set_config('astra.tenant', 'saigon-express', true)
B->>P: BEGIN; set_config('astra.tenant', 'saigon-express', true)
A->>P: INSERT … ON CONFLICT DO UPDATE current+1 RETURNING current
P-->>A: 7 (dòng ORD-20261021-##### bị A khoá)
B->>P: INSERT … ON CONFLICT DO UPDATE current+1
Note over B,P: B chờ khoá dòng của A
A->>P: INSERT astra_doc ORD-20261021-00007; COMMIT
P-->>B: 8 (khoá được nhả, B thấy 7)
B->>P: INSERT astra_doc ORD-20261021-00008; COMMITVì sao thiết kế như vậy:
- Tính duy nhất đến từ khoá dòng.
ON CONFLICT … DO UPDATEkhoá dòng bộ đếm; một transaction thứ hai tăng cùng key phải chờ đến khi transaction thứ nhất commit hoặc rollback, rồi mới thấy giá trị mới. - Không hổng số đến từ việc dùng chung transaction. Nếu validation, một hook hoặc lệnh insert thất bại sau
khi số đã được cấp, toàn bộ transaction rollback, kể cả lần tăng bộ đếm, nên đơn tiếp theo nhận đúng số đó.
SEQUENCEcủa database không làm được điều này. - Theo tenant, theo kỳ đến từ key. Khoá chính là
(tenant_id, series_key)và key đã được thay ngày vào (ORD-.20261021.-.#####), nên mỗi tenant có dòng riêng cho mỗi ngày và việc đánh số bắt đầu lại hằng ngày mà không cần job reset. Bảng cũng chịu RLS, nên một tenant thậm chí không đọc được bộ đếm của tenant khác. - Không cần thêm hạ tầng. Tính đúng đắn chỉ phụ thuộc vào PostgreSQL. Redis chậm hay sập cũng không ảnh hưởng tới việc đánh số.
Cái giá là tuần tự hoá: mọi lần tạo trên dãy số của một tenant trong một ngày đều xếp hàng trên một dòng, và khoá
được giữ từ assignName đến khi transaction commit, bao gồm lệnh insert và mọi hook afterSave. Với một tenant
tạo vài đơn mỗi giây thì điều này không thể nhận ra; xem "Dành cho tech lead" để biết khi nào thì có.
Hai pod, một dòng bộ đếm.
Rollback trả lại con số.
Rolling restart
Probe được ghi lại (docs/video/v10-k8s/assets/rollout-summary.txt) đếm mã phản hồi của 47 lần đọc gửi mỗi
0,25 s qua ingress trong lúc rollout restart:
2 000
45 200000 nghĩa là curl hoàn toàn không nhận được phản hồi HTTP. Theo brief của video, một trong hai lần đó chạm
timeout 5 s của probe, đúng lúc một pod cũ dừng. Nguyên nhân nằm ở những gì manifest không có: không có hook
preStop và không có độ trễ drain. Khi Kubernetes đánh dấu một pod để kết thúc, pod nhận SIGTERM gần như cùng
lúc ingress controller biết để ngừng định tuyến tới nó. Các request mà ingress gửi đi trong khoảng đó tới một
tiến trình đã bắt đầu tắt.
Chính các script demo cho thấy cách giảm thiểu phía client. scripts/lib/demo-common.sh thử lại một request tối
đa 15 lần khi ingress trả 502 hoặc 503, vì "the ingress found no ready pod (a rollout is draining), so
nothing ran yet". Cách đó phủ 502/503, không phủ một kết nối bị rớt, mà đó lại là thứ probe gặp phải.
Rolling restart, khoảng hở còn lại.
Ví dụ thực tế
Đỉnh 11.11 của một 3PL với phiếu nộp tiền COD đánh số theo tháng. Một công ty chuyển phát chạy Astra với
hai pod kernel. Trong đợt cao điểm của lễ hội mua sắm, nhân viên bán hàng của tenant Sài Gòn và một tích hợp với
sàn thương mại điện tử cùng tạo đơn, còn kế toán lập phiếu nộp tiền COD theo lô, dùng dãy số
CODR-.YYMM.-.####
(astra-apps/express/src/main/resources/META-INF/astra/doctypes/cod-remittance.doctype.yaml).
Đầu vào:
- 08:00:00.100: tích hợp với sàn gửi một đơn, được định tuyến tới pod A.
- 08:00:00.105: một nhân viên bán hàng lưu một đơn trong Desk, được định tuyến tới pod B.
- 08:00:00.110: một đơn thứ ba từ pod A qua được validation, nhưng một hook
afterSaveném lỗi sau lệnh insert. (Validation vàbeforeSavechạy trướcassignName, nên lỗi ở đó không bao giờ chạm tới bộ đếm.) - 23:30: trưởng nhóm vận hành chạy rolling restart để nhận một thay đổi cấu hình.
Astra xử lý thế nào:
- Transaction của pod A lấy dòng bộ đếm của ngày và nhận
ORD-<date>-01234. Upsert của pod B chờ khoá dòng đó, rồi nhận01235sau khi A commit. - Đơn thứ ba nhận
01236bên trong transaction của nó; hookafterSaveném lỗi; transaction rollback, và bộ đếm quay về01235. Đơn thành công tiếp theo là01236. - Lúc 23:30 một pod mới khởi động và ready trước khi một pod cũ bị dừng (
maxSurge: 1,maxUnavailable: 0). Một request rơi vào pod đang dừng đúng khoảnh khắc cuối có thể thất bại; người dùng bấm Save lại.
Đầu ra: một dãy số đơn liên tục trong ngày cho mỗi tenant, không có nhãn vận đơn trùng, một dãy số phiếu nộp tiền COD bắt đầu lại mỗi tháng mà không cần cron job, và nhiều nhất là một lần thử lại cho người dùng gặp đúng lúc restart.
Các số kiểu 01234 chỉ để minh hoạ; lần chạy được đo là dãy 12 đơn ở trên.
Dành cho tech lead
- Phần nào không trạng thái. Các pod kernel: danh tính theo từng request từ token Keycloak, tenant theo từng request, dữ liệu trong PostgreSQL. Bạn có thể thêm replica mà không cần session affinity.
- Phần nào có trạng thái. PostgreSQL (
postgres-0, một StatefulSet trong demo), database của Keycloak, Redis (giữ chỗ TCC) và Seata server.deploy/k8s/base/postgres.yamlnói thẳng: "Demo-grade PostgreSQL. Production uses an operator (CloudNativePG, Patroni) or a managed service." - Trần thông lượng của việc đánh số. Một dòng bộ đếm cho mỗi tenant, dãy số và kỳ. Theo kinh nghiệm, nếu
một transaction tạo chứng từ giữ khoá trong t ms, dãy số đó đạt tối đa khoảng 1000/t lần tạo mỗi giây,
bất kể số pod. Hãy để việc nặng ra khỏi hook
afterSavetrên các DocType khối lượng lớn, hoặc chọn key thô hơn chỉ khi nghiệp vụ chấp nhận. Repo chưa công bố load test nào cho mức trần này. - "Không hổng số" có phạm vi. Các số liên tục đối với những lần tạo đã commit. Chứng từ bị Cancel giữ nguyên
số của nó; một bản Amend nhận tên của chứng từ bị Cancel kèm hậu tố
-1thay vì một số mới.DocResourcekhông có endpoint xoá chứng từ, nên các số đã commit không biến mất. - Múi giờ. Phần ngày lấy từ
LocalDate.now()theo múi giờ mặc định của JVM, vàastra-server/src/main/docker/Dockerfile.jvmkhông đặt múi giờ. Nếu ranh giới ngày phải theo giờ Việt Nam, hãy đặt múi giờ cho pod một cách tường minh. - Chi phí deploy hôm nay. Đã đo: 2 trên 47 lần đọc thất bại trong một lần rolling restart. Trước khi hứa
deploy zero downtime, hãy thêm độ trễ
preStop, một termination grace period đủ phủ các request đang xử lý, và cơ chế thử lại phía client cho các lời gọi idempotent. - Nên đo gì. Thời gian chờ khoá trên
astra_series(wait event trongpg_stat_activitycủa PostgreSQL), tỉ lệ lỗi ở ingress trong mỗi lần rollout, thời gian để một pod mới ready, và bộ nhớ so với giới hạn 1536 Mi. - Khi nào không nên dùng bộ đếm này. Các định danh khối lượng rất lớn, không mang tính tài chính (event ID, lượt quét theo dõi) không cần đánh số liên tục; đặt tên bằng UUID (mặc định khi không đặt series) tránh hẳn khoá này.
Tự chạy thử
Yêu cầu: JDK 25, Docker với 8 GB RAM, kubectl, k3d, jq, Bash 5.
make k3d-up # builds images, deploys deploy/k8s/base, waits for rollouts
make demo-reset && make demo-seed # clean demo data, including the astra_series counters
kubectl -n astra get pods # 2 astra-server pods + astra-cdc, keycloak, postgres-0, redis, seata-server
kubectl -n astra get deployment astra-server -o wideKết quả mong đợi của get deployment (từ docs/video/v10-k8s/assets/deploy.txt; cột age sẽ khác):
NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
astra-server 2/2 2 2 7h45m astra-server astra/server:dev app=astra-serverMở http://localhost:8080/desk/, đăng nhập bằng tuan (mật khẩu Astra@123), và tạo vài Customer Order: số
tiếp nối từ các đơn đã seed. Để tái hiện lần chạy song song, gửi các request POST /api/doc/CustomerOrder đồng
thời tới từng pod riêng (ví dụ qua một kubectl port-forward cho mỗi pod), dùng body đơn hàng được định nghĩa
là ORDER trong scripts/demo-express.sh. Script ghi lại dùng cho video không được đưa vào repository.
Rolling restart khi Desk đang mở:
kubectl -n astra rollout restart deployment/astra-server
kubectl -n astra rollout status deployment/astra-serverKết quả mong đợi: deployment "astra-server" successfully rolled out, hai tên pod mới trong kubectl -n astra get pods, và Desk vẫn dùng được, có khả năng một thao tác trong lúc chuyển đổi cần thử lại.
Dọn dẹp bằng make k3d-down.
Nói đúng
- Gần như không gián đoạn, chưa phải zero downtime. Không có hook
preStophay thời gian drain trongdeploy/k8s/base/astra-server.yaml, nên các request tới một pod đúng lúc nó dừng có thể thất bại. Lần chạy đã đo: 2 trên 47 request không nhận được phản hồi. - PostgreSQL của demo là một pod StatefulSet duy nhất, không phải cấu hình high availability.
- Tài liệu thiết kế (explain.md) mô tả naming series là "Redis counters, gap-free mode for fiscal documents" và rolling upgrade của kernel có drain transaction. Những gì đã xây là bộ đếm khoá dòng PostgreSQL trình bày ở đây, dùng cho mọi dãy số, và một rolling update thường không có drain.
- Không có test tự động nào trong repository kiểm tra
SeriesServicevới nhiều bên ghi đồng thời; bằng chứng là bản ghi 12 đơn trongdocs/video/v10-k8s/assets/. - Các đơn
ORD-20261021-00007đến00018được tạo trong dữ liệu demo dùng chung; hãy reset và seed trước khi quay các video sau.
Hình minh hoạ
- Hình 1 · Hai pod, một dòng bộ đếm
- Hình 2 · Rollback trả lại con số
- Hình 3 · Rolling restart, khoảng hở còn lại
Liên quan
- Trước: Video 9 · Dành cho developer: viết một app Astra · Tiếp: Video 11 · Keycloak
- Video 4 · Vòng đời chứng từ (Cancel và Amend giữ số gốc) · Video 7 · Đa thuê bao (vì sao bộ đếm theo tenant) · Video 8 · Bảo mật fail-closed (các role mà những pod này dùng để kết nối)
- Thiết lập: demo-video-series.md, chuẩn bị môi trường quay
- Mã nguồn:
deploy/k8s/base/astra-server.yaml,deploy/k8s/base/postgres.yaml,scripts/k3d-up.sh,astra-doctype/src/main/java/io/astra/doctype/store/SeriesService.java,astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java