GIS数据加载太慢?Streamlit多线程优化方案(附:并发处理代码)

编程与开发
Dr.GIS
wowwwai GIS研习社 · 工具流程与项目排障

GIS数据加载太慢?Streamlit多线程优化方案(附:并发处理代码)这类问题在做 WebGIS 原型、空间数据质检面板、遥感样本浏览器时很常见:页面一刷新,Shapefile、GeoPackage、GeoJSON、栅格缩略图或多个 CSV 点位文件就要重新读取,用户只能盯着“Running…”等待。

本文以 Streamlit GIS 应用为场景,讲清楚 GIS数据加载太慢 的常见原因,并给出一个可直接改造的 Streamlit多线程优化方案。重点不是“盲目开线程”,而是把适合并发的 I/O 读取、元数据扫描、文件预处理拆出来,同时配合缓存、文件格式优化和进度反馈,让 GIS 数据加载更稳定。

引言:GIS数据加载太慢通常慢在哪里

在 Streamlit 中做 GIS 数据展示时,很多人会把代码写成这样:用户上传文件或选择目录后,程序依次读取每个图层,再统一显示到地图或表格中。数据少时问题不明显,一旦文件变多,就会出现明显卡顿。

典型慢点包括:

  • 一次读取多个 Shapefile、GeoPackage、GeoJSON 或 CSV 文件。
  • 每次页面交互都会触发 Streamlit 脚本重新运行。
  • 读取后立即做坐标转换、字段清洗、几何修复、空间范围计算。
  • 把完整 GeoDataFrame 直接传给前端地图组件,导致序列化和渲染变慢。
  • 没有使用 st.cache_datast.cache_resource,重复读取同一份数据。
GIS数据加载太慢 Streamlit多线程优化方案流程图
Streamlit GIS 应用中,适合并发优化的是多文件读取、元数据扫描和轻量预处理环节。

背景:为什么 Streamlit GIS 数据加载容易变慢

Streamlit 的运行模型很适合快速做数据应用,但它有一个重要特点:用户点击按钮、调整下拉框、修改参数时,脚本通常会从上到下重新执行。如果 GIS 数据读取逻辑直接写在脚本顶层,就很容易反复加载。

对于 GIS 项目来说,慢的原因往往不是单一问题,而是几类开销叠加:

  • 磁盘 I/O 开销:多个矢量文件分散存储,逐个读取会产生大量等待时间。
  • 格式解析开销:Shapefile 由多个文件组成,GeoJSON 是文本格式,大文件解析成本较高。
  • 几何处理开销:计算边界、修复无效几何、坐标转换都需要 CPU。
  • 前端传输开销:把大量要素转成 GeoJSON 后传给浏览器,可能比读取文件更慢。
  • 重复执行开销:没有缓存时,每次交互都重新读取和处理。

所以,Streamlit多线程优化方案 不能只看“线程数开多少”,还要判断当前瓶颈到底是 I/O、CPU、缓存失效,还是前端渲染。

原理:Streamlit多线程优化方案适合解决什么问题

Python 中常用 concurrent.futures.ThreadPoolExecutor 做线程池并发。它适合处理大量 I/O 等待任务,例如多个文件读取、网络请求、数据库查询、对象存储下载等。

在 GIS 数据加载中,下面这些任务比较适合多线程:

  • 并发读取多个 GeoPackage 图层或多个 Shapefile 文件。
  • 并发读取多个 CSV 点位文件并转换为 GeoDataFrame。
  • 并发扫描文件大小、字段名、坐标系、空间范围等元数据。
  • 并发从 PostGIS 查询多个独立图层的小范围数据。
  • 并发生成小尺寸预览图、缩略图或统计摘要。

但下面这些任务不一定适合用多线程解决:

  • 大规模叠加分析、缓冲区、空间连接等 CPU 密集型空间分析。
  • 复杂栅格计算、批量重采样、影像分类。
  • 单个超大 GeoJSON 的完整解析和前端渲染。
  • 数据库本身没有索引导致的慢查询。

简单判断方法是:如果程序大部分时间在“等文件、等数据库、等网络”,多线程通常有效;如果程序大部分时间在“算几何、算栅格、做投影转换”,更应该考虑进程池、数据库侧计算、数据切片或格式优化。

步骤:用多线程优化 Streamlit GIS 数据加载

