Vitess TabletManager 模型重构:从「记录驱动轮询」到「vttablet 自述权威状态」
【免费下载链接】vitessVitess is a database clustering system for horizontal scaling of MySQL.项目地址: https://gitcode.com/gh_mirrors/vi/vitess
导读
本文基于 Vitess 仓库中的设计文档 TabletManagerModel.md,剖析 Vitess tablet 状态管理模型的一次关键演进:将 tablet 记录(topo 中的 Tablet record)从「权威事实」降级为「发现用缓存」,让 vttablet 进程成为自身状态的唯一权威发布者。读完本文,你将理解为何单次 RPC 往返即可完成状态变更、为何主库选举必须把 topo 当作例外权威、vttablet 启动时信任哪些 topo 字段,以及当前仓库源码(tm_state.go、tm_init.go)是如何落地这套模型的。
背景:旧模型的脆弱之处
设计文档首先描述了旧模型的运行方式:tablet 记录被视为权威(authoritative)。具体表现为:
- vttablet 进程持续轮询(poll)自己在 topo 中的 tablet 记录,并对记录的变化做出反应;
- 代码中散布着大量「先更新 tablet 记录,再调用
RefreshTablet或RefreshState」的调用点。
这套「改记录 + 主动刷新」的两段式流程存在两个问题:
- 不符合实际运维方式:更新 tablet 记录并期望 tablet 自己刷新,并不能带来任何实际收益;
- 链路脆弱:每一次状态变更都把多余的组件(topo 存储 + 轮询观察者)拉进动作链中,链路越长,失败概率越高。
因此文档提出的新模型是:
tablet 进程(vttablet)是自己当前状态的权威来源;它把状态「发布」到 tablet 记录中,而该记录仅用于发现(discovery)。
换句话说,topo 中的 tablet 记录不再是「命令源头」,而是「状态展示板」。
新模型核心:单次 RPC 往返 + 尽力而为发布
vttablet 存活时:直接 RPC,不再轮询
在新模型下,所有需要改变 tablet 状态的流程,直接向 vttablet 发起一次 RPC 请求:
- tablet 立即执行该请求;
- 执行成功后,vttablet尽力而为(best-effort)地把新状态写回 tablet 记录;
- 如果写回失败,vttablet 会持续重试直到成功。
这一设计带来的核心收益是请求只需一次到 tablet 的往返即可成功:
| 场景 | 结果 |
|---|---|
| RPC 请求失败 | 操作视为失败,直接返回错误 |
| RPC 成功、但 tablet 记录更新失败 | 操作仍然成功,记录稍后会被 vttablet 的发布重试补齐 |
这里的关键哲学是:操作的成功不依赖 topo 写入的即时成功。因为 tablet 记录迟早会收敛到真实状态,调用方无需为了「写记录」而多等一次 topo 往返。
源码印证:publishStateLocked 与 retryPublish
在 tm_state.go 中,publishStateLocked正是这套「尽力而为发布」的实现:
func (ts *tmState) publishStateLocked(ctx context.Context) { // ... _, err := ts.tm.TopoServer.UpdateTabletFields(ctx, ts.tm.tabletAlias, func(tablet *topodatapb.Tablet) error { if err := topotools.CheckOwnership(tablet, ts.tablet); err != nil { // ... return topo.NewError(topo.NoUpdateNeeded, "") } proto.Reset(tablet) proto.Merge(tablet, ts.tablet) return nil }) if err != nil { // ... log.Error(fmt.Sprintf("Unable to publish state to topo, will keep retrying: %v", err)) ts.isPublishing = true // Keep retrying until success. go ts.retryPublish() } }注意几个细节:
- 写入使用
UpdateTabletFields并经过topotools.CheckOwnership校验,确保只有 tablet 自己(或拥有者)能改写自己的记录; - 发布失败后进入
retryPublish()后台重试循环(tm_state.go),每轮间隔由--publish-retry-interval控制,默认30 秒(见 tm_state.go); - 一个特殊分支:如果
UpdateTabletFields返回NoNode(有人把 tablet 记录删了),vttablet 会认为自己的身份已不存在,主动向servenv.ExitChan发送 SIGTERM 优雅退出——因为记录被删意味着它已不属于任何拓扑。
这套「先执行、后发布、失败续传」的结构,与设计文档描述的模型完全一致。
vttablet 宕机或不可达时:操作直接失败
如果 vttablet 不可达,操作直接失败。设计文档特别指出:
这种失败模式并不比「我们无法更新 tablet 记录」更糟。
理由很朴素:无论采用哪种模型,vttablet 不可达时状态变更都无法完成;而且代码本来就默认 tablet 记录可能与 vttablet 实际状态不同步——这是 Vitess 长期存在并已被各方代码(如 health check、vtgate 的发现逻辑)处理过的前提,新模型只是把这个前提显式化了。
RefreshState:保留 API,但语义收窄
RefreshState仍然作为一个 API 存在,但它的用途被明确限制为:针对 global topo 中状态变化的刷新(例如 shard 记录、SrvKeyspace 的变化),而不再用于「刷新自身 tablet 记录」。
源码中的实现印证了这一点——rpc_actions.go 中RefreshState只是转调tmState.RefreshFromTopo,而后者读取的是shard 记录和 SrvKeyspace(见 tm_state.go):
func (ts *tmState) RefreshFromTopo(ctx context.Context) error { // ... shardInfo, err := ts.tm.TopoServer.GetShard(ctx, ts.Keyspace(), ts.Shard()) // ... srvKeyspace, err := ts.tm.TopoServer.GetSrvKeyspace(ctx, ts.tm.tabletAlias.Cell, ts.Keyspace()) // ... return ts.RefreshFromTopoInfo(ctx, shardInfo, srvKeyspace) }RefreshFromTopoInfo会从中提取分片级信息:是否处于 resharding(SourceShards)、TabletControls 中的 denied tables 规则、SrvKeyspace 各 partition 中本分片是否 serving 等,并据此调用updateLocked调整本地查询服务状态(tm_state.go)。也就是说,刷新不再是为了「同步自己的记录」,而是为了感知全局拓扑(分片、keyspace、serving 状态)的变化。
两个例外:哪里仍然信任 topo?
设计文档明确列出了两个 topo 依然作为权威的例外场景,这两处也正是新模型边界的关键。
例外一:集群主库选举(Cluster Leadership)
对于「谁是这个分片的主库(primary)」这类流程,topo 是权威。对于这类请求,tablet 会先尝试更新自己的记录,成功后才算操作成功——顺序与常规流程相反。
原因在于新的集群主库选举(cluster leadership redesign)机制的依赖:分片记录(shard record)中的PrimaryAlias与PrimaryTermStartTime是全体组件(vtgate、vttablet、vtorc)判定当前主库的唯一依据,必须先落盘,才能避免「DB 层已是双主、而 topo 层毫不知情」的脑裂窗口。
源码中可以看到这一顺序被严格保证——tm_state.go 的ChangeTabletType:
if tabletType == topodatapb.TabletType_PRIMARY { primaryTermStartTime = protoutil.TimeToProto(time.Now()) // Update the tablet record first. _, err := topotools.ChangeType(ctx, ts.tm.TopoServer, ts.tm.tabletAlias, tabletType, primaryTermStartTime) if err != nil { // 读取验证或持续重试,直到确认写入成功 // ... } } err := ts.updateTypeAndPublish(ctx, tabletType, primaryTermStartTime, action)转为 PRIMARY 时先通过topotools.ChangeType写 topo;若写失败,代码会反复读取 topo 验证写入是否真的发生了(因为不确定是「没写进去」还是「写了但响应丢失」),确认Type与PrimaryTermStartTime都匹配后才继续本地状态切换。updateTypeAndPublish中还注释解释了顺序的必要性:只有当 topo 更新完成之后才调用SetReadOnly(false),避免出现「DB 层双主、Vitess 层只认一个主」的情况(tm_state.go)。
例外二:vttablet 启动时
vttablet 启动时,会把来自 topo 的以下信息当作权威:
- Keyspace
- Shard
- Tablet Type
- DBName
设计文档要求:如果这些信息与 init 参数不匹配,进程应直接退出(exit),而不是带着错误的身份进入服务。
源码中,这一校验体现在 tm_init.go 的initTablet里——当 tablet 记录已存在(NodeExists)时:
oldTablet, err := tm.TopoServer.GetTablet(ctx, tablet.Alias) // ... // Sanity check the keyspace and shard if oldTablet.Keyspace != tablet.Keyspace || oldTablet.Shard != tablet.Shard { return fmt.Errorf("initTablet failed because existing tablet keyspace and shard %v/%v differ from the provided ones %v/%v", oldTablet.Keyspace, oldTablet.Shard, tablet.Keyspace, tablet.Shard) }keyspace与shard不一致时直接返回错误、拒绝启动。而这些值正是通过一系列init-*启动参数注入的,注册于 tm_init.go:
| 参数 | 说明 |
|---|---|
--init-keyspace | 该 tablet 所属 keyspace |
--init-shard | 该 tablet 所属 shard |
--init-tablet-type | 初始 tablet 类型,合法值仅REPLICA、RDONLY、EXPERIMENTAL、SPARE,默认REPLICA(构建时校验见 tm_init.go) |
--init-db-name-override | 覆盖 vttablet 使用的数据库名,缺省为vt_<keyspacename> |
--init-tablet-type-lookup | (实验性)重启时从已有 topo 记录中恢复 tablet 类型,使 RDONLY/DRAINED 等角色在重启后保持,见 tm_init.go |
--init-timeout | init 阶段超时,默认 1 分钟 |
--init-tags | 逗号分隔的key:value标签列表,写入 tablet 记录 |
BuildTabletFromInput(tm_init.go)会基于这些参数构造初始 tablet 记录,checkMysql还会把探测到的 MySQL 地址/端口合并进记录(tm_init.go)。
设计文档还提出一个可选项:如果 tablet 类型是 primary,可以在确认自己是主库之前,强制与 shard 记录做一次同步(force a sync)。源码中的checkPrimaryShip(tm_init.go)正是这类逻辑——它会读取 shard 记录中的PrimaryAlias与PrimaryTermStartTime,与已有 tablet 记录交叉比对:
- shard 记录指向本 tablet 且旧记录也同意 → 带上旧记录的
PrimaryTermStartTime恢复为 PRIMARY; - shard 记录指向本 tablet 但旧记录不是 primary → 采用 shard 记录的
PrimaryTermStartTime升主; - 旧记录是 primary 但 shard 不认 → 仅当本 tablet 的
PrimaryTermStartTime更新时才接管,否则保持 replica。
这套启动期逻辑确保「主库身份」在极端重启场景下(如旧主先重启、新主还在接管中)不会产生双主或丢主。
新模型的收益
设计文档总结了四个层面的收益:
- vttablet 成为 tablet 记录的权威所有者:不再需要持续轮询记录,也无需处理记录中可能出现的意外或非法变更,复杂度大幅下降;
- 自由覆盖本地记录:既然假设没有别人会改这条记录,vttablet 就可以放心地用本地副本整体覆盖 tablet 记录,而无需做字段级合并或同步协商(源码中
proto.Reset+proto.Merge的整记录覆盖正是这一假设的体现); - 流程从两步变一步:大量流程从「改记录 + 刷新」两段式变成「单次 RPC」,调用链变短、失败面变窄;
- 减轻 topo 负载:tablet 不再轮询,topo 的读压力显著下降。
过渡路径(Transition):当前仓库的落地现状
设计文档给出的过渡分两步:
- 第一步:把所有调用点改成对 tablet 的单次往返 RPC;此时 tabletmanager 仍然更新 tablet 记录,并继续依赖现有的轮询器(poller);
- 第二步:当 tabletmanager 已成为其记录的唯一所有者(sole owner)后,把行为切换为非轮询(non-polling)。
从当前仓库源码结构看,这套演进仍在推进中,且已经能看到大量第一步的痕迹:
- 所有状态变更(
ChangeType、SetServingType引发的状态调整等)都经由 rpc_actions.go 的 RPC 入口直接驱动,配合actionSema(重量级信号量)保证同一时刻只有一个动作在执行; - 记录写入统一收敛到
tmState.publishStateLocked+retryPublish的「发布 + 重试」模式; - 同时,shard_sync.go 中仍然存在
shardSyncLoop:它维护对 shard 记录的 topo watch(仅 primary 时监听),并通过notifyChan接收 tablet 本地状态变化通知,保持 tablet 记录与 shard 记录(如 PrimaryTermStartTime)的一致性,重试间隔由--shard-sync-retry-delay控制(默认 30 秒)。
这说明当前实现正处于「tablet 已自述状态、但 shard 级同步与轮询辅助机制仍在」的过渡阶段——主库选举对 topo 的强依赖(例外一)决定了 shard 记录的 watch 在短期内不可完全移除。
小结
TabletManager 模型的这次重构,本质是职责归位:vttablet 负责「我是谁、我处于什么状态」,topo 中的 tablet 记录退化为「给 vtgate 等组件做发现用的缓存视图」。单次 RPC 往返、尽力而为发布与后台重试、启动时的字段强校验、主库选举时 topo 优先——这四个支柱构成了新模型的完整轮廓。对于想在 Vitess 上做二次开发或运维调优的读者,tm_state.go(状态发布与刷新)、tm_init.go(启动与初始化)和 shard_sync.go(shard 记录同步)是理解这套模型最直接的三个入口。
【免费下载链接】vitessVitess is a database clustering system for horizontal scaling of MySQL.项目地址: https://gitcode.com/gh_mirrors/vi/vitess
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考