跳到主内容

高可用集群部署

教程

高可用集群部署

本教程演示如何部署一个 3 节点 EnerOS 高可用集群,满足调度主站 RTO < 5s、RPO = 0 的容灾要求。

HA 架构概览

EnerOS 的 HA 能力由 eneros-os::ha 模块提供,包含以下子模块:

子模块核心类型职责
heartbeatHeartbeatManager / HeartbeatPacket / NodeStateUDP 多播心跳检测(100ms 间隔,300ms 故障检测)
syncSyncManager / SyncBatch / SyncMessageTCP 状态同步(SCADA / Agent / 命令 / 配置,<100ms 延迟)
storageSharedStore / StorageEntry / ConflictResolution应用级复制引擎,含冲突检测与解决
fencingFencingManager / FencingStrategy / SplitBrainConfig脑裂防护(STONITH / Disk / Network)
clusterClusterManager / ClusterMember / QuorumResult多节点集群与 Quorum 仲裁(>2 节点)
failoverFailoverEngine / FailoverState / FailoverConfig热备切换引擎(VIP 接管 / 状态机)
drillDrillScheduler / DrillScenario / DrillSchedule灾备演练自动化(Daily / Weekly / Monthly)
regionRegionId / RegionMember / RegionAwareElection多区域原语(v0.41.0,跨 DC 容灾)
replicationWalStorage / WalFile / WalEntry文件级 WAL 复制原语

Failover 状态机:

            ┌──────────┐
            │ Standby  │ ←─────────────┐
            └────┬─────┘               │
                 │ take_over()         │ fail_back()
            ┌────▼─────┐          ┌────┴──────┐
            │ TakingOver│ ───────►│  Active   │
            └──────────┘          └────┬──────┘
                                       │ primary_down()
                                  ┌────▼──────┐
                                  │ FailingBack│
                                  └────┬──────┘
                                       │ error
                                  ┌────▼──────┐
                                  │   Failed  │
                                  └───────────┘
状态含义VIP 持有只读
Standby备用待命
TakingOver正在接管是(迁移中)
Active主用服务
FailingBack回切中否(迁移中)
Failed故障不可用

准备工作

  • 3 台物理机或虚拟机,配置相同(16C / 64G / NVMe)
  • 共享存储或分布式块存储(用于 Raft WAL 持久化)
  • 已完成 安装与构建
  • 已配置内部 CA,签发节点间 mTLS 证书
  • 已配置 NTP(节点间时钟偏差 < 50ms,否则心跳误判)
  • 已关闭 SELinux / firewalld,或放行 UDP 5400、TCP 5401、TCP 7878 端口

步骤 1:规划拓扑

节点角色优先级IP数据库区域
node-1Primary20010.0.0.11eneros-db-1region-a
node-2Secondary15010.0.0.12eneros-db-2region-a
node-3Secondary10010.0.0.13eneros-db-3region-b
node-wWitnessN/A10.0.0.20(无)region-b

数据库采用 Raft 三副本复制,保证强一致。Witness 节点不承载数据,仅参与 Quorum 投票以打破偶数节点僵局。

Quorum 仲裁算法:

  • total_members:非 Witness 成员数(本例 3)
  • alive_count:存活(Alive)的非 Witness 成员数
  • witness_count:Witness 节点数(本例 1)
  • 有多数派:alive_count + witness_count > (total_members + witness_count) / 2
  • Leader:优先级最高的存活非 Witness 成员

本例中:

  • 正常状态:alive=3, witness=1, total=33+1 > 2 ✓ 有 Quorum,Leader = node-1
  • node-1 宕机:alive=2, witness=1, total=32+1 > 2 ✓ 有 Quorum,Leader = node-2(次高优先级)
  • node-1 + node-2 宕机:alive=1, witness=1, total=31+1 > 2 ✗ 无 Quorum,服务降级

步骤 2:配置集群

/etc/eneros/ha.toml 完整配置示例:

# /etc/eneros/ha.toml
# EnerOS 高可用配置

# 必填字段
node_id = "node-1"
role = "primary"

# 心跳参数(毫秒)
heartbeat_interval_ms = 100      # 心跳发送间隔
heartbeat_suspect_ms = 100       # Suspect 阈值(>interval 触发可疑)
heartbeat_dead_ms = 300          # Dead 阈值(>suspect 触发下线)