步骤 1:准备依赖环境

示例使用 Streamlit、GeoPandas 和 Pyogrio。Pyogrio 通常比传统 Fiona 读取矢量数据更快,适合 GeoPackage、FlatGeobuf、Shapefile 等常见格式。

pip install streamlit geopandas pyogrio pandas shapely

如果你的环境中 GeoPandas 读取空间文件较慢,可以优先检查是否安装并启用了 pyogrio。在较新的 GeoPandas 版本中,可以通过 engine="pyogrio" 指定读取引擎。

步骤 2:建立单文件读取函数

先把“读取一个 GIS 文件”的逻辑封装成函数。这样后面才能放进线程池中并发执行。

from pathlib import Path
import geopandas as gpd

def read_vector_file(file_path: str):
    path = Path(file_path)
    suffix = path.suffix.lower()

    if suffix in [".shp", ".gpkg", ".geojson", ".json", ".fgb"]:
        gdf = gpd.read_file(path, engine="pyogrio")
    else:
        raise ValueError(f"不支持的文件格式: {path.name}")

    if gdf.crs is None:
        crs_text = "未知坐标系"
    else:
        crs_text = gdf.crs.to_string()

    return {
        "name": path.name,
        "path": str(path),
        "rows": len(gdf),
        "crs": crs_text,
        "bounds": gdf.total_bounds.tolist() if len(gdf) > 0 else None,
        "gdf": gdf
    }

这里返回的不只是 gdf,还包括文件名、行数、坐标系和空间范围。对于 Streamlit GIS 应用来说,这些信息可以先展示给用户,避免一上来就把所有数据都渲染到地图上。

步骤 3:使用 ThreadPoolExecutor 并发读取多个文件

下面是核心的 Streamlit多线程优化方案:把多个文件路径提交给线程池,并用 as_completed 获取完成结果。

from concurrent.futures import ThreadPoolExecutor, as_completed

def load_files_concurrently(file_paths, max_workers=4):
    results = []
    errors = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_map = {
            executor.submit(read_vector_file, file_path): file_path
            for file_path in file_paths
        }

        for future in as_completed(future_map):
            file_path = future_map[future]
            try:
                result = future.result()
                results.append(result)
            except Exception as e:
                errors.append({
                    "path": str(file_path),
                    "error": str(e)
                })

    return results, errors

max_workers 不建议无限加大。对于本地磁盘读取,通常从 2 到 4 开始测试;对于网络存储或对象存储,可以适当增加。线程太多可能造成磁盘竞争,反而更慢。

步骤 4:放进 Streamlit 页面并加入缓存

多线程解决的是“同时读取多个文件”的问题,缓存解决的是“不要重复读取同一批文件”的问题。两者要一起使用。

import streamlit as st
from pathlib import Path
import pandas as pd

st.set_page_config(page_title="GIS 数据并发加载示例", layout="wide")

@st.cache_data(show_spinner=False)
def cached_load_files(file_paths, max_workers):
    return load_files_concurrently(file_paths, max_workers=max_workers)

st.title("GIS 数据并发加载示例")

data_dir = st.text_input("输入GIS数据目录", value="./data")
max_workers = st.slider("并发线程数", min_value=1, max_value=8, value=4)

supported_suffixes = [".shp", ".gpkg", ".geojson", ".json", ".fgb"]

if st.button("开始加载"):
    folder = Path(data_dir)

    if not folder.exists():
        st.error("目录不存在,请检查路径。")
    else:
        file_paths = [
            str(p) for p in folder.iterdir()
            if p.suffix.lower() in supported_suffixes
        ]

        if not file_paths:
            st.warning("没有找到支持的GIS矢量文件。")
        else:
            with st.spinner("正在并发加载GIS数据..."):
                results, errors = cached_load_files(file_paths, max_workers)

            st.success(f"成功加载 {len(results)} 个文件,失败 {len(errors)} 个文件。")

            summary = [
                {
                    "文件名": item["name"],
                    "要素数": item["rows"],
                    "坐标系": item["crs"],
                    "范围": item["bounds"]
                }
                for item in results
            ]

            st.dataframe(pd.DataFrame(summary), use_container_width=True)

            if errors:
                st.subheader("加载失败文件")
                st.dataframe(pd.DataFrame(errors), use_container_width=True)

