GitHub user thunguo added a comment to the discussion: Proposal: support 
Master-Slave architecture among components

# Dubbo Admin Leader Election 设计方案

## 背景

Dubbo Admin 以多副本方式部署时,`ResourceDiscovery` 和 `ResourceEngine` 
两个组件负责从注册中心和运行时引擎拉取数据并写入 DB Store。每个副本都会独立执行 
list-watch,并发写入同一数据库会产生数据覆盖和索引不一致的问题。

解决思路是为这两个组件引入主从架构:任意时刻只有一个副本(Leader)主动执行 list-watch 并写 
Store,其余副本(Follower)处于待机状态,持续参与选举以在 Leader 故障时接管。本方案仅适用于 DB 存储模式,Memory Store 
为单副本设计,无需参与选举。

## 方案选型

有两种可行方案:

- **Kubernetes Lease**:利用 `k8s coordination.k8s.io/v1` 的原生租约 API,但前提是 Engine 
组件必须是 Kubernetes 类型,而 Admin 支持 `kubernetes` 和 `mock` 两种 Engine,不能强依赖 k8s 环境。
- **基于 GORM + 数据库实现租约机制**:利用 DB 操作的原子性保证唯一 Leader。

选择后者,原因如下:DB 存储模式下 `ConnectionPool` 在 `storeComponent.Init` 
阶段已就绪,可直接复用,不引入新的基础设施依赖;租约表与业务数据同库,事务边界清晰;与 Admin 现有存储抽象完全兼容。

## Leader Election 实现

### 租约表结构

在数据库中维护一张 `leader_leases` 表,每一行对应一个参与选举的组件:

```sql
CREATE TABLE leader_leases (
    id          BIGINT UNSIGNED  PRIMARY KEY AUTO_INCREMENT,
    component   VARCHAR(64)      NOT NULL,
    holder_id   VARCHAR(255)     NOT NULL,
    acquired_at DATETIME(3)      NOT NULL,
    expires_at  DATETIME(3)      NOT NULL,
    version     BIGINT           NOT NULL DEFAULT 0,
    UNIQUE INDEX idx_component (component)
);
```

字段说明:

* `component`:组件名(如 `ResourceDiscovery`),加唯一索引,确保每个组件同时只有一条租约记录。
* `holder_id`:持有者标识,由 `hostname + UUID` 拼接而成,全局唯一,用于区分不同副本。
* `expires_at`:租约到期时间,等于 `acquired_at + leaseDuration`。
* `version`:乐观锁版本号,每次续约后自增,用于防止并发更新冲突。

表由 GORM `AutoMigrate` 在 `Init` 阶段自动创建,幂等,无需手动维护 migration 文件。

### 竞选逻辑(TryAcquire)

竞选分两步,均为数据库单语句原子操作。

​**第一步**​,尝试 UPDATE 现有记录:

```sql
UPDATE leader_leases
SET holder_id = ?, acquired_at = NOW(), expires_at = NOW() + leaseDuration, 
version = version + 1
WHERE component = ?
  AND (expires_at < NOW() OR holder_id = ?)
```

WHERE 条件覆盖两种场景:

* 租约已过期(上一任 Leader 故障或停机后租约自然失效)
* 当前副本本身就是 Leader 在续约

执行后检查 `RowsAffected`,大于 0 表示命中了一行,竞选成功,更新本地 `isLeader = true` 并记录当前版本号。

​**第二步**​,处理记录不存在的情况:若第一步 `RowsAffected` 为 0,说明 `leader_leases` 
表中还没有该组件的记录(初次启动),执行 INSERT,依赖 `component` 列的 UNIQUE 约束保证并发时只有一个副本插入成功。INSERT 
成功则竞选成功,INSERT 失败(UNIQUE 冲突)表示另一副本刚完成插入,竞选失败,`isLeader = false`。

整个逻辑确保:无论多少副本并发参与竞选,最终只有一个副本 `RowsAffected > 0` 或 INSERT 成功。

### 续约逻辑(Renew)

Leader 在任期内需要周期性续约以维持租约:

```sql
UPDATE leader_leases
SET acquired_at = NOW(), expires_at = NOW() + leaseDuration, version = version 
+ 1
WHERE component = ? AND holder_id = ? AND version = ?
```

WHERE 条件包含当前本地版本号,这是乐观锁的核心。若 `RowsAffected` 为 
0,说明版本号已被其他副本修改(租约被抢占),续约失败,`isLeader = false`,当前副本降级为 Follower。

### 释放逻辑(Release)

Leader 主动停机(收到 `stopCh`)时调用 `Release`,将 `expires_at` 设置为 `now - 
1s`,使租约立即过期。这样其他 Follower 在下次 `TryAcquire` 时能立即通过 `expires_at < NOW()` 
条件竞选成功,无需等待 `leaseDuration` 自然过期。

## 选举全流程

以三个副本 A、B、C 为例,描述从 Leader 任期结束到新 Leader 选出并完成接管的完整过程。

### 初始选举

三个副本均启动,在 Start 阶段各自启动 `RunLeaderElection` goroutine,所有副本初始为 
Follower,`acquireTicker` 以 5s 为周期触发 `TryAcquire`。

三个副本同时或先后调用 `TryAcquire`,表中无记录,均尝试 INSERT。DB 唯一索引只允许一个 INSERT 成功,假设副本 A 成功,A 成为 
Leader,`isLeader = true`;B、C 的 INSERT 因 UNIQUE 冲突失败,保持 Follower 状态。

