土法炼钢兴趣小组的算法知识备份

【TiKV / HTAP 内核】TSO:物理加逻辑的全局时间戳

文章导航

分类入口
databasestorage
标签入口
#tikv#pd#tso#timestamp-oracle#hybrid-logical-clock#spanner#truetime#percolator

目录

Percolator 风格的事务(第 10 篇)依赖一个前提:任意两个事务,只要提交顺序不同,拿到的时间戳就必须严格反映这个顺序,而且这个时间戳要能在整个集群范围内比较大小。单机自增计数器做不到这件事——多个 TiDB 节点、多个 TiKV Region 并发写入,谁的计数器说了算?TiDB 把这个职责单独抽成一个服务:TSO(Timestamp Oracle),跑在 PD leader 里,是全局唯一、严格单调递增的时间戳分配点。

本篇钉住 TSO 的编码格式、分配路径、故障恢复机制,以及批量取号如何把”每个事务都要一次网络往返”的延迟摊薄。最后用一句话把它放进更大的坐标系:TSO 和 Spanner 的 TrueTime 是解决同一个问题(全局事务排序)的两条相反路线。

本文是「TiKV / HTAP 内核」系列第 9 篇(共 18 篇)。上一篇Split / Merge / 热点下一篇Percolator 乐观事务落地→ 系列目录

版本锚定:TiKV / PD 7.x–8.x。TSO 的核心算法自早期版本至今保持稳定;microservice 模式下 TSO 可拆成独立的 tso 服务组件,本文只讨论默认的 PD 内置 TSO,不展开该模式。


一、编码:46 位物理毫秒 + 18 位逻辑计数器

一个 TSO 是一个 64 位整数,拆成两段:

0000011000101000111000010001011110111000110111 000000000000000100
└──────────────── 46 bits physical ────────────┘└─── 18 bits logical ───┘
     Unix 毫秒时间戳(自 1970-01-01)              同一毫秒内的递增计数器

来源:PD Wiki Timestamp Oracle;TiDB Docs TSOTIDB_PARSE_TSO() / TIDB_PARSE_TSO_LOGICAL() 用同样的位运算解析)。用位移表示:

\[ \text{TSO} = (\text{physical\_ms} \ll 18) \;\vert\; \text{logical} \]

18 位逻辑计数器的范围是 \([0, 2^{18})\),即每毫秒最多分配 262,144 个时间戳,折算到每秒理论上限约 2.6 亿。PD Wiki 给出的实测数字是”单机每秒可分配数百万级 TSO”——理论位宽上限远高于工程上实际打满的量级,二者不要混为一谈。


二、分配路径:PD leader 内存自增

TSO 只能由 PD leader 分配,这是保证全局单调的前提——如果两个 PD 节点同时对外发号,谁先谁后就失去了意义。分配逻辑(简化自 PD Wiki 给出的伪代码):

// 简化自 tikv/pd Wiki《Timestamp Oracle》给出的分配伪代码
for i := 0; i < maxRetryCount; i++ {
    current := atomic.LoadPointer(&allocator.ts) // {physical, logical}
    resp.Physical = current.physical.UnixMilli()
    resp.Logical = atomic.AddInt64(&current.logical, int64(count)) // count: 本批要几个
    if resp.Logical >= maxLogical { // 逻辑计数器超过 2^18 的一半阈值
        time.Sleep(UpdateTimestampStep) // 默认 50ms,等物理时钟往前走
        continue
    }
    if allocator.lease == nil || allocator.lease.IsExpired() {
        return errors.New("lease expired, no longer leader")
    }
    return resp, nil
}

三个细节决定正确性:

  1. 物理时钟只增不减:PD leader 定期(每 50ms,即 UpdateTimestampStep)用系统时钟校准内存中的 physical,但只允许向前跳,不允许回退——即使系统时钟本身回退,PD 也会拒绝更新,继续用旧值加逻辑位撑住单调性。
  2. 逻辑计数器耗尽就等:如果同一毫秒内分配的时间戳超过阈值(PD Wiki:超过范围一半时即触发保护,不是等到硬上限 \(2^{18}\) 才处理),leader 会 sleep(UpdateTimestampStep)(默认 50ms)等物理时间前进一毫秒,逻辑计数器归零后继续分配——这是一次会阻塞后续 TSO 请求的真实延迟尖峰,不是理论上限。
  3. lease 校验:每次分配前检查自己持有的 etcd lease 是否仍然有效;lease 过期意味着可能已经有新 leader 选出,必须拒绝分配,否则会有两个”leader”同时发号。

三、故障恢复:etcd 时间窗口

PD leader 崩溃后,新 leader 必须保证接下来分配的 TSO 严格大于旧 leader 曾经分配过的所有值,否则会出现时间戳回退,破坏事务可见性判断。做法是预留一个时间窗口并持久化窗口终点,而不是每次分配都写 etcd(PD Wiki Timestamp Oracle、PingCAP Blog Time Synchronization in TiDB):