# UDP 多播地址(必须在 224.0.0.0/4 范围)
multicast_addr = "239.0.0.1"
heartbeat_port = 5400
sync_port = 5401
multicast_ttl = 32

# 节点优先级(数字越大优先级越高,Leader 选举时使用)
priority = 200

# 双网卡冗余
interfaces = ["eth0", "eth1"]

# Fencing 策略(生产环境必须非 None)
fencing_strategy = "stonith"     # 可选:none / stonith / disk / network

# 生产环境标志(true 时强制 fencing_strategy != none)
is_production = true

# HMAC 认证密钥(保护心跳/同步消息,所有节点必须一致)
auth_key = "ha-shared-secret-change-me"

# 同步范围
[sync_scope]
sync_scada = true                # SCADA 实时数据
sync_agent_state = true          # Agent 状态
sync_command_history = true      # 命令历史
sync_config = true               # 配置文件

# Failover 配置
[failover]
vip = "10.0.0.10/24"             # 虚拟 IP(CIDR 表示法)
vip_interface = "eth0"           # VIP 绑定的网卡
cleanup_arp = true               # 切换时发送 gratuitous ARP
takeover_timeout_ms = 3000       # 接管超时(3 秒)
recovery_policy = "auto_prefer_primary"  # 或 "manual"
cross_region_takeover_timeout_ms = 60000  # 跨区域接管超时(60 秒)

# 集群配置(多节点 Quorum)
[cluster]
quorum_policy = "majority"       # 或 "manual"

[[cluster.members]]
node_id = "node-1"
role = "primary"
priority = 200
region_id = "region-a"

[[cluster.members]]
node_id = "node-2"
role = "secondary"
priority = 150
region_id = "region-a"

[[cluster.members]]
node_id = "node-3"
role = "secondary"
priority = 100
region_id = "region-b"

# Witness 节点(不承载数据,仅投票)
cluster.witness = ["node-w"]

# 区域定义(v0.41.0 多区域支持)
[[cluster.regions]]
id = "region-a"
datacenter = "dc-beijing"
priority = 200
endpoints = ["10.0.0.11:7878", "10.0.0.12:7878"]

[[cluster.regions]]
id = "region-b"
datacenter = "dc-shanghai"
priority = 100
endpoints = ["10.0.0.13:7878"]

# 灾备演练配置
[drill]
enabled = true
schedule = "weekly"              # daily / weekly / monthly
auto_rollback = true

HaConfig 字段说明:

字段类型默认值说明
node_idString(必填)本节点 ID,非空
roleNodeRole(必填)primary / secondary
heartbeat_interval_msu64100心跳发送间隔
heartbeat_suspect_msu64100Suspect 阈值,必须 ≥ interval
heartbeat_dead_msu64300Dead 阈值,必须 > suspect
multicast_addrString239.0.0.1UDP 多播地址,224.0.0.0/4
heartbeat_portu165400心跳端口,不能与 sync_port 相同
sync_portu165401状态同步端口
interfacesVec[]双网卡冗余列表
priorityu32100节点优先级
fencing_strategyFencingStrategynoneFencing 策略
sync_scopeSyncScope全 true同步范围开关
auth_keyOptionNoneHMAC-SHA256 密钥
multicast_ttlu832多播 TTL
is_productionbooltrue生产环境标志
failoverOptionNoneFailover 配置
clusterOptionNone集群配置
drillOptionNone演练配置

