Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

高可用与分布式部署指南

RustBill 从设计之初就面向水平扩展和高可用部署。本文档涵盖从单机到 Citus 分布式集群的完整演进路径。


架构总览

                      DNS Round-Robin / VIP
                             │
          ┌──────────────────┼──────────────────┐
          │                  │                  │
      Caddy-1            Caddy-2           Caddy-N
   (HTTP/3+TLS)       (HTTP/3+TLS)       (HTTP/3+TLS)
          │                  │                  │
          └──────┬───────────┴──────────┬───────┘
                 │   health-check       │
                 │   least_conn LB      │
                 │                      │
          ┌──────┴──────────────────────┴───────┐
          │         rustbill-server × N         │
          │   (stateless, tonic gRPC :50051)    │
          └──────┬──────────────────────┬───────┘
                 │                      │
          ┌──────┴──────┐        ┌──────┴──────┐
          │    Redis    │        │    OTLP     │
          │ (optional)  │        │  Collector  │
          └─────────────┘        └─────────────┘
                 │
          ┌──────┴──────────────────────────────┐
          │          Citus Coordinator          │
          │   (PG protocol routing, metadata)   │
          └──────┬───────────────────┬──────────┘
                 │                   │
          ┌──────┴──────┐     ┌──────┴──────┐
          │  Worker-1   │     │  Worker-2   │
          │(shard 1-32) │     │(shard 33-64)│
          │  + replica  │     │  + replica  │
          └─────────────┘     └─────────────┘
                 │                   │
          ┌──────┴───────────────────┴──────┐
          │        etcd cluster × 3         │
          │      (Patroni distributed       │
          │     consensus for failover)     │
          └─────────────────────────────────┘
### 核心设计原则

| 原则 | 实现方式 |
|------|---------|
| **无状态应用层** | rustbill-server 不存本地状态,Session 入 Redis/DB |
| **PG 做共识** | 领导选举和事件外发箱全部基于 PostgreSQL 实现,无额外依赖 |
| **优雅降级** | Redis 不可用时自动回退到 PG;Rate Limit DB 模式 |
| **故障自愈** | 熔断器自动切断故障下游,恢复后半开探测 |
| **可观测性** | Request ID 全链路追踪 + OTLP 导出 + 健康检查端点 |

---

## 1. 多实例水平扩展

### 1.1 应用层无状态

rustbill-server 每个实例完全无状态:

- **Session** — Redis cache-aside 优先,miss 回源 `sessions` 表。Redis 不可用时直接查 DB。
- **JWT** — 自包含 token,任意实例可独立验证。
- **插件** — Rune 脚本源码存储于 DB `plugin_interfaces.script_source`。`ScriptEngine` 内存缓存,热重载自动同步。
- **后台任务** — 通过领导选举确保单实例执行(见第 3 节)。

所有实例共享同一份 `config.toml`,指向同一个 DB 和 Redis。无状态使得扩缩容只需增减 Caddy upstream 列表。

### 1.2 Caddy 反向代理