SaveTimestamp 在 etcd 事务里同时校验”当前仍是这个 leader key 对应的 leader”和”新写入的时间戳大于上一次持久化的值”两个条件(PD 源码 pkg/storage/endpoint/tso.go),任一失败都拒绝写入——避免了网络分区下的旧 leader 误写。

sequenceDiagram
    participant OldL as Old PD leader
    participant Etcd
    participant NewL as New PD leader

    OldL->>Etcd: SaveTimestamp(Tlast = Tnext + 3s)
    Note over OldL: allocate TSO in-memory<br/>within [Tnext, Tlast)
    OldL--xOldL: crash
    Etcd->>NewL: leader election (etcd lease)
    NewL->>Etcd: read last saved Tlast
    Note over NewL: calibrate physical clock<br/>to be strictly greater than Tlast
    NewL->>Etcd: SaveTimestamp(new window)
    Note over NewL: resume allocating TSO,<br/>strictly monotonic across the crash

这个设计牺牲了窗口内未分配的时间戳(崩溃时窗口里剩下的号段作废,永不使用)来换取”绝大多数时候不用为每次分配都写一次 etcd”的性能——PD Wiki 明确说这是”浪费几个时间戳,换取绝不重复分配”的权衡,而不是免费的午餐。


四、批量取号:把网络往返摊薄到多个事务上

如果每次 BEGIN/COMMIT 都单独发一次 RPC 问 PD 要一个时间戳,网络延迟会直接叠加到事务延迟上。PD 客户端(client/clients/tso/dispatcher.go)在 TiDB 侧做批量合并:

  1. 客户端收集一个时间窗口(MaxTSOBatchWaitInterval,可配置)内到达的多个 GetTS() 请求。
  2. 合并成一次 gRPC 调用,带上 count 参数(要几个时间戳)。
  3. PD leader 一次性把 logical 计数器加 count,返回起始 (physical, logical)
  4. 客户端在本地把这个区间拆分给各个等待中的请求:第一个拿 (physical, logical-count+1),第二个拿 (physical, logical-count+2),以此类推。
flowchart LR
  T1["txn A: GetTS()"] --> Q["client-side batch queue<br/>(wait up to MaxTSOBatchWaitInterval)"]
  T2["txn B: GetTS()"] --> Q
  T3["txn C: GetTS()"] --> Q
  Q -- "one gRPC call, count=3" --> PD["PD leader<br/>logical += 3 (atomic)"]
  PD -- "physical, logical_end" --> Q
  Q -- "physical, logical_end-2" --> T1
  Q -- "physical, logical_end-1" --> T2
  Q -- "physical, logical_end" --> T3

来源:tikv/rfcs0078-improve-tso-scalability;PD 源码 client/clients/tso/dispatcher.gotsoDispatcherFetchPendingRequests)。这一层批量合并对使用者透明——业务代码看到的仍是”一次调用拿到一个时间戳”,只是这个调用可能在客户端里和其他并发事务的请求拼车。


五、TSO 承担的两种角色

TiKV 的事务模型(第 10 篇)里,TSO 至少要发两次:

角色 取号时机 决定什么
start_ts 事务开始(或语句开始,取决于隔离级别) 该事务的快照读视图上界
commit_ts Prewrite 全部成功后 该事务的写入对其他事务可见的时间点

两次取号中间隔着一次完整的 Prewrite 往返,这也是为什么 第 10 篇 会讨论 Async Commit——把 commit_ts 的确定提前到 Prewrite 阶段,省掉一次专门问 PD 要 commit_ts 的往返。


六、只读事务:省掉一半的取号

start_tscommit_ts 是写事务的最低配置;只读事务只需要 start_ts——它定义快照读的可见性上界,读完就结束,没有 Prewrite/Commit,也就没有第二次取号的必要。这也是为什么 第 10 篇 讨论的 Async Commit/1PC 只优化写路径的延迟:只读事务本来就只有一次 TSO 往返,没有可压缩的空间。

TiDB 对强一致只读事务tidb_txn_read_tsSTART TRANSACTION READ ONLY WITH TIMESTAMP BOUND等场景)仍然要求这个 start_ts 来自 TSO,保证它不会读到”未来”的数据;只有在明确接受陈旧读的场景(Stale Read、Follower Read 搭配 resolved-ts)才会绕开实时问询 PD 这一步,改用 Region 侧已经推进过的时间戳下界——这部分内容属于新鲜度与一致性的话题,留给后续新鲜度篇幅展开,本篇只标出”TSO 不是每次读都必须现场问”这个边界。


七、一句话对照:Spanner TrueTime 与向量引擎 TSO

