第20章 - 性能优化与容量规划
20.1 一次瓦片请求的成本结构
动态瓦片服务和静态文件服务器的根本差别在于:每张瓦片都是一次计算。一次 /cog/tiles/... 请求在后端要做的事:
HTTP 请求进入
├─ 参数解析与校验(bidx / expression / rescale / colormap / tilesize)
├─ rasterio.Env(**env) 应用 GDAL 配置
├─ Reader(url) 打开 COG
│ ├─ GET 文件头(IFD,含每个瓦片的字节偏移)
│ └─ GDAL 据 overview 选择读取层级
├─ 计算目标窗口 → 读内部 tile 字节区间(若干次 HTTP Range GET)
├─ 解码 + 重投影/重采样(nearest 或 bilinear)
├─ rescale / colormap / 掩膜
├─ 编码 PNG/JPEG/WebP
└─ 响应(默认带 Cache-Control: public, max-age=3600)
TiTiler 官方文档对成本有明确表述:动态渲染一张瓦片至少 2 个 GET 请求(Web 优化场景),而静态切好的瓦片只是”读一个文件”。这条差异决定了后面所有优化手段的优先级——先减少请求次数,再谈 CPU。
20.2 GDAL 环境变量:收益最大的一步
TiTiler 的性能文档(docs/src/advanced/performance_tuning.md)把 GDAL 配置列为部署的第一要务。核心变量与推荐值:
| 变量 | 推荐值 | 作用 |
|---|---|---|
GDAL_DISABLE_READDIR_ON_OPEN |
EMPTY_DIR |
打开文件时不再 LIST 目录,直接省掉一次(且更贵的)请求 |
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES |
YES |
把相邻 Range 请求合并成一个 |
GDAL_HTTP_MULTIPLEX |
YES |
同一 TCP 连接上并行多个 Range(需服务端支持 HTTP/2) |
GDAL_HTTP_VERSION |
2 |
启用 HTTP/2,配合 multiplex 才有效果 |
GDAL_CACHEMAX |
200(MB) |
GDAL 块缓存 |
CPL_VSIL_CURL_CACHE_SIZE |
200000000(约 190 MB) |
全局 Range 内容 LRU 缓存,跨文件句柄复用 |
VSI_CACHE |
TRUE |
开启 GDAL 内部缓存 |
VSI_CACHE_SIZE |
5000000(每文件句柄 5 MB) |
注意是 per-file handle,开 10 个文件就是 50 MB |
GDAL_BAND_BLOCK_CACHE |
HASHSET |
块缓存数据结构 |
CPL_VSIL_CURL_ALLOWED_EXTENSIONS |
.tif,.TIF,.tiff |
只允许打开这些扩展名,减少误探测 |
GDAL_INGESTED_BYTES_AT_OPEN |
按数据实测 | 打开时预读的头部字节数,默认 16 KB |
GDAL_DISABLE_READDIR_ON_OPEN 是影响最大也最容易踩坑的一个。文档写得很清楚:设为 FALSE(默认)时 GDAL 会尝试列出目录下所有文件,对于一个对象存储前缀就是一次 LIST 请求;EMPTY_DIR 让 GDAL 当作目录是空的,直接跳过。在 requester-pays 桶上,LIST 比 GET 贵得多,这一条同时影响性能与账单(第 21 章展开)。
但反过来也有必须设 FALSE 的场景:概览文件在外部 .ovr 里(例如部分 Landsat 数据集)。设成 EMPTY_DIR 后 GDAL 找不到 .ovr,会退化成读全分辨率,性能反而崩。判断依据是数据本身:COG 内部带 overview 的,用 EMPTY_DIR;依赖外部 sidecar 的,用 FALSE。
生产环境的写法是在容器里直接注入环境变量,或在 Helm values 里配置:
# deployment/k8s/charts/values.yaml 的 env 段(TiTiler 官方 chart 的结构)
env:
- name: GDAL_CACHEMAX
value: "200"
- name: GDAL_DISABLE_READDIR_ON_OPEN
value: "EMPTY_DIR"
- name: GDAL_HTTP_MERGE_CONSECUTIVE_RANGES
value: "YES"
- name: GDAL_HTTP_MULTIPLEX
value: "YES"
- name: GDAL_HTTP_VERSION
value: "2"
- name: CPL_VSIL_CURL_CACHE_SIZE
value: "200000000"
- name: VSI_CACHE
value: "TRUE"
- name: VSI_CACHE_SIZE
value: "5000000"
- name: CPL_VSIL_CURL_ALLOWED_EXTENSIONS
value: ".tif,.TIF,.tiff"
也可以在单个工厂上配置,让某个路由用不同的 GDAL 参数:
from fastapi import FastAPI
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from titiler.core.factory import TilerFactory
def gdal_env() -> dict[str, str]:
"""每次请求的 rasterio.Env 覆盖,与进程级环境变量叠加。"""
return {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
"GDAL_INGESTED_BYTES_AT_OPEN": "32768",
}
cog = TilerFactory(environment_dependency=gdal_env)
app = FastAPI(title="COG Tiler")
app.include_router(cog.router, prefix="/cog", tags=["COG"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)
没有 gdal_config 这个构造参数。TiTiler 给的口子是 environment_dependency(默认 lambda: {}),它的返回值会作为 rasterio.Env(**env) 的关键字参数,在每个端点打开数据集前生效。这样做只影响该工厂的请求,不改变进程级环境变量,适合”某个路由要特殊配置”的场景。
20.3 overview 与读取层级
COG 的核心价值在于金字塔式的内部概览。客户端要一张 zoom=10 的瓦片时,服务端不应该去读 zoom=18 的全分辨率像素再缩小——GDAL 会按目标分辨率自动选择最接近的 overview 层。
这带来两条实践规则:
- 生成 COG 时必须建 overview,且层级要覆盖实际使用的缩放范围。 缺层时 GDAL 只能读全分辨率,单张瓦片的 IOPS 与解码量会涨一个数量级。第 5 章讲
rio-cogeo的建层策略。 - 限制 maxzoom。 前端 source 的
maxzoom一旦超过数据可用层级,请求会打到最底层数据,读放大最严重。TiTiler 的 TileJSON 会返回数据自身的minzoom/maxzoom,直接用它。
读取层的选择可以通过 resampling 与 reproject 参数间接影响观感,但不改变 IO 量:
| 参数 | 取值 | 说明 |
|---|---|---|
resampling |
nearest / bilinear / cubic / average 等 |
同投影下的重采样算法,默认 nearest |
reproject |
同类取值 | 需要重投影时的算法,默认 nearest |
buffer |
0.5 的倍数 | 每边扩张的像素,输出变为 tilesize + 2*buffer |
影像(连续色调)用 bilinear 或 cubic 观感更好;分类图(土地利用)必须用 nearest,否则会出现不存在的类别值。
20.4 并发模型
TiTiler 是 FastAPI 应用,默认单进程。吞吐瓶颈通常不在 Python 层,而在”每个请求要等对象存储的 Range 响应”这一段 IO 等待。
同步读取路径(rio-tiler + rasterio 是同步 API)
FastAPI 事件循环
└─ run_in_threadpool ──► 线程池(默认 40 个线程)
└─ rasterio 读取(阻塞在 HTTP 上)
三个可调的层次:
| 层次 | 手段 | 影响 |
|---|---|---|
| 进程 | uvicorn --workers N / gunicorn worker |
每进程独立内存与缓存,N 受 CPU 与内存限制 |
| 线程 | Starlette 线程池 | 单进程内并发处理 IO 阻塞型请求 |
| 存储 | HTTP/2 multiplex | 同一连接并行多个 Range,减少握手 |
进程数不是越多越好:GDAL_CACHEMAX 与 VSI_CACHE_SIZE 是每进程的内存,4 个 worker × 200 MB 就是 800 MB 只算 GDAL 缓存。容器内存上限要先扣掉这部分,再分配 Python 堆与图像缓冲。
线程池的默认值由 Starlette 决定(40),对 IO 密集场景通常够用;但如果单进程同时处理上百个瓦片请求,会看到明显排队。此时优先加进程(受 CPU 限制)或加缓存层,而不是无限加线程——每个线程都在等同一块网卡与同一个对象存储的配额。
20.5 缓存:三层,代价依次上升
动态瓦片的缓存收益极高,因为同一张瓦片在短时间内会被多个用户重复请求。按距离用户的远近分三层:
| 层 | 实现 | 命中后成本 | 失效控制 |
|---|---|---|---|
| 浏览器 | Cache-Control(默认 public, max-age=3600) |
0 | 换 URL(参数变化即新键) |
| CDN | 边缘缓存,回源率是关键指标 | 一次边缘响应 | Cache-Control / 手动 purge |
| 服务端 | 进程内 LRU 或 Redis(aiocache) | 一次内存读 | TTL |
TiTiler 演示应用已经内置了两件与缓存相关的事:CacheControlMiddleware 给响应加 Cache-Control(默认 public, max-age=3600,/healthz 除外),以及 CompressionMiddleware 对文本响应做 gzip、对图片类型(jpeg/png/jp2/webp)跳过压缩。
进程内/Redis 缓存的写法在官方示例 tiler_with_cache.md 里,核心是一个继承 aiocache.cached 的装饰器,它解决了两点:非 async 函数用 run_in_threadpool 包一层,命中时给响应加 X-Cache: HIT 头便于验证。
# 缓存键 = 请求参数组合;TTL 与内存缓存配置
import aiocache
aiocache.caches.set_config({
"default": {
"cache": "aiocache.RedisCache",
"endpoint": "redis.internal",
"port": 6379,
"ttl": 3600,
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
}
})
# 在自定义 TilerFactory 的 tile 端点上挂缓存(摘自官方示例的写法)
from .cache import cached
from titiler.core.factory import img_endpoint_params
class TilerFactory(TiTilerFactory):
def register_routes(self):
@self.router.get(r"/tiles/{tileMatrixSetId}/{z}/{x}/{y}", **img_endpoint_params)
@cached(alias="default")
def tile(z, x, y, tileMatrixSetId, ...):
...
缓存键天然包含全部查询参数,这意味着参数组合越多,命中率越低。一个常见错误是前端给每张瓦片加一个时间戳参数(”&t=${Date.now()}“)来”防缓存”,结果 CDN 命中率归零、后端压力翻倍。真要刷新,用版本号或数据集的 ETag 做参数,让相同的请求共享同一个键。
20.6 压测方法
容量规划的前提是有一组可重复的测量。瓦片服务的压测比普通 API 多两个注意点:URL 空间大(z/x/y 组合)和 冷热差异极大(CDN 命中 vs 回源)。
# 1) 生成一批真实瓦片 URL(从 TileJSON 的模板展开)
python - <<'PY'
import math, json, urllib.parse
template = "https://titiler.example.com/cog/tiles/WebMercatorQuad/{z}/{x}/{y}"
params = {"url": "s3://bucket/scene.tif", "tilesize": 256, "rescale": "0,3000"}
z = 13
n = 2 ** z
urls = []
for x in range(int(n*0.72), int(n*0.72)+8):
for y in range(int(n*0.30), int(n*0.30)+8):
urls.append(template.format(z=z, x=x, y=y) + "?" + urllib.parse.urlencode(params))
open("urls.txt", "w").write("\n".join(urls))
print(len(urls), "URLs")
PY
# 2) 单进程基准(先看单请求延迟分布)
hey -n 200 -c 8 -f urls.txt
# 3) 并发爬升,观察延迟分位与 5xx
hey -n 2000 -c 32 -f urls.txt
要看的是分位而不是均值:p50 与 p95 的差距反映排队程度,p99 决定用户感知的卡顿。同时记录后端侧的 CPU、内存、对象存储出向流量与 5xx,三者一起才能定位瓶颈在 CPU、在存储还是在出口带宽。
压测时必须区分冷热:
- 冷缓存:每次换不同的 z/x/y,测的是完整链路(含 Range 请求)。
- 热缓存:固定几十个 URL 反复请求,测的是 CDN / 服务端缓存命中路径。
真实流量是两者的混合。容量规划要用”峰值 QPS × 冷缓存占比”来估算回源压力。
20.7 容量估算公式
三个公式够用,关键在于所有系数都要用自己数据实测,而不是套行业均值。
回源 QPS:
回源 QPS = 前端峰值 QPS × (1 - 缓存命中率)
计费请求 QPS ≈ 回源 QPS × 单瓦片平均 Range 请求数
单瓦片平均 Range 请求数 至少是 2(头 + 数据),多波段或多内部 tile 时更高。这个数字可以从对象存储的请求日志里直接统计,比估算靠谱。
出口带宽:
出向带宽 = 峰值 QPS × 平均单瓦片字节数 × 8
256×256 的 PNG/JPEG 瓦片通常在几十 KB 量级,具体值取决于影像复杂度与格式:JPEG 有损压缩后明显小于 PNG;带透明的 PNG 又比不透明的大。取 P95 字节数而不是均值,避免峰值打满带宽。
前端连接数:
峰值并发请求数 ≈ 在线用户数 × 每屏瓦片数 × 1.2
每屏瓦片数 ≈ (视口宽 / tileSize + 1) × (视口高 / tileSize + 1) × (缓存层数)
以 1920×1080 视口、tileSize 256 计算,一屏约 (8+1)×(5+1)=54 张;再乘以地图保留的 1–2 个缩放层,就是每个用户可能同时挂起的请求量级。这个数决定了后端并发上限与 CDN 的连接预算。
示例:10 万日活,晚高峰 5% 同时在线 = 5000 人
每人一屏 54 张,平均每 3 秒换一次视图 → 5000 × 54 / 3 ≈ 90000 QPS(前端)
CDN 命中率 95% → 回源 4500 QPS
单瓦片 2 次 Range → 对象存储 9000 请求/秒
这个量级下,回源 4500 QPS 已经必须多副本 + 缓存,而对象存储 9000 请求/秒的成本要单独算(第 21 章)。反过来说,命中率从 95% 提到 98%,回源就从 4500 降到 1800——缓存命中率是容量规划里最敏感的杠杆。
20.8 优化清单(按收益排序)
| 优先级 | 措施 | 预期收益 | 代价 |
|---|---|---|---|
| 1 | GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR |
每请求少一次 LIST,显著降延迟与成本 | 外部 .ovr 场景不可用 |
| 2 | CDN 缓存瓦片 | 回源量降一到两个数量级 | 需处理失效与 purge |
| 3 | COG 建 overview + 限制 maxzoom | 读放大降一个数量级 | 存储变大(约 +30%) |
| 4 | GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES |
Range 请求数减少 | 几乎无 |
| 5 | 服务端共享缓存(Redis) | 跨进程复用热瓦片 | 内存/网络开销 |
| 6 | 提高 worker 数 | 线性提升吞吐(到 CPU 上限) | 内存翻倍 |
| 7 | HTTP/2 + multiplex | 减少连接开销 | 依赖存储端支持 |
| 8 | 调 tilesize 与格式 |
单请求字节数下降 | 影响观感 |
顺序很重要:前三条做完,往往不需要加机器。反过来先加机器、再发现 90% 的请求都在做无意义的 LIST,钱就白花了。
20.9 排错
单瓦片耗时 1–3 秒。 先看对象存储的请求日志:如果一次瓦片读触发几十次 GET,基本是 GDAL_DISABLE_READDIR_ON_OPEN 没设或数据不是 COG(内部 tile 太大或没分块)。
CPU 不高但延迟高。 时间花在等存储响应上。检查是否跨区域访问(计算与存储不同 region)、是否开了 HTTP/2、单文件是否在同一个前缀下被高频打开。
容器 OOM。 GDAL_CACHEMAX 与 VSI_CACHE_SIZE 是每进程的,乘以 worker 数后经常超出 limit。把 VSI_CACHE_SIZE 从 5 MB 调到 1–2 MB,或减少 worker。
压测数据很好,线上仍慢。 压测用了固定 URL(热缓存),线上是长尾分布。用真实流量回放或按 20.6 的冷热混合方式重新压测。
CDN 命中率只有 60%。 检查前端是否在 URL 里加了随机参数、Cache-Control 是否被网关覆盖、查询参数顺序是否稳定(参数顺序不同会产生不同的缓存键)。
升级 TiTiler 后延迟上升。 2.3.0 起官方镜像切换为 wolfi-base 且以 nonroot(uid 65532)运行,2.2.2 起默认用户也变了。挂载路径、临时目录权限问题会让 GDAL 的临时缓存失效,进而拖慢读取——升级时把这类运行时差异纳入回归。
20.10 本章小结
| 主题 | 结论 |
|---|---|
| 成本结构 | 动态瓦片每张至少 2 次 GET;静态瓦片只是读文件 |
| GDAL 首选 | GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR、GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES |
| 缓存配置 | GDAL_CACHEMAX=200、CPL_VSIL_CURL_CACHE_SIZE=200000000、VSI_CACHE=TRUE、VSI_CACHE_SIZE=5000000(每文件句柄) |
| overview | COG 必须建 overview,前端 maxzoom 不得超过数据可用层级 |
| 并发 | 进程(uvicorn workers)、线程(默认 40)、HTTP/2 三层;内存按”每进程缓存 × 进程数”核算 |
| 缓存层 | 浏览器(1 小时)→ CDN → 服务端(Redis/LRU),参数组合越少命中率越高 |
| 容量公式 | 回源 QPS = 前端 QPS ×(1−命中率);带宽 = QPS × 瓦片字节 × 8 |
| 杠杆 | 命中率最敏感:95%→98% 可让回源降到 40% |