第 14 篇讲到 TiFlash 读请求会向 TiKV Leader 发一次 ReadIndex 来确认复制进度,从而拿到与 TiKV 对齐的快照隔离。但”确认复制进度”只是机制描述,工程上真正要回答的问题是两个具体数字:分析查询能读到多新的数据(可见时延)、读到的数据满足什么级别的一致性。TiKV/TiFlash 把第一个问题的答案固化成一个叫 safe-ts 的时间戳,第二个问题的答案是”快照一致(Snapshot Isolation),不是最终一致”。
本文只做一件事:把 safe-ts 的推进机制讲清楚,量化影响可见时延的因素,并回链《HTAP 新范式》第 1.2 节给出的三档新鲜度坐标,说明 TiDB + TiFlash 具体落在哪一档、边界条件是什么。TiFlash 的存储引擎细节(DeltaTree)已经在上一篇讲过,这里不重复。
版本锚定:TiFlash 7.x/8.x。核心概念引用 TiDB 官方文档 TiFlash Overview(A 级)与 Huang et al., VLDB 2020 §4.2、§6(A 级);三档新鲜度坐标引用本站 db-frontier/12(A/B 级综合)。
一、safe-ts 的定义
对某个 Region 而言,safe-ts 是一个时间戳
\(T\),满足:所有
commit_ts \(\le
T\) 的事务,其写入都已经在该 Region 对应的 TiFlash
副本上 apply 完毕。查询到达 TiFlash 时携带一个读取时间戳
\(Ts\)(通常是事务的
start_ts):
\[ \text{可直接读} \iff Ts \le \text{safe-ts}(R) \]
如果 \(Ts > \text{safe-ts}(R)\),说明 TiFlash 的复制进度还没追上这次读取要求的快照点,只能等待 safe-ts 推进,或者退回到 TiKV 读(如果查询允许)。
safe-ts 的推进依赖两个输入:
- Raft apply 位点对应的最大 commit_ts:TiFlash Learner 每 apply 完一批日志,就能确定”这批日志里最大的 commit_ts 是多少”,这个值是 safe-ts 的候选上界;
- 未决事务的 resolve
结果:如果范围内存在已经 prewrite 但还没决出
commit/abort 的事务(残留的锁),safe-ts 不能越过这些事务的
start_ts,直到通过 第 11 篇讲过的 ResolveLock 机制确定它们的最终状态。
flowchart TD
APPLY["Learner apply progress<br/>max commit_ts in applied log"]
LOCK["Pending locks in range<br/>(unresolved prewrite)"]
APPLY --> CANDIDATE["Candidate safe-ts"]
LOCK -->|"unresolved lock with smaller start_ts"| BLOCK["safe-ts capped below lock's start_ts"]
LOCK -->|"resolved via ResolveLock"| CANDIDATE
CANDIDATE --> SAFETS["safe-ts(R) advances"]
SAFETS --> READ{"query Ts <= safe-ts?"}
READ -->|"yes"| DIRECT["Read directly from TiFlash"]
READ -->|"no"| WAIT["Wait for safe-ts to advance,<br/>or fall back to TiKV"]
这套推进逻辑说明了一个容易被忽略的事实:safe-ts
卡住,往往不是网络问题,而是有事务的锁没被及时清理。一个跑了很久的大事务(百万行以上的写入)会让相关
Region 的 safe-ts 长时间停在这个事务的 start_ts
附近,因为在它 commit 或 abort 之前,所有更晚的写入即使已经
apply,也不能被判定为”安全可读”——这与第
11 篇讲的 TTL、ResolveLock
是同一套机制在分析读路径上的体现。
1.1 resolved-ts 与 safe-ts:谁在哪里计算
前面把 safe-ts 描述成一个笼统的”Region 级别时间戳”,但 TiKV 内部实际上有两个概念,容易被混用(TiDB Docs, Understanding Stale Read and safe-ts in TiKV,A 级):
- resolved-ts:只由 Region
Leader 维护。TiKV 内部有一个
Resolver结构(源码resolved_ts/resolver.rs,A 级),持续跟踪CF_LOCK里当前未解决的锁,取这些锁里最小的start_ts;如果当前没有任何未解决的锁,就用一个外部输入的min_ts(来自 PD TSO)代替。公式是resolved_ts = max(resolved_ts, min(min_start_ts, min_ts))——resolved-ts 单调不减,且严格保证”所有commit_ts小于 resolved-ts 的事务都已经提交”。 - safe-ts:由每个副本自己的
RegionReadProgress模块维护,包括 Follower 和 Learner。因为 Follower/Learner 的 apply 进度可能落后于 Leader,它们不能直接把 Leader 的 resolved-ts 当作自己的 safe-ts——Leader 通过CheckLeaderRPC 把自己的 resolved-ts 和”验证这个 resolved-ts 所需的最小 apply index”一起下发给所有副本,副本收到后只有在自己的 apply index 已经达到这个要求时,才能把 safe-ts 推进到这个 resolved-ts。
sequenceDiagram
participant Leader as Region Leader
participant Resolver
participant Follower as Follower / Learner (TiFlash)
Leader->>Resolver: track CF_LOCK, compute resolved_ts
Note over Resolver: resolved_ts = max(resolved_ts,<br/>min(min_start_ts_of_locks, min_ts))
Leader->>Follower: CheckLeader RPC:<br/>(resolved_ts, required_apply_index)
Follower->>Follower: local apply_index >= required_apply_index?
Follower->>Follower: if yes, advance local safe-ts to resolved_ts
这解释了第一节图里”pending locks”和”apply progress”两个输入分别来自哪里:resolved-ts 的推进只看锁(Leader 侧计算),safe-ts 的推进还要额外核对本副本自己的 apply 进度是否追上——resolved-ts 是”数据在逻辑上已经安全”的判定,safe-ts 是”这份数据在本副本上已经物理可读”的判定,两者缺一个都不能对外提供一致性读。TiCDC 的增量订阅复用的正好是 resolved-ts 这一层(TiDB Docs, Architecture and Principles of TiCDC,A 级):TiCDC 只关心”哪些提交已经不会再变化了”,不需要关心某个具体副本的物理 apply 进度,因此它读的是各 Region Leader 上报的 resolved-ts 取最小值,作为全局的 checkpoint 水位,这与 TiFlash 读路径依赖的 safe-ts 是同一套底层机制在两个不同消费场景下的复用。
二、可见时延的组成
可见时延(Visibility Lag)= 事务提交到分析查询能读到该提交之间的时间差。拆开看,它由几段串联的延迟构成:
| 阶段 | 耗时来源 |
|---|---|
| 提交到日志复制 | TiKV Leader 把已提交的日志异步发送给 TiFlash Learner;正常网络下这一步是毫秒级 |
| 日志 apply | TiFlash 解码日志、转换为列存格式写入 Delta Layer;受写入吞吐与 CPU 影响 |
| safe-ts 推进 | 需要等待 apply 完成 且 相关锁已经 resolve;大事务、锁堆积会显著拉长这一段 |
| 查询等待 | 若查询 \(Ts\) 恰好略大于当前 safe-ts,需要等待下一次推进,或触发退避 |
正常情况下(无大事务、无锁堆积、网络健康),这条链路的量级是亚秒级——这是 TiDB 官方文档与 VLDB 2020 论文共同支持的结论,但论文没有给出一个可以直接套用到任意生产环境的具体数字,具体延迟取决于 Raft 复制带宽、TiFlash 节点的 apply 吞吐和当前锁的分布,本文不编造一个通用基准数字。
三、回链 db-frontier/12 的三档新鲜度坐标
《HTAP 新范式》第 1.2 节给出过一个跨系统的三档坐标:
| 档位 | 可见时延 | 一致性 | 典型实现 |
|---|---|---|---|
| CDC 型 | 秒级到分钟级 | 最终一致 | F1 Lightning、传统数仓管线 |
| 准实时 | 亚秒级 | 快照一致(基于 Raft/Paxos 日志同步) | TiDB + TiFlash |
| 完全实时 | 事务提交即可见 | 强一致(同一存储、同一事务) | SingleStore Universal Storage、SAP HANA |
TiDB + TiFlash 落在”准实时”这一档,落点的机制原因现在可以说清楚了:
- 不是完全实时,因为 Learner 复制与 apply 是异步的,即使正常情况下延迟很低,也存在一个真实的、非零的复制窗口,不像 SingleStore 那样事务提交与分析可见共享同一次写入;
- 不是仅 CDC 型的最终一致,因为 safe-ts 提供的不是”迟早会一致”的弱保证,而是可验证的快照隔离——一旦查询确定 \(Ts \le \text{safe-ts}\),读到的结果保证等价于对 TiKV 该时间点的一致性快照,不会读到部分提交或乱序的数据。
也就是说,safe-ts 机制是”准实时 + 快照一致”这一档位在 TiDB 生态里的具体实现细节;三档坐标是跨系统的抽象分类,safe-ts 是 TiFlash 交出的工程答卷。理解这一点后再看 F1 Lightning(changepump 拉取变更流,多一跳)与 SingleStore(同一存储同一事务)的差异会更直观:三者的新鲜度差异根本上来自”复制通道离原始事务提交路径有多近”。
flowchart LR
T["Transaction commit<br/>(TiKV Raft group)"]
T -->|"raft replicate<br/>(same log, no extra hop)"| TF["TiFlash Learner apply"]
TF -->|"safe-ts confirms snapshot"| Q["Analytical query, Ts <= safe-ts"]
T2["Spanner commit"] -->|"changepump, extra hop"| L["F1 Lightning"]
T3["SingleStore commit"] -->|"same engine, same tx"| S["Universal Storage read"]
四、故障场景下的新鲜度退化
safe-ts 推进依赖 Learner 正常接收和 apply 日志,一旦这条链路出现异常,新鲜度会明确退化而不是悄悄变差:
- TiFlash 节点短暂下线:因为 Learner 不参与 Raft 提交多数派(第 14 篇 §一),TiKV 侧的读写完全不受影响;受影响的只是该节点上的分析查询,要等它重新上线、补齐日志差距、safe-ts 重新推进后才能恢复正常新鲜度;
- Region 迁移/Leader 切换:TiFlash 的分区要跟随对齐的 Region 重新确认同步目标,这个过程通常很短,但仍会造成一次短暂的 safe-ts 停滞窗口;
- 大批量数据导入(bulk load):如果导入路径走的是快照发送而不是逐条 Raft 日志(第 14 篇 §四提到的初始化同步机制),短期内 TiFlash 需要补齐的数据量会明显偏大,safe-ts 推进速度会明显跟不上导入速度,这是”新鲜度阶段性变差”里最常见、也最容易被误判为”故障”的一种场景,本质上是补数据的正常代价而非异常。
这三种场景的共同点是:它们都体现为 safe-ts 推进变慢或停滞,而不是读到错误结果——TiFlash 宁可让查询等待或回退到 TiKV,也不会为了新鲜度牺牲快照一致性的正确性保证。这是排查”TiFlash 结果好像不对”类问题时最先要确认的前提:先看是不是新鲜度滞后(查询等待中或已回退),再怀疑是不是真正的正确性 bug。
五、主动接受陈旧:Stale Read 与 Resolved TS
前四节讲的是”分析查询被动等待新鲜度追上”;TiDB 还提供了反过来的选项——主动声明可以接受多旧的数据,用换取更低延迟或更均衡的负载。这就是 Stale Read:
-- 读取指定时间点的历史数据
SELECT * FROM t AS OF TIMESTAMP '2026-07-16 10:00:00';
-- 读取「10 秒前」这个时间点附近的数据
SELECT * FROM t AS OF TIMESTAMP NOW() - INTERVAL 10 SECOND;
-- 在一个时间范围内,让 TiDB 自己选一个不会被阻塞的时间点
SELECT * FROM t AS OF TIMESTAMP TIDB_BOUNDED_STALENESS(NOW() - INTERVAL 20 SECOND, NOW());TIDB_BOUNDED_STALENESS(t1, t2)
的语义值得细看:TiDB 会在 [t1, t2]
范围内选一个”合适”的时间戳——官方文档对”合适”的定义是该时间点之前没有尚未提交、且会阻塞当前副本读取的事务,也就是选一个不需要等待、不需要触发额外
resolve 的时间点,尽量选新但不强求最新。这与 safe-ts
是同一类思路在读请求可以自己放宽新鲜度要求这个方向上的延伸:safe-ts
是”存储侧告诉你现在能保证读到多新”,Stale Read
是”查询侧告诉存储侧我能接受多旧,你挑一个不用等待的时间点”。
支撑 Stale Read 的机制是 Resolved TS:TiDB 集群会周期性地推进一个全局的 Resolved TS 水位,含义与 safe-ts 类似——所有小于等于 Resolved TS 的事务都已经确定状态(提交或中止)。如果查询要求的时间戳超过当前 Resolved TS,Stale Read 会先触发 Resolved TS 推进、等待完成后再读,这时反而会增加延迟,与”Stale Read 一定更快”的直觉相反——这也是本文要强调的一点:Stale Read 的延迟收益只在查询时间戳明显落后于当前 Resolved TS 时才成立,卡在水位线附近反而更慢。
Stale Read 默认允许从任意副本(Leader/Follower,以及
TiFlash)读取,这与单纯的 Follower
Read(tidb_replica_read
系统变量,只是把读请求分散到 Follower
而不涉及历史时间点)是两个独立但常被一起讨论的机制——Follower
Read 解决的是负载均衡与跨机房延迟问题,Stale Read
解决的是”我愿意读旧数据换取确定不被阻塞”的问题,两者可以叠加使用。
六、一致性级别:为什么不是”最终一致”
把 TiFlash 的一致性简单称为”最终一致”是常见的不准确说法。最终一致只承诺”所有副本迟早收敛”,不承诺任何时刻读到的数据处于某个确定的逻辑时间点;而 safe-ts 的语义是:只要查询的 \(Ts \le\) safe-ts,读到的结果就精确对应 TiKV 在时间戳 \(Ts\) 的那个快照,这是一个可以被验证、可以被推理的边界,属于快照隔离(Snapshot Isolation)而不是最终一致。
与之相对,代价是查询侧要接受”如果 \(Ts\) 比 safe-ts 新,就必须等待或退回 TiKV”,而不能像最终一致系统那样无条件读到当前能拿到的任何版本。TiDB 优化器决定一张表走 TiKV 还是 TiFlash 时的代价模型(db-frontier/12 §2.3已给出简化版本,本文不重复),隐含的前提就是 TiFlash 侧的读永远伴随这一层一致性检查,不是无条件更快。
七、safe-ts 与外部一致性:和 TrueTime 的差距具体在哪
第一节的公式给出的是 safe-ts 的工程定义,但它对应的学术概念是什么、边界在哪,值得单独钉一次。快照隔离本身有一个被反复引用的形式化定义(Berenson, H., Bernstein, P., Gray, J., Melton, J., O’Neil, E. & O’Neil, P., A Critique of ANSI SQL Isolation Levels, SIGMOD 1995,A 级):事务在开始时刻看到数据库的一个快照,之后只读这个快照,写入在提交时刻生效,且两个事务的写集合有交集时后提交者中止。safe-ts 保证的正是”查询时间戳 \(Ts \le\) safe-ts 时,读到的结果等价于 TiKV 在 \(Ts\) 时刻的快照”——这与 Berenson et al. 给出的快照隔离定义在语义上完全对应,只是把”事务开始时刻”换成了”分析查询指定的读取时间戳”,把”数据库快照”换成了”TiKV 在该 Region 上的 MVCC 状态”。
但 safe-ts 没有回答另一个更强的问题:跨数据中心的多个观察者,看到的事件顺序是否与真实物理时间一致——这是外部一致性(External Consistency),比快照隔离更强的一类保证,经典定义来自 Herlihy & Wing 的可线性化(Linearizability: A Correctness Condition for Concurrent Objects, ACM TOPLAS 1990,A 级):一个操作的效果必须在其调用和返回之间的某个时刻生效,且所有观察者看到的操作顺序与真实时间顺序一致。Spanner 用 TrueTime(Corbett et al., OSDI 2012)实现的正是这一层更强的保证——TrueTime 给每个事件绑定一个带有界误差的时间区间 \([t_{earliest}, t_{latest}]\),硬件(GPS + 原子钟)保证真实时间必然落在这个区间内,Spanner 靠”提交前等待直到误差区间也过去”(commit wait)把外部一致性坐实。
| 保证 | 定义来源 | TiKV/TiFlash 的对应机制 | 是否达到 |
|---|---|---|---|
| 快照隔离(SI) | Berenson et al., SIGMOD 1995 | safe-ts + MVCC 版本过滤 | 是,safe-ts 语义与 SI 定义直接对应 |
| 事务内可线性化(单 Region) | Herlihy & Wing, TOPLAS 1990 | Raft 日志的全序 + apply 顺序 | 单 Region 内成立(第 4–5 篇) |
| 跨数据中心外部一致性 | Corbett et al., OSDI 2012(TrueTime + commit wait) | TSO 集中分配时间戳(第 9 篇),但没有硬件级误差区间与 commit wait | 不确定,缺少可公开核对的证明或反例 |
这里的关键区别不是”TiDB 比 Spanner弱”这种简单排名,而是保证的层级不同、依赖的硬件假设不同:TrueTime 依赖专用硬件(GPS 天线、原子钟)把时钟不确定性做成一个可验证的区间,再用等待把不确定性”花掉”;TiDB 的 TSO 提供的是一个逻辑上单调递增、任意两个时间戳可比较的序列,但没有绑定物理时间的误差区间,也没有类似 commit wait 的机制去主动消除跨机房时钟偏差可能带来的因果顺序问题。TiDB 官方文档和 VLDB 2020 论文都没有正式声称 TiDB 达到了与 Spanner 同等的外部一致性等级——这正是第九节「开放问题」第二条的具体内容,本节只是把这个问题在学术定义层面钉得更精确,不代替官方给出未经证明的结论。
八、常见误解
误解一:TiFlash 是最终一致,所以偶尔读到脏数据是正常的。 不对。safe-ts 保证的是快照一致,一旦 \(Ts \le\) safe-ts,结果不会包含未提交或乱序的写入;如果 \(Ts >\) safe-ts,TiFlash 的默认行为是等待或报错/回退,而不是”读到能读的就返回”。
误解二:新鲜度只取决于网络延迟。 网络只是可见时延链路的一环,很多生产场景里主因是未决锁:一个长事务、一批未及时 resolve 的锁会把某个 Region 的 safe-ts 卡住,即使日志早已复制完成。排查可见时延异常时应该先看是否有大事务或锁堆积,而不是只看网络指标。
九、开放问题
- safe-ts 的可观测粒度:目前 safe-ts 是 Region 级别的推进,同一张大表跨多个 Region 时,用户面对的是”表级别的分析结果是否够新鲜”,但底层保证是逐 Region 给出的,跨 Region 聚合查询的最终新鲜度是所有涉及 Region safe-ts 的下确界——这个聚合口径的可观测性与运维告警标准,官方文档未给出统一规范。
- 与外部一致性的关系:safe-ts 定义的是”与 TiKV 该时间戳快照一致”,但没有像 Spanner TrueTime 那样给出跨数据中心的外部一致性(External Consistency)证明;TiDB 的 TSO(第 9 篇)本身是否达到与 TrueTime 等价的边界保证,仍是值得对照阅读但本系列不展开的话题。
十、小结
三句话小结:
- safe-ts 是 Region 级别的时间戳下界,只有查询时间戳不超过它,TiFlash 才能保证返回的是与 TiKV 对齐的快照一致结果。
- 可见时延的主要变量是日志复制 + apply + 锁 resolve 三段,其中未决锁堆积往往比网络延迟更容易被忽视。
- TiDB + TiFlash 落在 db-frontier/12 三档坐标的”准实时、快照一致”档,safe-ts 是这一档位在工程上的具体实现,不等价于最终一致。
参考资料
- TiDB Docs, TiFlash Overview(docs.pingcap.com/tidb/stable/tiflash-overview,A 级,safe-ts/ReadIndex 一致性描述)。
- Huang D., et al. TiDB: A Raft-based HTAP Database. VLDB 2020, §4.2、§6(A 级,Learner 机制与实验数据)。
- TiDB Docs, Understanding Stale Read and safe-ts in
TiKV;
tikv/tikv源码components/resolved_ts/resolver.rs、lib.rs(A 级,resolved-ts 计算公式与RegionReadProgress/CheckLeader机制)。 - TiDB Docs, Architecture and Principles of TiCDC(A 级,TiCDC 对 resolved-ts 的复用方式)。
- 本站 db-frontier/12 §1.2 新鲜度边界(三档新鲜度坐标,A/B 级综合)。
- TiDB Docs, Usage Scenarios of Stale Read、Read Historical Data Using the AS OF TIMESTAMP Clause、tidb_read_staleness、Follower Read(docs.pingcap.com/tidb,A 级)。
论文(A 级)
- Berenson, H., Bernstein, P., Gray, J., Melton, J., O’Neil, E. & O’Neil, P. A Critique of ANSI SQL Isolation Levels. SIGMOD 1995(快照隔离的形式化定义)。
- Herlihy, M. & Wing, J. M. Linearizability: A Correctness Condition for Concurrent Objects. ACM TOPLAS, Vol. 12, No. 3, 1990(可线性化/外部一致性的经典定义)。
- Corbett, J. C., et al. Spanner: Google’s Globally-Distributed Database. OSDI 2012(TrueTime 与 commit wait 实现外部一致性)。
站内
- 本系列 第 11 篇 悲观事务与 ResolveLock(锁与未决事务对 safe-ts 的影响)、第 14 篇 TiFlash Learner(日志到列存机制)、第 9 篇 TSO。
下一篇:CockroachDB 对照:range + Raft
返回 系列目录
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【TiKV / HTAP 内核】HTAP / 分布式 KV 全景:TiDB–TiKV–PD–TiFlash 四件套
定位 TiKV 7.x/8.x 在 RocksDB 单机 LSM、Raft 协议与 HTAP 范式之间补的那一层:TiDB、TiKV、PD、TiFlash 四组件各自的抽象边界,以及本系列 18 篇的读法。
【TiKV / HTAP 内核】TiFlash Learner:Raft 日志到列存
拆解 TiFlash 如何以 Raft Learner 身份接收日志、把行存变更重放进 DeltaTree 列存引擎;锚定 Huang et al. VLDB 2020,并与 columnar-engine 系列划清分工,不重讲列存执行引擎全书。
【TiKV / HTAP 内核】Region · Multi-Raft · PD · Percolator · TiFlash
补齐 RocksDB 单 Region、Raft 协议与 HTAP 范式之间的工程链路:以 TiKV 7.x/8.x 拆解 Region、Multi-Raft、raftstore、PD/TSO 与 Percolator 事务,并以 TiFlash Learner 收束新鲜度;CockroachDB 对照与选型。
【TiKV / HTAP 内核】选型与阅读地图:TiKV vs etcd vs FDB vs 单机 RocksDB vs 湖 + CDC
给出分布式强一致 KV 的扩展决策树:数据规模、事务需求、新鲜度要求如何在 TiKV、etcd、FoundationDB、单机 RocksDB、湖仓+CDC 之间做选型;回链全系列 18 篇,收束学术谱系与开放问题,标志系列完成。