Cobble là gì
Storage engine LSM cho SurrealDB: một node ghi, nhiều node đọc bám theo, dữ liệu nằm trên object store (S3/MinIO), độ bền do WAL cục bộ bảo đảm. Trang này là hướng dẫn dùng thật — kiến trúc, luồng, triển khai Kubernetes, và những chỗ nó không làm được.
Node ghi cobble://
Duy nhất một. Sở hữu WAL, memtable, và việc chụp snapshot lên object store. Mở cổng stream cho replica bám.
Node đọc cobblero://
Đọc snapshot từ object store, phủ thêm các bản ghi nhận qua stream. Giữ oplog bền để có thể được promote.
Witness cobble-witness
Không mang dữ liệu, ~380 KB. Chỉ đếm phiếu đa số và nhớ epoch. Phải đặt ở miền lỗi thứ ba.
Chạy thử trong 2 phút
Một node ghi đơn, không replica, không object store ngoài — đủ để thấy nó chạy.
surreal start --user root --pass "$PASS" --bind 127.0.0.1:8000 \
cobble:///var/lib/cobble/data
Thêm một node đọc thì cần object store dùng chung và cổng stream:
# node ghi
export SURREAL_COBBLE_DISTRIBUTED=1
export SURREAL_COBBLE_S3_ENDPOINT=http://minio.internal:9000
export SURREAL_COBBLE_S3_BUCKET=cobble SURREAL_COBBLE_S3_ROOT=surreal
export SURREAL_COBBLE_S3_ACCESS_KEY=... SURREAL_COBBLE_S3_SECRET_KEY=...
export SURREAL_COBBLE_SYNC=stream SURREAL_COBBLE_STREAM_ADDR=0.0.0.0:8100
export SURREAL_COBBLE_STREAM_SECRET="$SHARED_SECRET"
surreal start --bind 0.0.0.0:8000 cobble:///var/lib/cobble/data
# node đọc — cùng bucket, trỏ upstream về cổng stream của node ghi
export SURREAL_COBBLE_UPSTREAM=writer.internal:8100
surreal start --bind 0.0.0.0:8000 cobblero:///var/lib/cobble/cache
- Chỉ một node ghi. Hai node cùng ghi một bucket là hỏng dữ liệu, không phải chậm. Cơ chế term fencing sinh ra để chặn đúng điều này.
- Endpoint S3 được nhúng trong manifest. Node được promote phải phục vụ được đúng endpoint cũ ⇒ luôn dùng tên DNS ổn định, đừng bao giờ dùng IP. Trên Kubernetes thì Service DNS giải quyết sẵn.
- Replica chỉ tham gia được stream sau khi node ghi đã publish snapshot đầu tiên. Đặt
SURREAL_COBBLE_SNAPSHOT_SECSđể điều đó xảy ra sớm.
Kiến trúc
Bấm vào từng khối để xem nó giữ gì và hỏng thì mất gì.
Mỗi khối nói rõ: giữ gì, mất nó thì mất gì, và ai thay thế.
Ba đường dữ liệu, đừng lẫn
| Đường | Đi đâu | Bảo đảm |
|---|---|---|
| Độ bền | Commit → WAL cục bộ (fsync) → memtable | Đây là nguồn sự thật. Snapshot chỉ là bản chụp định kỳ. |
| L1 — bản ghi | Node ghi → node đọc, sau khi đã fsync | Best-effort, có thứ tự, có backfill từ WAL. Mất kết nối thì replica quay về đọc snapshot. |
| L2 — control | Node đọc → node ghi, trên cùng socket | Chuyển tiếp đăng ký LIVE/KILL. Có timeout, có khử trùng lặp theo request_id. |
Luồng
Bốn luồng đáng hiểu. Bấm từng bước.
Semisync đổi điều gì
Mặc định (SURREAL_COBBLE_ACK=local) client được báo OK ngay khi WAL cục bộ đã fsync — mất nguyên máy là mất phần chưa kịp stream. Bật semi thì client chỉ được báo OK sau khi một replica đã fsync bản ghi vào oplog của nó.
| local | semi | |
|---|---|---|
| RPO khi mất node ghi | = độ trễ stream | 0 cho ghi đã được báo OK |
| Giá mỗi commit | — | +1 vòng mạng (đo được +0,2–0,45 ms trên loopback) |
| Không có replica | vẫn ghi | từ chối ghi sau ACK_TIMEOUT_MS |
- Cửa sổ khởi động. Những lệnh ghi đầu tiên là phần tự khai báo node của chính SurrealDB, xảy ra trước khi replica kịp nối. Trong
ACK_ARM_MS(mặc định 30 giây, và chỉ cho lần boot đầu) ack vẫn dựa trên độ bền cục bộ. Khi node đã từng có replica bám luồng, fileSURREAL_COBBLE_REPLICATEDxuất hiện và mặc định thành 0 — restart lúc replica đang chết thì node từ chối phục vụ chứ không âm thầm hạ cấp. - Hết giờ ≠ mất dữ liệu. Bản ghi đã bền trong WAL; cái không giữ được là lời hứa "sống sót khi mất máy này".
- Ghi hiện ra trước khi client biết kết quả. Bản ghi vào memtable và bắn LIVE tại thời điểm commit, nên phiên khác có thể đọc thấy dòng mà chủ nhân của nó vừa nhận lỗi.
Triển khai trên Kubernetes
Kubernetes hợp với cobble hơn máy trần ở đúng một điểm quan trọng: Service DNS là tên ổn định, nên chuyện endpoint S3 bị nhúng trong manifest không còn là cái bẫy khi failover.
- Node ghi đúng 1 pod. StatefulSet
replicas: 1, không HPA, không hai bản cùng lúc lúc rolling — dùngpodManagementPolicy: OrderedReadyvà cập nhật kiểuOnDeletenếu muốn kiểm soát tay. - Mỗi vai trò cần PVC riêng: node ghi giữ WAL + con trỏ; node đọc giữ oplog + base.
- Witness phải ở miền lỗi khác — khác zone, tối thiểu khác node. Đặt cùng chỗ với node đọc là có hại: nó tự cấp đa số cho chính mình.
- Endpoint S3 dùng Service DNS (
http://minio.cobble.svc.cluster.local:9000), không dùng IP pod.
apiVersion: v1
kind: Secret
metadata: { name: cobble-shared, namespace: cobble }
type: Opaque
stringData:
# Bí mật dùng chung cho stream + peer. Phải giống nhau ở MỌI node và witness.
STREAM_SECRET: "thay-bang-32-byte-ngau-nhien"
S3_ACCESS_KEY: "..."
S3_SECRET_KEY: "..."
ROOT_PASS: "..."
---
# Tên cụm: buộc vào mọi MAC. Hai cụm dùng chung secret mà khác CLUSTER_ID
# thì KHÔNG xác thực được với nhau — đó là mục đích.
apiVersion: v1
kind: ConfigMap
metadata: { name: cobble-env, namespace: cobble }
data:
SURREAL_COBBLE_CLUSTER_ID: "prod"
SURREAL_COBBLE_DISTRIBUTED: "1"
SURREAL_COBBLE_S3_ENDPOINT: "http://minio.cobble.svc.cluster.local:9000"
SURREAL_COBBLE_S3_BUCKET: "cobble"
SURREAL_COBBLE_S3_ROOT: "surreal"
SURREAL_COBBLE_SYNC: "stream"
SURREAL_COBBLE_SNAPSHOT_EVERY: "256"
SURREAL_COBBLE_SNAPSHOT_SECS: "30"apiVersion: apps/v1
kind: StatefulSet
metadata: { name: cobble-writer, namespace: cobble }
spec:
serviceName: cobble-writer
replicas: 1 # BẤT BIẾN. Không bao giờ 2.
podManagementPolicy: OrderedReady
updateStrategy: { type: RollingUpdate }
selector: { matchLabels: { app: cobble, role: writer } }
template:
metadata: { labels: { app: cobble, role: writer } }
spec:
terminationGracePeriodSeconds: 60 # để WAL kịp flush
containers:
- name: surreal
image: your-registry/surreal-cobble:3.3.0
args: ["start","--bind","0.0.0.0:8000","cobble:///data"]
ports:
- { name: http, containerPort: 8000 }
- { name: stream, containerPort: 8100 }
- { name: peer, containerPort: 8110 }
envFrom:
- configMapRef: { name: cobble-env }
env:
- { name: SURREAL_COBBLE_STREAM_ADDR, value: "0.0.0.0:8100" }
- { name: SURREAL_COBBLE_PEER_ADDR, value: "0.0.0.0:8110" }
# Chính nó KHÔNG nằm trong danh sách. Cluster size = peers + 1.
- { name: SURREAL_COBBLE_PEERS,
value: "cobble-replica-0.cobble-replica.cobble.svc.cluster.local:8110,cobble-witness.cobble.svc.cluster.local:8110" }
- { name: SURREAL_COBBLE_WAL, value: "every" }
# Trần đĩa nóng: không có nó, PVC phải to bằng cả dataset.
# Offload SST nguội xuống S3 ở mức dùng 0.85, chặn ghi ở 0.95. Sàn 4 GiB.
- { name: SURREAL_COBBLE_LOCAL_CAP_GB, value: "20" }
- { name: SURREAL_COBBLE_WAL_MAX_MB, value: "1024" } # cứng = 2x
- { name: SURREAL_COBBLE_WAL_KEEP_MB, value: "256" } # đuôi cho replica backfill
- { name: SURREAL_COBBLE_BLOCK_CACHE_MB, value: "512" }
- { name: SURREAL_COBBLE_READ_CACHE_MB, value: "256" }
- { name: SURREAL_COBBLE_ACK, value: "semi" }
- { name: SURREAL_COBBLE_ACK_TIMEOUT_MS, value: "5000" }
- { name: SURREAL_USER, value: root }
- { name: SURREAL_PASS, valueFrom: { secretKeyRef: { name: cobble-shared, key: ROOT_PASS } } }
- { name: SURREAL_COBBLE_STREAM_SECRET, valueFrom: { secretKeyRef: { name: cobble-shared, key: STREAM_SECRET } } }
- { name: SURREAL_COBBLE_S3_ACCESS_KEY, valueFrom: { secretKeyRef: { name: cobble-shared, key: S3_ACCESS_KEY } } }
- { name: SURREAL_COBBLE_S3_SECRET_KEY, valueFrom: { secretKeyRef: { name: cobble-shared, key: S3_SECRET_KEY } } }
volumeMounts: [{ name: data, mountPath: /data }]
readinessProbe: { httpGet: { path: /health, port: http }, periodSeconds: 5 }
# KHÔNG dùng livenessProbe hung hăng: node bị fence cố ý từ chối phục vụ,
# khởi động lại nó chỉ tạo vòng lặp restart, không sửa được gì.
resources: { requests: { cpu: "2", memory: 4Gi } }
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 40Gi } } # >= LOCAL_CAP_GB + 2x WAL_MAX_MB + dưapiVersion: apps/v1
kind: StatefulSet
metadata: { name: cobble-replica, namespace: cobble }
spec:
serviceName: cobble-replica
replicas: 2 # muốn bao nhiêu cũng được
selector: { matchLabels: { app: cobble, role: replica } }
template:
metadata: { labels: { app: cobble, role: replica } }
spec:
topologySpreadConstraints: # đừng dồn hết vào một zone
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector: { matchLabels: { app: cobble, role: replica } }
containers:
- name: surreal
image: your-registry/surreal-cobble:3.3.0
args: ["start","--bind","0.0.0.0:8000","cobblero:///data"]
ports:
- { name: http, containerPort: 8000 }
- { name: peer, containerPort: 8110 }
envFrom: [{ configMapRef: { name: cobble-env } }]
env:
- { name: SURREAL_COBBLE_UPSTREAM,
value: "cobble-writer-0.cobble-writer.cobble.svc.cluster.local:8100" }
- { name: SURREAL_COBBLE_PEER_ADDR, value: "0.0.0.0:8110" }
- { name: SURREAL_COBBLE_PEERS,
value: "cobble-writer-0.cobble-writer.cobble.svc.cluster.local:8110,cobble-witness.cobble.svc.cluster.local:8110" }
- { name: SURREAL_COBBLE_ACK, value: "semi" } # phải khớp node ghi
- { name: SURREAL_COBBLE_OPLOG_KEEP_MB, value: "4096" }
- { name: SURREAL_COBBLE_OVERLAY_CAP_MB, value: "512" }
- { name: SURREAL_COBBLE_BLOCK_CACHE_MB, value: "256" }
- { name: SURREAL_COBBLE_READ_CACHE_MB, value: "128" }
- { name: SURREAL_COBBLE_REFRESH_MS, value: "1000" }
- { name: SURREAL_LIVE_QUERY_ENGINE, value: "router" } # cần cho LIVE trên replica
- { name: SURREAL_USER, value: root }
- { name: SURREAL_PASS, valueFrom: { secretKeyRef: { name: cobble-shared, key: ROOT_PASS } } }
- { name: SURREAL_COBBLE_STREAM_SECRET, valueFrom: { secretKeyRef: { name: cobble-shared, key: STREAM_SECRET } } }
- { name: SURREAL_COBBLE_S3_ACCESS_KEY, valueFrom: { secretKeyRef: { name: cobble-shared, key: S3_ACCESS_KEY } } }
- { name: SURREAL_COBBLE_S3_SECRET_KEY, valueFrom: { secretKeyRef: { name: cobble-shared, key: S3_SECRET_KEY } } }
volumeMounts: [{ name: data, mountPath: /data }]
readinessProbe: { httpGet: { path: /health, port: http }, periodSeconds: 5 }
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 60Gi } } # oplog + base mirrorapiVersion: apps/v1
kind: Deployment
metadata: { name: cobble-witness, namespace: cobble }
spec:
replicas: 1
selector: { matchLabels: { app: cobble, role: witness } }
template:
metadata: { labels: { app: cobble, role: witness } }
spec:
affinity:
podAntiAffinity: # KHÔNG ở chung node với writer/replica
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector: { matchExpressions: [{ key: role, operator: In, values: [writer, replica] }] }
topologyKey: kubernetes.io/hostname
containers:
- name: witness
image: your-registry/cobble-witness:3.3.0 # ~380 KB, không phụ thuộc
args: ["/state"]
ports: [{ name: peer, containerPort: 8110 }]
env:
- { name: SURREAL_COBBLE_PEER_ADDR, value: "0.0.0.0:8110" }
- { name: SURREAL_COBBLE_PEERS,
value: "cobble-writer-0.cobble-writer.cobble.svc.cluster.local:8110,cobble-replica-0.cobble-replica.cobble.svc.cluster.local:8110" }
- { name: SURREAL_COBBLE_CLUSTER_ID, value: "prod" }
- { name: SURREAL_COBBLE_STREAM_SECRET, valueFrom: { secretKeyRef: { name: cobble-shared, key: STREAM_SECRET } } }
volumeMounts: [{ name: state, mountPath: /state }]
resources: { requests: { cpu: 10m, memory: 32Mi } }
volumes:
- name: state
persistentVolumeClaim: { claimName: cobble-witness-state }
---
# Term phải sống sót qua restart: witness quên epoch là witness cho phép
# node ghi cũ quay lại.
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: cobble-witness-state, namespace: cobble }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 64Mi } }# Headless: cần tên pod ổn định cho danh sách peer và cho upstream.
apiVersion: v1
kind: Service
metadata: { name: cobble-writer, namespace: cobble }
spec:
clusterIP: None
selector: { app: cobble, role: writer }
ports:
- { name: http, port: 8000 }
- { name: stream, port: 8100 }
- { name: peer, port: 8110 }
---
apiVersion: v1
kind: Service
metadata: { name: cobble-replica, namespace: cobble }
spec:
clusterIP: None
selector: { app: cobble, role: replica }
ports: [{ name: http, port: 8000 }, { name: peer, port: 8110 }]
---
# Endpoint client dùng để ĐỌC: cân tải qua các replica.
apiVersion: v1
kind: Service
metadata: { name: cobble-read, namespace: cobble }
spec:
selector: { app: cobble, role: replica }
ports: [{ name: http, port: 8000 }]
---
# Endpoint client dùng để GHI. Khi failover, chỉ cần đổi selector sang node
# được promote — client không phải cấu hình lại.
apiVersion: v1
kind: Service
metadata: { name: cobble-write, namespace: cobble }
spec:
selector: { app: cobble, role: writer }
ports: [{ name: http, port: 8000 }]
---
apiVersion: v1
kind: Service
metadata: { name: cobble-witness, namespace: cobble }
spec:
selector: { app: cobble, role: witness }
ports: [{ name: peer, port: 8110 }]
---
# Chỉ cho phép ai được nói chuyện với cổng stream/peer. Giao thức có xác thực
# challenge-response, nhưng đừng phơi cổng ra ngoài cụm.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: cobble-internal, namespace: cobble }
spec:
podSelector: { matchLabels: { app: cobble } }
policyTypes: [Ingress]
ingress:
- from: [{ podSelector: { matchLabels: { app: cobble } } }]
ports: [{ port: 8100 }, { port: 8110 }]
- from: [{ namespaceSelector: {} }]
ports: [{ port: 8000 }]# CHẠY TAY, sau khi đã rào node ghi cũ. Không đặt vào CI, không tự động hoá.
#
# 1) kubectl -n cobble scale statefulset/cobble-writer --replicas=0
# 2) đợi pod biến mất thật: kubectl -n cobble get pod -l role=writer -w
# 3) chạy Job này (nó chạy trên PVC của replica được chọn)
apiVersion: batch/v1
kind: Job
metadata: { name: cobble-promote, namespace: cobble }
spec:
backoffLimit: 0 # thất bại thì DỪNG, đừng thử lại mù
template:
spec:
restartPolicy: Never
containers:
- name: promote
image: your-registry/surreal-cobble:3.3.0
command: ["/scripts/cobble-ra-promote.sh", "/data", "/data/base", "--", "true"]
env:
- { name: SURREAL_COBBLE_FENCE_CONFIRMED, value: "1" }
# Cụm 3 thành viên (writer + replica + witness) thì KHÔNG cần cờ dưới đây:
# script tự thấy đa số 2/3 qua file SURREAL_COBBLE_QUORUM.
# Cụm 2 thành viên thì luôn cần — và đó là chữ ký của con người.
# - { name: SURREAL_COBBLE_PROMOTE_WITHOUT_QUORUM, value: "1" }
volumeMounts: [{ name: data, mountPath: /data }]
volumes:
- name: data
persistentVolumeClaim: { claimName: data-cobble-replica-0 }
---
# 4) đổi Service cobble-write sang node vừa promote (hoặc gắn nhãn lại pod)
# 5) node ghi cũ: GIỮ replicas=0 cho tới khi đã cắt đuôi WAL và cho nó
# gia nhập lại làm replica. Nếu nó bật dậy chui, pre-vote sẽ tự poison nó.Image
Chưa có image chính thức — build từ nguồn:
cargo build --release --no-default-features \
--features storage-cobble,http,scripting
cargo build --release -p cobble-witness # binary ~380 KB, không phụ thuộc
GLIBC_2.32 not found. Trong container thì dùng base image cùng đời hoặc mới hơn; trên máy trần thì build bằng toolchain cũ hơn, hoặc dựng bản musl tĩnh.Cấu hình
Chọn ở đây, dán ra dùng.
Kết quả
Bảng biến môi trường
Chung cho mọi vai trò
| Biến | Mặc định | Ý nghĩa |
|---|---|---|
SURREAL_COBBLE_STREAM_SECRET | — | Bắt buộc khi có nhiều node. Phải khớp ở mọi node và witness. Không bao giờ được gửi lên dây — hai bên chứng minh bằng challenge-response. |
SURREAL_COBBLE_CLUSTER_ID | rỗng | Trộn vào mọi MAC. Hai cụm dùng chung secret mà khác id thì không xác thực được với nhau. |
SURREAL_COBBLE_PEER_ADDR | — | Cổng trả lời thăm dò term/seq. |
SURREAL_COBBLE_PEERS | rỗng | Các thành viên khác, ngăn bằng dấu phẩy. Không gồm chính mình. Cluster size = số peer + 1. |
SURREAL_COBBLE_PEER_INTERVAL_MS / _TIMEOUT_MS | 2000 / 1500 | Nhịp thăm dò và timeout mỗi lần. |
SURREAL_COBBLE_S3_* | — | ENDPOINT, BUCKET, ROOT, ACCESS_KEY, SECRET_KEY. |
Node ghi
| Biến | Mặc định | Ý nghĩa |
|---|---|---|
SURREAL_COBBLE_DISTRIBUTED | 0 | Đưa manifest/schema/con trỏ global lên S3. Bắt buộc nếu muốn có node đọc. |
SURREAL_COBBLE_SYNC | — | stream để bật replication. |
SURREAL_COBBLE_STREAM_ADDR | — | Cổng cho node đọc bám vào. |
SURREAL_COBBLE_STREAM_BUF_MB | 64 | Vùng đệm mỗi subscriber. Đầy thì cắt subscriber chậm — fail-safe có chủ ý. |
SURREAL_COBBLE_WAL | every | every (fsync mỗi commit, gộp nhóm) · interval · never. |
SURREAL_COBBLE_SNAPSHOT_EVERY / _SECS | — | Chu kỳ chụp snapshot. Phải đặt _SECS nếu không replica mới không bao giờ vào được stream. |
SURREAL_COBBLE_ACK | local | local hoặc semi. Giá trị lạ là lỗi khởi động, không im lặng lùi về local. |
SURREAL_COBBLE_ACK_TIMEOUT_MS | 5000 | Chờ replica xác nhận. Tính từ lúc commit, không phải lúc tới lượt. |
SURREAL_COBBLE_ACK_ARM_MS | 30000 (chỉ boot đầu) | Cửa sổ ack cục bộ lúc khởi động. Sau khi đã từng có replica → mặc định 0. |
SURREAL_COBBLE_LOCAL_CAP_GB | không đặt = không giới hạn | Trần tầng nóng cục bộ — xem mục Dung lượng bên dưới. Không đặt thì đĩa local phình theo kích thước dữ liệu. |
Node đọc
| Biến | Mặc định | Ý nghĩa |
|---|---|---|
SURREAL_COBBLE_UPSTREAM | — | host:port cổng stream của node ghi. |
SURREAL_COBBLE_REFRESH_MS | 1000 | Nhịp poll con trỏ snapshot mới. |
SURREAL_COBBLE_OVERLAY_CAP_MB | 512 | Trần overlay RAM; vượt thì quay về đọc snapshot. |
SURREAL_COBBLE_OPLOG_KEEP_MB | 1024 | Trần oplog bền. Đầy thì fail-closed — replica ngừng nhận. Cần cảnh báo trước khi chạm. |
SURREAL_COBBLE_CONTROL_TIMEOUT_MS | 5000 | Chờ node ghi xác nhận lệnh LIVE/KILL. |
SURREAL_LIVE_QUERY_ENGINE | inline | Đặt router để LIVE hoạt động trên node đọc. |
Dung lượng & bộ nhớ — nhóm hay bị bỏ sót
SURREAL_COBBLE_LOCAL_CAP_GB thì đĩa local phình theo dữ liệu.
Đây là biến duy nhất biến đĩa cục bộ thành cache nóng có trần thay vì bản sao đầy đủ của toàn bộ dataset.
Nó đặt size_limit cho volume local; tiến trình offload nền của cobble sẽ đẩy các SST nguội xuống S3
khi mức dùng vượt 0,85, và chặn ghi ở 0,95.| Biến | Mặc định | Ý nghĩa và cái bẫy |
|---|---|---|
SURREAL_COBBLE_LOCAL_CAP_GB | không đặt | Trần đĩa nóng cục bộ (GiB). Offload ở 0,85 · write-stop ở 0,95. Chỉ có tác dụng khi đã cấu hình S3 — offload cần một tầng dưới để đẩy file xuống. Giá trị bị kẹp sàn ở 4 GiB vì hybrid block cache đã giữ trước 2 GiB trên chính volume đó. |
SURREAL_COBBLE_BLOCK_CACHE_MB | 64 | Ngân sách block cache (foyer). Áp dụng cho cả node ghi lẫn node đọc. Khác với cache đọc điểm của SurrealDB. |
SURREAL_COBBLE_READ_CACHE_MB | 128 (sàn 1) | Cache đọc điểm dùng chung. Khoá vắng mặt cũng được cache. |
SURREAL_COBBLE_WAL_MAX_MB | 1024 | Ngưỡng mềm của WAL: chạm thì kích snapshot. Ngưỡng cứng = gấp đôi giá trị này, chạm thì dừng ghi. |
SURREAL_COBBLE_WAL_KEEP_MB | 256 | Giữ lại ngần này WAL đã niêm phong để replica backfill. Chỉ có tác dụng khi bật stream. Quá nhỏ ⇒ replica tụt lại sẽ bị đẩy về đường snapshot. |
SURREAL_COBBLE_WAL_GROUP_MAX | 64 (sàn 1) | Số commit tối đa gộp sau một fsync. |
SURREAL_COBBLE_SNAPSHOT_RETENTION | 2 | Số đời snapshot giữ lại. Mặc định của chính cobble là vô hạn — sẽ rò rỉ metadata/dữ liệu mãi mãi; SurrealDB ghi đè xuống 2 vì đường khôi phục con trỏ cần đúng 2 đời. |
SURREAL_COBBLE_QUEUE_CAPACITY | 1024 | Trần hàng đợi thao tác. Chặn việc một lượt đọc object store bị treo biến tồn đọng kênh thành phình RAM. |
SURREAL_COBBLE_OPLOG_KEEP_MB | 1024 | (node đọc) Trần oplog bền. Đầy là fail-closed. |
SURREAL_COBBLE_OVERLAY_CAP_MB | 512 | (node đọc) Trần overlay RAM; vượt thì quay về đọc snapshot. |
SURREAL_COBBLE_STREAM_BUF_MB | 64 | (node ghi) Vùng đệm mỗi subscriber; đầy thì cắt subscriber chậm. |
Ước lượng PVC: node ghi cần LOCAL_CAP_GB + WAL (tới 2×WAL_MAX_MB) + dư. Node đọc cần base mirror + OPLOG_KEEP_MB + dư. Không đặt LOCAL_CAP_GB thì phải cấp PVC bằng cả dataset.
Còn lại
| Biến | Mặc định | Ý nghĩa |
|---|---|---|
SURREAL_COBBLE_S3_REGION | us-east-1 | Region. MinIO không quan tâm, S3 thật thì có. |
SURREAL_COBBLE_PEER_TIMEOUT_MS | 1500 | Timeout mỗi lần thăm dò peer. |
SURREAL_COBBLE_EXIT_ON_PANIC | bật | Worker panic ⇒ tiến trình thoát mã 70 để trình giám sát khởi động lại. Đặt 0/false để giữ tiến trình sống mà khám nghiệm — nó sẽ từ chối mọi thao tác. |
Biến cho script promote
| Biến | Ý nghĩa |
|---|---|
SURREAL_COBBLE_FENCE_CONFIRMED=1 | Bắt buộc. Xác nhận đã rào node ghi cũ. Script không chạy nếu thiếu. |
SURREAL_COBBLE_PROMOTE_WITHOUT_QUORUM=1 | Bỏ qua luật đa số — chữ ký của con người. Cụm 2 thành viên thì luôn cần. |
SURREAL_COBBLE_QUORUM_MAX_AGE_S | Tuổi tối đa của file chứng nhận còn được tin (mặc định 30 giây). |
SURREAL_COBBLE_BASE_MC_URL | Đặt thì script kiểm con trỏ base có tồn tại thật trong mirror không trước khi làm gì. Nên đặt — thiếu nó thì lỗi lộ ra dưới dạng 404 khó hiểu giữa lúc sự cố. |
File trong thư mục dữ liệu
| File | Ai ghi | Ý nghĩa |
|---|---|---|
SURREAL_COBBLE_TERM | promote script, engine | Epoch. Tăng đơn điệu, bền. Trái tim của fencing. |
SURREAL_COBBLE_QUORUM | luồng peer | epoch_ms reachable configured peer_term local_term local_seq. Promote script đọc file này. |
SURREAL_COBBLE_REPLICATED | node ghi | Đã từng có replica bám luồng ⇒ semisync không tự cấp lại cửa sổ khởi động. |
SURREAL_COBBLE_SNAPSHOT | node ghi / mirror script | Con trỏ snapshot bền, kèm .prev. |
SURREAL_COBBLE_LOCK | engine | Khoá cố vấn: chặn hai tiến trình mở cùng thư mục trên cùng một máy. Không chặn được hai máy — đó là việc của term. |
SURREAL_COBBLE_INITIALIZED | engine | Đánh dấu thư mục đã khởi tạo, để phân biệt "mới tinh" với "con trỏ hỏng". |
Vận hành
Failover — thứ tự không được đổi
- Xác nhận chết bằng ≥2 kênh độc lập. "Client kêu" không phải một kênh. Node ghi vẫn có thể sống và đang phục vụ trong khi bạn không thấy nó.
- Rào (F1).
kubectl scale sts/cobble-writer --replicas=0, hoặcsystemctl disable --nowtrên máy trần. Đợi cho tới khi nó thật sự biến mất. - Chốt base.
cobble-ra-mirror-base.sh SRC DST BASE_DIR— dạng có BASE_DIR để con trỏ được suy ra từ những gì mirror thật sự có. Bỏ bước này thì node promote chết lúc boot vớiSNAPSHOT-N: NotFound. - Chuyển endpoint object store nếu node được promote dùng store riêng (trên K8s thì Service DNS lo sẵn).
- Promote.
SURREAL_COBBLE_FENCE_CONFIRMED=1 cobble-ra-promote.sh REPLICA_DIR BASE_DIR -- surreal start .... Script sẽ kiểm đa số, kiểm tuổi file chứng nhận, từ chối nếu peer đã giữ term mới hơn, rồi tăng term. - Kiểm chứng số bản ghi so mốc trước, ghi thử một dòng, xem term mới.
- Đổi hướng client ghi.
- Ghi biên bản: thời điểm, seq mốc, term mới, kênh xác nhận chết.
- Node cũ sống lại thì gia nhập làm node đọc, không bao giờ tự làm lại node ghi. Nếu nó bật dậy chui, pre-vote sẽ tự poison nó.
Cụm 2 thành viên: đọc kỹ
SURREAL_COBBLE_PROMOTE_WITHOUT_QUORUM=1. Đây là toán học, không phải thiếu tính năng. Muốn tự quyết thì thêm thành viên thứ ba: một witness ~380 KB là đủ.Cần theo dõi gì
| Chỉ số | Nguồn | Ngưỡng cảnh báo |
|---|---|---|
| Độ trễ stream (seq) | log stream_lag_seq | Tăng đều không tụt ⇒ replica không theo kịp |
unreplicated_seq | log node ghi | > 0 kéo dài ⇒ RPO thật đang lớn dần |
| Oplog sắp chạm cap | dung lượng thư mục oplog | Phải cảnh báo trước khi đầy — đầy là fail-closed |
| Subscriber = 0 | subscriber_count | Với semi: mọi ghi sắp fail |
| Tuổi file quorum | SURREAL_COBBLE_QUORUM | > 30 giây ⇒ promote sẽ bị từ chối |
| Đĩa của node đọc | PVC | Cần chứa base mirror + oplog |
Đọc log
| Dòng log | Nghĩa là gì |
|---|---|
lost an election and is now poisoned | Node ghi này đã bị thay thế. Không restart — khôi phục từ node thắng. |
pre-vote lost: a peer holds fencing term N | Node cũ vừa cố bật dậy và bị chặn ngay lúc khởi động. |
cobble semisync: commit N is durable locally but no replica… | Ghi đã bền tại chỗ nhưng chưa nhân bản. Client nhận lỗi. Không mất dữ liệu. |
disconnected slow subscriber (buffer full) | Replica không theo kịp và bị cắt. Nó sẽ quay lại qua đường snapshot. |
refusing promotion: … majority needs N | Ứng viên đang là thiểu số. Đây là script làm đúng việc. |
Mô hình bảo mật
Một bí mật dùng chung, không bao giờ nằm trên dây.
Xác thực
Cả cổng peer lẫn cổng stream đều dùng challenge-response hai chiều trên HMAC-SHA256:
A → B nonce_A
B → A nonce_B
A → B HMAC(secret, domain ‖ cluster_id ‖ nonce_A ‖ nonce_B ‖ "client" ‖ dữ_liệu_khẳng_định)
B → A dữ_liệu ‖ HMAC(secret, … ‖ "server" ‖ dữ_liệu)
- Secret không rời tiến trình. Trên dây chỉ là MAC trên hai nonce.
- Câu trả lời cũng được ký. Nếu chỉ ký phía gọi, kẻ giả mạo vẫn dúi được một term khổng lồ vào node đang khởi động — mà term thì chỉ tăng, không lùi được.
- Những trường có hậu quả bền đều nằm trong phần được ký: điểm resume, cả hai term.
- Domain tách biệt giữa hai giao thức, nên MAC của cái này không replay sang cái kia.
- Cluster id chặn việc hai cụm dùng chung secret dạy epoch cho nhau qua một dòng cấu hình gõ nhầm.
Chống ép cạn tài nguyên
Node không trả lời được sẽ bị các peer đọc là unreachable — mà unreachable chính là trạng thái cho phép một node ghi cũ vượt qua pre-vote. Nên im lặng không phải lỗi trung tính; đó là mục tiêu của kẻ tấn công. Vì thế:
| Biện pháp | Giá trị |
|---|---|
| Một luồng cho mỗi request | Vòng accept không bao giờ làm việc thay cho caller chưa xác thực |
| Trần luồng đồng thời | 32, dư thì drop ngay; cảnh báo giới hạn theo luỹ thừa 2 |
| Trần khung tin trước xác thực | 64 KiB |
| Hạn chót theo đồng hồ | 2 giây cho cả request — timeout theo từng syscall là không đủ, kẻ nhỏ giọt 1 byte sẽ giữ luồng vô hạn |
Chưa làm — nói thẳng
MAC từng frame sẽ bịt vế đầu; vế sau là việc của tầng mạng. Lưu ý: đường object store cũng là cleartext, nên mã hoá riêng luồng replication là an ninh trình diễn. Cách đúng cho cụm chạy qua mạng không tin cậy: mTLS / service mesh trong Kubernetes, hoặc WireGuard giữa các máy trần — cả hai bọc luôn cả đường object store.
Ngoài ra: semisync tin biên nhận của replica. Sau khi có xác thực thì chỉ node hợp lệ mới ACK được, nhưng một replica lỗi vẫn có thể báo sai. Đó là giới hạn của mô hình, không phải lỗi lập trình.
Giới hạn đã biết
Danh sách này quan trọng hơn phần tính năng. Đọc trước khi đưa vào production.
| Giới hạn | Hệ quả thật | Ai gánh |
|---|---|---|
| Một node ghi | Không mở rộng được đường ghi; mất node ghi là mất khả năng ghi cho tới khi promote xong | Failover là thao tác có con người; RTO tính bằng phút |
| Endpoint S3 nhúng trong manifest | Node promote phải phục vụ được đúng endpoint cũ | Dùng DNS ổn định. K8s Service DNS xử lý sẵn; máy trần cần floating IP / DNS TTL ngắn |
| Chia cắt mạng | Không cấu hình nào cho phép promote tự động an toàn khi chỉ có 2 thành viên | Thêm thành viên thứ 3, hoặc chấp nhận mất khả năng ghi tạm thời |
| Node bị cô lập vẫn phục vụ đọc | Client còn tới được nó sẽ đọc dữ liệu cũ dần, vô hạn định | Chưa có cơ chế tự tắt đọc khi mất quorum |
| Oplog fail-closed | Oplog đầy thì replica ngừng nhận thay vì xoá lịch sử | Đặt cap đủ lớn + cảnh báo trước khi chạm |
| Replica cần snapshot đầu tiên | Cụm mới toanh: replica không vào được stream cho tới khi có snapshot | Đặt SNAPSHOT_SECS |
| LIVE đăng ký sau snapshot cuối | Giữ được nếu đuôi oplog còn; ngoài ra thì mất khi failover | Client LIVE phải tự đăng ký lại sau failover — SDK làm sẵn |
| Chèn frame bởi kẻ on-path | Bắt tay đã xác thực, nhưng frame sau đó chưa được ký từng cái | mTLS / mesh / WireGuard |
Trước khi gọi là "có HA"
Phải diễn tập thật, giết máy thật, bấm đồng hồ thật:
- Giết node ghi khi đang ghi đều → đếm bản ghi trước/sau, đo RTO có người thật bấm giờ.
- Chia cắt mạng hai chiều, cả hai bên còn sống → xác nhận không bên nào tự promote.
- Node ghi restart trong lúc bị chia cắt → đo đúng bao nhiêu ghi lọt vào cửa sổ arming.
- Bơm tải tới khi oplog chạm cap → xác nhận fail-closed đúng và cảnh báo nổ trước.
- Diễn tập đổi endpoint object store bằng DNS thật, không phải mẹo sửa
/etc/hosts.