配置校验规则(HaConfig::validate):

  1. heartbeat_suspect_ms < heartbeat_dead_ms(suspect 必须小于 dead)
  2. heartbeat_interval_ms > 0(interval 必须大于 0)
  3. heartbeat_suspect_ms >= heartbeat_interval_ms(suspect 必须 ≥ interval)
  4. multicast_addr 在 224.0.0.0/4 范围(首字节 224-239)
  5. heartbeat_port != sync_port(端口不能冲突)
  6. node_id 非空
  7. 生产环境(is_production == truefencing_strategy != None
  8. failover.takeover_timeout_ms > 0
  9. failover.vip(若非空)必须是合法 IPv4/IPv6 地址
  10. cluster.members 非空
  11. cluster.witness 中的节点 ID 不能出现在 cluster.members
  12. node_id 必须在 cluster.members
  13. drill.scenarios 非空(若 drill.enabled == true

步骤 3:启动服务

node-1node-2node-3node-w 顺序启动:

# node-1(Primary)
ssh node-1
sudo systemctl start eneros-os
journalctl -u eneros-os -f | grep -E "ha|cluster|heartbeat"

# node-2(Secondary)
ssh node-2
sudo systemctl start eneros-os

# node-3(Secondary)
ssh node-3
sudo systemctl start eneros-os

# node-w(Witness,轻量进程不承载数据)
ssh node-w
sudo systemctl start eneros-witness

集群自动完成 Leader 选举,并通过 mTLS 互连。启动日志示例:

2026-07-06T10:00:00.123Z INFO  eneros_os::ha::heartbeat  node-1 started heartbeat on 239.0.0.1:5400
2026-07-06T10:00:00.234Z INFO  eneros_os::ha::sync        sync server listening on 0.0.0.0:5401
2026-07-06T10:00:00.345Z INFO  eneros_os::ha::cluster     cluster initialized: members=3, witness=1
2026-07-06T10:00:00.456Z INFO  eneros_os::ha::cluster     quorum acquired: alive=3, leader=node-1
2026-07-06T10:00:00.567Z INFO  eneros_os::ha::failover    state=Active, vip=10.0.0.10, readonly=false
2026-07-06T10:00:00.678Z INFO  eneros_os::ha::fencing     fencing_strategy=Stonith, cooldown=30s

步骤 4:加载配置并启动 HA 引擎

编程化启动 HA 全栈:

use std::sync::Arc;
use eneros_os::ha::{
    BatchConfig, ClusterConfig, FailoverConfig, FailoverEngine, FencingManager,
    FencingStrategy, HaConfig, HeartbeatManager, NodeRole, RecoveryPolicy,
    SharedStore, SyncManager, SyncScope, ClusterManager,
};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 1. 加载 HA 配置
    let config = HaConfig::load("/etc/eneros/ha.toml")?;
    println!("loaded ha config: node_id={}, role={:?}", config.node_id, config.role);

    // 2. 初始化共享状态存储(带 WAL 持久化)
    let store = Arc::new(SharedStore::new(
        config.node_id.clone(),
        config.role,
        "/var/lib/eneros/ha-store",  // WAL 文件目录
    )?);
    store.load_from_disk()?;  // 重启后恢复状态
    println!("shared store loaded: entries={}", store.len());

    // 3. 启动心跳管理器
    let heartbeat = Arc::new(HeartbeatManager::new(config.clone())?);
    heartbeat.start().await?;
    println!("heartbeat started: interval={}ms", config.heartbeat_interval_ms);

    // 4. 启动同步管理器
    let batch_config = BatchConfig {
        batch_size: 100,
        batch_timeout_ms: 10,
    };
    let sync = Arc::new(SyncManager::new(
        config.clone(),
        store.clone(),
        batch_config,
    )?);
    sync.start().await?;
    println!("sync started: port={}", config.sync_port);

    // 5. 启动 Fencing 管理器
    let fencing = Arc::new(FencingManager::new(
        config.node_id.clone(),
        config.role,
        config.fencing_strategy,
    )?);
    println!("fencing ready: strategy={:?}", config.fencing_strategy);

    // 6. 启动集群管理器(多节点 Quorum)
    let cluster_config = config.cluster.clone()
        .expect("cluster config required for multi-node HA");
    let cluster = Arc::new(ClusterManager::new(
        cluster_config,
        config.node_id.clone(),
    ));
    println!("cluster manager initialized: members={}",
        cluster.config_read().members.len());

    // 7. 启动 Failover 引擎
    let failover_config = config.failover.clone()
        .expect("failover config required");
    let failover = Arc::new(FailoverEngine::new(
        config.clone(),
        store.clone(),
        failover_config,
    ).with_sync_manager(sync.clone()));
    println!("failover engine: initial_state={:?}", failover.current_state());

    // 8. 注册成员变更回调
    cluster.register_member_callback(Arc::new(|event| {
        tracing::info!(
            "成员变更: member={}, status={:?}, cluster_size={}",
            event.member_id, event.status, event.cluster_size
        );
    }));

    // 9. 主循环:监控心跳超时并触发 failover
    let heartbeat_clone = heartbeat.clone();
    let failover_clone = failover.clone();
    let cluster_clone = cluster.clone();
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(
            std::time::Duration::from_millis(50)
        );
        loop {
            interval.tick().await;
            // 检查心跳超时
            let changes = heartbeat_clone.check_timeouts();
            for change in changes {
                tracing::warn!(
                    "节点状态变更: {} {:?} -> {:?}",
                    change.node_id, change.old_state, change.new_state
                );
                // 更新集群成员状态
                cluster_clone.update_member_state(
                    &change.node_id,
                    change.new_state,
                );
                // 若 Leader 下线,触发 failover
                if change.new_state == eneros_os::ha::NodeState::Dead
                    && change.node_id != config.node_id {
                    // 由 Quorum 决定是否接管
                    let quorum = cluster_clone.evaluate_quorum();
                    if let eneros_os::ha::QuorumResult::Leader(new_leader) = quorum {
                        if new_leader == config.node_id {
                            tracing::info!("本节点被选为新 Leader,触发接管");
                            let _ = failover_clone.take_over().await;
                        }
                    }
                }
            }
        }
    });

    // 等待退出信号
    tokio::signal::ctrl_c().await?;
    println!("shutting down...");
    Ok(())
}