注意:st.cache_data 会根据输入参数缓存结果。如果文件内容变化但路径不变,可能不会自动刷新。生产环境中可以把文件修改时间、文件大小或数据版本号也作为缓存参数。

步骤 5:加入文件修改时间,避免缓存读到旧数据

GIS 项目经常会更新数据。如果只用文件路径作为缓存参数,用户可能看到旧结果。可以构造一个文件签名列表,把路径、大小和修改时间一起传入缓存函数。

def build_file_signatures(file_paths):
    signatures = []
    for file_path in file_paths:
        p = Path(file_path)
        stat = p.stat()
        signatures.append({
            "path": str(p),
            "size": stat.st_size,
            "mtime": stat.st_mtime
        })
    return signatures

@st.cache_data(show_spinner=False)
def cached_load_files_by_signature(file_signatures, max_workers):
    paths = [item["path"] for item in file_signatures]
    return load_files_concurrently(paths, max_workers=max_workers)

这样,当文件被覆盖、更新或重新导出时,缓存参数会变化,Streamlit 会重新执行数据加载。

步骤 6:只在需要时渲染地图,不要默认显示全部要素

GIS数据加载太慢 有时不是读取慢,而是前端地图渲染慢。尤其是把几十万面要素转成 GeoJSON 后传给浏览器,Streamlit 页面会明显卡顿。

建议采用两级加载策略:

  • 第一步只显示文件列表、要素数、坐标系、空间范围。
  • 第二步用户选择某个图层后,再显示预览地图。
  • 预览时限制要素数量,例如只取前 1000 条或按当前范围过滤。
  • 大数据量图层优先转为切片、FlatGeobuf、PostGIS 或矢量瓦片服务。
selected_name = st.selectbox(
    "选择要预览的图层",
    options=[item["name"] for item in results]
)

selected = next(item for item in results if item["name"] == selected_name)
preview_gdf = selected["gdf"].head(1000)

st.write(f"当前仅预览前 {len(preview_gdf)} 条要素,避免浏览器渲染过慢。")
st.map(preview_gdf.to_crs(4326))

st.map 更适合点数据快速预览。对于线、面数据,可以使用 PyDeck、Folium、Leafmap 或把数据发布为 WebGIS 服务后再加载。

常见坑:Streamlit多线程优化 GIS 数据时容易忽略的问题

坑 1:把 CPU 密集型空间分析也放进线程池

如果任务是空间连接、批量缓冲区、几何修复、栅格计算,多线程不一定明显提速。Python 中 CPU 密集型任务常受全局解释器锁影响,应该考虑:

  • 使用 ProcessPoolExecutor 做多进程。
  • 把空间分析放到 PostGIS 中执行。
  • 用 QGIS、GDAL、Rasterio 等工具做离线预处理。
  • 提前生成分析结果,Streamlit 只负责展示。

坑 2:线程数越大越好

并发线程数不是越高越好。线程过多会带来磁盘竞争、内存占用上升、文件句柄不足等问题。建议从 2、4、6、8 逐步测试,并观察总耗时和内存。

坑 3:读取完整大文件后再过滤

如果用户只看某个行政区或某个图层范围,不要先读完整全国数据再过滤。更好的做法是:

  • 使用 GeoPackage、FlatGeobuf、PostGIS 等支持空间过滤的格式或存储。
  • 在读取时使用边界框过滤。
  • 按行政区、时间、业务类型提前拆分数据。

坑 4:忽略坐标系导致地图显示异常

很多 Streamlit 地图组件默认需要 WGS84 经纬度,也就是 EPSG:4326。如果原始数据是 CGCS2000 高斯投影、Web Mercator 或地方坐标系,直接显示可能偏移或不显示。

if gdf.crs is None:
    st.warning("该图层缺少坐标系,请先定义 CRS。")
else:
    gdf_4326 = gdf.to_crs(4326)

坑 5:缓存了过大的 GeoDataFrame

st.cache_data 很方便,但缓存对象过大时会占用大量内存。对于特别大的 GIS 数据,可以只缓存元数据、文件索引、统计摘要,把完整数据放在磁盘、数据库或对象存储中按需读取。

方法比较:多线程、缓存、格式优化该怎么选

