Vitess TabletManager 模型重构:从「记录驱动轮询」到「vttablet 自述权威状态」
2026/9/21 1:52:57 网站建设 项目流程

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 记录,再调用RefreshTabletRefreshState」的调用点。

这套「改记录 + 主动刷新」的两段式流程存在两个问题:

  1. 不符合实际运维方式:更新 tablet 记录并期望 tablet 自己刷新,并不能带来任何实际收益;
  2. 链路脆弱:每一次状态变更都把多余的组件(topo 存储 + 轮询观察者)拉进动作链中,链路越长,失败概率越高。

因此文档提出的新模型是:

tablet 进程(vttablet)是自己当前状态的权威来源;它把状态「发布」到 tablet 记录中,而该记录仅用于发现(discovery)。

换句话说,topo 中的 tablet 记录不再是「命令源头」,而是「状态展示板」。

新模型核心:单次 RPC 往返 + 尽力而为发布

vttablet 存活时:直接 RPC,不再轮询

在新模型下,所有需要改变 tablet 状态的流程,直接向 vttablet 发起一次 RPC 请求

  1. tablet 立即执行该请求;
  2. 执行成功后,vttablet尽力而为(best-effort)地把新状态写回 tablet 记录;
  3. 如果写回失败,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)中的PrimaryAliasPrimaryTermStartTime是全体组件(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 验证写入是否真的发生了(因为不确定是「没写进去」还是「写了但响应丢失」),确认TypePrimaryTermStartTime都匹配后才继续本地状态切换。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) }

keyspaceshard不一致时直接返回错误、拒绝启动。而这些值正是通过一系列init-*启动参数注入的,注册于 tm_init.go:

参数说明
--init-keyspace该 tablet 所属 keyspace
--init-shard该 tablet 所属 shard
--init-tablet-type初始 tablet 类型,合法值仅REPLICARDONLYEXPERIMENTALSPARE,默认REPLICA(构建时校验见 tm_init.go)
--init-db-name-override覆盖 vttablet 使用的数据库名,缺省为vt_<keyspacename>
--init-tablet-type-lookup(实验性)重启时从已有 topo 记录中恢复 tablet 类型,使 RDONLY/DRAINED 等角色在重启后保持,见 tm_init.go
--init-timeoutinit 阶段超时,默认 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 记录中的PrimaryAliasPrimaryTermStartTime,与已有 tablet 记录交叉比对:

  • shard 记录指向本 tablet 且旧记录也同意 → 带上旧记录的PrimaryTermStartTime恢复为 PRIMARY;
  • shard 记录指向本 tablet 但旧记录不是 primary → 采用 shard 记录的PrimaryTermStartTime升主;
  • 旧记录是 primary 但 shard 不认 → 仅当本 tablet 的PrimaryTermStartTime更新时才接管,否则保持 replica。

这套启动期逻辑确保「主库身份」在极端重启场景下(如旧主先重启、新主还在接管中)不会产生双主或丢主。

新模型的收益

设计文档总结了四个层面的收益:

  1. vttablet 成为 tablet 记录的权威所有者:不再需要持续轮询记录,也无需处理记录中可能出现的意外或非法变更,复杂度大幅下降;
  2. 自由覆盖本地记录:既然假设没有别人会改这条记录,vttablet 就可以放心地用本地副本整体覆盖 tablet 记录,而无需做字段级合并或同步协商(源码中proto.Reset+proto.Merge的整记录覆盖正是这一假设的体现);
  3. 流程从两步变一步:大量流程从「改记录 + 刷新」两段式变成「单次 RPC」,调用链变短、失败面变窄;
  4. 减轻 topo 负载:tablet 不再轮询,topo 的读压力显著下降。

过渡路径(Transition):当前仓库的落地现状

设计文档给出的过渡分两步:

  1. 第一步:把所有调用点改成对 tablet 的单次往返 RPC;此时 tabletmanager 仍然更新 tablet 记录,并继续依赖现有的轮询器(poller)
  2. 第二步:当 tabletmanager 已成为其记录的唯一所有者(sole owner)后,把行为切换为非轮询(non-polling)

从当前仓库源码结构看,这套演进仍在推进中,且已经能看到大量第一步的痕迹:

  • 所有状态变更(ChangeTypeSetServingType引发的状态调整等)都经由 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询