A 获得 Leader 后,`renewTicker` 启动(10s 周期),开始执行业务逻辑(`subscribe + informer.Run`)。B、C 
的 `acquireTicker` 继续运行,但每次 `TryAcquire` 都因 `expires_at` 未过期且 `holder_id` 不匹配而失败。

### 稳定运行期

A 每 10s 续约一次,每次将 `expires_at` 延长到 `now + 30s`,`version` 自增。B、C 每 5s 竞选一次,UPDATE 
因 `expires_at > NOW()` 而命中 0 行,INSERT 因 UNIQUE 冲突失败,均保持 Follower 待机。

### Leader 任期结束——故障场景

副本 A 宕机,不再续约。`leader_leases` 中 A 的 `expires_at` 逐渐临近当前时间。在 A 的租约到期后(最多 30s),B 或 
C 的某次 `TryAcquire` 执行 UPDATE 时,`expires_at < NOW()` 条件成立,命中该行,`RowsAffected > 
0`,竞选成功。

假设 B 先竞选成功,`isLeader = true`,`renewTicker` 启动,B 调用 
`onStartLeading`,开始执行业务逻辑(重新建立 list-watch,启动 informers)。此后 B 的 `version` 持续自增,C 
的 `TryAcquire` 因 `expires_at` 未过期而持续失败,C 保持 Follower 待机。

> 故障接管的最坏时延为:等待租约自然过期(`leaseDuration = 30s`)加上 Follower 
> 下次竞选窗口(`acquireRetryInterval = 5s`),共约 ​**35s**​。

### Leader 任期结束——主动停机场景

若 A 正常停机(收到停止信号),`RunLeaderElection` 的 `stopCh` channel 关闭,A 主动调用 `Release()`,将 
`expires_at` 设为 `now - 1s`,租约立即失效。B 或 C 的下一次 `TryAcquire`(最多 5s 
后)即可竞选成功,接管最坏等待时间仅为 `acquireRetryInterval`(​**5s**​)。

### 选举主循环状态机

<img width="4316" height="1998" alt="image" 
src="https://github.com/user-attachments/assets/fbd90641-e2c8-49b3-a152-b301183e27b7";
 />

## 融入组件生命周期

Admin 的组件生命周期有 `Init` 和 `Start` 两个阶段,选举逻辑分别介入如下。

### Init 阶段

`Init` 原本负责从 `BuilderContext` 获取 `EventBus` 和 `Store`,初始化 informers 和 
subscribers。在此基础上,`Init` 末尾新增选举初始化逻辑:

1. 检查当前 Store 类型是否为 DB 模式(`ctx.Config().Store.Type != Memory`),若是,则从 
`storeComponent` 获取 `*gorm.DB` 连接。这里引入了一个 `DBSource` 接口(定义在 
`pkg/core/leader/`),由 `storeComponent` 实现,通过内部反射调用 `ConnectionPool.GetDB()` 返回 
`*gorm.DB`,规避 `pkg/core/store` 直接引入 `pkg/store/dbcommon` 导致的循环导入。
2. 拿到 DB 连接后,生成本副本的 `holderID`(格式为 `{hostname}-{uuid}`),创建 `LeaderElection` 
实例,并调用 `EnsureTable()` 确保 `leader_leases` 表存在。
3. 若以上步骤全部成功,则将 `needsLeaderElection` 标记为 `true`,否则降级为 `false`,记录 Warn 
日志,全部副本直接运行业务逻辑。

整个初始化过程对原有 informer/subscriber 初始化逻辑没有任何修改,informers 和 subscribers 始终在 `Init` 
阶段完成构建,只是 `Start` 阶段决定是否真正运行它们。

### Start 阶段

`Start` 阶段是选举逻辑真正介入的地方,分两条路径:

**Memory Store 路径(不变)**:`needsLeaderElection == false` 时,直接调用 
`startBusinessLogic()`,执行原有的 `subscriber.Subscribe` 和 `informer.Run`,行为与改造前完全相同。

**​DB Store 路径(新增)**:`needsLeaderElection == true` 时,用 `context.WithCancel` 
创建一个可取消的 context,并启动一个 goroutine 监听 `stopCh`,一旦 `stopCh` 关闭就取消该 context,确保整条选举 
goroutine 能跟随组件停机而退出。然后调用:

```go
leaderElection.RunLeaderElection(ctx, stopCh, onStartLeading, onStopLeading)
```

这是一个阻塞调用,在 `RunLeaderElection` 返回前 `Start` 方法不会返回,符合 Admin 框架对 `Start` 
的语义(`runtime.Start` 以 goroutine 方式调用每个组件的 `Start`,因此阻塞是安全的)。

* `onStartLeading` 回调封装了原有业务逻辑(`startBusinessLogic(ch)`),在 `TryAcquire` 成功后由 
`RunLeaderElection` 内部触发,正式启动 list-watch。
* `onStopLeading` 回调当前仅记录日志,informer goroutine 本身会随 `ch` 关闭而自然停止,无需额外干预。

两个组件(`Discovery` 和 `Engine`)的改造完全对称,唯一差异是注册到 `LeaderElection` 的 `component` 
名称不同(`ResourceDiscovery` 和 `ResourceEngine`),因此两者在 `leader_leases` 
表中各自持有独立的一行,选举互不干扰,可以由不同副本分别担任 Leader。


GitHub link: 
https://github.com/apache/dubbo-admin/discussions/1380#discussioncomment-16034446

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: 
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to