高可用集群部署
本教程演示如何部署一个 3 节点 EnerOS 高可用集群,满足调度主站 RTO < 5s、RPO = 0 的容灾要求。
HA 架构概览
EnerOS 的 HA 能力由 eneros-os::ha 模块提供,包含以下子模块:
| 子模块 | 核心类型 | 职责 |
|---|---|---|
heartbeat | HeartbeatManager / HeartbeatPacket / NodeState | UDP 多播心跳检测(100ms 间隔,300ms 故障检测) |
sync | SyncManager / SyncBatch / SyncMessage | TCP 状态同步(SCADA / Agent / 命令 / 配置,<100ms 延迟) |
storage | SharedStore / StorageEntry / ConflictResolution | 应用级复制引擎,含冲突检测与解决 |
fencing | FencingManager / FencingStrategy / SplitBrainConfig | 脑裂防护(STONITH / Disk / Network) |
cluster | ClusterManager / ClusterMember / QuorumResult | 多节点集群与 Quorum 仲裁(>2 节点) |
failover | FailoverEngine / FailoverState / FailoverConfig | 热备切换引擎(VIP 接管 / 状态机) |
drill | DrillScheduler / DrillScenario / DrillSchedule | 灾备演练自动化(Daily / Weekly / Monthly) |
region | RegionId / RegionMember / RegionAwareElection | 多区域原语(v0.41.0,跨 DC 容灾) |
replication | WalStorage / 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-1 | Primary | 200 | 10.0.0.11 | eneros-db-1 | region-a |
node-2 | Secondary | 150 | 10.0.0.12 | eneros-db-2 | region-a |
node-3 | Secondary | 100 | 10.0.0.13 | eneros-db-3 | region-b |
node-w | Witness | N/A | 10.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=3→3+1 > 2✓ 有 Quorum,Leader = node-1 - node-1 宕机:
alive=2, witness=1, total=3→2+1 > 2✓ 有 Quorum,Leader = node-2(次高优先级) - node-1 + node-2 宕机:
alive=1, witness=1, total=3→1+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_id | String | (必填) | 本节点 ID,非空 |
role | NodeRole | (必填) | primary / secondary |
heartbeat_interval_ms | u64 | 100 | 心跳发送间隔 |
heartbeat_suspect_ms | u64 | 100 | Suspect 阈值,必须 ≥ interval |
heartbeat_dead_ms | u64 | 300 | Dead 阈值,必须 > suspect |
multicast_addr | String | 239.0.0.1 | UDP 多播地址,224.0.0.0/4 |
heartbeat_port | u16 | 5400 | 心跳端口,不能与 sync_port 相同 |
sync_port | u16 | 5401 | 状态同步端口 |
interfaces | Vec | [] | 双网卡冗余列表 |
priority | u32 | 100 | 节点优先级 |
fencing_strategy | FencingStrategy | none | Fencing 策略 |
sync_scope | SyncScope | 全 true | 同步范围开关 |
auth_key | Option | None | HMAC-SHA256 密钥 |
multicast_ttl | u8 | 32 | 多播 TTL |
is_production | bool | true | 生产环境标志 |
failover | Option | None | Failover 配置 |
cluster | Option | None | 集群配置 |
drill | Option | None | 演练配置 |
配置校验规则(HaConfig::validate):
heartbeat_suspect_ms < heartbeat_dead_ms(suspect 必须小于 dead)heartbeat_interval_ms > 0(interval 必须大于 0)heartbeat_suspect_ms >= heartbeat_interval_ms(suspect 必须 ≥ interval)multicast_addr在 224.0.0.0/4 范围(首字节 224-239)heartbeat_port != sync_port(端口不能冲突)node_id非空- 生产环境(
is_production == true)fencing_strategy != None failover.takeover_timeout_ms > 0failover.vip(若非空)必须是合法 IPv4/IPv6 地址cluster.members非空cluster.witness中的节点 ID 不能出现在cluster.members中node_id必须在cluster.members中drill.scenarios非空(若drill.enabled == true)
步骤 3:启动服务
按 node-1 → node-2 → node-3 → node-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 | < 5s | 0 | cluster status 显示新 Leader |
| 网络分区 | iptables -A INPUT -p udp --dport 5400 -j DROP | < 5s | 0 | Quorum 仲裁生效 |
| 磁盘故障 | dd if=/dev/zero of=/dev/sda | < 10s | < 100ms | WAL 恢复 |
| 主节点恢复 | systemctl start eneros-os | N/A | 0 | 自动 fail_back |
| 脑裂模拟 | 双节点互不可达 + 无 witness | 拒绝双写 | 0 | Fencing 触发 |
步骤 8:监控与告警
接入 Prometheus 抓取 /metrics,关键指标如下:
| 指标 | 类型 | 告警阈值 | 说明 |
|---|---|---|---|
eneros_cluster_leader_changes_total | Counter | > 0 / 1h | Leader 频繁切换告警 |
eneros_raft_log_lag | Gauge | > 100 | Follower 落后主过多 |
eneros_agent_active_count | Gauge | 节点间差异 > 20% | Agent 分布不均 |
eneros_ha_heartbeat_latency_ms | Gauge | > 200ms | 心跳延迟过高 |
eneros_ha_quorum_status | Gauge (0/1) | == 0 | 无 Quorum 立即告警 |
eneros_ha_failover_state | Gauge (0-4) | == 4 (Failed) | Failover 失败告警 |
eneros_ha_sync_lag_ms | Gauge | > 500ms | 状态同步延迟过高 |
eneros_ha_fencing_triggered_total | Counter | > 0 | Fencing 触发告警 |
eneros_shared_store_entries | Gauge | 持续下降 | 共享状态丢失 |
eneros_shared_store_conflicts_total | Counter | > 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 | 不启用 | 仅测试环境 | 双写风险 |
Stonith | IPMI / PDU 远程关机 | 物理机部署 | 最高(强制断电) |
Disk | SCSI Reservation 锁定 | 共享存储 | 高(锁定存储) |
Network | 切断网络访问 | 虚拟机部署 | 中(依赖防火墙) |
脑裂检测逻辑(FencingManager::detect_split_brain):
dead_nodes为空 →NoSplitBrain- 本节点在死节点列表中 → 本节点应被 fencing
- 有仲裁节点:超过半数可达 → 本节点有 quorum,对端应被 fencing;否则本节点应被 fencing
- 无仲裁节点(双节点):Primary → 对端应被 fencing;Secondary → 保守策略,本节点应被 fencing
安全加固特性:
- 拒绝自 fencing:
target_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, ...)
恢复流程:
- 读取
manifest.json获取上次快照信息 - 加载
snapshot.bin到内存 - 重放
wal.log中快照点之后的记录 - 若 WAL 损坏,回退到上一个快照
验证
按以下清单逐项验证 HA 集群是否达标:
| 验证项 | 命令 / 操作 | 预期结果 |
|---|---|---|
| Quorum 仲裁 | eneros-cli cluster status | has_quorum=true |
| Leader 选举 | 停止 Leader 进程 | 5s 内选出新 Leader |
| VIP 切换 | ip addr show eth0 on node-2 | VIP 在新 Leader 上 |
| 状态同步延迟 | eneros-cli ha sync lag | < 100ms |
| 数据零丢失 | 主写 1000 条后立即宕机 | 备节点全部可见(RPO=0) |
| Fencing 触发 | 模拟脑裂(双节点互不可达) | 一方被 fencing |
| 只读保护 | 备节点尝试写入 | 拒绝并记录日志 |
| WAL 恢复 | 重启 eneros-os 进程 | 状态完整恢复 |
| 灾备演练 | eneros-cli ha drill run | 演练成功且自动回滚 |
| 跨区域 failover | 停止整个 region-a | region-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.log | HA 主日志(INFO+) | logrotate 7 天 |
/var/log/eneros/failover.log | Failover 切换记录(JSON Lines) | 最多 100 条 |
/var/log/eneros/fencing.log | Fencing 操作记录(JSON Lines) | 永久保留 |
/var/log/eneros/drill.log | 灾备演练记录(JSON Lines) | 最多 50 条 |
/var/lib/eneros/ha-store/snapshot.bin | 共享状态快照 | 自动滚动 |
/var/lib/eneros/ha-store/wal.log | WAL 日志 | 自动滚动 |
性能调优
| 参数 | 默认值 | 调优建议 | 影响 |
|---|---|---|---|
heartbeat_interval_ms | 100 | 实时性要求高→50;网络差→200 | 心跳频率 |
heartbeat_dead_ms | 300 | 误判多→500;故障感知慢→200 | 故障检测时间 |
batch_size | 100 | 同步量大→500;延迟敏感→50 | 同步吞吐/延迟 |
batch_timeout_ms | 10 | 延迟敏感→5;吞吐优先→50 | 同步延迟 |
WAL_SNAPSHOT_THRESHOLD | 1000 | 写入频繁→5000;恢复快→500 | 恢复时间 |
multicast_ttl | 32 | 跨网段→64;同网段→1 | 多播范围 |