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]