Galera 集群下的业务资源分配一致性实践:多 Pod 部署如何避免超卖

16 阅读8分钟
💡

本文由实践出发,文章经过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_vfuuid = ?是(唯一索引)单行记录锁
infra_corenode_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 更复杂,因为:

  1. 需要锁定多行:一个 VM 可能需要多个核心
  2. 资源是连续分配的:按 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 补偿模式正向执行 + 反向回滚
间隙锁范围索引优化覆盖查询条件

核心经验

  1. 锁是加在索引上的:合理设计索引可以减少锁范围,提高并发度
  2. FOR UPDATE 是防超卖的利器:先锁后查再更新,保证原子性
  3. RR 隔离级别需要处理间隙锁:通过重试机制应对死锁
  4. Saga 模式解决长事务问题:每个步骤有对应的补偿操作