步骤 5:配置故障切换

Gateway 前置负载均衡(HAProxy / Keepalived),健康检查指向 /healthz

# /etc/haproxy/haproxy.cfg
frontend eneros_frontend
    bind 10.0.0.10:443 ssl crt /etc/eneros/certs/gateway.pem
    mode http
    default_backend eneros_gateway

backend eneros_gateway
    mode http
    option httpchk GET /healthz
    http-check expect status 200
    # 健康检查间隔 1s,失败 3 次剔除
    default-server inter 1s fall 3 rise 2
    # 主节点权重高,备节点仅当主不可用时承接流量
    server node-1 10.0.0.11:8080 check weight 100
    server node-2 10.0.0.12:8080 check weight 50 backup
    server node-3 10.0.0.13:8080 check weight 50 backup

Keepalived 配置(VIP 双机热备):

# /etc/keepalived/keepalived.conf
vrrp_instance VI_1 {
    state MASTER
    interface eth0
    virtual_router_id 51
    priority 100
    advert_int 1
    authentication {
        auth_type PASS
        auth_pass ener0s_ha
    }
    virtual_ipaddress {
        10.0.0.10/24
    }
    track_script {
        chk_eneros
    }
}

vrrp_script chk_eneros {
    script "/usr/local/bin/check-eneros-health.sh"
    interval 2
    fall 2
    rise 2
}

check-eneros-health.sh 健康检查脚本:

#!/bin/bash
# /usr/local/bin/check-eneros-health.sh
# 返回 0 表示健康,1 表示不健康

# 1. 检查 HTTP 健康端点
if ! curl -sf http://127.0.0.1:8080/healthz > /dev/null; then
    exit 1
fi