方法 适合场景 优点 限制
ThreadPoolExecutor 多线程 多个文件、多个图层、多个独立查询并发读取 改造成本低,适合 I/O 等待型任务 不适合重型空间分析,线程过多会竞争资源
st.cache_data 缓存 同一数据被反复读取和展示 减少重复加载,Streamlit 中使用简单 需要处理数据更新和缓存失效问题
格式优化 GeoJSON 太大、Shapefile 文件多、读取慢 从源头减少解析和 I/O 成本 需要提前转换数据格式
PostGIS 数据量大、需要空间索引和条件查询 适合按范围、属性和空间关系查询 需要数据库部署和索引维护
矢量瓦片或地图服务 前端地图展示大量线面要素 浏览器渲染压力小,适合 WebGIS 展示 不适合直接做复杂编辑和全量分析

如果你的问题是“第一次打开就要读取很多文件”,优先考虑 Streamlit多线程优化方案 加缓存;如果问题是“地图一显示就卡”,优先考虑抽稀、切片、PostGIS 或服务化。

检查清单:排查 GIS数据加载太慢 的实用顺序

  • 确认慢点:分别记录读取、处理、缓存、地图渲染的耗时,不要凭感觉优化。
  • 减少重复读取:使用 st.cache_data,并把文件修改时间纳入缓存参数。
  • 并发读取多文件:ThreadPoolExecutor 并发加载独立文件。
  • 控制线程数:从 2 到 4 开始测试,不要直接开到几十个线程。
  • 优化文件格式:大 GeoJSON 可考虑转 GeoPackage、FlatGeobuf 或 PostGIS。
  • 减少前端数据量:地图预览不要默认加载全部要素。
  • 检查坐标系:地图展示前统一转为 EPSG:4326 或组件要求的坐标系。
  • 处理异常文件:单个文件损坏时不要让整个应用崩溃,要记录错误并继续加载其他文件。
  • 关注内存:不要把所有大图层都长期缓存为完整 GeoDataFrame。

FAQ

Streamlit多线程优化方案一定能解决 GIS数据加载太慢 吗?

不一定。它主要解决多个文件或多个独立数据源的 I/O 等待问题。如果瓶颈是空间分析计算、前端地图渲染或数据库没有空间索引,多线程效果有限。建议先分段计时,再决定优化方向。

GeoPandas 读取 Shapefile 很慢怎么办?

可以尝试使用 engine="pyogrio",并把常用数据转换为 GeoPackage 或 FlatGeobuf。Shapefile 文件分散、字段限制多、编码问题也常见,不适合作为复杂 Streamlit GIS 应用的长期存储格式。

Streamlit 缓存和多线程可以一起用吗?

可以,而且通常建议一起用。多线程减少第一次加载多个文件的等待时间,缓存减少后续交互时的重复读取。需要注意的是,缓存参数要能反映文件更新,例如加入文件大小和修改时间。

并发线程数设置多少合适?

本地磁盘读取可以从 2 到 4 开始;网络存储或远程数据库查询可以逐步增加到 6 或 8。最稳妥的方法是记录不同线程数下的加载耗时和内存占用,选择收益稳定的值。

为什么数据读取很快,但 Streamlit 地图还是很卡?

这通常是前端渲染问题。大量线面要素转成 GeoJSON 后传到浏览器,会产生序列化、网络传输和浏览器绘制压力。可以只预览部分要素,或使用 PostGIS、矢量瓦片、地图服务来展示大数据。

大规模 GIS 数据应该直接放在 Streamlit 里读吗?

不建议。Streamlit 更适合做交互式分析面板和轻量数据应用。对于百万级要素、全国范围面数据、大量栅格影像,应优先使用 PostGIS、GeoParquet、对象存储、瓦片服务或预处理结果,Streamlit 只负责查询和展示。

结论

GIS数据加载太慢 不是简单加一个线程池就能彻底解决的问题。对于 Streamlit GIS 应用,正确的优化思路是:先定位瓶颈,再把多文件读取、元数据扫描这类 I/O 任务并发化,同时用缓存避免重复读取,并控制前端地图一次展示的数据量。

如果你的应用只是读取几个小文件,多线程未必必要;但如果要同时加载多个 Shapefile、GeoPackage、GeoJSON 或 CSV 点位文件,本文这套 Streamlit多线程优化方案 可以作为一个可靠起点。后续再结合 GeoPackage、FlatGeobuf、PostGIS 和矢量瓦片,才能让 GIS 数据应用在数据量增长后仍然保持可用。