Tiny_SQL 中Schema同步与租约机制的实现
Tiny_SQL 中 Schema 同步与租约机制的实现
当 TiDB 接受一个 Schema 变更的 DDL 任务后, 我们分析了这个 Job 是如何构造的, 以及如何一步一步的调用后台的 worker goroutine 的, 那么 worker 究竟做了什么呢, worker 做完之后需要做哪些检查和校验呢?
worker 执行 Job 的流程
worker 在收到通知后, 开始执行一个 DDL 变更任务, 这个函数如下:
// handleDDLJobQueue handles DDL jobs in DDL Job queue.
func (w *worker) handleDDLJobQueue(d *ddlCtx) error {
once := true
waitDependencyJobCnt := 0
for {
// 如果收到 worker 退出的信号, 直接退出
if isChanClosed(w.quitCh) {
return nil
}
// 定义本次 job 执行相关的变量
var (
job *model.Job
schemaVer int64
runJobErr error
)
// 本次执行的等待时间为 2 倍的租约时间,
waitTime := 2 * d.lease
// 进入一个新事务(因为 TiDB 的 Job 元数据是存在 TiKV 里的,需事务保护)
err := kv.RunInNewTxn(d.store, false, func(txn kv.Transaction) error {
// We are not owner, return and retry checking later.
if !d.isOwner() {
return nil
}
var err error
// t 是元信息操作封装器 Meta,用于操作元信息表
t := newMetaWithQueueTp(txn, w.typeStr())
// We become the owner. Get the first job and run it. 从 ddl_job 队列中取出第一个 job
job, err = w.getFirstDDLJob(t)
if job == nil || err != nil {
return errors.Trace(err)
}
// 如果当前 job 依赖于另一个未完成的 job(比如 drop partition 依赖于 truncate table),不能立即执行。
if isDone, err1 := isDependencyJobDone(t, job); err1 != nil || !isDone {
return errors.Trace(err1)
}
// 确保集群内所有 TiDB 节点都同步了上一个 schema 变更(防止 schema 版本不一致)
if once {
w.waitSchemaSynced(d, job, waitTime)
once = false
return nil
}
if job.IsDone() || job.IsRollbackDone() {
if !job.IsRollbackDone() {
job.State = model.JobStateSynced
}
err = w.finishDDLJob(t, job)
return errors.Trace(err)
}
// OnJobRunBefore 是一个 hook,允许在 job 执行前打断、注入逻辑,常用于 failpoint 或调试
d.mu.RLock()
d.mu.hook.OnJobRunBefore(job)
d.mu.RUnlock()
// If running job meets error, we will save this error in job Error
// and retry later if the job is not cancelled.
tidbutil.WithRecovery(func() {
schemaVer, runJobErr = w.runDDLJob(d, t, job)
}, func(r interface{}) {
if r != nil {
// If run ddl job panic, just cancel the ddl jobs.
job.State = model.JobStateCancelling
}
})
if job.IsCancelled() {
txn.Reset()
err = w.finishDDLJob(t, job)
return errors.Trace(err)
}
err = w.updateDDLJob(t, job, runJobErr != nil)
if err = w.handleUpdateJobError(t, job, err); err != nil {
return errors.Trace(err)
}
return nil
})
if runJobErr != nil {
// wait a while to retry again. If we don't wait here, DDL will retry this job immediately,
// which may act like a deadlock.
logutil.Logger(w.logCtx).Info("[ddl] run DDL job failed, sleeps a while then retries it.",
zap.Duration("waitTime", WaitTimeWhenErrorOccurred), zap.Error(runJobErr))
time.Sleep(WaitTimeWhenErrorOccurred)
}
if err != nil {
return errors.Trace(err)
} else if job == nil {
// No job now, return and retry getting later.
return nil
}
// 等待依赖 job 完成;
w.waitDependencyJobFinished(job, &waitDependencyJobCnt)
// 执行 hook(如用于记录/测试 job 状态更新)
d.mu.RLock()
d.mu.hook.OnJobUpdated(job)
d.mu.RUnlock()
// Here means the job enters another state (delete only, write only, public, etc...) or is cancelled.
// If the job is done or still running or rolling back, we will wait 2 * lease time to guarantee other servers to update
// the newest schema.
w.waitSchemaChanged(nil, d, waitTime, schemaVer, job)
if job.IsSynced() || job.IsCancelled() {
asyncNotify(d.ddlJobDoneCh)
}
}
}
从上面的代码可以看出, 每个 worker 除了执行 Job 的具体操作外, 还存在两个特殊的等待操作, 一个是在执行一个 Job 之前, 使用下面的代码等待两个祖约时间.
// 确保集群内所有 TiDB 节点都同步了上一个 schema 变更(防止 schema 版本不一致)
if once {
w.waitSchemaSynced(d, job, waitTime)
once = false
return nil
}
然后是在设置 Job 任务状态为结束之前, 使用下面的代码等待两个租约时间:
w.waitSchemaChanged(nil, d, waitTime, schemaVer, job)
为什么要在这两个地方等待两倍的租约时间呢?
TiDB 节点间的 Schema 同步机制
我们知道在 TiDB 中, 同一时刻只有一个 Owner 节点(KV 的 etcd 机制控制). 当 Owner 完成了 DDL 变更的任务后, 修改了 KV 存储引擎中存储的 schema 元数据, 此时由于其他节点并非 Owner 节点, 其他节点的 schema 还没有更新, 如果此时直接执行查询, 或者插入等操作, 会由于不同的节点的 schema 版本的不同, 导致 KV 存储数据不一致的问题, Schema 变更的算法我们在之前已经讲述过了.
而其中, 保证系统中, 同一时刻只有两个 Schema 版本以及保证数据库一致性的方式则是租约机制与 Schema 同步机制.
TiDB Schema 租约机制
为什么要有租约机制呢? 这是因为DDL 的变更是集中执行的(由 DDL Owner 执行), 但是 Schema 变更要广播到所有 TiDB Server,否则会有读写冲突, 而 TiDB 为了避免使用复杂的强一致性广播(F1 Schema 变更也是如此, F1 服务器是无状态的), TiDB 采用了 租约机制来实现一种 最终一致 + 可控延迟 的同步策略.
最终一致性
- 每个 TiDB 节点会每隔 lease 时间(默认 45s)就去 etcd(KV 存储引擎) 中拉取最新的 schema version.
- 每个 TiDB 保证我加载的 schema 至少在 lease 时间内是有效的.
- 同一时刻, 系统的所有节点中, 最多存在两个 Schema 版本, 分别是 KV 存储引擎中的最新版本, 与上一个版本. 同时存在两个 Schema 版本也能保证 KV 存储引擎一致性的方法就是引入 Schema 变更的中间态, 这个我们前面介绍过.
- 因此, 当系统中没有 DDL 变更时, 最多等待 2 倍 lease 时间, 系统中所有的节点的 Schema 一定保持一致.
可控延迟
租约机制实现的可控延迟是指当 Owner 节点执行一个数据库变更后, 所有节点的 schema 变更不会立刻生效,而是延迟可控, 以及分阶段生效的.
两次等待
第一次等待
if once {
w.waitSchemaSynced(d, job, waitTime)
once = false
return nil
}
第一次等待的目的是: 目的:确保所有 TiDB 节点都已经 加载了当前最新 schema, 以便下一条 DDL 不会基于旧 schema 错误执行;如果不等,可能多个 DDL 串行执行时, 后续 DDL 执行的前提条件(如列是否存在)因 schema 不一致出错.
为什么在存在第二次等待的情况下, 仍然需要第一次等待呢? 每次 DDL 变更后都主动等待了 2*Lease Time, 为什么只有这一次等待无法避免呢?
这是因为加入执行流程如下, 当某一个 Owner 节点崩溃后, 在另一个节点上重启执行时, 如果这个新的 Owner 节点已经更新到最新版本的 Schema, 但是它看不到其他节点是否已经更新, 所以必须等待两倍的租约时间, 确保所有节点的 Schema 版本都是最新的.
job.state == WriteOnly
→ 刚更新为 WriteReorg
→ 写入 TiKV 完成
→ 写入 etcd 的 schema version 完成
→ 开始 waitSchemaChanged() 等待其他节点同步
→ ❗ DDL Owner 崩溃重启
第二次等待
第二次等待的目的是确保所有 TiDB 节点都已经感知了最新 schema(如 Add Column -> Public); 例如, 在一个 Add Index 变更完成后, 如果某些节点还在使用旧 schema 查询, 就会出错或数据不一致.
这两个等待保证了, 在某次 DDL 变更执行前, TiDB 内的所有节点的 schema 版本保持一致, 避免了 schema 变更不一致的问题, 同时, 节点中 schema 版本不一致的时刻, 仅存在于等待的时间区间内. 而前面的博客也证明了, 即使某一个时刻, 节点间的 schema 版本不一致, KV 存储引擎也不会出现数据不一致的问题, 分布式数据也不会出现对某个 Schema 版本的不一致问题.
schemaSyncer: Schema 同步器的实现
schemaSyncer 是 TiDB 中在 Owner 节点修改 KV 引擎中的 schema 版本后, 同步所有的节点时使用的同步器, 它是一个 DDL 执行时上下文的一部分, 在 Domain 初始化的时候使用下面的方式初始化 err := do.ddl.SchemaSyncer().Init(ctx)
.
schemaSyncer 作用的位置
Owner 节点会使用 schemaSyncer 来同步 fellow 节点的 schema 版本, 也就是在 Owner 节点执行变更之前执行的 waitSchemaSynced
函数与变更执行之后执行的 waitSchemaChanged
函数, 这两个函数如下:
// waitSchemaSynced handles the following situation:
// If the job enters a new state, and the worker crashes when it's in the process of waiting for 2 * lease time,
// Then the worker restarts quickly, we may run the job immediately again,
// but in this case we don't wait enough 2 * lease time to let other servers update the schema.
// So here we get the latest schema version to make sure all servers' schema version update to the latest schema version
// in a cluster, or to wait for 2 * lease time.
func (w *worker) waitSchemaSynced(d *ddlCtx, job *model.Job, waitTime time.Duration) {
if !job.IsRunning() && !job.IsRollingback() && !job.IsDone() && !job.IsRollbackDone() {
return
}
// 设定最大等待时间为 2 倍 lease(默认 90 秒),防止阻塞
// 这里是构造了一个带超时时间的上下文对象, ctx 是一个上下文对象, 用于控制生命周期
ctx, cancelFunc := context.WithTimeout(context.Background(), waitTime)
// cancelFunc 是一个函数, 调用时会取消这个 context, 释放上下文资源, defer 表示函数返回的时候, 如果提前返回
// 会调用 cancelFunc() 函数释放上下文资源
defer cancelFunc()
// 传入的参数是 ctx, 根据上述的定义, 表示在两倍的 lease time 时间内获取全局的 schema 版本
// 这里, MustGetGlobalVersion 返回失败的唯一情况是 ctx 超时, 也就是时间已经超过两倍的 lease time
latestSchemaVersion, err := d.schemaSyncer.MustGetGlobalVersion(ctx)
if err != nil {
logutil.Logger(w.logCtx).Warn("[ddl] get global version failed", zap.Error(err))
return
}
// 等待所有节点更新 schema, 等待的时间就是 2*lease time
w.waitSchemaChanged(ctx, d, waitTime, latestSchemaVersion, job)
}
在每个 DDL 任务执行完之后, Owner 还会调用 waitSchemaChanged
函数, 这个函数是每次变更完后调用的, 用于向所有 fellower 节点同步 schema 的变更. 该函数如下:
// waitSchemaChanged waits for the completion of updating all servers' schema. In order to make sure that happens,
// we wait 2 * lease time.
func (w *worker) waitSchemaChanged(ctx context.Context, d *ddlCtx, waitTime time.Duration, latestSchemaVersion int64, job *model.Job) {
if !job.IsRunning() && !job.IsRollingback() && !job.IsDone() && !job.IsRollbackDone() {
return
}
if waitTime == 0 {
return
}
timeStart := time.Now()
var err error
defer func() {
}()
if latestSchemaVersion == 0 {
logutil.Logger(w.logCtx).Info("[ddl] schema version doesn't change")
return
}
// 如果事件时间已经过期了, 重新申请时间
if ctx == nil {
var cancelFunc context.CancelFunc
ctx, cancelFunc = context.WithTimeout(context.Background(), waitTime)
defer cancelFunc()
}
// Owner 使用 schemaSyncer 通知所有的 follower 节点更新 Schema 版本, 通知的方式并不是发送信息到 follower
// 而是将 KV 存储引擎中的全局 Schema 修改, 所有 follower 会通过 WatchGlobalSchemaVer 监听这个 key 的变化
err = d.schemaSyncer.OwnerUpdateGlobalVersion(ctx, latestSchemaVersion)
if err != nil {
logutil.Logger(w.logCtx).Info("[ddl] update latest schema version failed", zap.Int64("ver", latestSchemaVersion), zap.Error(err))
if terror.ErrorEqual(err, context.DeadlineExceeded) {
// If err is context.DeadlineExceeded, it means waitTime(2 * lease) is elapsed. So all the schemas are synced by ticker.
// There is no need to use etcd to sync. The function returns directly.
return
}
}
// OwnerCheckAllVersions returns only when context is timeout(2 * lease) or all TiDB schemas are synced.
err = d.schemaSyncer.OwnerCheckAllVersions(ctx, latestSchemaVersion)
if err != nil {
// 如果成功, 所有 follower 节点的 Schema 已经同步
logutil.Logger(w.logCtx).Info("[ddl] wait latest schema version to deadline", zap.Int64("ver", latestSchemaVersion), zap.Error(err))
if terror.ErrorEqual(err, context.DeadlineExceeded) {
return
}
// NotifyCleanExpiredPaths informs to clean up expired paths. 删除旧的 Schema
d.schemaSyncer.NotifyCleanExpiredPaths()
// Wait until timeout.
select {
case <-ctx.Done():
return
}
}
logutil.Logger(w.logCtx).Info("[ddl] wait latest schema version changed",
zap.Int64("ver", latestSchemaVersion),
zap.Duration("take time", time.Since(timeStart)),
zap.String("job", job.String()))
}
另一个地点就是每一个 worker 协程都会主动从 KV 存储引擎中拉取最新的版的 Schema, 这里的周期性拉取的实现方式如下:
func (do *Domain) loadSchemaInLoop(lease time.Duration) {
defer do.wg.Done()
// Lease renewal can run at any frequency.
// Use lease/2 here as recommend by paper.
ticker := time.NewTicker(lease / 2)
defer ticker.Stop()
defer recoverInDomain("loadSchemaInLoop", true)
syncer := do.ddl.SchemaSyncer()
for {
select {
// 定时器触发, 每半个租约时间会从 etcd 中获取最新的 schema 版本
case <-ticker.C:
err := do.Reload()
if err != nil {
logutil.BgLogger().Error("reload schema in loop failed", zap.Error(err))
}
// 监听 schema 版本变化, 当 etcd 中的 schema 版本发生变化时, 会触发此 case
// 这里的 syncer.GlobalVersionCh() 是一个 channel, 当 etcd 中的 schema 版本发生变化时, 会向该 channel 发送一个信号
case _, ok := <-syncer.GlobalVersionCh():
err := do.Reload()
if err != nil {
logutil.BgLogger().Error("reload schema in loop failed", zap.Error(err))
}
if !ok {
logutil.BgLogger().Warn("reload schema in loop, schema syncer need rewatch")
// Make sure the rewatch doesn't affect load schema, so we watch the global schema version asynchronously.
// If the channel is closed, we need to rewatch the global schema version.
// This can happen when the schema syncer is restarted.
syncer.WatchGlobalSchemaVer(context.Background())
}
case <-syncer.Done():
// The schema syncer stops, we need stop the schema validator to synchronize the schema version.
logutil.BgLogger().Info("reload schema in loop, schema syncer need restart")
// The etcd is responsible for schema synchronization, we should ensure there is at most two different schema version
// in the TiDB cluster, to make the data/schema be consistent. If we lost connection/session to etcd, the cluster
// will treats this TiDB as a down instance, and etcd will remove the key of `/tidb/ddl/all_schema_versions/tidb-id`.
// Say the schema version now is 1, the owner is changing the schema version to 2, it will not wait for this down TiDB syncing the schema,
// then continue to change the TiDB schema to version 3. Unfortunately, this down TiDB schema version will still be version 1.
// And version 1 is not consistent to version 3. So we need to stop the schema validator to prohibit the DML executing.
do.SchemaValidator.Stop()
err := do.mustRestartSyncer()
if err != nil {
logutil.BgLogger().Error("reload schema in loop, schema syncer restart failed", zap.Error(err))
break
}
// The schema maybe changed, must reload schema then the schema validator can restart.
exitLoop := do.mustReload()
// domain is cosed.
if exitLoop {
logutil.BgLogger().Error("domain is closed, exit loadSchemaInLoop")
return
}
do.SchemaValidator.Restart()
logutil.BgLogger().Info("schema syncer restarted")
case <-do.exit:
return
}
}
}
SchemaSyncer 提供的接口
SchemaSyncer 提供了下列的接口, 根据注释以及我们前面所讲述的内容很容易理解这些接口的作用.
// SchemaSyncer is used to synchronize schema version between the DDL worker leader and followers through etcd.
type SchemaSyncer interface {
// Init sets the global schema version path to etcd if it isn't exist,
// then watch this path, and initializes the self schema version to etcd.
Init(ctx context.Context) error
// UpdateSelfVersion updates the current version to the self path on etcd.
UpdateSelfVersion(ctx context.Context, version int64) error
// RemoveSelfVersionPath remove the self path from etcd.
RemoveSelfVersionPath() error
// OwnerUpdateGlobalVersion updates the latest version to the global path on etcd until updating is successful or the ctx is done.
OwnerUpdateGlobalVersion(ctx context.Context, version int64) error
// GlobalVersionCh gets the chan for watching global version.
GlobalVersionCh() clientv3.WatchChan
// WatchGlobalSchemaVer watches the global schema version.
WatchGlobalSchemaVer(ctx context.Context)
// MustGetGlobalVersion gets the global version. The only reason it fails is that ctx is done.
MustGetGlobalVersion(ctx context.Context) (int64, error)
// Done returns a channel that closes when the syncer is no longer being refreshed.
Done() <-chan struct{}
// Restart restarts the syncer when it's on longer being refreshed.
Restart(ctx context.Context) error
// OwnerCheckAllVersions checks whether all followers' schema version are equal to
// the latest schema version. If the result is false, wait for a while and check again util the processing time reach 2 * lease.
// It returns until all servers' versions are equal to the latest version or the ctx is done.
OwnerCheckAllVersions(ctx context.Context, latestVer int64) error
// NotifyCleanExpiredPaths informs to clean up expired paths.
// The returned value is used for testing.
NotifyCleanExpiredPaths() bool
// StartCleanWork starts to clean up tasks.
StartCleanWork()
// CloseCleanWork ends cleanup tasks.
CloseCleanWork()
}
总结
之前虽然将 F1 Schema 变更协议与 TiDB 的变种方式都看了一遍, 但是理解还是不够透彻, 很多地方不太确定, 例如在哪个时间段内各个 TiDB 服务器存在多个 Schema 版本, 同步是如何实现的, 租约和同步之间有什么关系等, 这些现在看来源码都搞清楚了, 希望对你也有帮助.