第09章 - pgstac 与 PostgreSQL 后端
第 8 章的内存后端能跑通协议,但它把“怎么查”这件事留给了 Python:遍历字典、手工过滤 bbox、切片分页。Item 上万之后这套做法就崩了——每次 /search 都要把所有 Item 加载进内存。
pgstac 把这件事搬进数据库:Item 存成 jsonb,几何与时间抽成独立列建索引,检索编译成一条 SQL,分页用 keyset 而不是 OFFSET。它不是 ORM,也不是应用框架,就是一套 PostgreSQL schema 加函数库。
这一章讲清三件事:表怎么设计、检索怎么执行、运维怎么做。版本基线是 pgstac 0.10.1。
9.1 表结构
9.1.1 items 表
0.10 的 items 表是“去规范化 + 提升列”的设计:
CREATE TABLE items (
id text NOT NULL,
geometry geometry NOT NULL,
collection text NOT NULL,
datetime timestamptz NOT NULL,
end_datetime timestamptz NOT NULL,
datetime_is_range boolean NOT NULL DEFAULT false,
stac_version text,
stac_extensions jsonb DEFAULT '[]'::jsonb,
pgstac_updated_at timestamptz NOT NULL DEFAULT now(),
item_hash bytea NOT NULL DEFAULT '\x'::bytea,
fragment_id bigint,
bbox jsonb,
links jsonb,
assets jsonb,
properties jsonb,
extra jsonb,
-- 提升列(部分)
created timestamptz,
platform text,
instruments text[],
eo_cloud_cover float8,
bands jsonb,
gsd float8,
proj_code text,
proj_shape jsonb,
proj_transform jsonb,
private jsonb
)
PARTITION BY LIST (collection);
几个设计决策值得理解:
按 collection 做 LIST 分区。 一个 Collection 一个分区(或一组子分区),查询带 collection 条件时能直接剪枝。这也是为什么 pgstac 推荐“Collection 数量不要太多,单个 Collection 的 Item 数量可以很大”。
datetime 与 end_datetime 都是 NOT NULL。 单时刻的 Item 把两者设成同值,并用 datetime_is_range 标记是否真的是区间。这样区间查询可以统一写成 datetime <= q_end AND end_datetime >= q_start,不需要区分两种情况。
properties / assets / links / extra 分开存。 检索主要看 properties,资产列表只在取详情时用。分开存让 fields 扩展的 include/exclude 可以只读需要的列——fields_to_itemcols 会把不需要的重列替换成 NULL::type AS col,保持结果 schema 稳定但不传输数据。
提升列(promoted columns)。 eo_cloud_cover、proj_code、gsd 这些高频过滤字段被复制成原生列,可以建 btree 索引。0.10 的提升列表与 STAC 扩展版本对齐过:proj:epsg(int)换成了 proj:code(text),eo:bands 换成了核心 bands。
item_hash。 32 字节的 sha256,用于外部客户端判断“这条 Item 有没有变”,不必拉全量。
9.1.2 collections 表
CREATE TABLE collections (
key bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id text GENERATED ALWAYS AS (content->>'id') STORED UNIQUE NOT NULL,
content JSONB NOT NULL,
fragment_config text[],
geometry geometry GENERATED ALWAYS AS (pgstac.collection_geom(content)) STORED,
datetime timestamptz GENERATED ALWAYS AS (pgstac.collection_datetime(content)) STORED,
end_datetime timestamptz GENERATED ALWAYS AS (pgstac.collection_enddatetime(content)) STORED,
private jsonb,
partition_trunc text CHECK (partition_trunc IN ('year', 'month'))
);
id 是从 content 里生成出来的(content-»‘id’),所以插入时只需要给完整 JSON。geometry 与 datetime 也是生成列,从 extent 里算。
partition_trunc 决定子分区粒度:NULL 表示只有一层(按 collection),year 或 month 表示再按时间切一层。
9.1.3 分区策略
两层分区是 pgstac 的核心性能设计:
items (PARTITION BY LIST (collection))
├─ _items_1 ← collection 1,无时间子分区
├─ _items_2 ← collection 2 的父分区(PARTITION BY RANGE (datetime))
│ ├─ _items_2_2023 ← 2023 年
│ ├─ _items_2_2024
│ └─ _items_2_2025
└─ _items_3_202305 ← collection 3,按月
创建子分区时用 advisory lock 串行化,避免并发建表冲突:
PERFORM pg_advisory_xact_lock(hashtext('pgstac.check_partition'), hashtext(_partition_name));
分区名的规则是 items{key} 加时间后缀(YYYY 或 YYYYMM)。key 是 collections 表的自增主键,不是 collection id——因为 id 里可能有特殊字符。
时间子分区的好处:查询带时间范围时,Postgres 能直接跳过不相关的分区。一个 5 年、每年 2000 万条的 Collection,查某一天的数据只需要扫 1/60 的分区。
9.1.4 其他表
| 表 | 作用 |
|---|---|
| item_fragments | Item 内容的去重片段,按 (collection, hash) 唯一 |
| item_field_registry | 记录每个 Collection 出现过哪些 JSON 路径,用于生成 queryables |
| searches | 检索缓存:WHERE 子句、ORDER BY、命中数、最后使用时间 |
| queryables | 可查询字段的注册表,支持按 Collection 限定 |
| migrations | 已应用的迁移版本 |
| pgstac_settings | 运行期配置,可被会话参数与请求内的 conf 覆盖 |
| partition_stats | 每个分区的时空范围与索引状态 |
item_fragments 是 0.10 引入的去重机制:同一个 Collection 里大量 Item 的 assets、links 模板完全相同,把公共部分存一份,Item 只存 fragment_id。这对 Sentinel-2 这种“几万景、资产结构一模一样”的数据能把存储压掉一半以上。
9.2 检索是怎么执行的
9.2.1 从 JSON 到 SQL
一个 POST /search 的请求体最终变成一条 SQL:
{"collections":["demo"],"bbox":[100,20,110,30],
"datetime":"2023-01-01T00:00:00Z/2023-12-31T23:59:59Z",
"limit":10}
↓ search_to_cql2()
CQL2 JSON 表示
↓ cql2_query()
WHERE i.collection = ANY('{demo}') AND i.datetime <= '...' AND i.end_datetime >= '...'
AND ST_Intersects(i.geometry, ST_MakeEnvelope(100,20,110,30,4326))
↓ search_page()
keyset 分页查询
三个函数串起来:
CREATE OR REPLACE FUNCTION stac_search_to_where(j jsonb) RETURNS text AS $$
DECLARE _where text := cql2_query(search_to_cql2(j));
BEGIN
IF _where IS NULL OR btrim(_where) = '' THEN RETURN ' TRUE '; END IF;
RETURN _where;
END;
$$ LANGUAGE PLPGSQL STABLE;
空搜索返回 TRUE,这样调用方不需要特判“没有条件”。
9.2.2 keyset 分页
pgstac 不用 OFFSET。OFFSET 100000 会让数据库扫过前 10 万行再丢弃,越翻越慢。
keyset 分页记录“上一页最后一行的排序键”,下一页从那里继续:
第一页:ORDER BY datetime DESC, id DESC LIMIT 11
取前 10 行,第 11 行用来判断 has_more
下一页:WHERE (datetime, id) < ('2023-05-01T...', 'demo-001')
ORDER BY datetime DESC, id DESC LIMIT 11
返回给客户端的 token 编码了这个位置。pgstac 的 token 带方向前缀:
keyset text := nullif(regexp_replace(coalesce(_token,''), '^(next|prev):', ''), '');
is_prev boolean := (_token LIKE 'prev:%') AND keyset IS NOT NULL;
所以客户端拿到的链接形如:
{"rel": "next", "href": "https://api.example.com/search?token=next:eyJ...", "method": "GET"}
{"rel": "prev", "href": "https://api.example.com/search?token=prev:eyJ...", "method": "GET"}
token 对客户端是不透明的,不要解析它,也不要假设它能跨版本复用。
9.2.3 命中数统计
numberMatched 有两种算法,由 context 配置控制:
| context | 行为 |
|---|---|
| off | 不返回 numberMatched |
| on | 每次精确 count(*) |
| auto | 先用 EXPLAIN 估算,估算值够大就直接用估算值 |
auto 的逻辑在 where_stats 里:
EXECUTE format('EXPLAIN (format json) SELECT 1 FROM items i WHERE %s', ...) INTO explain_json;
sw_estimated_count := (explain_json->0->'Plan'->>'Plan Rows')::bigint;
...
IF _context = 'auto' AND sw_estimated_count >= _estimated_count_threshold
AND sw_estimated_cost >= _estimated_cost_threshold THEN
sw.context_count := sw_estimated_count;
RETURN sw;
END IF;
对百万级结果,精确 count 要几秒;估算值误差可能有 10%–30%,但快两个数量级。前端做分页器时通常只需要“大概有多少页”,auto 是合理默认。
统计结果缓存在 searches 表里,TTL 由 context_stats_ttl 控制。相同 WHERE 的重复查询直接命中缓存。
9.3 部署
9.3.1 装 PostGIS 与 pgstac
pgstac 依赖 PostGIS,顺序不能反:
# 1. 起一个带 PostGIS 的 Postgres
docker run -d --name pg -p 5432:5432 \
-e POSTGRES_USER=pgstac -e POSTGRES_PASSWORD=pgstac -e POSTGRES_DB=pgstac \
postgis/postgis:16-3.4
# 2. 等就绪
until docker exec pg pg_isready -U pgstac; do sleep 1; done
# 3. 迁移:0.10 起迁移是独立的 Python 包,不在 PostGIS 镜像里
python -m pip install pgstac-migrate
pgstac-migrate migrate --dsn "postgresql://pgstac:pgstac@localhost:5432/pgstac"
# 4. 确认版本
psql "postgresql://pgstac:pgstac@localhost:5432/pgstac" -c "SELECT pgstac.get_version();"
# get_version
# -------------
# 0.10.1
0.10 把原来合一的 pypgstac 拆成三块:pgstac-migrate(迁移)、pgstac Rust CLI(加载与检索)、pgstac Python wheel(读写 API)。0.9 及以前只有 pypgstac,迁移命令是 pypgstac migrate;照老文档在新版本上执行会报 command not found。
手工安装(不用 Docker)时,把 SQL 按序执行:
for f in src/pgstac/sql/*.sql; do
psql -U pgstac -d pgstac -v ON_ERROR_STOP=1 -f "$f"
done
文件名前缀就是执行顺序:000_idempotent_pre、001_core、001a_jsonutils、001s_stacutils、002_collections、002a_queryables、002b_cql、002c_envelope、002d_keyset、003a_items、003b_partitions、003c_ingest、004_search、004a_collectionsearch、005_tileutils、006_tilesearch、997_maintenance、998_idempotent_post、999_version。
9.3.2 创建 Collection 与加载 Item
pgstac 提供 SQL 函数直接写:
-- 建集合,按月做时间子分区
SELECT pgstac.create_collection(
'{
"type": "Collection",
"stac_version": "1.1.0",
"id": "demo-landsat",
"description": "示例 Landsat 集合",
"license": "CC-BY-4.0",
"extent": {
"spatial": {"bbox": [[100, 20, 110, 30]]},
"temporal": {"interval": [["2023-01-01T00:00:00Z", null]]}
},
"links": []
}'::jsonb,
'month'
);
-- 插入一个 Item
SELECT pgstac.create_item('{
"type": "Feature",
"stac_version": "1.1.0",
"id": "demo-001",
"collection": "demo-landsat",
"geometry": {"type": "Polygon",
"coordinates": [[[100,20],[110,20],[110,30],[100,30],[100,20]]]},
"bbox": [100, 20, 110, 30],
"properties": {"datetime": "2023-05-01T00:00:00Z",
"eo:cloud_cover": 12.5, "platform": "landsat-8"},
"assets": {},
"links": []
}'::jsonb);
create_collection 的第二个参数是 partition_trunc,可选 ‘year’ 或 ‘month’,不传就是只有 collection 一层。
create_item 会自动路由到对应分区,并在分区不存在时先建分区。批量导入用 staging 表更快:
-- 批量:一次插入多行,触发器逐行处理
INSERT INTO items_staging (content) VALUES
('{...}'::jsonb),
('{...}'::jsonb);
-- 或者用封装函数,参数是 JSON 数组
SELECT pgstac.create_items('[{...}, {...}]'::jsonb);
三张 staging 表的语义不同:items_staging 冲突时报错(纯新增),items_staging_ignore 冲突时跳过,items_staging_upsert 冲突时覆盖。它们都是 UNLOGGED 表,写入不写 WAL,速度快很多,但崩溃会丢数据——只用于可重跑的导入。
9.3.3 接上 stac-fastapi
export PGUSER=pgstac PGPASSWORD=pgstac PGHOST=localhost PGPORT=5432 PGDATABASE=pgstac
export STAC_FASTAPI_TITLE="示例影像目录"
export ENABLE_TRANSACTIONS_EXTENSIONS=TRUE
export ENABLED_EXTENSIONS="query,sort,fields,filter,pagination"
uvicorn stac_fastapi.pgstac.app:create_app --factory --port 8082
ENABLED_EXTENSIONS 的可选键来自扩展映射表:query、sort、fields、filter、pagination(检索侧),collection_search、free_text(集合检索),extra。不设就是全开。
9.3.4 验证
curl -s "http://localhost:8082/search?collections=demo-landsat&limit=1" \
| python -c "import json,sys; d=json.load(sys.stdin); print(d['numberMatched'], d['features'][0]['id'])"
# 1 demo-001
# 看查询计划,确认分区剪枝生效
docker exec pg psql -U pgstac -d pgstac -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM pgstac.search('{\"collections\":[\"demo-landsat\"],\"limit\":10}'::jsonb);
"
计划里应该只出现 items*_202305 一个分区,而不是扫描所有分区。看到 Append 下挂十几个子计划,说明 collection 条件没被下推。
9.4 运维
9.4.1 分区维护
新建分区是惰性的:第一次插入某个月的数据时才建。这会导致“写入偶发变慢”。可以提前建:
-- 预建未来 3 个月的分区
SELECT pgstac.ensure_partitions(3);
# 定时任务:每天凌晨补建
0 2 * * * psql -U pgstac -d pgstac -c "SELECT pgstac.ensure_partitions(3);"
删除旧数据用 DROP PARTITION,不要用 DELETE:
-- 快:直接摘掉分区
DROP TABLE pgstac._items_2_202301;
-- 慢:逐行删,还会留下大量死元组
DELETE FROM pgstac.items WHERE datetime < '2023-02-01';
9.4.2 索引
pgstac 默认建两个索引:
CREATE INDEX "datetime_idx" ON items USING BTREE (datetime DESC, end_datetime ASC);
CREATE INDEX "geometry_idx" ON items USING GIST (geometry);
datetime_idx 的列顺序与排序方向是按“最新优先”设计的,正好匹配默认排序。geometry_idx 用 GiST,因为要支持 ST_Intersects 而不是简单的 bbox 比较。
提升列(eo_cloud_cover、gsd 等)的索引由 queryables 机制按需创建。注册一个 queryable 时会生成对应的表达式索引:
out := format($q$CREATE INDEX ON %%I USING %s (%s(((content -> 'properties'::text) -> %L::text)))$q$, ...);
注意这里索引的是 content->’properties’-»字段 的表达式,不是提升列——两套机制并存。
9.4.3 统计与 VACUUM
jsonb 列更新频繁的表,autovacuum 默认参数往往不够。建议按表调:
ALTER TABLE pgstac.items SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.01
);
scale_factor 从默认 0.2 降到 0.02,意味着 2% 的行变化就触发 vacuum。对大表这是必要的,否则统计信息滞后会让查询计划选错索引。
9.4.4 连接池
stac-fastapi-pgstac 自带连接池,参数是环境变量:
| 变量 | 默认 | 说明 |
|---|---|---|
| DB_MIN_CONN_SIZE | 1 | 最小连接数 |
| DB_MAX_CONN_SIZE | 10 | 最大连接数 |
| DB_MAX_QUERIES | 50000 | 单连接查询上限,超过后重建 |
| DB_MAX_INACTIVE_CONN_LIFETIME | 300 | 空闲连接存活秒数 |
DB_MAX_QUERIES 是个容易被忽略的保护:长连接跑了几万次查询后,Postgres 的执行计划缓存与内存可能劣化,定期重建连接能缓解。
多 worker 部署时,总连接数 = workers × DB_MAX_CONN_SIZE。4 个 worker、每个 10 个连接 = 40,加上运维连接,Postgres 的 max_connections 至少给 100。
9.5 排错
| 现象 | 原因 | 修法 |
|---|---|---|
| function search(jsonb) does not exist | pgstac 版本过旧,或 search_path 不对 | 设 search_path=pgstac,public |
| 查询扫全部分区 | collection 条件没下推,或统计信息过期 | ANALYZE,检查 WHERE 里 collection 的类型 |
| 插入报 partition not found | 分区没建,且 ensure_partitions 没跑 | 跑 ensure_partitions 或让 create_item 自动建 |
| numberMatched 与实际差很多 | context=auto 用的是估算值 | 需要精确值就设 context=on |
| 分页翻到后面越来越慢 | 用了 OFFSET(自实现的分页) | 用 pgstac 的 token |
| 迁移报 extension postgis does not exist | 没装 PostGIS | CREATE EXTENSION postgis |
| 内存暴涨 | 大结果集全量 hydration | 用 fields 限制字段,或开 USE_API_HYDRATE |
第一条最常见。pgstac 的函数都在 pgstac schema 下,连接时必须把 search_path 设对。stac-fastapi-pgstac 的 ServerSettings 默认就是 “pgstac,public”,自己写 psql 时容易忘。
9.6 生产建议
Collection 数量控制在几十个以内。 每个 Collection 至少一个分区,分区太多会让查询计划变复杂。用 properties 区分数据集,不要用 Collection。
时间子分区按数据量选粒度。 单个 Collection 每年超过 5000 万条用 month,否则 year 就够。粒度太细会让分区数量爆炸。
定期 ANALYZE 大表。 jsonb 的统计信息质量直接影响计划选择。pgstac 的 search 缓存也依赖统计,统计不准会导致 context 估算失真。
把 searches 表当成缓存管理。 它有 TTL 与使用计数,可以定期清理长期未用的行:
DELETE FROM pgstac.searches WHERE lastused < now() - interval '30 days';
读副本分担检索。 pgstac 的检索是只读的,可以挂只读副本给 /search,主库只负责写入。stac-fastapi-pgstac 的连接串指向副本即可,但要注意复制延迟——刚写入的 Item 可能搜不到。
9.7 本章小结
| 要点 | 结论 |
|---|---|
| 表设计 | items 按 collection LIST 分区,可再按 datetime RANGE 子分区 |
| 检索 | JSON → CQL2 → WHERE → keyset 分页,不用 OFFSET |
| 命中数 | context=off/on/auto,auto 用 EXPLAIN 估算并缓存 |
| 迁移 | 0.10 用 pgstac-migrate;0.9 及以前用 pypgstac |
| 索引 | datetime btree + geometry GiST,提升列按 queryables 建表达式索引 |
| 运维 | ensure_partitions 预建、DROP PARTITION 删数据、调 autovacuum |
下一章回到 API 侧:/search 的参数细节、CQL2 过滤的写法、扩展怎么组合,以及分页与排序的边界。