尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

TiDB 内部事务 GC 保护机制:将内部事务 startTS 纳入 GC Safepoint 计算的设计与实现

TiDB 内部事务 GC 保护机制:将内部事务 startTS 纳入 GC Safepoint 计算的设计与实现 TiDB 内部事务 GC 保护机制将内部事务 startTS 纳入 GC Safepoint 计算的设计与实现【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidbGC垃圾回收安全点推进过快是 TiDB 内部事务偶发读取被清理数据的根因之一。本文基于 docs/design/2022-03-09-optimize-gc-for-internal-transaction.md 这份设计文档系统讲解 TiDB 如何把「内部会话internal session」与RunInNewTxn执行的内部事务的startTS一并纳入 GC safepoint 计算从而避免内部事务因超龄被 GC 误伤。读完本文你将掌握 TiDB safepoint 计算全链路、SessionManager接口扩展方式、全局内部事务 startTS 容器globalInnerTxnTsBox的数据结构与配套系统变量并能结合当前仓库源码定位每一处关键实现。背景与问题为何 GC 会误伤内部事务GC safepoint 的推进机制TiDB 依赖 MVCC 多版本机制实现读写不互斥历史版本的数据依赖 GC 机制回收。GC 的安全性由safepoint安全点保证所有早于 safepoint 的旧版本数据可以被清除而活跃事务仍可能访问的数据则必须保留。在经典实现中TiDB 每隔tidb_gc_run_interval时间周期性地推进一次 safepoint推进目标值为now - tidb_gc_life_time即「当前时间减去 GC 生命周期」。用户客户端发起的长事务如果存活时间超过tidb_gc_life_timesafepoint 就不能无限制推进——它必须等到该事务结束或事务存活超过上限时间历史上为 24 小时见下文tidb_gc_max_wait_time的演进之后才能继续前进。这套机制保证了 safepoint 持续推进的同时活跃事务需要访问的数据不会被清除。内部事务的例外与隐患问题在于运行于 TiDB 内部的内部事务并不遵循上述约束。TiDB 内部有大量由代码自身发起的后台事务例如系统表写入、统计信息收集、DDL 相关的元数据更新等。如果某个内部事务存活时间超过tidb_gc_life_time它需要访问的数据可能已经被 GC 清除从而直接导致内部事务执行失败。本文所描述的设计Tracking Issuepingcap/tidb#32725该链接属于上游公开 issue仅供参考正是为了解决「内部事务因数据被 GC 清理而失败」这一问题把所有内部事务的startTS事务起始时间戳纳入 GC safepoint 计算过程让 safepoint 在推进时主动避让仍在执行的内部事务。现状梳理两类内部事务设计文档明确指出TiDB 中目前存在两类内部事务由内部会话internal session执行的事务内部会话从系统会话池sysSessionPool中获取用于执行各种受限 SQL如系统表读写由函数RunInNewTxn执行的事务RunInNewTxn是kv包中提供给「仅限内部事务」使用的便捷封装负责在一个全新事务环境中执行回调函数f。本设计的目标就是计算 GC safepoint 时将以上两类内部事务的startTS都纳入考量。整体思路分两路内部会话被登记进session manager内部事务的startTS被登记进全局变量globalInnerTxnTsBox从而在计算 GC safepoint 时能够统一获取它们的起始时间。详细设计GC Safepoint 计算流程改造设计摘要safepoint 计算的主流程实现在(is *InfoSyncer) ReportMinStartTS中。改造前TiDB 计算 safepoint 的流程是从 PD 获取当前时间戳保存为变量now再获取比now早 24 小时的时间戳保存为变量startTSLowerLimit获取所有用户客户端会话保存到切片processlist遍历processlist从每个客户端会话中取得startTS并与now比较求出「晚于startTSLowerLimit的最小时间戳」记为minStartTS作为 GC safepoint。改造后ReportMinStartTS在原有逻辑基础上增加对内部事务的处理新流程为从 PD 获取当前时间戳保存为now获取比now更早、且间隔由系统变量tidb_gc_max_wait_time设计文档写作时期表述为tidb_gc_txn_max_wait_time指定的时间戳保存为startTSLowerLimit获取所有用户客户端会话保存到切片processlist获取内部会话所运行的全部内部事务的startTS保存到切片InnerSessionStartTSList获取由RunInNewTxn执行的内部事务的startTS保存到 mapinnerTxnStartTsMap遍历processlist取晚于startTSLowerLimit的最小startTS作为minStartTS遍历InnerSessionStartTSList与minStartTS比较继续取晚于startTSLowerLimit的最小值作为minStartTS遍历innerTxnStartTsMap同样与minStartTS比较取最小值最终得到 GC safepoint。当前仓库中的落地实现上述流程在当前的 TiDB 源码中已经完整落地核心实现位于 pkg/domain/infosync/info.go 的ReportMinStartTS见 info.go#L629-L693// ReportMinStartTS reports self server min start timestamp to ETCD. func (is *InfoSyncer) ReportMinStartTS(store kv.Storage, session *concurrency.Session) { sm : is.GetSessionManager() if sm nil { return } pl : sm.ShowProcessList() innerSessionStartTSList : sm.GetInternalSessionStartTSList() // Calculate the lower limit of the start timestamp to avoid extremely old transaction delaying GC. currentVer, err : store.CurrentVersion(kv.GlobalTxnScope) if err ! nil { logutil.BgLogger().Warn(update minStartTS failed, zap.Error(err)) return } now : oracle.GetTimeFromTS(currentVer.Ver) // GCMaxWaitTime is in seconds, GCMaxWaitTime * 1000 converts it to milliseconds. startTSLowerLimit : oracle.GoTimeToLowerLimitStartTS(now, vardef.GCMaxWaitTime.Load()*1000) minStartTS : oracle.GoTimeToTS(now) ... for _, info : range pl { if info.StmtCtx ! nil info.StmtCtx.IsDDLJobInQueue.Load() { // Ignore DDL sessions. continue } if info.CurTxnStartTS startTSLowerLimit info.CurTxnStartTS minStartTS { minStartTS info.CurTxnStartTS } ... } for _, innerTS : range innerSessionStartTSList { logutil.BgLogger().Debug(ReportMinStartTS, zap.Uint64(Internal Session Transaction StartTS, innerTS)) kv.PrintLongTimeInternalTxn(now, innerTS, false) if innerTS startTSLowerLimit innerTS minStartTS { minStartTS innerTS } } if is.infoCache ! nil { schemaTS : is.infoCache.GetAndResetRecentInfoSchemaTS(currentVer.Ver) ... if schemaTS startTSLowerLimit schemaTS minStartTS { minStartTS schemaTS } } is.minStartTS kv.GetMinInnerTxnStartTS(now, startTSLowerLimit, minStartTS) err is.storeMinStartTS(context.Background(), session) ... }从源码可以归纳出当前实现相对原始设计文档的几处演进细节下限时间戳startTSLowerLimit的来源现在由vardef.GCMaxWaitTime.Load()*1000换算为毫秒后调用oracle.GoTimeToLowerLimitStartTS得到。GCMaxWaitTime是系统变量tidb_gc_max_wait_time的原子缓存其默认值DefTiDBGCMaxWaitTime 24 * 60 * 6024 小时见 pkg/sessionctx/vardef/tidb_vars.go#L1199-L1200 与 tidb_vars.go#L1739。该变量的作用正如设计文档所描述避免极老的事务无限期拖延 GC——超过这个窗口的事务不再获得保护。minStartTS的初值取值为now当前时间对应的 TSO随后通过多次「与当前最小值比较并取更小者」的扫描逐步下探最终得到全局最保守的 safepoint 候选。额外纳入的两个来源实现中还顺带把 cursor游标与SELECT ... FOR UPDATE游标相关的startTS、以及 InfoSchema 近期使用的schemaTS一并纳入比较进一步避免 GC 破坏正在使用的元数据与游标读取。上报方式计算完成后调用is.storeMinStartTS将本 TiDB 实例的minStartTS写入 etcd 的/tidb/server/minstartts/uuid路径带 lease 会话路径常量定义在 info.go#L67-L68。GC 侧当前 TiDB 版本中由InitializeGCV2等机制承接汇总所有 TiDB 实例上报的minStartTS后确定全局可安全回收的版本。数据结构的扩展SessionManager 接口扩展统一管理客户端与内部会话设计文档提出TiDB 原本通过SessionManager接口管理客户端会话本次改造把该接口扩展为同时管理内部会话。文档中给出的接口形态如下type SessionManager interface { // Put the internal session pointer to the map in the SessionManager StoreInternalSession(se interface{}) // Delete the internal session pointer from the map in the SessionManager DeleteInternalSession(se interface{}) // Get all startTS of every transactions running in the current internal sessions GetInternalSessionStartTSList() []uint64 }从当前仓库的源码结构看SessionManager的概念已经演进并拆分管理进程/会话能力的核心接口定义在 pkg/session/sessmgr/processinfo.go 的Manager接口中见 processinfo.go#L272-L289其中保留了GetInternalSessionStartTSList()注释说明其语义为“获取当前内部会话中运行的所有事务的 startTS”以及ShowProcessList()、Kill()、ServerID()等原有能力。pkg/domain/infosync也提供了全局入口函数StoreInternalSession/DeleteInternalSession内部先取得全局InfoSyncer再转发给 session manager见 pkg/domain/infosync/info.go#L1149-L1174。作为 TiDB Server 端的实现pkg/server/server.go 中的*Server维护了专门的internalSessions集合并提供线程安全的增删查方法见 server.go#L1224-L1271StoreInternalSession(se any)在sessionMapMutex保护下把内部会话放入 map并同步更新metrics.InternalSessions指标DeleteInternalSession(se any)在加锁后从 map 删除并更新指标GetInternalSessionStartTSList() []uint64遍历内部会话通过session.GetStartTSFromSession(se)取出每个会话当前事务的startTS若该会话正属于全局自动 analyze 流程statsutil.GlobalAutoAnalyzeProcessList则跳过——这是为规避统计信息后台任务与 GC 之间的相互干扰而增加的细节。此外 pkg/testkit/mocksessionmanager.go 提供了测试用的MockSessionManager实现便于在单元测试中模拟内部会话集合。innerTxnStartTsBox全局内部事务 startTS 容器对于由RunInNewTxn发起的内部事务设计文档定义了一个全局变量globalInnerTxnTsBox来统一登记与管理事务startTS。文档中的数据结构如下var globalInnerTxnTsBox innerTxnStartTsBox{ innerTSLock: sync.Mutex{}, innerTxnStartTsMap: make(map[uint64]struct{}, 256), } type innerTxnStartTsBox struct { innerTSLock sync.Mutex innerTxnStartTsMap map[uint64]struct{} }该设计在当前仓库的 pkg/kv/txn.go 中原样落地见 txn.go#L43-L83var globalInnerTxnTsBox innerTxnStartTsBox{ innerTSLock: sync.Mutex{}, innerTxnStartTsMap: make(map[uint64]struct{}, 256), } type innerTxnStartTsBox struct { innerTSLock sync.Mutex innerTxnStartTsMap map[uint64]struct{} } func (ib *innerTxnStartTsBox) storeInnerTxnTS(startTS uint64) { ib.innerTSLock.Lock() ib.innerTxnStartTsMap[startTS] struct{}{} ib.innerTSLock.Unlock() } func (ib *innerTxnStartTsBox) deleteInnerTxnTS(startTS uint64) { ib.innerTSLock.Lock() delete(ib.innerTxnStartTsMap, startTS) ib.innerTSLock.Unlock() }要点说明使用map[uint64]struct{}作为集合存储各内部事务的startTS利用struct{}零内存开销的特性且天然具备去重能力初始容量预分配为 256所有读写均在sync.MutexinnerTSLock保护下进行因为该容器可能被多个后台 goroutine 并发访问对外暴露的查询入口是GetMinInnerTxnStartTS(now, startTSLowerLimit, curMinStartTS)内部调用getMinStartTS遍历容器找出「晚于startTSLowerLimit且小于当前minStartTS」的最小值返回给ReportMinStartTS作为 safepoint 的进一步下探结果。功能设计登记与注销的完整生命周期内部会话的登记与注销原设计基于当时的会话池实现(s *session) getInternalSessionTiDB 从系统会话池取内部会话时将它登记进 session manager事务结束、会话归还池子时从 session manager 删除。文档给出的伪代码如下func (s *session) getInternalSession(execOption sqlexec.ExecOption) (*session, func(), error) { tmp, err : s.sysSessionPool().Get() se : tmp.(*session) // Put the internal session to the map of SessionManager infosync.StoreInternalSession(se) return se, func() { // Delete the internal session to the map of SessionManager infosync.DeleteInternalSession(se) s.sysSessionPool().Put(tmp) } }这一设计思路沿用至今且在其后的「系统会话管理增强」重构中进一步正规化。当前仓库中内部会话从池中取出与归还时分别触发 owner 回调完成登记与注销见 pkg/session/syssession/session.go#L595-L603func (*Session) onBecameOwner(sctx sessionctx.Context) error { infosync.StoreInternalSession(sctx) return nil } func (*Session) onResignOwner(sctx sessionctx.Context) error { infosync.DeleteInternalSession(sctx) return nil }在 pkg/session/syssession/pool.go 中归还会话时会持续尝试infosync.StoreInternalSession保证登记最终成功。可以看到「取会话即登记、还会话即注销」的核心语义与设计文档完全一致只是登记点从getInternalSession内迁移到了池化生命周期回调中。内部事务的登记与注销RunInNewTxn对由RunInNewTxn执行的内部事务设计文档要求事务开始时调用wrapStoreInterTxnTS()登记其startTS事务结束时调用wrapDeleteInterTxnTS()注销。这一逻辑在当前 pkg/kv/txn.go#L106-L177 的实现中以defer 首轮登记的方式实现// RunInNewTxn will run the f in a new transaction environment, should be used by inner txn only. func RunInNewTxn(ctx context.Context, store Storage, retryable bool, f func(ctx context.Context, txn Transaction) error) error { var ( err error originalTxnTS uint64 txn Transaction ) defer func() { globalInnerTxnTsBox.deleteInnerTxnTS(originalTxnTS) }() for i : range MaxRetryCnt { txn, err store.Begin() if err ! nil { logutil.BgLogger().Error(RunInNewTxn, zap.Error(err)) return err } setRequestSourceForInnerTxn(ctx, txn) // originalTxnTS is used to trace the original transaction when the function is retryable. if i 0 { originalTxnTS txn.StartTS() globalInnerTxnTsBox.storeInnerTxnTS(originalTxnTS) } err f(ctx, txn) ... if err nil { err txn.Commit(ctx) if err nil { break } } if retryable IsTxnRetryableError(err) { ... BackOff(i) continue } return err } return err }实现要点只登记首次事务的startTSRunInNewTxn支持可重试执行retryable重试会开启新事务并获得新的startTS但容器只登记i 0时首个事务的startTSoriginalTxnTS用于追溯「原始事务」的存活时间——这正是 GC 保护的关键因为后续重试事务的生命周期会被计入重试等待defer保证注销无论函数最终以成功还是失败返回originalTxnTS都会在函数退出时从globalInnerTxnTsBox中删除避免泄漏导致 safepoint 被永久拖住重试退避可重试错误会调用BackOff(i)进行带全抖动full jitter的指数退避相关重试上限与退避参数定义在同文件MaxRetryCnt100、retryBackOffBase、retryBackOffCap中见 txn.go#L179-L196。长事务观测与告警PrintLongTimeInternalTxn为了让长存活的内部事务可被观测容器遍历与内部会话遍历时都会调用kv.PrintLongTimeInternalTxn(now, startTS, runByFunction)见 txn.go#L85-L104。该函数将startTS还原为起始时刻若存活超过阈值则打印告警日志并根据runByFunction区分事务来源RunInNewTxn或internal sessionfunc PrintLongTimeInternalTxn(now time.Time, startTS uint64, runByFunction bool) { if startTS 0 { innerTxnStartTime : oracle.GetTimeFromTS(startTS) if now.Sub(innerTxnStartTime) TimeToPrintLongTimeInternalTxn { callerName : internal session if runByFunction { callerName RunInNewTxn } infoHeader : fmt.Sprintf(An internal transaction running by %s lasts long time, callerName) logutil.BgLogger().Info(infoHeader, zap.Duration(time, now.Sub(innerTxnStartTime)), zap.Uint64(startTS, startTS), zap.Time(start time, innerTxnStartTime)) } } }注意设计文档「Unresolved Questions」部分描述的超时阈值为 1 分钟而当前源码中的常量TimeToPrintLongTimeInternalTxn time.Minute * 5见 txn.go#L37-L41即当前实现只在内部事务存活超过 5 分钟时才打印提示日志。这属于实现过程中的合理调参演进阅读源码时需以仓库现状为准。调用链全景从周期任务到 safepoint结合 pkg/domain/serverinfo/syncer.go 可以看出ReportMinStartTS由 server info 同步器周期性驱动reporter.ReportMinStartTS(store, s.session)见 syncer.go#L383完整的调用链可概括为serverinfo.Syncer周期性调度 │ ▼ (InfoSyncer) ReportMinStartTS ← pkg/domain/infosync/info.go#L630 │ 1. 获取所有用户会话ShowProcessList │ 2. 获取内部会话事务 startTSGetInternalSessionStartTSList │ 3. 遍历计算 minStartTS跳过 DDL、比较 cursor startTS │ 4. 纳入 InfoSchema 近期使用时间戳 │ 5. kv.GetMinInnerTxnStartTS(...) ← 纳入 RunInNewTxn 事务 ▼ storeMinStartTS → etcd: /tidb/server/minstartts/uuid │ ▼ GC Worker / GC v2 汇总各 TiDB 实例 minStartTS决定可回收版本即最终能够安全回收的版本一定严格早于所有 TiDB 实例上报的minStartTS从而在宏观上同时保护了用户事务、内部会话事务与RunInNewTxn内部事务。配置与观测指引围绕本特性与运维和调优直接相关的配置项主要有系统变量 / 概念说明当前默认值tidb_gc_max_wait_time活跃事务可延迟 GC safepoint 推进的最大时间窗口秒即startTSLowerLimit的计算依据窗口之外的事务不再获得 GC 保护8640024 小时定义见 pkg/sessionctx/vardef/tidb_vars.go#L1739范围约束MinValue: 600, MaxValue: 31536000见 pkg/sessionctx/variable/sysvar.go#L1156-L1157tidb_gc_life_time定义数据版本被 GC 保留的时长与运行时长超限的内部事务是否受保护密切相关上下文变量运行入口见 cmd/tidb-server/main.go#L373-L394历史默认10m按部署形态由 GC 初始化逻辑读取metrics.InternalSessions记录当前内部会话数量的监控指标在 pkg/server/server.go 的登记/注销逻辑中维护无默认值随内部会话数动态变化观测建议若在 TiDB 日志中看到形如An internal transaction running by RunInNewTxn lasts long time或... running by internal session lasts long time的日志说明存在超过 5 分钟的异常长存活内部事务应结合日志中的startTS、start time与time存活时长字段定位具体事务来源若担心内部事务拖慢 GC可关注/tidb/server/minstartts下各实例上报值的最小者确认是否存在长期未释放的内部事务卡住 safepoint由于 GC 保护仅覆盖晚于startTSLowerLimit的事务人为调小tidb_gc_max_wait_time会缩短异常内部事务的「豁免期」需在回收及时性与事务安全性之间权衡。兼容性与后续演进设计文档明确指出该特性不影响兼容性。它只改变 TiDB 实例自身向 etcd 上报minStartTS的取值来源不改变 SQL 语义、事务协议或客户端可见行为因此升级前后用户侧无需任何改动。在「未决问题Unresolved Questions」部分原设计也坦诚指出当前的不足当RunInNewTxn内部事务超过阈值时目前只打印一条简单日志未来应补充更详细的日志信息并将长事务观测数据例如超长内部事务数量、存活时长分布等以监控指标形式呈现在 Grafana 面板上。从当前仓库看metrics.InternalSessions指标与PrintLongTimeInternalTxn的结构化日志携带startTS、time、start time等字段已在相当程度上回应了上述演进方向后续仍可在此基础上继续完善监控大盘。总结本文围绕「optimize gc advance for internal transaction」设计文档完整还原了 TiDB 将内部事务纳入 GC safepoint 计算的前因后果与实现路径问题本质长存活的内部事务可能因数据被 GC 清理而失败解决思路把内部会话与RunInNewTxn内部事务的startTS全部纳入ReportMinStartTS的 safepoint 计算且用tidb_gc_max_wait_time作为下界防止无限期拖延 GC两套登记机制内部会话通过 session managerStoreInternalSession/DeleteInternalSession/GetInternalSessionStartTSList管理RunInNewTxn事务通过全局容器globalInnerTxnTsBox互斥锁 map[uint64]struct{}登记与注销验证与观测相关代码分别位于 pkg/domain/infosync/info.go、pkg/kv/txn.go、pkg/server/server.go 与 pkg/session/syssession/session.go并可通过超长内部事务日志与InternalSessions指标进行观测。对希望深入理解 TiDB 存储引擎生命周期管理的读者而言这份设计及其实现是理解「分布式数据库如何在正确性保护活跃事务与可用性及时回收空间之间取平衡」的绝佳切片。【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表