Trong trang này

▶ 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-00007 khô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.
  • SEQUENCE củ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.
  • INCR củ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-00018

Dã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 headroom

Cù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 *")) {
        // …
Sơ đồ
Sơ đồ; Xem nguồn
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; COMMIT

Vì sao thiết kế như vậy:

  • Tính duy nhất đến từ khoá dòng. ON CONFLICT … DO UPDATE khoá 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ố đó. SEQUENCE củ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ó.

Hình 1: Hai pod, một dòng bộ đếm Hai pod, một dòng bộ đếm.

Hình 2: Rollback trả lại con số 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 200

000 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.

Hình 3: Rolling restart, khoảng hở còn lạ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 afterSave ném lỗi sau lệnh insert. (Validation và beforeSave chạy trước assignName, 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:

  1. 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ận 01235 sau khi A commit.
  2. Đơn thứ ba nhận 01236 bên trong transaction của nó; hook afterSave ném lỗi; transaction rollback, và bộ đếm quay về 01235. Đơn thành công tiếp theo là 01236.
  3. 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.yaml nó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 afterSave trê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ố -1 thay vì một số mới. DocResource khô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.jvm khô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 trong pg_stat_activity củ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 wide

Kế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-server

Mở 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-server

Kế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 preStop hay thời gian drain trong deploy/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 SeriesService với nhiều bên ghi đồng thời; bằng chứng là bản ghi 12 đơn trong docs/video/v10-k8s/assets/.
  • Các đơn ORD-20261021-00007 đến 00018 đượ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ạ

Liên quan

Tài nguyên AI

Cung cấp cho trợ lý AI tài liệu của công ty hiện tại, các trang Markdown và đặc tả OpenAPI.

Nguồn
Help me integrate with AstraCMS Docs (Không áp dụng).
Read https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/llms.txt first, then https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/llms-full.txt and https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/openapi.json.
Use only the documented endpoints, authentication, schemas and examples. Ask me about my application stack and the operation I need before writing code. Identify any documented limitations or inconsistencies. Keep credentials in environment variables, never in client-side code. Use the sandbox for examples; do not execute requests or change production data without my explicit approval.
Documentation locale: vi. API language value: vi.
Current page: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/k8s.