1. 先把问题摆上台面:为什么值得为一条错误码写一份 RFC
GDAL的OGR子模块里,SetFeature()和DeleteFeature()是两个再普通不过的写接口:一个按 FID 更新要素,一个按 FID 删除要素。但凡做过矢量数据入库、增量同步、成果回写的人,大概率都在它们身上栽过一次跟头。栽的方式还挺隐蔽——代码没报错,日志没异常,但数据就是不对。OGRERR_NON_EXISTING_FEATURE这个错误码,以及它背后那份编号 55 的 RFC,干的就是把这层模糊语义钉死的事。
我把这个话题单独拎出来讲,是因为它属于那种“看文档觉得理所当然、写代码时完全想不起来、出了事故才回头翻”的知识点。它解决的核心问题只有一个:当你要更新或删除的要素根本不存在时,接口应该告诉你什么。答案是OGRERR_NON_EXISTING_FEATURE,而不是沉默地返回成功,也不是含糊地丢一个OGRERR_FAILURE让你去猜。
这篇内容适合三类人:正在用 OGR 做数据写入的开发者、维护空间数据同步管线的工程师、以及被“删除成功但数据还在”这类问题折磨过的人。哪怕你只用 Python 调osgeo.ogr,不碰 C++,第四节里关于返回值与异常的差异也值得看一眼——那是最容易埋雷的地方。
2. 语义统一之前:一个不存在的要素能引出多少种结局
2.1 老版本里“删除失败的删除”到底返回什么
在语义统一之前,DeleteFeature(fid)面对一个不存在的 FID,各家驱动的表现堪称百花齐放。数据库型驱动的自然实现是发一条DELETE FROM tbl WHERE fid = ?,语句执行成功,影响行数为零,于是驱动老老实实返回OGRERR_NONE。文件型驱动则往往在内部维护一个索引表,FID 越界时索引查找失败,于是返回OGRERR_FAILURE。还有一些驱动干脆不支持删除,返回OGRERR_UNSUPPORTED_OPERATION。
这三类返回值对调用方的含义完全不同:第一种说“我干完了”,第二种说“出错了”,第三种说“我不干这事”。但调用方真正想知道的是第四种信息——“这个要素本来就不在”。老接口没有这个词汇,于是调用方只能自己脑补:拿到OGRERR_NONE不敢确定是真删了还是本来就没有;拿到OGRERR_FAILURE又分不清是 FID 不存在还是磁盘满了。
这个歧义在单向同步、断点续传、删除重放这类场景里会放大成事故。举个我亲身经历的情形:一批要素在上游被删掉,下游按 FID 逐条重放删除操作,用的驱动对不存在的 FID 返回OGRERR_NONE。听起来挺宽容,问题是同一批任务里还夹着真正的写入失败,两者混在一个返回值里,日志上看不出区别,对账时才发现下游多了几百条幽灵要素。
注意:判断“删除是否生效”这件事,不能靠
OGRERR_NONE,必须靠返回码的精确区分。这是这条 RFC 最直接的落地价值。
2.2 OGRErr 家族的完整取值,以及第 9 号的位置
要把这个错误码放对位置,得先看清OGRErr这个枚举的全貌。它定义在ogr_core.h里,取值从 0 开始,OGRERR_NON_EXISTING_FEATURE排在最后一位,值为 9。这个顺序不是随意排的——新增枚举值放在末尾,是为了不破坏已有值的二进制兼容性,老代码编译出来的常量依然指向同一个含义。
| 取值 | 枚举名 | 典型触发场景 |
|---|---|---|
| 0 | OGRERR_NONE | 操作成功 |
| 1 | OGRERR_NOT_ENOUGH_DATA | 数据不完整,WKB 截断 |
| 2 | OGRERR_NOT_ENOUGH_MEMORY | 内存分配失败 |
| 3 | OGRERR_UNSUPPORTED_GEOMETRY_TYPE | 几何类型不被支持 |
| 4 | OGRERR_UNSUPPORTED_OPERATION | 驱动不支持该操作 |
| 5 | OGRERR_CORRUPT_DATA | 数据损坏 |
| 6 | OGRERR_FAILURE | 通用失败,兜底用 |
| 7 | OGRERR_UNSUPPORTED_SRS | 坐标系不被支持 |
| 8 | OGRERR_INVALID_HANDLE | 句柄无效 |
| 9 | OGRERR_NON_EXISTING_FEATURE | 目标要素不存在 |
把这张表记住,你在读驱动源码或者翻日志时就能立刻定位问题层级。比如返回 4,说明你压根不该对这个图层调用删除,去检查TestCapability(OLCRandomWrite);返回 8,说明图层或数据集对象已经失效,通常是DataSource被提前析构;返回 9,才是“要素不在”。区分清楚这几种,排查效率能差出好几倍。
顺带说一句,OGRERR_FAILURE是个语义极弱的兜底值,任何“我不知道该返回啥”的路径都会掉进去。所以我个人的习惯是:在自己的封装层里,绝不允许 6 这个值原样冒到业务逻辑层,一定要翻译成带上下文的具体错误。
2.3 为什么不能靠“先查再删”绕开这个问题
有人会说,那我先GetFeature(fid)判断存不存在,再决定要不要删,不就绕开歧义了吗。逻辑上没错,但这种写法有两个隐性成本。
第一是性能。多一次查询就多一次 IO,在批量删除几千上万条的场景下,这个开销是实打实的两倍。数据库型驱动内部本来就要执行DELETE,影响行数是免费拿到的信息,没必要再单独跑一条SELECT。
第二是竞态。先查后删之间有个时间窗口,在多进程或并发连接的环境里,这个窗口足以让判断失效:你查到存在,删的时候已经被别人删了,于是又回到“删了但没有完全删”的尴尬。而“删除并检查影响行数”是原子的,一步到位。
所以 RFC 55 的做法是正路:让操作本身携带语义,而不是让调用方在外面套一层判断。这也解释了为什么它只规定返回值、不规定实现方式——数据库型驱动靠影响行数,文件型驱动靠索引边界检查,各自用最自然的方式拿到答案就行。
3. RFC 55 立下的契约:两个接口的状态机
3.1 DeleteFeature() 的四种合法结局
按 RFC 55 的约定,DeleteFeature(fid)的返回值收敛为四类,每一类都对应一个明确的现实:
OGRERR_NONE:要素存在且被成功删除。这是唯一可以认为“删除动作生效”的返回值。OGRERR_NON_EXISTING_FEATURE:图层可写,但指定 FID 的要素不存在。注意这不是错误,是事实陈述。OGRERR_UNSUPPORTED_OPERATION:图层根本不具备删除能力,比如只读打开的图层,或者驱动本身不支持随机写。OGRERR_FAILURE:其他失败,比如事务冲突、存储介质异常、锁等待超时。
这个分类的关键在于第二项和第三项的区分。前者说“我想删,但这个要素不在”,后者说“我压根不会删”。写容错逻辑时,这两者的处理方式截然不同:前者通常可以直接忽略(幂等语义下,删一个不存在的东西等于已经达成目标),后者必须告警,因为你的代码路径选错了。
我在自己的封装里会给这两种情况打不同的日志级别:OGRERR_NON_EXISTING_FEATURE走 debug 或干脆不记,OGRERR_UNSUPPORTED_OPERATION走 error 并中断批次。这个区分看着琐碎,但它把“数据符合预期”和“代码有 bug”两类信号彻底分开了。
3.2 SetFeature() 的状态机:FID 为空、存在、不存在
SetFeature()的语义比删除复杂一档,因为它的输入是一个完整要素对象,而 FID 有三种可能的状态,每种状态导向不同分支。
FID 为空(OGRNullFID,值为 -1):这时候要素是“未定位”的,语义上等价于新增。多数驱动会把它转成CreateFeature(),成功后回写一个新的 FID 到要素对象上。但这不是所有驱动都保证的行为,有的驱动会直接返回OGRERR_UNSUPPORTED_OPERATION或者OGRERR_FAILURE。想做纯追加,老老实实调CreateFeature()更稳。
FID 存在且要素在图层里找得到:走更新路径,返回OGRERR_NONE。这是SetFeature()最正统的用法,也是“读出来、改属性、写回去”这套流程的核心。
FID 非空但要素不存在:这就是 RFC 55 要解决的关键分支。约定是——如果驱动同时具备创建能力,某些实现会选择插入一条新要素(FID 由驱动重新分配);如果驱动只支持更新、不支持创建,则必须返回OGRERR_NON_EXISTING_FEATURE。
第三种情况里藏着一个大坑:“顺手插入”会让调用方手里的 FID 失效。你传进去的是 1001,插入后驱动分配了 5003,如果代码没读回GetFID(),后续所有基于 1001 的操作全部错位。这个坑在数据库型驱动上尤其容易踩,因为它的自增主键策略和你的预期经常不一致。
提示:如果你的业务语义是“存在就更新,不存在就新增”,别指望
SetFeature()帮你兜住。要么显式判断后分派到CreateFeature(),要么用较新版本提供的UpsertFeature(),语义清楚得多。
3.3 为什么必须把 CreateFeature 和 SetFeature 的边界划清楚
这两个接口看着像“新增”和“更新”的简单分工,实际使用中却经常被混为一谈。根源在于很多驱动对SetFeature()的容错太宽松,导致开发者养成了“反正它能处理”的错觉。
把边界划清楚的理由有三条。第一是 FID 的所有权。CreateFeature()明确表示“新要素的 FID 由驱动分配”,SetFeature()明确表示“FID 由调用方指定”。混用会让 FID 归属变得模糊,后面做增量同步时说不清哪条是谁写的。
第二是失败语义。CreateFeature()失败通常是约束冲突、字段类型不匹配、必填字段缺失这类结构性问题;SetFeature()失败则更多是目标缺失、并发覆盖。同样是OGRERR_FAILURE,排查方向完全不同,混用会让日志失去指向性。
第三是性能。纯追加场景下,CreateFeature()可以批量攒批、可以关掉索引维护、可以用事务包裹,比逐条SetFeature()快一个数量级。反过来,纯更新场景用SetFeature()也比走一遍“先删后插”高效得多。
我见过的最典型反面案例,是把整个图层的重建写成“先全部 DeleteFeature,再全部 SetFeature”。这个写法在数据量上千之后就明显卡顿,而且中间状态一旦断电就是半截数据。
4. 驱动实现层:能力标志怎么决定了行为
4.1 TestCapability 与写入能力的对应关系
在调用写接口之前,先问一句“这个图层能不能写”,比事后解析错误码高效得多。OGR 提供了TestCapability()来做这件事,和本文相关的标志主要有三个。
OLCRandomWrite表示图层支持按任意 FID 随机写入,这是DeleteFeature()能工作的前提。没有这个能力,删除调用必然返回OGRERR_UNSUPPORTED_OPERATION。OLCSequentialWrite表示支持顺序追加新要素,这是CreateFeature()的前提。这两个标志互相独立,有的驱动只有前者(纯更新),有的只有后者(只写不删不改),有的两者都有。
还有一个容易被忽略的点:能力标志是图层级的,不是数据集级。同一个数据源里,主表可能可写,关联的注记表可能只读。我遇到过在循环里对多个图层统一执行删除、结果在第三个图层崩掉的情况,原因就是那个图层是视图,天然不可写。加上能力检查之后,这类问题就变成了可预期的跳过,而不是运行时异常。
if (!poLayer->TestCapability(OLCRandomWrite)) { // 这个图层不支持按 FID 写,直接跳过,别浪费一次调用 return; }这段检查的成本几乎为零,收益是把“运行时错误”提前到“流程分支”。凡是批量操作,我都会在批次开始前统一做一遍能力探测,把结果缓存起来,避免每条要素都问一次。
4.2 常见驱动的行为对照与自测脚本
不同驱动的具体表现,我不建议背表格——版本迭代快,背了也不准。更靠谱的做法是自己写个二十行的探针脚本,跑一遍就有结论。下面这段 Python 是我常用的模板,换成你手头的驱动名和数据源路径就能用。
from osgeo import ogr import os path = "/tmp/rfc55_probe.gpkg" if os.path.exists(path): os.remove(path) drv = ogr.GetDriverByName("GPKG") ds = drv.CreateDataSource(path) lyr = ds.CreateLayer("probe", geom_type=ogr.wkbPoint) lyr.CreateField(ogr.FieldDefn("name", ogr.OFTString)) def call(label, fn): try: print(label, "->", fn()) except Exception as e: print(label, "-> exception:", type(e).__name__, e) # 1) 删除一个不存在的 FID call("DeleteFeature(999)", lambda: lyr.DeleteFeature(999)) # 2) 正常写入一条,拿到真实 FID f = ogr.Feature(lyr.GetLayerDefn()) f.SetField("name", "a") lyr.CreateFeature(f) print("created fid:", f.GetFID()) # 3) SetFeature 一个不存在的 FID ghost = ogr.Feature(lyr.GetLayerDefn()) ghost.SetFID(999) ghost.SetField("name", "ghost") call("SetFeature(ghost)", lambda: lyr.SetFeature(ghost)) # 4) SetFeature 一个存在的 FID f.SetField("name", "b") call("SetFeature(exist)", lambda: lyr.SetFeature(f)) # 5) 收尾确认 lyr.ResetReading() print("final count:", lyr.GetFeatureCount()) ds = None这段脚本的输出信息量很大。第 1 步直接告诉你这个驱动对缺失 FID 的删除返回什么;第 3 步告诉你SetFeature()面对幽灵 FID 是报错还是顺手插入;第 5 步的计数则验证了插入是否真的发生。我拿它在几个驱动上跑过,结论是数据库型驱动普遍规规矩矩返回 9,文件型和文本型驱动的差异就比较大,有的返回 6,有的直接抛不支持。
注意脚本里给每个调用都套了try/except。这不是防御性编程洁癖,是因为 Python 绑定在不同版本对错误码的处理策略不一致——有的把非零码原样返回成整数,有的直接抛RuntimeError。两种都得兜住,脚本才能跑完整。
4.3 事务包裹时错误码的表现
把删除和更新放进事务里,错误码的语义会发生一点微妙变化,这点值得单独说。
开启事务之后,DeleteFeature()返回OGRERR_NON_EXISTING_FEATURE并不代表事务有问题,它只是陈述“这一条不在”。事务本身依然可以正常提交,前面成功删除的记录也不会回滚。换句话说,这个错误码是语句级的,不是事务级的。
这个区别在写批量逻辑时很关键。我见过这样的代码:循环删除,遇到任何非零返回就RollbackTransaction()。结果一条不存在的 FID 把整批几百条已经删掉的记录全撤销了,重跑一遍又是同样结局,陷入死循环。正确做法是把 9 当作可继续的信号,只有遇到OGRERR_FAILURE才考虑回滚。
poDS->StartTransaction(); for (const auto nFID : fids) { const OGRErr eErr = poLayer->DeleteFeature(nFID); if (eErr == OGRERR_NONE || eErr == OGRERR_NON_EXISTING_FEATURE) { continue; // 达成目标,继续 } // 真正的问题,记下来 oFailed.push_back({nFID, eErr}); } poDS->CommitTransaction();还有一种情况要留意:某些驱动在事务未提交时,对刚删除的要素再查一次可能仍然查得到(读到了旧快照),这时候SetFeature()会返回成功,但提交后数据可能不符合预期。这属于隔离级别的问题,不属于 RFC 55 的范畴,但两者经常一起出现,排查时容易混淆。
5. 代码落地:把错误码处理对
5.1 C++ 侧的分支写法与常见误判
C++ 里最常见的误判是把!= OGRERR_NONE一律当失败。这个写法在语义统一之前是合理的,在之后就成了噪音源。
const OGRErr eErr = poLayer->DeleteFeature(nFID); if (eErr == OGRERR_NONE) { ++nDeleted; } else if (eErr == OGRERR_NON_EXISTING_FEATURE) { ++nMissing; // 目标本就不在,属于正常业务分支 } else if (eErr == OGRERR_UNSUPPORTED_OPERATION) { // 能力判断失误,属于代码问题,要暴露出来 CPLError(CE_Failure, CPLE_AppDefined, "layer is not writable"); break; } else { // 真正的失败 CPLError(CE_Failure, CPLE_AppDefined, "delete failed: %d", eErr); break; }把nMissing单独计数,好处是对账时能一眼看出“这批任务里有多少条是空跑”。如果这个数字突然从个位数涨到几千,说明上游的 FID 映射出了偏差,比等到数据不一致再回头查要早得多。
另一个误判是拿OGRERR_FAILURE做精确比较。老代码里常写if (eErr == OGRERR_FAILURE),语义统一之后,缺失要素返回的是 9 而不是 6,这个判断会直接漏掉。如果那段代码的意图是“捕获所有异常情况”,应该改成if (eErr != OGRERR_NONE && eErr != OGRERR_NON_EXISTING_FEATURE)。
5.2 Python 绑定的返回值与异常差异
Python 这边的坑更隐蔽,因为不同版本、不同方法的处理策略不统一。有的方法把OGRErr映射成整数返回,0 表示成功;有的包装层会在非零时直接抛异常。同一个脚本换个 GDAL 版本,行为可能就变了。
稳妥的写法是两层都兜住:
def safe_delete(layer, fid): try: err = layer.DeleteFeature(fid) except RuntimeError as e: msg = str(e).lower() if "non existing" in msg or "not found" in msg: return "missing" raise if err == 0: return "deleted" if err == 9: # OGRERR_NON_EXISTING_FEATURE return "missing" if err == 4: # OGRERR_UNSUPPORTED_OPERATION return "unsupported" return "failed:%d" % err这里的 9 和 4 是硬编码的枚举值。看着不优雅,但在跨版本兼容上最可靠——Python 绑定里ogr.OGRErr的可用性和命名在不同版本间有过调整,直接比对整数反而稳定。如果确实想用常量,用getattr(ogr, "OGRERR_NON_EXISTING_FEATURE", 9)做一层兜底。
另外提醒一点:Python 里SetFeature()抛异常的情况比DeleteFeature()更常见,因为前者涉及字段类型转换,转换失败会先抛ValueError再谈错误码。所以处理SetFeature()时,except子句最好把ValueError也带上,否则一个字段类型不对就会把整个批次打断。
5.3 跨版本兼容的宏判断与降级策略
如果代码需要同时支持较老的 GDAL 版本,OGRERR_NON_EXISTING_FEATURE这个符号可能压根不存在,编译期就会报未定义。由于它是枚举常量而不是宏,#ifdef判断不了,只能用版本号宏。
#if GDAL_VERSION_NUM >= 1900 constexpr OGRErr kNonExisting = OGRERR_NON_EXISTING_FEATURE; #else constexpr OGRErr kNonExisting = static_cast<OGRErr>(9); #endif对于更老的版本,还有个降级策略:在调用DeleteFeature()之前先GetFeature(fid)判断一下存在性,不存在就跳过。这个写法有前面说的性能和竞态问题,但作为兼容分支是可以接受的,毕竟老版本上你本来也拿不到准确语义。
判断版本号的时候要注意,GDAL_VERSION_NUM是编译期宏,反映的是编译时头文件的版本,而运行时链接的库版本可能不同。如果你的项目是预编译分发,这两者不一致会导致很诡异的行为。稳妥做法是在初始化时用GDALVersionInfo("--version")打一条日志,出问题时能立刻确认环境。
5.4 高频写入场景的替代方案
当 FID 层面的逐条操作成为瓶颈,就该考虑绕开接口层,直接走 SQL。数据库型驱动普遍支持ExecuteSQL(),一条带条件的批量语句比几百次函数调用快得多。
-- 存在则更新,不存在则插入 INSERT INTO probe(fid, name) VALUES (?, ?) ON CONFLICT(fid) DO UPDATE SET name = excluded.name;-- 批量删除,缺失的 FID 不会报错,只会影响 0 行 DELETE FROM probe WHERE fid IN (1001, 1002, 1003);这两条语句天然具备“缺失即忽略”的语义,和 RFC 55 想要达成的效果一致,但省掉了逐条往返的开销。代价是失去了跨驱动的可移植性,SQL 方言绑死在某类存储上。我的取舍标准是:数据量在千条以下,用 OGR 接口图个省心;上万条并且目标存储固定,果断走 SQL。
还有一条容易忽略的优化:SetFeature()是整条替换语义,即使你只改了一个字段,驱动也可能把所有字段重写一遍。较新版本的UpdateFeature()支持只更新指定字段,在宽表场景下收益很明显。不过它的可用性依赖版本,用之前先探一下。
6. 排查实录:几个反复出现的情形
6.1 现象速查表
| 现象 | 最可能的返回值 | 处理方向 |
|---|---|---|
| 删除报错但要素确实存在 | OGRERR_UNSUPPORTED_OPERATION | 检查图层是否只读、是否开启写模式 |
| 删除返回成功但数据还在 | 老版本驱动返回 OGRERR_NONE | 升级版本,或改用影响行数判断 |
| 更新后 FID 全变了 | SetFeature 触发了插入分支 | 改用 CreateFeature 或 UpsertFeature |
| 批量删除中途整体回滚 | 把 9 当失败触发了 Rollback | 把缺失要素当正常分支 |
| Python 脚本换个环境就崩 | 绑定层返回策略差异 | 同时兜住返回码和异常 |
| 事务提交后数据不符合预期 | 读到旧快照导致误判存在 | 缩短事务,或提交后重新读取校验 |
这张表我贴在工位旁边很久了,覆盖了八成以上的现场问题。剩下两成通常是驱动特有的怪异行为,只能靠探针脚本加二分定位。
6.2 几条不那么显然的经验
第一条,别把OGRERR_NON_EXISTING_FEATURE当异常往上抛。它是业务信息,不是技术故障。我早期在一个服务里把它包成了自定义异常,结果监控面板上一片红,全是“要素不存在”的告警,把真正的故障淹没了。后来改成计数器指标,世界清净了。
第二条,FID 的语义要提前和上游对齐。很多跨系统同步的错位,根源不是接口用错,而是双方对 FID 的理解不一致:一边认为是稳定主键,一边认为是临时序号,重新导入一次就全变了。这种情况下你错误码处理得再精确也没用,因为传进来的 FID 本身就是错的。
第三条,写操作前先确认图层打开模式。ogr.Open(path, 0)是只读,ogr.Open(path, 1)才是可写。这个 0 和 1 的区别,我在赶工的时候至少忘过五次,每次的表现都是清一色的OGRERR_UNSUPPORTED_OPERATION,然后花十分钟翻驱动源码。
第四条,别在循环里反复调用GetLayerDefn()。它看着像个轻量访问器,实际在某些驱动上会触发元数据重载。我做过一次对比,同样的写入量,把GetLayerDefn()提到循环外面,耗时降了将近三成。这跟 RFC 55 没关系,但和SetFeature()的性能表现关系很大,顺手记在这里。
第五条,验收时看计数,不看返回值。批量操作结束后,GetFeatureCount()的实际变化量才是唯一可信的证据。返回值全绿但计数不对,说明中间有静默失败;返回值里有几个 9 但计数符合预期,那就是正常。
我在实际项目里最后形成的一套做法是:所有写操作统一走一层薄封装,返回一个三态结果(成功、目标缺失、失败),并且强制携带操作前后的计数快照。这套东西加起来不到两百行,但它把“要素不存在”这个原本散落在各个调用点的判断,收敛成了一个可以统一监控的指标。踩过几次坑之后我才明白,RFC 55 给的不只是一个错误码,它给的是一种把“缺失”从“失败”里分离出来的能力——用不用得上,取决于你有没有认真对待过那些被静默吞掉的写操作。