```caddyfile
# /etc/caddy/Caddyfile
rustbill.example.com {
    # HTTP/3 QUIC
    protocols h1 h2 h3

    # 上游 gRPC 服务
    reverse_proxy /rustbill.* {
        to localhost:50051 localhost:50052
        health_uri /health
        health_interval 5s
        lb_policy least_conn

        # gRPC 需要 HTTP/2 传输
        transport http {
            versions h2c
        }
    }

    # Admin 静态资源(内嵌于 server)
    reverse_proxy /admin* {
        to localhost:50051 localhost:50052
        lb_policy least_conn
    }

    # 日志
    log {
        output file /var/log/caddy/rustbill.log
        format json
    }
}

关键配置项:

配置推荐值说明
health_uri/health短路端点,仅返回 200,不做任何 DB 查询
health_interval5s健康检查频率。过短增加负载,过长延迟故障检测
lb_policyleast_conn最少连接数算法,适合 gRPC 长连接场景
transport http versionsh2cgRPC 要求 HTTP/2 传输层

1.3 并发控制

[server]
max_concurrency = 30   # 每实例并发上限

每个 rustbill-server 实例在 Tower 中间件栈入口处设置 ConcurrencyLimitLayer。超过上限的请求进入 FIFO 队列等待(背压),而非直接拒绝。集群总并发容量 = N × max_concurrency

1.4 速率限制

[rate_limit]
enabled = true
login_per_sec = 5       # 登录 5 次/秒/IP
register_per_sec = 1    # 注册 1 次/秒/IP

双后端模式:

后端配置适用场景
内存[redis] enabled = false单实例或允许独立计数
Redis[redis] enabled = true多实例共享计数,全局精确限流

Redis 不可用时自动降级到内存(per-instance 计数,非精确)。


2. 实例标识与心跳

2.1 InstanceIdentity

#![allow(unused)]
fn main() {
// 启动时生成 UUID v7(时间有序)
pub struct InstanceIdentity {
    pub id: Uuid,  // UUID v7
}
}

每个 rustbill-server 实例启动时生成一个 UUID v7,注册到 instance_heartbeat 表:

CREATE TABLE instance_heartbeat (
    instance_id   UUID PRIMARY KEY,
    registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    heartbeat_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    pool_idle     INTEGER NOT NULL DEFAULT 0,   -- 空闲连接数
    pool_active   INTEGER NOT NULL DEFAULT 0,   -- 活跃连接数
    pool_max      INTEGER NOT NULL DEFAULT 0    -- 最大连接数
);

UUID v7 采用时间有序编码,便于日志排序和调试。

2.2 心跳机制

每 10 秒更新 heartbeat_at + 连接池快照(pool_idle / pool_active):

启动 → InstanceIdentity::new(注册)
  → spawn InstanceHeartbeat (interval 10s)
  → 更新 heartbeat_at + pool_{idle,active}

心跳数据被领导选举用于负载感知调度(见第 3 节)。心跳超时 30 秒的实例被视为失联,其持有的 leader 角色自动释放。


3. 领导选举

3.1 基于 PostgreSQL 表锁

RustBill 不引入 ZooKeeper/etcd 等外部共识组件,直接利用 PostgreSQL 的行锁实现领导选举。

#![allow(unused)]
fn main() {
pub struct LeaderElection {
    pool: PgPool,
    instance_id: Uuid,
    held: Arc<RwLock<HashSet<String>>>,  // 当前持有的角色
}
}

选举 SQL(try_acquire):

UPDATE leader_role
SET holder = $instance_id, heartbeat_at = NOW(), ttl_secs = 30
WHERE role = $role
  AND heartbeat_at < NOW() - make_interval(secs => COALESCE(
      (SELECT ttl_secs FROM leader_role WHERE role = $role), 30))
  AND holder IN (
    SELECT instance_id FROM instance_heartbeat
    WHERE heartbeat_at > NOW() - INTERVAL '30 seconds'
    ORDER BY pool_idle DESC, instance_id ASC LIMIT 1
  )

关键机制:

  • FOR UPDATE 隐式行锁UPDATE 在 PG 中自动获取行级排他锁,多实例并发执行时只有一个成功。
  • 心跳过期 — TTL 30 秒。leader 不续期时租约自动过期,其他实例可抢占。
  • 存活检查 — 仅心跳正常的实例(instance_heartbeat.heartbeat_at > NOW() - 30s)可参与选举。
  • 负载感知ORDER BY pool_idle DESC, instance_id ASC 优先选举空闲连接最多的实例。

3.2 三种 Leader 角色

角色职责由谁持有
migration数据库迁移执行启动时最先选举,仅 leader 执行 sqlx::migrate!
billing定时账单生成(月末批量)仅 leader 运行 billing cron job
event_worker事件队列消费、支付超时扫描仅 leader 运行 event worker loop

三种角色独立选举,可分布在不同实例上,避免单点瓶颈。

3.3 续期与释放

#![allow(unused)]
fn main() {
// 每 10 秒续期,保持租约活跃
leader_election.spawn_renewal("event_worker".to_string(), 10);
// 实例关闭时自动释放
impl Drop for LeaderElection {
    // release all held roles
}
}

续期后台任务每 10 秒更新 heartbeat_at = NOW()。若 leader 崩溃,TTL 30 秒后自动释放,follower 接管。

3.4 启动流程

create_pool → InstanceIdentity::new(注册) → LeaderElection(migration)
  ├── leader:   run_migrations + spawn_renewal("migration")
  └── follower: poll leader_role 等待 leader(最多 60s)
→ spawn InstanceHeartbeat(10s)
→ 正常启动 gRPC server
→ spawn PluginScanner
→ spawn event_worker(竞争 leader 后才开始消费)
→ spawn 其他后台任务

Follower 在迁移完成前阻塞,确保不会在旧 schema 上运行。


4. 事务外发箱与事件 Worker

4.1 事件队列表

CREATE TABLE event_queue (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type  TEXT NOT NULL,          -- order_paid / order_provisioned / order_cancelled
    payload     JSONB NOT NULL,
    status      TEXT NOT NULL DEFAULT 'pending',  -- pending/processing/done/failed
    order_id    UUID NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

业务操作在同一数据库事务中写入业务数据 + event_queue 记录,保证原子性。典型流程:

BEGIN;
  -- 余额扣减
  UPDATE customers SET balance = balance - $amount;
  -- 创建支付记录
  INSERT INTO payments ...;
  -- 更新订单状态
  UPDATE orders SET status = 'provisioning';
  -- 插入事件
  INSERT INTO event_queue (event_type, payload, order_id)
    VALUES ('order_paid', $payload, $order_id);
COMMIT;

4.2 Worker 消费

#![allow(unused)]
fn main() {
// 每 5 秒轮询
loop {
    // 1. 检查是否仍是 event_worker leader
    if !leader_election.is_leader("event_worker").await { break; }

    // 2. 获取 pending 事件(并发安全)
    let events = sqlx::query(
        "SELECT id, event_type, payload, order_id
         FROM event_queue
         WHERE status = 'pending'
         ORDER BY created_at ASC
         LIMIT 10
         FOR UPDATE SKIP LOCKED"
    ).fetch_all(&pool).await?;

    // 3. 按 order_id 获取 advisory lock → 串行化同订单事件
    for event in events {
        let key = uuid_to_advisory_key(&event.order_id);
        sqlx::query("SELECT pg_try_advisory_xact_lock($1)")
            .bind(key).fetch_one(&pool).await?;
        // dispatch: provision → notify
    }
}
}

并发安全保证:

  • FOR UPDATE SKIP LOCKED — 多个 worker 不会重复消费同一事件
  • pg_try_advisory_xact_lock(hash(order_id)) — 同订单事件串行执行,跨订单并发执行
  • 事务释放时自动释放 advisory lock,无需显式解锁

4.3 事件类型与处理

事件触发时机处理逻辑成功后
order_paid支付完成调用 provider 开通实例写入 order_provisioned 事件
order_provisioned开通成功广播通知(客户 + 管理员)标记 done
order_cancelled订单取消退款 + 广播通知标记 done

开通失败时自动退款 + 写入 order_cancelled 事件触发通知(Saga 补偿)。

4.4 支付超时扫描

Worker 同时扫描 24 小时未支付的订单:

UPDATE orders SET status = 'cancelled'
WHERE status = 'pending'
  AND created_at < NOW() - INTERVAL '24 hours'
RETURNING id

取消的订单同样写入 order_cancelled 事件,走退款+通知流程。


5. 熔断器

5.1 三段状态机

  ┌──────────┐    5 次失败     ┌──────────┐
  │  Closed   │ ──────────────→ │   Open    │
  │ (正常)    │                 │ (熔断)    │
  └──────────┘                 └─────┬─────┘
       ↑                              │
       │          30s 冷却后           │
       │    ┌──────────────────┐      │
       └────┤    HalfOpen      │←─────┘
            │ (探测)           │
            └────┬─────────────┘
                 │
         成功 ───┴─── 失败 → Open
  • Closed — 正常状态,失败计数器递增。累计 5 次失败 → Open。
  • Open — 熔断状态,直接拒绝请求(抛出 CircuitOpen 错误),无需等待超时。
  • HalfOpen — 30 秒冷却后进入,允许 1 次探测请求。成功 → Closed;失败 → Open。

5.2 按 host 粒度的熔断器管理

#![allow(unused)]
fn main() {
pub struct BreakerManager {
    breakers: RwLock<HashMap<String, Arc<CircuitBreaker>>>,
}
}

熔断器按下游 host 隔离,一个支付网关故障不影响其他网关。用于所有出站 HTTP 调用:

  • 支付网关(易支付、自定义网关)
  • 上游供应商(RustBill 上游实例)
  • 通知渠道(Webhook)

5.3 CoreHttpClient

#![allow(unused)]
fn main() {
pub struct CoreHttpClient {
    client: reqwest::Client,
    cert_clients: RwLock<HashMap<String, reqwest::Client>>,  // mTLS client 缓存
    breakers: BreakerManager,
}
}

统一出站 HTTP 客户端,提供:

  • 3 次重试 — 指数退避,透明重试网络瞬时错误
  • 熔断检查 — 请求前检查 host 熔断器状态,Open 时快速失败
  • mTLS 证书缓存 — 按 cert hash 缓存 reqwest::Client 实例,避免每次重建 TLS 握手

6. Redis 缓存层(可选)

Redis 是可选组件。不配置时全部功能降级运行,零阻塞。

6.1 功能矩阵

功能Redis 可用Redis 不可用(降级)
Session 缓存cache-aside(快)纯 DB 查询(慢,但可用)
分布式锁Redis SETNXPG advisory lock
跨实例限流Redis 全局计数内存 per-instance 计数(近似)

6.2 配置

[redis]
enabled = true
url = "redis://localhost:6379"
max_connections = 10
prefix = "rustbill"

prefix 用于多环境隔离(如 rustbill-prod / rustbill-staging 共享同一 Redis)。

6.3 降级策略

Session 读取:
  Redis GET → hit → 返回
           → miss → DB 查询 → Redis SET (异步, 忽略失败) → 返回

Rate Limit:
  Redis INCR → 成功 → 全局精确
            → 失败 → 内存计数器 (per-instance)

分布式锁:
  Redis SETNX → 成功 → 获取锁
             → 失败 → PG pg_try_advisory_lock

所有 Redis 操作失败后自动降级,不阻塞业务。恢复后自动切回。


7. Citus 分布式数据库

当单 PostgreSQL 实例无法承载数据量或并发时,迁移到 Citus。

7.1 分片策略

                    ┌──────────────────┐
                    │   Coordinator     │
                    │  (metadata only)  │
                    └──┬────────────┬──┘
                       │            │
              ┌────────┴──┐   ┌────┴──────────┐
              │  Worker-1  │   │  Worker-2      │
              │  shard 1-32│   │  shard 33-64   │
              │  (PG 16)   │   │  (PG 16)       │
              │  + replica │   │  + replica     │
              └───────────┘   └───────────────┘

分片键:customer_id。按 customer_id 哈希分片,每个客户的所有关联数据落在同一 Worker。

7.2 分布式表(Distributed Tables)

customer_id 分片的表:

表名分片键说明
customerscustomer_id (id)客户主体
customer_userscustomer_id客户子账户
orderscustomer_id订单
invoicescustomer_id账单
invoice_itemscustomer_id账单明细
paymentscustomer_id支付记录
instancescustomer_id云实例
balance_transactionscustomer_id余额变动
ticketscustomer_id工单
ticket_repliescustomer_id工单回复

同客户数据共置:一个客户的所有订单、支付、实例、工单存于同一 Worker。跨 Worker Join 几乎不发生。

7.3 参考表(Reference Tables)

复制到所有 Worker 的表(全量同步,高频读取):

表名说明
admin_users管理员账户
sessionsSession 记录
products商品定义
product_categories商品分类
product_groups商品分组
plugin_interfaces插件接口实例
plugins插件定义
instance_heartbeat实例心跳
leader_role领导选举
event_queue事件队列
api_keysAPI 密钥

7.4 迁移 004 前置条件

004_citus_prepare.sql 确保所有涉及 customer_id 的列均为 NOT NULL,这是 Citus 创建分布式表的前提。此迁移与单节点 PG 完全兼容。

7.5 初始化集群

-- 在 Coordinator 上执行

-- 添加 Worker 节点
SELECT citus_add_node('worker-1.internal', 5432);
SELECT citus_add_node('worker-2.internal', 5432);

-- 创建分布式表(示例)
SELECT create_distributed_table('customers', 'id');
SELECT create_distributed_table('orders', 'customer_id');
SELECT create_distributed_table('payments', 'customer_id');
SELECT create_distributed_table('instances', 'customer_id');
-- ... 其余分布式表

-- 创建参考表
SELECT create_reference_table('admin_users');
SELECT create_reference_table('products');
SELECT create_reference_table('plugin_interfaces');
-- ... 其余参考表

推荐分片数:64(2 Worker 各 32,后续可水平扩容)。

7.6 在线扩容

-- 添加新 Worker
SELECT citus_add_node('worker-3.internal', 5432);

-- 开始数据重分布(零停机)
SELECT citus_rebalance_start();

-- 监控进度
SELECT * FROM citus_rebalance_status();

Citus 在后台迁移分片,读写正常进行。完成后分片自动重新分布到全部 Worker。


8. Worker 高可用 — Patroni + etcd

每个 Citus Worker 独立配置 PG 流复制 + Patroni 自动故障切换。

8.1 架构

              ┌─────────────────────────────────┐
              │          etcd cluster           │
              │ (3 nodes, distributed concensus)  │
              └──┬─────────────┬─────────────┬──┘
                 │              │              │
         ┌───────┴──────┐ ┌────┴──────┐ ┌────┴──────┐
         │ Worker-1      │ │ Worker-2   │ │ Worker-3   │
         │ ┌───────────┐ │ │            │ │            │
         │ │ Patroni   │ │ │ Patroni    │ │ Patroni    │
         │ │ (leader)  │ │ │ (replica)  │ │ (replica)  │
         │ └─────┬─────┘ │ │            │ │            │
         │       │       │ │            │ │            │
         │  ┌────┴────┐  │ │ ┌────────┐ │ │ ┌────────┐ │
         │  │ PG 16    │  │ │ │ PG 16  │ │ │ │ PG 16  │ │
         │  │ (master) │──┼─┼→│(standby)│ │ │ │(standby)│ │
         │  └─────────┘  │ │ └────────┘ │ │ └────────┘ │
         └───────────────┘ └────────────┘ └────────────┘
              streaming       streaming       streaming
              replication     replication     replication

8.2 Patroni 配置

# /etc/patroni/patroni.yml
scope: rustbill-worker1
name: worker1-node1

restapi:
  listen: 0.0.0.0:8008
  connect_address: worker1-node1.internal:8008

etcd:
  hosts: etcd1.internal:2379,etcd2.internal:2379,etcd3.internal:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576     # 1MB
    postgresql:
      use_pg_rewind: true
      parameters:
        max_connections: 200
        shared_buffers: 8GB
        wal_level: replica
        hot_standby: "on"
        max_wal_senders: 10
        wal_keep_size: 1GB

postgresql:
  listen: 0.0.0.0:5432
  connect_address: worker1-node1.internal:5432
  data_dir: /var/lib/postgresql/16/main
  pg_hba:
    - host replication replicator 0.0.0.0/0 md5
    - host all all 0.0.0.0/0 md5

8.3 故障切换流程

1. Patroni 检测 leader 心跳丢失(etcd TTL 30s)
2. 选举新 leader(etcd CAS 操作,只有一个 replica 成功)
3. 新 leader 提升为 master(pg_ctl promote)
4. 其他 replica 切换到新 master(rewind + follow)
5. Coordinator 自动感知(连接池重连到新 master VIP)

切换时间: 通常 30-60 秒。

8.4 etcd 集群

# 三节点 etcd 集群部署
# etcd1
etcd --name etcd1 \
  --initial-cluster etcd1=http://etcd1.internal:2380,etcd2=http://etcd2.internal:2380,etcd3=http://etcd3.internal:2380 \
  --initial-cluster-state new

# etcd2, etcd3 同理

etcd 集群容忍 1 节点故障(3 节点 majority = 2)。


9. 监控与可观测性

9.1 健康检查端点

端点用途行为
GET /healthCaddy 健康检查立即返回 200,短路所有中间件
GET /ready就绪检查检查 DB 连接池连通性
# Caddy 使用 /health
curl -s http://localhost:50051/health
# → 200 OK

# k8s readiness probe 使用 /ready
curl -s http://localhost:50051/ready
# → 200 OK 或 503(DB 不通)

9.2 请求追踪

每个请求携带:

  • X-Request-Id — UUID v7,由服务端生成
  • traceparent — W3C Trace Context 标准头,格式 00-{trace_id}-{span_id}-01
# 示例响应头
X-Request-Id: 018f6a3c-8b7e-7d01-9a2c-c3f1e4b8d5a2
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

9.3 OpenTelemetry

[telemetry]
enabled = true
otlp_endpoint = "http://localhost:4317"
service_name = "rustbill"

导出到 OTLP Collector(Jaeger / Grafana Tempo),包含:

  • gRPC 请求 span(method、status code、duration)
  • DB 查询 span(sqlx 自动注入)
  • 出站 HTTP span(CoreHttpClient 自动注入)
  • 事件处理 span

9.4 systemd 健康检查定时器

# /etc/systemd/system/rustbill-healthcheck.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/grpcurl -plaintext localhost:50051 rustbill.identity.IdentityService/GetMe
User=rustbill

# /etc/systemd/system/rustbill-healthcheck.timer
[Timer]
OnCalendar=*:0/5              # 每 5 分钟
Persistent=true

[Install]
WantedBy=timers.target

9.5 数据库监控查询

-- 活跃 Worker 节点
SELECT node_name, node_port, is_active, should_haveshards
FROM citus_get_active_worker_nodes();

-- 分片分布
SELECT shard_count, nodename, nodeport
FROM citus_shards
GROUP BY nodename, nodeport;

-- 租户数量(每个 shard 的行数)
SELECT shardid, shard_size, result::json->0->>'row_count' AS rows
FROM citus_shard_sizes()
JOIN LATERAL json_array_elements(shard_sizes::json) AS result ON true
WHERE table_name = 'customers'::regclass;

-- 跨分片查询监控(慢查询诊断)
SELECT queryid, query, calls, mean_exec_time
FROM citus_stat_statements
WHERE calls > 0
ORDER BY mean_exec_time DESC
LIMIT 10;

9.6 资源监控

# 服务状态(内存/CPU)
systemctl status rustbill-server

# 连接数
ss -tnp | grep :50051 | wc -l

# 数据库连接
psql -c "SELECT count(*) FROM pg_stat_activity WHERE datname = 'rustbill'"

10. 故障恢复

10.1 故障场景与响应

场景影响自动恢复手动操作
单个 server 实例宕机无(Caddy 自动摘除)Caddy health check 检测后摘除重启故障实例
所有 server 实例宕机服务完全中断逐台重启
Worker 宕机 + 有 replica无(Patroni 自动切换)30-60s 自动 failover修复故障节点后重新加入
Worker 宕机 + 无 replica该 Worker 的分片不可用从备份恢复
Coordinator 宕机所有查询中断无(需手动切换)DNS/VIP 切换到备用 Coordinator
Redis 宕机性能下降(无中断)自动降级到 PG恢复 Redis
etcd 单节点宕机无(集群仍可工作)集群自愈恢复故障节点
etcd 多节点宕机Patroni 无法选举恢复 etcd 集群
下游 API 熔断该下游请求快速失败30s 后半开探测,成功后恢复修复下游后等待自动恢复

10.2 Worker 故障恢复步骤

# 1. 确认 Worker 状态
psql -h coordinator -c "SELECT * FROM citus_get_active_worker_nodes();"

# 2. 如果 Worker 不可恢复(数据丢失)
psql -h coordinator <<SQL
SELECT citus_remove_node('broken-worker.internal', 5432);
SELECT citus_add_node('new-worker.internal', 5432);
SELECT citus_rebalance_start();
SQL

# 3. 监控重分布
psql -h coordinator -c "SELECT * FROM citus_rebalance_status();"

10.3 Coordinator 切换

# 方案 A: DNS 切换(推荐)
# 将 DNS A 记录从 coordinator-1 指向 coordinator-2
# TTL 设置 60s 以加速切换

# 方案 B: VIP 漂移
# keepalived 或类似工具将 VIP 从 coordinator-1 迁移到 coordinator-2

# 切换后重启所有 rustbill-server 实例(或等待连接池自动重连)
sudo systemctl restart rustbill-server

10.4 从 Citus 回退到单机 PG

# 从 Coordinator 导出全量数据
pg_dump -h coordinator.internal -U rustbill -d rustbill \
  --no-owner --no-acl -F c -f rustbill_full.dump

# 导入到单机 PG
pg_restore -h single-pg.internal -U rustbill -d rustbill \
  --no-owner --no-acl -j 4 rustbill_full.dump

# 更新 config.toml 的 DB URL
# 重启所有 rustbill-server 实例

11. 性能基准

测试环境:3 个 Citus Worker(8C32G),64 分片,100 万订单,50 个客户。

操作单机 PGCitus 3 Worker说明
客户查询自己的订单8ms9ms单 shard 查询,性能接近
管理员查询全部订单45ms62ms跨 shard 聚合,略有增加
创建订单4ms5ms单 shard 写入
支付回调(余额扣减+建支付+更新订单)3ms3ms同 shard 事务
Dashboard COUNT(6 表聚合)120ms180ms跨 shard COUNT 开销
订单列表分页(100 条/页)12ms15ms推送到 Coordinator 排序

结论: 单客户操作延迟几乎不变(+1ms)。跨客户聚合查询因 Coordinator 汇总而产生额外延迟(+30-60%),但仍在可接受范围内。


12. 部署检查清单

应用层

  • Caddy 配置 health_uri /healthhealth_interval 5s
  • Caddy lb_policy least_conn
  • [jwt].secret 所有实例一致
  • [db].url 指向同一个 Citus Coordinator 或 PG 实例
  • [redis].enabled 按需配置(多实例建议启用)
  • [telemetry].enabled 按需开启 OTLP

数据库层

  • Citus Coordinator 已添加所有 Worker 节点
  • 分布式表 customer_id 列全部 NOT NULL
  • 参考表已 create_reference_table
  • 每个 Worker 配置了至少一个 streaming replica
  • Patroni + etcd 集群正常运行

监控

  • /health 端点可被 LB 访问
  • /ready 端点配置为 k8s readiness probe
  • OTLP exporter 成功连接到 Collector
  • systemd healthcheck timer 启用
  • 告警规则配置(实例宕机、Worker 宕机、熔断器 Open)

备份

  • Coordinator 每日 pg_dump
  • Worker WAL 归档到远程存储
  • 备份恢复流程已文档化并测试

13. 运维命令速查

# === 实例管理 ===
systemctl status rustbill-server          # 实例状态
systemctl restart rustbill-server         # 重启(优雅关闭 + 启动)
journalctl -u rustbill-server -f          # 实时日志

# === Citus 集群 ===
psql -h coordinator -c "SELECT * FROM citus_get_active_worker_nodes();"
psql -h coordinator -c "SELECT count(*) FROM citus_shards;"
psql -h coordinator -c "SELECT * FROM citus_rebalance_status();"

# === Patroni ===
patronictl -c /etc/patroni/patroni.yml list          # 集群状态
patronictl -c /etc/patroni/patroni.yml switchover     # 手动切换

# === 健康检查 ===
curl -s http://localhost:50051/health
grpcurl -plaintext localhost:50051 list

# === 领导选举 ===
psql -c "SELECT role, holder, heartbeat_at, ttl_secs FROM leader_role;"

# === 熔断器状态 ===
# 通过 gRPC IntegrationService 或日志查看
journalctl -u rustbill-server | grep -i "circuit"

术语对照

术语说明
CoordinatorCitus 协调节点,接收查询并路由到 Worker
WorkerCitus 工作节点,存储实际数据分片
Shard / 分片数据分区单元,按 customer_id 哈希分配
Reference Table / 参考表复制到所有 Worker 的小表
Distributed Table / 分布式表按分片键分布到 Worker 的大表
PatroniPostgreSQL 高可用管理工具
etcd分布式键值存储,用于 Patroni 共识
TTL租约有效期(Time To Live)
Advisory LockPostgreSQL 应用层锁,用于并发控制
Saga分布式事务补偿模式