距离 tzf 系列项目启动已经过去了几年。上次系统性回顾开发历史,还是 2023 年初的 tzf 的演进过程。此后项目也有一些更新和维护,但主要集中在非核心功能优化和辅助功能补充上。
到了 2026 年春天,之前几个悬而未决的重要改动陆续完成了:
- 引入拓扑感知机制,解决多边形简化过程中额外引入的空隙和重叠问题;
- 基于拓扑感知机制,开发更高效的数据分发格式,完整精度数据约 17MB,简化数据约 5.4MB;
- 参考 tg 项目,引入 YStripes 索引加速。
拓扑感知机制
原始数据本质上是一组多边形。由于原始边界过于精细,数据体积很大,所以需要对多边形进行简化处理。这些多边形之间存在大量共享边界,但在之前的处理中,每个多边形都是各自独立进行 RDP 简化。这就带来了 tzf 系列项目从上线开始就存在的问题:原本完整覆盖的区域,在简化后出现了空隙,以及本不该有的多边形重叠:
See details in 
ringsaturn/tzf#183
解决方案几年前就已经明确:先识别共享边界,再对共享边界进行简化,最后把简化后的边界替换回两侧多边形。这样可以保证相邻多边形继续引用同一条简化后的边界,从而避免因为两侧独立简化而产生新的空隙或重叠。
但是数据量确实很大。过去几年我多次尝试手动实现这个策略,最终都失败了。各种边缘情况和复杂的策略设计不断叠加,最后都会让代码无法稳定运行。
2026 年再次尝试解决这个问题时,我借助 Claude 和 Codex 做了多轮实现、验证和重构,最终把这套策略完整实现了。大致流程可以参考下面这张策略说明图:
Made by ChatGPT
这个策略实现之后,也就具备了实现去年设计的新数据存储格式目标的基础。
为了维持向前兼容,新的二进制数据被拆分到了新的仓库中,用于承载下文提到的格式优化。原有的数据格式分发,也就是 tzf-rel 系列,还会继续维持一段时间,之后再准备停止运行。
既然已经可以识别共享边界,那么冗长的边界就没有必要存储两遍,只需要存储一次,再使用 polyline 进行编码压缩。
这个策略的效果非常明显。tzf 系列项目此前使用 pb 格式分发完整数据集,不做 zip 压缩大约 90MB,zip 后大约 50MB。现在共享边界只存储一次,并经过 polyline 编码压缩,完整精度数据约 17MB,再做 zip 压缩后约 10MB。完整精度数据能压缩到这个体积,我自己还是比较满意的。也正是因为这个体积已经可以接受,tzf-rs 终于开始提供可选 feature 来支持完整数据集了。在此之前,受限于 90MB 的庞大体积,完整数据集只能让用户自行下载并提供访问路径。
对于简化后的数据集,如果不做 polyline 压缩,体积还会轻微膨胀。原因是此前有很多小的多边形细节会被直接抹去,现在引入了新的判定条件,出于精度考虑保留了大量细小多边形细节。另一方面,因为边界本身已经被大幅简化,共享边界只存储一份带来的优化效果也没有完整精度数据那么显著。目前引入共享边界识别和 polyline 处理后,简化数据集大约 5.4MB,仍然可以接受。
不过这里还是要提一句,tzf 系列项目使用完整精度数据时,运行过程中需要的内存在 500MB 左右,这个占用还是很大,暂时没有进一步优化的计划,并且这个功能暂时不会下方到 Python binding 中。即使使用简化数据集,也需要约 100MB 内存。tzf 系列项目,特别是 Go、Rust、Python 三个版本,设计之初就是为了服务高并发后端 API 场景,可以接受一定的内存占用,换取几乎无感的处理耗时,同时边界精度也不能过度简化。在这个场景下,内存占用、处理速度、数据精度需要一起权衡。具体用什么、怎么用,还是要以各自的实际情况为准。
具体的功能可以参考代码的文档 internal/topology/README.md。
目前的数据文件列表如下:
| 文件名 | 大小 | 说明 |
|---|---|---|
combined-with-oceans.compress.topo.bin | ~17MB | 完整精度:共享边界去重 + polyline 压缩 |
combined-with-oceans.topology.compress.topo.bin | ~5.4MB | 简化版:拓扑感知简化 + 共享边界去重 + polyline 压缩 |
combined-with-oceans.reduce.preindex.bin | ~2MB | FuzzyFinder 使用的瓦片预索引 |
YStripes 索引
首先声明,YStripes 索引不是我发明的,它来自 Josh Baker 此前发布的 tidwall/tg 项目。只是将这个索引机制移植到了 tzf 的 Go 和 Rust 版本中。
从这个春天开始,这个索引已经成为 tzf 的 Go 和 Rust 版本的默认策略。它确实增加了一些内存占用,但性能收益更明显。在我的本地 benchmark 中,单次随机查询已经降到 1 微秒左右,基本不会构成我已知使用场景中的性能瓶颈。
对应的算法原理这里就不展开了,感兴趣可以直接阅读作者的说明文档 POLYGON_INDEXING.md。
GridIndex(格子索引)
YStripes 优化了单次 PIP 内部的线段遍历;但 DefaultFinder.GetTimezoneName 还有另一条性能洼地:FuzzyFinder 覆盖约 95% 的查询,剩余约 5% 的边界点要 fallback 到 Finder,对 ~444 个时区做全量线性扫描。
解决方案是引入 1°×1° 格子索引(GridIndex):将全球划分为 360×180 = 64,800 个格子,构建时预计算每个格子与哪些时区包围盒相交,并将格子索引嵌入到 .compress.topo.bin 文件中。查询时,O(1) 的 map 查找把候选时区从 ~444 个缩减到通常 1–3 个,再对候选做精确 PIP。对于候选只有 1 个、且不处于极端经纬度的格子,还可以直接短路跳过 PIP。
这个设计的灵感来自 twitchax/rtz 的格子预计算思路,并整合进了 tzf 自己的 protobuf 数据体系。GridIndex 随数据文件一并分发,对调用方完全透明——旧数据文件无 GridIndex 时自动回退全量扫描,API 不变。
实测效果(Apple M3 Max,go test -bench):
| 场景 | GridIndex 前 | GridIndex 后 | 提升 |
|---|---|---|---|
| Edge case(lite) | 2108 ns/op | 1060 ns/op | 2.0× |
| Random cities(lite) | 1742 ns/op | 452 ns/op | 3.9× |
| Edge case(full,无 preindex) | 2057 ns/op | 1019 ns/op | 2.0× |
| Random cities(full,无 preindex) | 1955 ns/op | 606 ns/op | 3.2× |
GridIndex 大幅压缩了 FuzzyFinder 在 fallback 路径上的边际收益,但两层结构暂时保留,后续再评估是否可以简化为单一的 Finder + GridIndex,也许在未来的 V2 系列会有比较大范围的架构调整。
这一优化已在 tzf Go v1.2.0 和 tzf-rs v1.3.3 中发布,Rust 端按相同 proto schema 实现了等效格子索引。
Benchmark
这里简单展示一下我本地的 benchmark 结果。测试设备是 MacBook Pro with Apple M3 Max。
以下结果主要用于观察不同策略之间的相对差异,不建议直接作为跨机器的绝对性能结论。
tzf(Go)
| Target | Dataset | Scenario | Median (ns) | p99 (ns) | Approx throughput (ops/s) | Memory (MiB) |
|---|---|---|---|---|---|---|
| DefaultFinder | topology-simplified + preindex | edge case · GetTimezoneName | 3000.0 | 3000.0 | 393.5K | 74.70 |
| Finder | topology-simplified | edge case · GetTimezoneName | 2000.0 | 3000.0 | 470.4K | 66.00 |
| FullFinder | full-precision + preindex | edge case · GetTimezoneName | 3000.0 | 3000.0 | 395.6K | 421.50 |
| Finder | full-precision | edge case · GetTimezoneName | 2000.0 | 3000.0 | 475.3K | 412.70 |
| DefaultFinder | topology-simplified + preindex | random world cities · GetTimezoneName | 1000.0 | 4000.0 | 1162.4K | 74.70 |
| FuzzyFinder | preindex | random world cities · GetTimezoneName | 469.8 | 1000.0 | 2128.6K | 8.90 |
| Finder | topology-simplified | random world cities · GetTimezoneName | 2000.0 | 4000.0 | 531.6K | 66.00 |
| FullFinder | full-precision + preindex | random world cities · GetTimezoneName | 1000.0 | 4000.0 | 1143.1K | 421.50 |
| Finder | full-precision | random world cities · GetTimezoneName | 2000.0 | 5000.0 | 468.6K | 412.70 |
| DefaultFinder | topology-simplified + preindex | random world cities · GetTimezoneNames | 5000.0 | 9000.0 | 208.0K | 74.70 |
| FuzzyFinder | preindex | random world cities · GetTimezoneNames | 462.7 | 1000.0 | 2161.2K | 8.90 |
| Finder | topology-simplified | random world cities · GetTimezoneNames | 5000.0 | 8000.0 | 211.5K | 66.00 |
| FullFinder | full-precision + preindex | random world cities · GetTimezoneNames | 5000.0 | 9000.0 | 192.8K | 421.50 |
tzf-rs(Rust)
Topology-Simplified (bundled):
| Target | Dataset | Scenario | Median estimate (µs) | Approx throughput (ops/s) | Avg peak RSS (MiB) |
|---|---|---|---|---|---|
| Finder | topology-simplified | YStripes only | 1.2296 | 813,273 | 103.30 |
| Finder | topology-simplified | No index | 6.5402 | 152,901 | 51.68 |
| DefaultFinder | topology-simplified + preindex | YStripes only | 1.1383 | 878,503 | 125.98 |
| DefaultFinder | topology-simplified + preindex | No index | 2.2514 | 444,168 | 77.79 |
Full-Precision (full):
| Target | Dataset | Scenario | Median estimate (µs) | Approx throughput (ops/s) | Avg peak RSS (MiB) |
|---|---|---|---|---|---|
| Finder (full) | full-precision | YStripes only | 2.0852 | 479,570 | 561.08 |
| Finder (full) | full-precision | No index | 37.6980 | 26,527 | 252.54 |
| DefaultFinder (full) | full-precision + preindex | YStripes only | 1.3488 | 741,400 | 584.30 |
| DefaultFinder (full) | full-precision + preindex | No index | 11.2750 | 88,692 | 278.63 |
Python
Python 本身主要是 binding,这里就不贴 benchmark 结果了。不过值得一提的是,whl 体积从 7MB 左右降到了 4MB 左右,也算是对镜像构建产物的一点小优化。
Continuous Benchmark in GitHub Actions
下面是利用 Continuous Benchmark 监控的长期性能指标:
tzf ns/op
tzf-rs ns/iter
tzf iter/sec
End
以上就是这个春天密集完成的主要功能。对 tzf 系列项目来说,这次更新补上了最早设计里的几项关键功能:用 Go 完成拓扑感知的多边形数据集简化和分发,再让 Go、Rust、Python 等不同语言版本直接复用同一套数据结果;格子索引则从另一个角度把 fallback 路径的性能缺口填平。tzf-swift 也在这次周期里跟进了同等更新(v1.2.2),保持了各语言版本的功能同步。
tzfpy 的格子索引更新会在下次数据集更新时一并发布,目前暂未上 PyPI。
后续维护工作会相对轻一些,主要集中在数据文件更新、项目依赖更新和少量接口兼容工作上。
上述的开发混在了不同时间段,对应的 release 参考:
- https://github.com/ringsaturn/geometry-rs/releases/tag/v0.4.1
- https://github.com/ringsaturn/tzf-rs/releases/tag/v1.2.0
- https://github.com/ringsaturn/tzf-rs/releases/tag/v1.3.0
- https://github.com/ringsaturn/tzf-rs/releases/tag/v1.3.3
- https://github.com/ringsaturn/tzfpy/releases/tag/v1.2.0
- https://github.com/ringsaturn/tzfpy/releases/tag/v1.3.0
- https://github.com/ringsaturn/tzf/releases/tag/v1.1.0
- https://github.com/ringsaturn/tzf/releases/tag/v1.2.0
- https://github.com/ringsaturn/tzf-swift/releases/tag/v1.2.2
- https://github.com/ringsaturn/tzf-dist/releases/tag/v0.0.2026-a
