本文由实践出发,文章经过AI优化,如有设计缺陷和技术错误,欢迎批评指正
一、为什么选择 Galera 多主集群
1.1 业务背景:边缘云平台运维与网络功能虚拟化(NFV)系统
我们的系统是一个边缘云平台运维与网络功能虚拟化(NFV)系统,管理的核心资源包括:
- 物理资源:计算节点、CPU 核心、SR-IOV 网卡(PF/VF)
- 虚拟化资源:VM 实例、NUMA 拓扑绑定
- 网络配置:BGP 邻居、路由策略、前缀过滤
这类系统有一个显著特点:配置即业务。一个 VF 被分配给哪个 VM,直接决定了网络流量的转发路径。如果同一个 VF 被错误地分配给两个 VM,会导致业务中断
1.2 主从模式的致命缺陷
传统主从复制架构存在两个无法接受的问题:
问题一:异步复制导致数据丢失
sequenceDiagram
participant Pod1 as Pod-1
participant Master as Master
participant Slave as Slave
participant Pod2 as Pod-2
Pod1->>Master: 写入 "VF-001 分配给 VM-A"
Master-->>Pod1: 返回成功(Slave尚未同步)
Master->>Slave: 同步中断(宕机)
Slave->>Slave: 提升为新 Master
Note over Slave: VF-001分配记录丢失
Pod2->>Slave: 查询 VF-001
Slave-->>Pod2: 显示"可用"
Pod2->>Slave: 分配 VF-001 给 VM-B
Note over Pod2: 同一个VF被分配两次!
问题二:单点写入瓶颈
flowchart LR
subgraph Pods
P1[Pod-1]
P2[Pod-2]
P3[Pod-3]
end
subgraph MasterNode[Master]
M[(写入)]
end
subgraph SlaveNode[Slave]
S[(只读)]
end
P1 --> M
P2 --> M
P3 --> M
M --> S
所有写请求汇聚到Master,批量配置下发时压力大,故障时整个系统 主从切换期间服务中断。
1.3 Galera 多主架构的优势
Galera 采用同步复制 + 多主写入
核心优势:
| 特性 | 主从模式 | Galera 多主 |
|---|---|---|
| 数据持久性 | 异步复制,可能丢失 | 同步复制,不丢失 |
| 写入能力 | 单点瓶颈 | 多点并发写入 |
| 故障恢复 | 分钟级切换 | 秒级自动摘除 |
| 服务可用性 | 切换期间只读 | 始终可用 |
二、为什么选择 REPEATABLE-READ 隔离级别
2.1 事务隔离级别对比
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 锁机制 |
|---|---|---|---|---|
| READ-UNCOMMITTED | 可能 | 可能 | 可能 | 无/少 |
| READ-COMMITTED | 不可能 | 可能 | 可能 | 记录锁 |
| REPEATABLE-READ | 不可能 | 不可能 | 不可能* | 记录锁+间隙锁 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 表锁 |
2.2 为什么不选 READ-COMMITTED
READ-COMMITTED 看起来更适合高并发场景(没有间隙锁),但存在一个关键问题:
sequenceDiagram
participant Pod1 as Pod-1
participant Pod2 as Pod-2
participant DB as 数据库
Note over Pod1,DB: 场景:两个Pod同时创建VM
Pod1->>DB: 查询 NUMA-0 可用 CPU = 2.0核心
DB-->>Pod1: 2.0核心
Pod2->>DB: 查询 NUMA-0 可用 CPU = 2.0核心<br/>(都看到了相同的快照)
DB-->>Pod2: 2.0核心
Pod1->>DB: 分配 1.5 核心成功,剩余 0.5
DB-->>Pod1: 成功
Pod2->>DB: 分配 1.5 核心成功,剩余 -1.0
Note over Pod2: 超卖!
Note right of DB: 问题:T2时刻Pod-2读到的是"旧数据"
2.3 REPEATABLE-READ 的选择理由
我们选择 RR 隔离级别基于以下考量:
1. Galera 的默认推荐
MariaDB/Galera 官方推荐使用 RR 隔离级别,与 Galera 的认证机制配合最佳。
2. 幻读防护
RR 通过间隙锁防止幻读,确保在同一事务中多次读取结果一致:
-- 事务开始时建立快照
START TRANSACTION;
-- 第一次读取:可用 VF 有 3 个
SELECT COUNT(*) FROM infra_vf WHERE status = 'available' AND node_uid = 'node-1';
-- 结果: 3
-- 其他事务插入或修改了数据...
-- 第二次读取:仍然是 3 个(RR 保证)
SELECT COUNT(*) FROM infra_vf WHERE status = 'available' AND node_uid = 'node-1';
-- 结果: 3
COMMIT;
3. 需要处理的代价:间隙锁
RR 的间隙锁会带来更高的死锁概率,需要通过合理的索引设计和锁策略来规避(详见后文)。
三、行锁与索引的关系
3.1 什么是行锁
行锁是 InnoDB 对单行记录加的锁,用于保证并发事务对同一行的修改互斥:
| 锁类型 | 说明 | 示例 |
|---|---|---|
| Record Lock(记录锁) | 锁定单条索引记录 | SELECT * FROM t WHERE id=1 FOR UPDATE |
| Gap Lock(间隙锁) | 锁定索引记录之间的间隙,防止其他事务在间隙中插入新记录,仅在 REPEATABLE-READ 下生效 | - |
| Next-Key Lock(临键锁) | Record Lock + Gap Lock,锁定记录本身 + 前面的间隙 | - |
3.2 索引如何影响锁范围
原则:锁是加在索引上的,不是加在数据行上的。
案例1:主键/唯一索引 → 精确的记录锁
-- infra_vf 表,uuid 是唯一索引
SELECT * FROM infra_vf WHERE uuid = 'vf-001' FOR UPDATE;
案例2:非唯一索引 → 间隙锁
-- infra_core 表,索引 idx_node_numa_available_del (node_uid, numa_node, available_core, del)
SELECT * FROM infra_core
WHERE node_uid = 'node-1' AND numa_node = 0 AND available_core > 0 AND del = 0
FOR UPDATE;
3.3 我们的表设计分析
-- infra_core 表
CREATE TABLE infra_core (
uuid VARCHAR(36) NOT NULL UNIQUE,
node_uid VARCHAR(36) NOT NULL,
numa_node INT NOT NULL,
available_core DECIMAL(6,4) NOT NULL,
del BOOLEAN DEFAULT FALSE,
...
INDEX idx_node_numa_available (node_uid, numa_node, available_core, del)
-- 注意:available_core 不在索引中!
);
-- infra_vf 表
CREATE TABLE infra_vf (
uuid VARCHAR(36) NOT NULL UNIQUE,
node_uid VARCHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL,
numa_node INT NOT NULL,
del BOOLEAN DEFAULT FALSE,
...
INDEX idx_node_status_numa (node_uid, status, numa_node, del)
);
设计考量:
由于VF资源更加稀缺,所以我们先分配VF资源再分配Core资源,即先分配一个未使用的VF,根据他的NUMA,再找到对应的,可以供使用的Core。
| 表 | 查询条件 | 索引覆盖 | 锁范围 |
|---|---|---|---|
| infra_vf | uuid = ? | 是(唯一索引) | 单行记录锁 |
| infra_core | node_uid=? AND numa_node=? AND available_core>0 | 部分 | 范围间隙锁 |
四、实战:如何防止超卖
4.1 问题场景
sequenceDiagram
participant Pod1 as Pod-1
participant Pod2 as Pod-2
participant DB as 数据库
Note over Pod1,DB: 初始状态:VF-001 可用
Pod1->>DB: 查询 VF-001 状态
DB-->>Pod1: available
Pod2->>DB: 查询 VF-001 状态
DB-->>Pod2: available
Pod1->>DB: 更新 VF-001 → vm_uid = vm-a
Pod2->>DB: 更新 VF-001 → vm_uid = vm-b
Note over Pod2: 超卖!
Note right of DB: 结果:同一个VF被分配给两个VM
4.2 解决方案:SELECT FOR UPDATE
核心思路:先锁定,再检查,最后更新,全程在事务中完成。
func (r *vfRepository) tryAllocateVF(ctx context.Context, vfUUID string, vmUID string) error {
// 1. 开启事务
tx, _ := r.db.BeginTxx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
})
defer tx.Rollback()
// 2. FOR UPDATE 锁定该行(其他事务会阻塞)
query := `SELECT * FROM infra_vf WHERE uuid = ? AND del = 0 FOR UPDATE`
var vf model.InfraVF
tx.GetContext(ctx, &vf, query, vfUUID)
// 3. 检查状态(此时其他事务无法修改)
if vf.Status != "available" {
return fmt.Errorf("vf is not available")
}
// 4. 更新状态
tx.Exec(`UPDATE infra_vf SET status = 'allocated', vm_uid = ? WHERE uuid = ?`,
vmUID, vfUUID)
// 5. 提交事务
return tx.Commit()
}
锁的防护机制:
sequenceDiagram
participant Pod1 as Pod-1
participant Pod2 as Pod-2
participant DB as 数据库
Pod1->>DB: SELECT ... FOR UPDATE
DB-->>Pod1: 获取 VF-001 的行锁<br/>查询结果: status = available
Pod2->>DB: SELECT ... FOR UPDATE
Note over DB: 尝试获取 VF-001 的行锁<br/>被阻塞,等待 Pod-1 释放锁
Pod1->>DB: UPDATE status = 'allocated', vm_uid = 'vm-a'
Pod1->>DB: 提交事务,释放锁
DB-->>Pod2: 获得锁
Pod2->>DB: 查询结果: status = 'allocated'(已变化)
Note over Pod2: 检查失败,返回错误
Note right of DB: 结果:成功避免超卖
4.3 CPU 核心分配的复杂性
CPU 核心分配比 VF 更复杂,因为:
- 需要锁定多行:一个 VM 可能需要多个核心
- 资源是连续分配的:按 available_core 排序,依次扣减
func (r *coreRepository) tryAllocateInTransaction(
ctx context.Context,
nodeUID string,
numaNode int,
requiredCore decimal.Decimal,
vmUID string,
) ([]*model.InfraCoreVmBinding, error) {
tx, _ := r.db.BeginTxx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
})
defer tx.Rollback()
// 1. 锁定该 NUMA 节点所有可用核心
query := `SELECT * FROM infra_core
WHERE node_uid = ? AND numa_node = ? AND available_core > 0 AND del = 0
ORDER BY available_core ASC
FOR UPDATE`
var cores []*model.InfraCore
tx.SelectContext(ctx, &cores, query, nodeUID, numaNode)
// 2. 计算可用总量
totalAvailable := decimal.Zero
for _, core := range cores {
totalAvailable = totalAvailable.Add(core.AvailableCore)
}
// 3. 检查是否充足
if totalAvailable.LessThan(requiredCore) {
return nil, fmt.Errorf("insufficient cores")
}
// 4. 依次扣减
remaining := requiredCore
for _, core := range cores {
if remaining.LessThanOrEqual(decimal.Zero) {
break
}
allocateAmount := decimal.Min(core.AvailableCore, remaining)
// 更新核心可用量
tx.Exec(`UPDATE infra_core
SET available_core = available_core - ?
WHERE uuid = ?`, allocateAmount, core.UUID)
// 创建绑定记录
tx.NamedExec(`INSERT INTO infra_core_vm_binding ...`, binding)
remaining = remaining.Sub(allocateAmount)
}
return bindings, tx.Commit()
}
4.4 处理 Galera 死锁:指数退避重试
REPEATABLE-READ + 范围锁 + 并发写入 = 高概率死锁。
死锁场景:
sequenceDiagram
participant T1 as Transaction-1
participant T2 as Transaction-2
T1->>T1: 锁定 VF-001 → 成功
T2->>T2: 锁定 VF-002 → 成功
T1->>T2: 尝试锁定 CPU核心(范围锁)→ 等待 T2 释放
T2->>T1: 尝试锁定 CPU核心(范围锁)→ 等待 T1 释放
Note over T1,T2: 检测到循环等待 → 死锁!<br/>MySQL选择回滚其中一个事务(错误码 1213)
解决方案:指数退避重试
func (r *coreRepository) AllocateCoresInTransaction(...) ([]*model.InfraCoreVmBinding, error) {
maxRetries := 3
for i := 0; i < maxRetries; i++ {
bindings, err := r.tryAllocateInTransaction(...)
if err == nil {
return bindings, nil
}
// 检查是否是死锁错误
if isDeadlockError(err) && i < maxRetries-1 {
// 指数退避:100ms, 200ms, 300ms
time.Sleep(time.Duration(100*(i+1)) * time.Millisecond)
continue
}
return nil, err
}
return nil, fmt.Errorf("max retries exceeded")
}
func isDeadlockError(err error) bool {
errStr := err.Error()
// MySQL 死锁错误码: 1213
// Galera 冲突错误码: 1047, 1205
return strings.Contains(errStr, "1213") ||
strings.Contains(errStr, "Deadlock") ||
strings.Contains(errStr, "WSREP") ||
strings.Contains(errStr, "1205") ||
strings.Contains(errStr, "lock wait timeout")
}
五、与 Saga 模式的结合
5.1 为什么需要 Saga
资源分配只是 VM 创建流程的一个环节,完整流程包括:
flowchart TB
subgraph Flow["VM 创建完整流程"]
direction TB
S1["Step 1: 分配资源(VF + CPU)"]
S2["Step 2: 创建 VM(PVE API)"]
S3["Step 3: 导入磁盘镜像"]
S4["Step 4: 挂载磁盘"]
S5["Step 5: 配置 SSH 密钥"]
S6["Step 6: 扩容磁盘"]
S7["Step 7: 启动 VM"]
S8["Step 8: 持久化状态"]
S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8
end
Note1["任何一步失败,都需要回滚已完成的操作<br/>如果 Step 3 失败,已分配的 VF 和 CPU 资源必须释放,否则会形成资源泄漏"]
5.2 Saga 模式实现
Saga 模式将长事务拆分为多个本地事务,每个事务有对应的补偿操作:
// Saga 步骤定义
type SagaStep interface {
Name() string
Action(ctx *SagaContext) error // 正向操作
Compensate(ctx *SagaContext) error // 补偿操作(回滚)
}
// 资源分配步骤
type AllocVmResourceAction struct {
VfDomain domain.VFDomain
CoreDomain domain.CoreDomain
}
func (a *AllocVmResourceAction) Action(ctx *SagaContext) error {
vm := ctx.Data["vm"].(*model.InfraVm)
// 分配 VF
vf, err := a.VfDomain.AllocateVF(ctx.Ctx, vm.NodeUID, vm.UUID)
if err != nil {
return err
}
// 分配 CPU 核心
bindings, err := a.CoreDomain.AllocateCoresBySpec(ctx.Ctx, ...)
if err != nil {
// 注意:这里不需要手动释放 VF
// 因为整个 Action 失败后,Compensate 会被调用
return err
}
// 保存到 Saga Context
ctx.Data["vf"] = vf
ctx.Data["bindings"] = bindings
return nil
}
func (a *AllocVmResourceAction) Compensate(ctx *SagaContext) error {
vm := ctx.Data["vm"].(*model.InfraVm)
// 释放 VF
a.VfDomain.ReleaseVFByVM(ctx.Ctx, vm.UUID)
// 释放 CPU 绑定
a.CoreDomain.ReleaseByVmUID(ctx.Ctx, vm.UUID)
return nil
}
5.3 Saga 编排器
func (o *SagaOrchestrator) execute(ctx *SagaContext) error {
for i, step := range o.steps {
// 持久化当前步骤
ctx.Task.Step = step.Name()
o.repo.Update(ctx.Task)
// 执行正向操作
if err := step.Action(ctx); err != nil {
// 执行补偿(反向回滚)
o.compensate(ctx, i)
return err
}
}
// 全部成功
ctx.Task.ExecState = "SUCCESS"
return o.repo.Update(ctx.Task)
}
func (o *SagaOrchestrator) compensate(ctx *SagaContext, failedIndex int) {
// 反向执行 Compensate
for i := failedIndex; i >= 0; i-- {
o.steps[i].Compensate(ctx)
}
}
5.4 资源释放的原子性
释放资源同样需要保证原子性:
func (r *coreRepository) ReleaseCoresByVmUIDInTransaction(ctx context.Context, vmUID string) error {
tx, _ := r.db.BeginTxx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
})
defer tx.Rollback()
// 1. FOR UPDATE 锁定绑定记录
query := `SELECT * FROM infra_core_vm_binding
WHERE vm_uid = ? AND del = 0
FOR UPDATE`
var bindings []*model.InfraCoreVmBinding
tx.SelectContext(ctx, &bindings, query, vmUID)
// 2. 归还核心资源 + 删除绑定记录
for _, binding := range bindings {
// 归还核心
tx.Exec(`UPDATE infra_core
SET available_core = LEAST(available_core + ?, 1.0)
WHERE uuid = ?`, binding.UsedCore, binding.CoreUID)
// 软删除绑定
tx.Exec(`UPDATE infra_core_vm_binding
SET del = 1
WHERE uuid = ?`, binding.UUID)
}
return tx.Commit()
}
六、架构全景图
flowchart TB
subgraph K8s["Kubernetes"]
P1[Pod-1]
P2[Pod-2]
P3[Pod-3]
end
subgraph Saga["Saga Orchestrator"]
direction TB
S1["Step 1<br/>分配资源"]
S2["Step 2<br/>创建VM"]
S3["Step 3<br/>配置网络"]
SN["Step N<br/>持久化"]
C1["Compensate<br/>释放资源"]
C2["Compensate<br/>删除VM"]
C3["Compensate<br/>清理网络"]
CN["Compensate<br/>标记失败"]
S1 --> S2 --> S3 --> SN
S1 -.-> C1
S2 -.-> C2
S3 -.-> C3
SN -.-> CN
end
subgraph Galera["MariaDB Galera Cluster"]
direction LR
N1["Node-1<br/>(读/写)"]
N2["Node-2<br/>(读/写)"]
N3["Node-3<br/>(读/写)"]
N1 <--> N2
N2 <--> N3
N1 <--> N3
Note2["隔离级别: REPEATABLE-READ<br/>锁机制: Record Lock + Gap Lock<br/>防超卖: SELECT FOR UPDATE + 重试机制"]
end
P1 --> Saga
P2 --> Saga
P3 --> Saga
Saga --> Galera
七、总结
| 问题 | 解决方案 | 关键技术 |
|---|---|---|
| 数据丢失风险 | Galera 同步复制 | 全局事务认证 |
| 单点写入瓶颈 | Galera 多主架构 | 任意节点可写 |
| 超卖问题 | SELECT FOR UPDATE | 行锁 + 事务原子性 |
| 死锁问题 | 指数退避重试 | 错误码识别 + 退避策略 |
| 长事务失败 | Saga 补偿模式 | 正向执行 + 反向回滚 |
| 间隙锁范围 | 索引优化 | 覆盖查询条件 |
核心经验
- 锁是加在索引上的:合理设计索引可以减少锁范围,提高并发度
- FOR UPDATE 是防超卖的利器:先锁后查再更新,保证原子性
- RR 隔离级别需要处理间隙锁:通过重试机制应对死锁
- Saga 模式解决长事务问题:每个步骤有对应的补偿操作