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
TSO(TIDB_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(¤t.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
}三个细节决定正确性:
- 物理时钟只增不减:PD leader 定期(每
50ms,即
UpdateTimestampStep)用系统时钟校准内存中的physical,但只允许向前跳,不允许回退——即使系统时钟本身回退,PD 也会拒绝更新,继续用旧值加逻辑位撑住单调性。 - 逻辑计数器耗尽就等:如果同一毫秒内分配的时间戳超过阈值(PD
Wiki:超过范围一半时即触发保护,不是等到硬上限
\(2^{18}\) 才处理),leader
会
sleep(UpdateTimestampStep)(默认 50ms)等物理时间前进一毫秒,逻辑计数器归零后继续分配——这是一次会阻塞后续 TSO 请求的真实延迟尖峰,不是理论上限。 - lease 校验:每次分配前检查自己持有的 etcd lease 是否仍然有效;lease 过期意味着可能已经有新 leader 选出,必须拒绝分配,否则会有两个”leader”同时发号。
三、故障恢复:etcd 时间窗口
PD leader 崩溃后,新 leader 必须保证接下来分配的 TSO 严格大于旧 leader 曾经分配过的所有值,否则会出现时间戳回退,破坏事务可见性判断。做法是预留一个时间窗口并持久化窗口终点,而不是每次分配都写 etcd(PD Wiki Timestamp Oracle、PingCAP Blog Time Synchronization in TiDB):
- PD leader 计算
Tnext(当前可用的起始物理时间),选定窗口长度Tx(默认 3 秒),把Tlast = Tnext + Tx写入 etcd。 - 在
[Tnext, Tlast)这个窗口内,所有 TSO 分配都在内存里完成,不必再访问 etcd——这是 TSO 分配延迟能做到亚毫秒级的原因。 - 当窗口只剩不到
1ms(
updateTimestampGuard)时,leader 把窗口再向后推 3 秒并持久化新的Tlast。 - 新 leader 选出后:从 etcd 读到上一个
leader 持久化的
Tlast,把自己的起始物理时间校准到 大于Tlast,才开始对外提供服务——这保证了旧 leader 已经写入 etcd 的窗口终点之前的所有时间戳都不会被重新分配。
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 侧做批量合并:
- 客户端收集一个时间窗口(
MaxTSOBatchWaitInterval,可配置)内到达的多个GetTS()请求。 - 合并成一次 gRPC 调用,带上
count参数(要几个时间戳)。 - PD leader 一次性把
logical计数器加count,返回起始(physical, logical)。 - 客户端在本地把这个区间拆分给各个等待中的请求:第一个拿
(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/rfcs,0078-improve-tso-scalability;PD
源码
client/clients/tso/dispatcher.go(tsoDispatcher、FetchPendingRequests)。这一层批量合并对使用者透明——业务代码看到的仍是”一次调用拿到一个时间戳”,只是这个调用可能在客户端里和其他并发事务的请求拼车。
五、TSO 承担的两种角色
TiKV 的事务模型(第 10 篇)里,TSO 至少要发两次:
| 角色 | 取号时机 | 决定什么 |
|---|---|---|
| start_ts | 事务开始(或语句开始,取决于隔离级别) | 该事务的快照读视图上界 |
| commit_ts | Prewrite 全部成功后 | 该事务的写入对其他事务可见的时间点 |
两次取号中间隔着一次完整的 Prewrite 往返,这也是为什么 第
10 篇 会讨论 Async Commit——把 commit_ts
的确定提前到 Prewrite 阶段,省掉一次专门问 PD 要
commit_ts 的往返。
六、只读事务:省掉一半的取号
start_ts 和 commit_ts
是写事务的最低配置;只读事务只需要
start_ts——它定义快照读的可见性上界,读完就结束,没有
Prewrite/Commit,也就没有第二次取号的必要。这也是为什么 第
10 篇 讨论的 Async Commit/1PC
只优化写路径的延迟:只读事务本来就只有一次 TSO
往返,没有可压缩的空间。
TiDB
对强一致只读事务(tidb_txn_read_ts、START 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 的 TrueTime
靠 GPS +
原子钟给出一个不确定性区间
[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
等物理时钟前进,属于设计内的退化路径,不是错误 |
九、小结
三句话小结:
- TSO 是 64 位「46 位物理毫秒 + 18 位逻辑计数器」的复合时间戳,只由 PD leader 在内存里原子分配,靠 etcd 持久化的时间窗口终点(默认 3 秒窗口)而不是每次分配都写盘来兼顾性能和崩溃恢复的单调性。
- 客户端把并发到达的取号请求批量合并成一次 gRPC 调用,摊薄了”每个事务都要联系 PD”的网络开销;这套批量机制对业务代码透明。
- TSO(中心化确定值)与 Spanner TrueTime(去中心化不确定性区间 + Commit Wait)是解决”全局事务排序”这同一个问题的两条相反路线,谁也没有淘汰谁——选择取决于是否愿意为 GPS/原子钟硬件基础设施买单。
下一篇进入 Percolator 乐观事务落地:start_ts/commit_ts 如何驱动 prewrite/commit,以及三 CF 如何落盘。
返回 系列目录
参考资料
规范 / 文档(A 级)
- tikv/pd Wiki, Timestamp Oracle(TSO
编码、
UpdateTimestampStep、etcd 时间窗口、分配伪代码)。 - TiDB Docs, TimeStamp Oracle (TSO) in
TiDB(
TIDB_PARSE_TSO()/TIDB_PARSE_TSO_LOGICAL()位运算解析)。 tikv/rfcs,0078-improve-tso-scalability.md(客户端批量取号的count机制与优化方向)。
源码(A 级)
tikv/pd,pkg/storage/endpoint/tso.go(SaveTimestamp的 etcd 事务校验逻辑)。tikv/pd,client/clients/tso/dispatcher.go、client/client.go(tsoDispatcher、GetTS、MaxTSOBatchWaitInterval)。
论文(A 级)
- Corbett, James C., et al. Spanner: Google’s Globally-Distributed Database. OSDI 2012(TrueTime、Commit Wait,与 TSO 的路线对照)。
博客(B 级)
- PingCAP Blog, Time Synchronization in TiDB: Timestamp Oracle (TSO)(时间窗口机制的工程叙述,与 PD Wiki 交叉核对)。
站内对读
- 《Spanner 与 TrueTime》(TrueTime 与 Commit Wait 的完整拆解)。
- vector-engine 系列第 12 篇:一致性(Milvus GuaranteeTs 与 TSO 分配器)。
- 本系列 第 10 篇:Percolator 乐观事务落地。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【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 内核】HTAP / 分布式 KV 全景:TiDB–TiKV–PD–TiFlash 四件套
定位 TiKV 7.x/8.x 在 RocksDB 单机 LSM、Raft 协议与 HTAP 范式之间补的那一层:TiDB、TiKV、PD、TiFlash 四组件各自的抽象边界,以及本系列 18 篇的读法。
【TiKV / HTAP 内核】Key 编码与 MVCC:三 CF 与时间戳
拆解 TiKV 的 user key + start_ts/commit_ts 编码规则与 default/lock/write 三 CF 分工,并说明为什么这套 MVCC 与 RocksDB Snapshot 的 sequence 号是两层完全不同的机制。
【TiKV / HTAP 内核】PD 元数据与调度:心跳、算子与调度器
拆解 PD 如何靠 store/region 心跳建立集群视图,用 balance-leader/balance-region/hot-region 等调度器算分,生成 transfer-leader、move-peer 等 Operator,再通过下一次心跳的响应把算子'建议'给 Region Leader;对照 Spanner placement driver 与 CockroachDB 去中心化再平衡的架构分歧。