与 Spanner TrueTime:两者解决的是同一个问题——给分布式事务一个全局可比较的时间戳来实现外部一致性——但走的是相反的路线。TSO 是单一逻辑服务给出确定值,正确性靠”只有一个 leader 在发号 + 严格单调”这两条不变量;Spanner 的 TrueTimeGPS + 原子钟给出一个不确定性区间 [earliest, latest](误差 ε 通常 1–7ms),事务提交前要执行 Commit Wait(等待真实时间确定超过所分配的时间戳,代价约 2ε)才能保证排序正确性(Corbett et al., OSDI 2012)。TSO 没有 Commit Wait 的延迟,但它是一个必须联系的中心节点;TrueTime 没有中心节点依赖,但每次提交都要为消除不确定性支付固定延迟,且依赖 Google 规模的专用硬件基础设施——多数开源系统复现不起这套硬件投入,这也是 TiDB/CockroachDB 等系统选择 TSO/HLC 路线而非 TrueTime 路线的现实原因。

与向量引擎的时间戳分配(可选对照):Milvus 的一致性模型同样用物理加逻辑混合时钟分配 GuaranteeTs,由 RootCoord 内置的 TSO 分配器统一发号(见 vector-engine 系列第 12 篇)。但用途不同:TiKV 的 TSO 定义事务提交序,直接决定 Percolator 的可见性判断;Milvus 的 GuaranteeTs 定义的是查询请求要等到哪个时间点的写入都已可见,服务的是”一致性级别”这一层旋钮,不参与任何跨行原子提交协议。两者共享编码思路,但不是同一个问题的答案。


八、常见误解

误解 事实
「TSO 就是当前系统时间」 是”物理毫秒 + 逻辑计数器”的复合编码,同一物理毫秒内可以分配多个不同的 TSO
「PD 崩溃会导致时间戳回退」 新 leader 靠 etcd 持久化的窗口终点校准,保证严格大于旧 leader 分配过的所有值
「每个事务都要单独一次网络往返问 PD 要时间戳」 客户端做批量合并,多个并发请求拼车成一次 gRPC 调用
「TSO 和 Spanner TrueTime 是同一种机制换了个名字」 TSO 是中心化确定值服务;TrueTime 是去中心化的不确定性区间 + Commit Wait,代价模型完全不同
「逻辑计数器用完就会出错」 用到阈值一半时会主动 sleep 等物理时钟前进,属于设计内的退化路径,不是错误

九、小结

三句话小结

  1. TSO 是 64 位「46 位物理毫秒 + 18 位逻辑计数器」的复合时间戳,只由 PD leader 在内存里原子分配,靠 etcd 持久化的时间窗口终点(默认 3 秒窗口)而不是每次分配都写盘来兼顾性能和崩溃恢复的单调性。
  2. 客户端把并发到达的取号请求批量合并成一次 gRPC 调用,摊薄了”每个事务都要联系 PD”的网络开销;这套批量机制对业务代码透明。
  3. TSO(中心化确定值)与 Spanner TrueTime(去中心化不确定性区间 + Commit Wait)是解决”全局事务排序”这同一个问题的两条相反路线,谁也没有淘汰谁——选择取决于是否愿意为 GPS/原子钟硬件基础设施买单。

下一篇进入 Percolator 乐观事务落地:start_ts/commit_ts 如何驱动 prewrite/commit,以及三 CF 如何落盘。


上一篇Split / Merge / 热点

下一篇Percolator 乐观事务落地

返回 系列目录


参考资料

规范 / 文档(A 级)

  1. tikv/pd Wiki, Timestamp Oracle(TSO 编码、UpdateTimestampStep、etcd 时间窗口、分配伪代码)。
  2. TiDB Docs, TimeStamp Oracle (TSO) in TiDBTIDB_PARSE_TSO()/TIDB_PARSE_TSO_LOGICAL() 位运算解析)。
  3. tikv/rfcs0078-improve-tso-scalability.md(客户端批量取号的 count 机制与优化方向)。

源码(A 级)

  1. tikv/pdpkg/storage/endpoint/tso.goSaveTimestamp 的 etcd 事务校验逻辑)。
  2. tikv/pdclient/clients/tso/dispatcher.goclient/client.gotsoDispatcherGetTSMaxTSOBatchWaitInterval)。

论文(A 级)

  1. Corbett, James C., et al. Spanner: Google’s Globally-Distributed Database. OSDI 2012(TrueTime、Commit Wait,与 TSO 的路线对照)。

博客(B 级)

  1. PingCAP Blog, Time Synchronization in TiDB: Timestamp Oracle (TSO)(时间窗口机制的工程叙述,与 PD Wiki 交叉核对)。

站内对读

  1. 《Spanner 与 TrueTime》(TrueTime 与 Commit Wait 的完整拆解)。
  2. vector-engine 系列第 12 篇:一致性(Milvus GuaranteeTs 与 TSO 分配器)。
  3. 本系列 第 10 篇:Percolator 乐观事务落地

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-16 · database / storage

【TiKV / HTAP 内核】PD 元数据与调度:心跳、算子与调度器

拆解 PD 如何靠 store/region 心跳建立集群视图,用 balance-leader/balance-region/hot-region 等调度器算分,生成 transfer-leader、move-peer 等 Operator,再通过下一次心跳的响应把算子'建议'给 Region Leader;对照 Spanner placement driver 与 CockroachDB 去中心化再平衡的架构分歧。


By .