# 2. 检查 FailoverEngine 是否为 Active
STATE=$(curl -sf http://127.0.0.1:8080/api/v1/ha/status | jq -r '.failover_state')
if [ "$STATE" != "active" ]; then
    exit 1
fi

# 3. 检查 Quorum 是否持有
QUORUM=$(curl -sf http://127.0.0.1:8080/api/v1/ha/quorum | jq -r '.has_quorum')
if [ "$QUORUM" != "true" ]; then
    exit 1
fi

exit 0

步骤 6:灾备演练

DrillScheduler 支持定期自动演练,验证 failover 流程可靠性。

use eneros_os::ha::{
    DrillConfig, DrillScenario, DrillSchedule, DrillScheduler,
    FailoverEngine,
};
use std::sync::Arc;

fn setup_drill_scheduler(
    failover: Arc<FailoverEngine>,
) -> anyhow::Result<Arc<DrillScheduler>> {
    let config = DrillConfig {
        enabled: true,
        schedule: DrillSchedule::Weekly,  // 每周演练一次
        auto_rollback: true,
        scenarios: vec![
            DrillScenario::PrimaryDown,
            DrillScenario::NetworkPartition,
            DrillScenario::DiskFailure,
            // v0.41.0 跨区域场景
            DrillScenario::CrossRegionFailover {
                from_region: "region-a".to_string(),
                to_region: "region-b".to_string(),
            },
            DrillScenario::DataCenterLoss {
                region: "region-a".to_string(),
            },
        ],
    };

    let scheduler = Arc::new(DrillScheduler::new(config, failover));
    scheduler.start()?;  // 启动后台调度任务
    println!("drill scheduler started: schedule={:?}", DrillSchedule::Weekly);

    Ok(scheduler)
}

演练场景说明:

场景模拟内容验证目标自动回滚
PrimaryDown主节点故障备节点接管 < 3s
NetworkPartition网络分区Quorum 仲裁正确
DiskFailure磁盘故障WAL 恢复
CrossRegionFailover源区域整体故障跨区域接管 < 60s
DataCenterLoss数据中心丢失DR 流程完整
NetworkPartitionMultiRegion多区域网络分区区域隔离与仲裁

手动触发演练:

# 立即执行 PrimaryDown 演练
eneros-cli ha drill run --scenario primary_down --auto-rollback

# 查看演练历史
eneros-cli ha drill history --limit 10

# 查看下次调度时间
eneros-cli ha drill next

演练日志记录在 /var/log/eneros/drill.log(JSON Lines 格式),最多保留 50 条历史记录。

步骤 7:验证容灾

# 1. 模拟 Leader 故障
ssh node-1 sudo systemctl stop eneros-os

# 2. 观察集群状态变化(5 秒内选出新 Leader)
eneros-cli cluster status --watch

# 3. 验证业务连续性(SCADA 数据不中断)
eneros-cli scada query --topic /topics/voltage --last 1m

# 4. 验证 RPO = 0(无数据丢失)
eneros-cli ha failover history --last 1 | jq '.records[0] | {duration_ms, result, reason}'

预期输出:

=== Cluster Status ===
node-1: DEAD    (last_seen: 2026-07-06T10:05:00Z)
node-2: ALIVE   (role: primary, priority: 150)  ← 新 Leader
node-3: ALIVE   (role: secondary, priority: 100)
node-w: ALIVE   (role: witness)

Quorum: ACQUIRED (alive=2, witness=1, total=3)
Failover: ACTIVE (state=active, vip=10.0.0.10, readonly=false)
Last Failover: 2026-07-06T10:05:03.123Z (duration=2.8s, reason="primary_down")

故障注入测试矩阵:

测试项注入方式预期 RTO预期 RPO验证方法
主节点宕机systemctl stop eneros-os< 5s0cluster status 显示新 Leader
网络分区iptables -A INPUT -p udp --dport 5400 -j DROP< 5s0Quorum 仲裁生效
磁盘故障dd if=/dev/zero of=/dev/sda< 10s< 100msWAL 恢复
主节点恢复systemctl start eneros-osN/A0自动 fail_back
脑裂模拟双节点互不可达 + 无 witness拒绝双写0Fencing 触发

步骤 8:监控与告警

接入 Prometheus 抓取 /metrics,关键指标如下:

指标类型告警阈值说明
eneros_cluster_leader_changes_totalCounter> 0 / 1hLeader 频繁切换告警
eneros_raft_log_lagGauge> 100Follower 落后主过多
eneros_agent_active_countGauge节点间差异 > 20%Agent 分布不均
eneros_ha_heartbeat_latency_msGauge> 200ms心跳延迟过高
eneros_ha_quorum_statusGauge (0/1)== 0无 Quorum 立即告警
eneros_ha_failover_stateGauge (0-4)== 4 (Failed)Failover 失败告警
eneros_ha_sync_lag_msGauge> 500ms状态同步延迟过高
eneros_ha_fencing_triggered_totalCounter> 0Fencing 触发告警
eneros_shared_store_entriesGauge持续下降共享状态丢失
eneros_shared_store_conflicts_totalCounter> 0 / 1h复制冲突频繁

Prometheus 抓取配置:

# /etc/prometheus/prometheus.yml
scrape_configs:
  - job_name: eneros-ha
    scrape_interval: 5s
    scrape_timeout: 3s
    static_configs:
      - targets:
          - 10.0.0.11:8080
          - 10.0.0.12:8080
          - 10.0.0.13:8080
    metrics_path: /metrics

Grafana 告警规则示例:

groups:
  - name: eneros-ha
    rules:
      - alert: EnerOSNoQuorum
        expr: eneros_ha_quorum_status == 0
        for: 10s
        labels:
          severity: critical
        annotations:
          summary: "EnerOS 集群无 Quorum ({{ $labels.node_id }})"
          description: "节点 {{ $labels.node_id }} 丢失 Quorum,服务可能降级"

      - alert: EnerOSLeaderFlapping
        expr: rate(eneros_cluster_leader_changes_total[1h]) > 0
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Leader 频繁切换"
          description: "1 小时内 Leader 切换 {{ $value }} 次"

      - alert: EnerOSSyncLagHigh
        expr: eneros_ha_sync_lag_ms > 500
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "状态同步延迟过高"
          description: "节点 {{ $labels.node_id }} 同步延迟 {{ $value }}ms"

      - alert: EnerOSFencingTriggered
        expr: increase(eneros_ha_fencing_triggered_total[5m]) > 0
        labels:
          severity: critical
        annotations:
          summary: "Fencing 被触发"
          description: "脑裂防护已启动,请立即排查网络"

步骤 9:脑裂防护

Fencing 策略对比:

策略实现方式适用场景数据安全
None不启用仅测试环境双写风险
StonithIPMI / PDU 远程关机物理机部署最高(强制断电)
DiskSCSI Reservation 锁定共享存储高(锁定存储)
Network切断网络访问虚拟机部署中(依赖防火墙)

脑裂检测逻辑(FencingManager::detect_split_brain):

  1. dead_nodes 为空 → NoSplitBrain
  2. 本节点在死节点列表中 → 本节点应被 fencing
  3. 有仲裁节点:超过半数可达 → 本节点有 quorum,对端应被 fencing;否则本节点应被 fencing
  4. 无仲裁节点(双节点):Primary → 对端应被 fencing;Secondary → 保守策略,本节点应被 fencing

安全加固特性:

  • 拒绝自 fencingtarget_node == self.node_id → 返回 InvalidTarget
  • 30 秒冷却期:同一目标在冷却期内重复请求返回 Skipped
  • 审计日志:每次 fencing 记录追加写入 /var/log/eneros/fencing.log(JSON Lines)
  • Quorum 校验(v0.29.0):执行 fencing 前必须持有 Quorum,否则返回 NoQuorum
use eneros_os::ha::{
    FencingManager, FencingStrategy, QuorumState,
};

async fn demonstrate_fencing() -> anyhow::Result<()> {
    let mut fencing = FencingManager::new(
        "node-1".to_string(),
        eneros_os::ha::NodeRole::Primary,
        FencingStrategy::Stonith,
    )?;

    // 更新 Quorum 状态(必须先调用,默认为单节点持有 Quorum)
    fencing.update_quorum_state(QuorumState {
        has_quorum: true,
        alive_count: 3,
        total_count: 4,  // 含 witness
    });

    // 检测脑裂
    let result = fencing.detect_split_brain(
        &["node-2"],               // dead_nodes
        &["node-w"],               // quorum_nodes
        std::time::Duration::from_millis(300),
    )?;
    println!("split-brain result: {:?}", result);

    // 执行 fencing(仅当本节点有 Quorum 时)
    match fencing.fence("node-2", "primary_down_no_response").await {
        Ok(record) => println!("fencing success: {:?}", record.result),
        Err(e) => println!("fencing failed: {}", e),
    }

    Ok(())
}

步骤 10:状态同步与冲突解决

SharedStore 提供应用级复制引擎,主节点写入触发复制回调,备节点通过 replicate 接收数据。

use eneros_os::ha::{
    ConflictResolution, SharedStore, StorageEntry, StorageQuota,
};
use std::sync::Arc;

fn setup_shared_store() -> anyhow::Result<Arc<SharedStore>> {
    let store = Arc::new(SharedStore::new(
        "node-1".to_string(),
        eneros_os::ha::NodeRole::Primary,
        "/var/lib/eneros/ha-store",
    )?);

    // 设置存储配额
    store.set_quota(StorageQuota {
        max_entries: 1_000_000,
        max_bytes: 1_073_741_824,  // 1 GB
    })?;

    // 设置冲突解决策略
    store.set_conflict_resolution(ConflictResolution::TimestampWins);

    // 注册复制回调(主节点写入时触发)
    let store_for_cb = store.clone();
    store.set_replicate_callback(Box::new(move |entry: StorageEntry| {
        // 序列化并通过 SyncManager 发送到备节点
        let store = store_for_cb.clone();
        tokio::spawn(async move {
            if let Err(e) = store.send_to_replica(&entry).await {
                tracing::error!("replicate failed: key={}, err={}", entry.key, e);
            }
        });
    }))?;

    Ok(store)
}

// 主节点写入示例
fn write_scada_snapshot(store: &SharedStore, bus_id: u32, voltage: f32) -> anyhow::Result<()> {
    let entry = StorageEntry {
        key: format!("scada/voltage/{}", bus_id),
        value: serde_json::json!({
            "bus_id": bus_id,
            "voltage_pu": voltage,
            "timestamp": chrono::Utc::now().timestamp_millis(),
        }),
        timestamp: chrono::Utc::now().timestamp_millis(),
        node_id: store.node_id().to_string(),
        version: store.next_version(),
    };
    store.put(entry)?;  // 触发复制回调
    Ok(())
}

// 备节点接收复制数据
fn receive_replica_data(store: &SharedStore, entry: StorageEntry) -> anyhow::Result<()> {
    match store.replicate(entry.clone()) {
        Ok(()) => tracing::debug!("replicated: key={}", entry.key),
        Err(e) => tracing::warn!("replicate conflict: key={}, err={}", entry.key, e),
    }
    Ok(())
}

冲突解决策略对比:

策略决策依据平局处理适用场景
PrimaryWins节点角色Primary 获胜强主从架构
TimestampWins写入时间戳node_id 字典序多写场景
VersionWins版本号回退到 TimestampWins带版本号写入

服务降级模式:

// 备节点只读保护(FailoverEngine 切换时自动调用)
fn enable_readonly_mode(store: &SharedStore) {
    store.set_readonly(true);
    tracing::warn!("共享存储进入只读模式(备节点)");
}

// 升主时解除只读
fn disable_readonly_mode(store: &SharedStore) {
    store.set_readonly(false);
    tracing::info!("共享存储解除只读模式(升主)");
}

步骤 11:持久化与恢复

SharedStore 支持 snapshot + WAL 持久化,ha-daemon 重启后自动恢复:

use eneros_os::ha::SharedStore;

fn demonstrate_persistence() -> anyhow::Result<()> {
    // 节点运行中:定期 snapshot
    let store = SharedStore::new(
        "node-1".to_string(),
        eneros_os::ha::NodeRole::Primary,
        "/var/lib/eneros/ha-store",
    )?;

    // WAL 记录数达到 1000 时自动触发快照
    // 也可手动触发
    store.snapshot()?;
    println!("snapshot created: entries={}", store.len());

    // 重启后恢复
    let restored = SharedStore::new(
        "node-1".to_string(),
        eneros_os::ha::NodeRole::Primary,
        "/var/lib/eneros/ha-store",
    )?;
    restored.load_from_disk()?;
    println!("restored from disk: entries={}", restored.len());

    Ok(())
}

WAL 文件结构:

/var/lib/eneros/ha-store/
├── snapshot.bin          # 最新快照
├── wal.log               # WAL 日志(追加写)
├── wal.index             # WAL 索引
└── manifest.json         # 元数据(version, last_snapshot_at, ...)

恢复流程:

  1. 读取 manifest.json 获取上次快照信息
  2. 加载 snapshot.bin 到内存
  3. 重放 wal.log 中快照点之后的记录
  4. 若 WAL 损坏,回退到上一个快照

验证

按以下清单逐项验证 HA 集群是否达标:

验证项命令 / 操作预期结果
Quorum 仲裁eneros-cli cluster statushas_quorum=true
Leader 选举停止 Leader 进程5s 内选出新 Leader
VIP 切换ip addr show eth0 on node-2VIP 在新 Leader 上
状态同步延迟eneros-cli ha sync lag< 100ms
数据零丢失主写 1000 条后立即宕机备节点全部可见(RPO=0)
Fencing 触发模拟脑裂(双节点互不可达)一方被 fencing
只读保护备节点尝试写入拒绝并记录日志
WAL 恢复重启 eneros-os 进程状态完整恢复
灾备演练eneros-cli ha drill run演练成功且自动回滚
跨区域 failover停止整个 region-aregion-b 接管 < 60s

RTO/RPO 验证脚本:

#!/bin/bash
# /usr/local/bin/verify-ha-rto-rpo.sh
# 持续写入测试数据,记录时间戳,模拟故障后校验

set -e
VIP="10.0.0.10"
TEST_FILE="/tmp/ha-rto-test-$(date +%s).log"

echo "开始 RTO/RPO 验证..."

# 1. 持续写入测试数据(每 10ms 一条,持续 60s)
for i in $(seq 1 6000); do
    TIMESTAMP=$(date +%s%3N)
    curl -s -X POST "https://${VIP}:443/api/v1/test/echo" \
        -H "Content-Type: application/json" \
        -d "{\"seq\": $i, \"ts\": $TIMESTAMP}" >> "$TEST_FILE" 2>/dev/null || \
        echo "FAIL,$i,$TIMESTAMP" >> "$TEST_FILE"
    sleep 0.01
done &

WRITER_PID=$!

# 2. 等待 10s 后注入故障
sleep 10
echo "注入故障:停止 node-1..."
ssh node-1 sudo systemctl stop eneros-os
FAULT_TIME=$(date +%s%3N)
echo "故障时间: $FAULT_TIME"

# 3. 等待写入完成
wait $WRITER_PID

# 4. 校验:找出第一个 FAIL 后第一个成功的记录
RECOVERY_TIME=$(grep -v "^FAIL" "$TEST_FILE" | awk -F',' '$2 > '"$FAULT_TIME"' {print $3; exit}')
if [ -z "$RECOVERY_TIME" ]; then
    echo "FAIL: 未恢复"
    exit 1
fi

RTO_MS=$((RECOVERY_TIME - FAULT_TIME))
echo "RTO: ${RTO_MS}ms"

# 5. 校验 RPO(应有 0 条数据丢失)
TOTAL_SUCCESS=$(grep -v "^FAIL" "$TEST_FILE" | wc -l)
TOTAL_FAIL=$(grep "^FAIL" "$TEST_FILE" | wc -l)
echo "成功写入: $TOTAL_SUCCESS, 失败: $TOTAL_FAIL"
echo "RPO: $TOTAL_FAIL 条数据丢失"

# 6. 结果判断
if [ "$RTO_MS" -lt 5000 ] && [ "$TOTAL_FAIL" -eq 0 ]; then
    echo "PASS: RTO < 5s, RPO = 0"
    exit 0
else
    echo "FAIL: 未达标"
    exit 1
fi

调试与排错

现象可能原因排查方法
Leader 频繁切换心跳延迟过高检查网络延迟、NTP 同步
无法形成 Quorum节点间网络中断ping 各节点,检查防火墙
Failover 超时VIP 接管失败检查 ip addr add 权限、网卡名
同步延迟过高数据量大或网络拥塞调整 batch_size / batch_timeout_ms
Fencing 拒绝执行无 Quorum检查集群成员状态,确保 witness 可达
Fencing 频繁触发网络抖动调整 heartbeat_dead_ms(如 300→500)
备节点数据落后WAL 未重放检查 /var/lib/eneros/ha-store/wal.log
共享存储冲突频繁双写或时钟偏差启用 NTP,检查 ConflictResolution 策略
重启后状态丢失WAL 损坏检查 manifest.json,回退到上一个快照

查看 HA 内部状态:

# 查看集群状态
eneros-cli cluster status --json | jq

# 查看 Failover 状态
eneros-cli ha status --json | jq

# 查看心跳延迟
eneros-cli ha heartbeat latency

# 查看同步统计
eneros-cli ha sync stats

# 查看共享存储条目数
eneros-cli ha store stats

# 查看 Fencing 历史
eneros-cli ha fencing history --limit 10

# 查看灾备演练历史
eneros-cli ha drill history --limit 10

# 查看 Failover 历史
eneros-cli ha failover history --limit 10

HA 日志文件:

文件路径内容保留策略
/var/log/eneros/ha.logHA 主日志(INFO+)logrotate 7 天
/var/log/eneros/failover.logFailover 切换记录(JSON Lines)最多 100 条
/var/log/eneros/fencing.logFencing 操作记录(JSON Lines)永久保留
/var/log/eneros/drill.log灾备演练记录(JSON Lines)最多 50 条
/var/lib/eneros/ha-store/snapshot.bin共享状态快照自动滚动
/var/lib/eneros/ha-store/wal.logWAL 日志自动滚动

性能调优

参数默认值调优建议影响
heartbeat_interval_ms100实时性要求高→50;网络差→200心跳频率
heartbeat_dead_ms300误判多→500;故障感知慢→200故障检测时间
batch_size100同步量大→500;延迟敏感→50同步吞吐/延迟
batch_timeout_ms10延迟敏感→5;吞吐优先→50同步延迟
WAL_SNAPSHOT_THRESHOLD1000写入频繁→5000;恢复快→500恢复时间
multicast_ttl32跨网段→64;同网段→1多播范围

下一步