第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 层。

这带来两条实践规则:

  1. 生成 COG 时必须建 overview,且层级要覆盖实际使用的缩放范围。 缺层时 GDAL 只能读全分辨率,单张瓦片的 IOPS 与解码量会涨一个数量级。第 5 章讲 rio-cogeo 的建层策略。
  2. 限制 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%