一致性怎么保证,取决于数据躺在内存还是磁盘上------两种存储,两套解法,别互相套。
矢量数据集要并发读写,几乎是绕不开的需求。但很多人踩的第一个坑,是以为"并发"只有一种解法:要么到处加锁,要么全都丢给数据库事务。等真正落地才发现,内存里的数据集和 SQLite 上的数据集,面对的是两类完全不同的矛盾。
我见过一个很典型的现场:一个地图编辑功能,主线程在渲染、用户在另一线程批量导入几百个要素。没加锁的时候,偶尔会渲染出"半截"要素------几何画了一半、属性还是空的;加了锁,导入一跑整个界面就卡住,因为渲染线程也在等同一把大锁。这个现象背后,其实就是"共享可变状态"的经典问题。而当你把同一份数据落到 SQLite,想用事务批量导入再回滚,又会发现:内存里的缓存根本不认事务的账,回滚了数据库、缓存却没退,下次查询又把这堆脏数据捞出来了。
内存是一块共享的可变状态,多线程同时改、同时查,稍微不留神就读到"改了一半"的数据;SQLite 是事务型的持久存储,你插入 100 条记录,在事务提交之前别人就不该看见,一旦回滚就得连内存缓存一起退回。两个场景都叫"矢量数据集",可解决一致性的思路完全不是一回事。
这篇文章把同一份矢量数据在两种存储形态下的并发处理放到一起讲:内存数据集靠索引加速 + 双锁护住临界区;SQLite 数据集靠双缓存 + 事务把"读"和"写"隔开。读完你该形成一个判断------别把内存的锁思路套到磁盘上,也别指望事务能救内存的竞态,一致性问题的解法由存储介质的特性决定。
一、内存矢量数据集:索引能加速,但会"骗人"
先说内存这一侧。在 GIS 和测绘软件里,矢量数据通常不会太大,但也不小------几千个点、几百个面,渲染和查询时还要频繁访问。很多人图省事,直接用 List 全量遍历,数据一多就卡。可一旦引入空间索引,又会遇到一个更隐蔽的坑:索引返回的并不一定是真正命中的数据。
一个内存矢量数据集,内存里存着很多个要素(Feature),每个要素有几何和属性。对外要支持几类查询:
- 按空间范围查:给定矩形 Bounds,返回与之相交的所有要素;
- 按 id 查:给一组 id,返回对应要素;
- 全量查:返回所有要素。
其中"按范围查"最常见,也是性能关键。朴素实现就是对所有要素逐个算几何相交:
kotlin
fun read(bounds: Bounds): List<Feature> =
features.values.filter { it.geometry.intersects(bounds) }
数据量大了,这种 O(n) 遍历就吃不消。假设有 1 万个面,每次查询都逐个算 intersects,光是几何相交测试本身就不便宜,几千次查询下来主线程直接掉帧。于是很自然会想到:加一个空间索引,把查询从 O(n) 降到 O(log n) 附近。
这里用四叉树(Quadtree)。原理是把空间递归切块,每个要素挂在能覆盖它的最小格子下。查询时先定位到与查询矩形相交的格子,把格子里的要素捞出来。这个阶段确实快,但它有个致命弱点:
索引比较的是"外包矩形(Envelope)"是否相交,不是几何本身是否相交。
换句话说,四叉树只认每个要素的外包矩形(最小外接矩形)。它帮你做的是"矩形对矩形"的粗筛,至于要素本体是不是真的和查询范围相交,索引并不知道,也不会去算。
举个具体的例子。有一个又细又高的长条要素,它的几何是一条贴近 x=12 的竖线,横坐标范围只在 12 附近,但纵坐标从 10 一直拉到 900。它的外包矩形就是 x∈10, 50、y∈10, 900。现在你查一个矩形 x∈60, 70、y∈60, 70。从外包矩形看,x 区间 10,50 和 60,70 明明不重叠------这个例子不巧选反了,我们换个更说明问题的:让长条要素的几何实际只在 x=12 附近,但外包矩形因为某些原因被撑到了 x∈10, 80。查询矩形 x∈60, 70、y∈60, 70 和它的外包矩形在 x、y 上都重叠,四叉树会把它当成候选捞出来;可要素真实的几何只在 x=12,根本碰不到查询矩形。索引只负责"用矩形粗筛",把候选集给你,精确的相交判断必须你自己再做一遍:
kotlin
// 注意:索引返回的要素不一定真正与查询范围相交,还需要额外判断
val candidates = index.query(bounds)
val result = candidates.filter { it.geometry.intersects(bounds) }
很多人栽在这里,就是因为只做了粗筛没做精判,结果多查出来一堆"看着相交其实不相交"的数据。索引是加速器,不是正确性的保证。这个"粗筛 + 精判"的两段式,是后面并发设计里要反复用到的前提------因为索引和数据是两份结构。
这里还有一个容易忽略的工程权衡:四叉树适合点、小面这种外包矩形很"紧"的要素;如果要素本身就是又细又长的线或面,外包矩形撑得很大,粗筛的命中率就会下降,精判阶段反而要过滤掉大量候选,索引的收益被抵消。所以索引选型本身也是一门学问------移动端常用四叉树做轻量实现,数据量大或对查询质量要求高时,R-tree、Geohash、S2 这类更现代的结构更合适。但无论哪种,原理都一致:索引只给候选集,精确命中得自己再算一次。
举个量化的例子,感受一下索引带来的差距:假设有 1 万个面,朴素遍历每次查询都要算 1 万次 intersects。即便单次 intersects 平均只要 1 微秒,一次查询也是 10 毫秒;如果主线程每秒要响应几十次查询,累积下来足以让界面明显掉帧。而上了四叉树后,与查询矩形相关的格子通常只挂几十到几百个要素,候选集骤降,精判阶段的成本直接下降一两个数量级。索引省下的不是"一次查询"的时间,而是"查询次数 × 要素数"里那个最致命的乘法因子。
二、内存数据集的并发:两份结构,两把锁
光有索引还不够,还有一个并发问题。数据集合和空间索引是两份不同的结构,写入时两边都要更新,读取时两边都要查。如果只用一把大锁,增删和查询会互相阻塞;如果完全不上锁,又会在"改数据没来得及改索引"的窗口读到脏数据。
为什么是"两份结构"?因为 features 这个 map 存的是要素本体,而 index(四叉树)存的是"要素外包矩形 → 要素"的索引。它们必须保持同步:插一个要素,既要写 features 也要插索引;删一个要素,两边都要移除。一旦某一刻只有一边更新了,另一边就是旧的,查询就会出错。
这个实现的做法是两把锁分离:
queryLocker管要素数据(featuresmap)的读写;indexLocker管空间索引(四叉树)的读写。
kotlin
class MemVectorDataset {
private val features = LinkedHashMap<Long, Feature>()
private val index = Quadtree()
private val queryLocker = Any()
private val indexLocker = Any()
}
写入一个要素时,数据放进 features,同时把要素的外包矩形插入索引;删除时则两边同步移除。查询时,先拿 indexLocker 从索引取候选,再拿 queryLocker 精确过滤。
这里有一个很关键的设计细节:取锁的顺序 。写路径是先拿 queryLocker(写数据)再拿 indexLocker(更新索引);读路径是先拿 indexLocker(取候选)再拿 queryLocker(精过滤)。虽然两把锁偶发会导致"索引已更新、数据还没更新"的窗口,但因为查询的两步会分别拿锁,最终结果仍然一致,不会读到"半更新"状态------你最多在极短窗口内漏掉一个刚插入、索引已建、数据还没写入的要素,但绝不会读到一个属性残缺的"半截"要素。
这里还有个隐藏的并发细节值得提:读、写两条路径的取锁顺序是交叉的(写先 queryLocker 后 indexLocker,读先 indexLocker 后 queryLocker),理论上存在锁顺序反转、可能死锁的隐患。实际工程里通常靠"缩短持锁时间、绝不在持锁期间做耗时操作"来规避------取锁、读写、放锁都极快,死锁窗口几乎不可能被两个线程同时踩中。如果追求彻底无死锁,也可以让读写都按同一固定顺序取锁,代价是临界区更大、并发度略降。这又是一个典型的"绝对安全 vs 并发度"权衡,没有标准答案,看你的性能预算。
如果只用一把大锁会怎样?所有读写都排队,导入 1 万个要素时渲染线程完全被饿死,界面卡死。如果完全不上锁?那"改了数据没改索引"或"改了索引没改数据"的窗口里,查询可能返回残缺结果,或者返回已被删除却还在索引里的要素。两把锁把临界区拆细,写数据的时候不挡读索引,读索引的时候不挡写数据,并发度明显提升,又守住了正确性底线。
再配一个"惰性 bounds"。数据集还维护了一个总范围 bounds,但它不是每次增删都立刻重算,而是打一个脏标记 isBoundsDirty,等真正有人要 bounds 时才全量扫描重算,重算后清掉标记:
kotlin
fun bounds(): Bounds {
if (isBoundsDirty) {
synchronized(queryLocker) {
features.values.forEach { b.expand(it.geometry.envelope) }
}
isBoundsDirty = false
}
return cachedBounds
}
这里为什么不能每次增删都重算?因为重算总范围要遍历所有要素、取每个要素的外包矩形做合并,本身就是 O(n) 的开销。如果数据频繁增删,每次都重算,成本会非常可观。而 bounds 这个总范围,大多数时候并没人真正去读------只有做"全图缩放""判断数据是否在某视图内"时才用到。所以用一个脏标记把它推迟到"真正有人要"的时候再算,把成本摊到真正需要的时刻,这就是典型的惰性计算(lazy computation)。
这样一来,"频繁增删"和"偶尔取范围"两种场景都拿到了不错的性能。到这里,内存侧的整套思路就清晰了:共享可变状态 → 用锁划定临界区 → 用索引把 O(n) 压到 O(log n),再用双锁把读写路径分开,最后用惰性计算摊薄重算成本。
三、SQLite 数据集:一个 Map 撑不住事务的可见性
看完内存这一侧,再看磁盘这一侧,你会发现矛盾的性质完全变了。假设有一个基于 SQLite 的矢量数据集,内部维护一份内存缓存,避免每次遍历都查库:
kotlin
class SqliteDataset {
private val items = LinkedHashMap<Long, Record>() // 已提交的缓存
private val db: SQLiteDatabase
}
为什么要在 SQLite 之上再维护一份内存缓存?因为 SQLite 在移动端每次 query 都有磁盘 IO、游标遍历、行到对象的反序列化开销。如果渲染或导出要频繁遍历全部要素,每次都查库会让主线程很吃力。所以常见做法是:首次加载时把数据全读进 items,之后遍历都走内存。
但正是这份内存缓存,把事务的麻烦引了进来。现在你要在一个事务里插入 100 条记录。如果直接把每条插进 items 和数据库,那么在事务结束前,这 100 条就已经被内存缓存"看到"了。可事务还没提交,一旦回滚,内存里的改动就再也洗不掉了------内存状态和数据库状态出现了不一致。
反过来,如果只在事务提交时才写入缓存,那事务进行中,其它并发读者就看不到这 100 条了,也不完全对(某些场景确实希望事务内不可见,但另一些场景又需要"我自己能看到我还没提交的东西")。
问题的本质是:内存缓存的"可见性"和数据库事务的"原子性"需要协调。 最朴素的想法是只有一个 items map,写操作先写数据库、再写 map。这个方案在非事务下没问题,但在事务下会踩坑:
- 回滚灾难 :事务中改了
items,事务回滚时数据库回去了,但items里改过的数据回不去,成了脏数据。 - 部分可见 :事务还没提交,其它读者从
items里已经能读到"半成品",违反了事务的隔离性。
也有人想:干脆事务期间不用内存缓存,只写库,提交后重新全量加载。这在数据量小时可行,但数据集一大(几万条),每次事务结束都全量 reload,就是性能灾难------一次导入触发一次全表扫描加反序列化,几百毫秒甚至上秒的卡顿,用户根本受不了。
所以需要一种机制,让内存缓存能区分"已提交"和"事务中暂存"。注意,这个需求和内存那一侧的"双锁"完全不是一回事:那边是为了在共享状态上划分临界区防止竞态,这边是为了在持久存储的事务边界上划分"可见 / 不可见"。存储介质不同,矛盾不同,解法自然不同。
这也解释了为什么很多人会误把内存经验直接搬过来用。如果你在 SQLite 数据集上照搬内存那套双锁,会发现根本不对路------SQLite 自身有事务机制,你加锁只是挡住了应用层的并发,却挡不住"事务回滚 vs 内存缓存"这条线;事务回滚时数据库把 100 条插入丢掉了,可你的 items 里还留着它们,锁管不了这件事。反过来,在纯内存数据集上硬搞双缓存 + 事务提交边界,也是多此一举,因为内存里没有"原子提交"的概念,锁才是它真正的隔离原语。两种存储的"正确解法"长在不同的维度上,硬套必然顾此失彼。
四、SQLite 数据集:双缓存 + 空事务把读写隔开
双缓存方案:在数据集里放两个 map------
kotlin
class SqliteDataset {
private val items = LinkedHashMap<Long, Record>() // 已提交
private val itemsTemp = LinkedHashMap<Long, Record>() // 事务中暂存
private val db: SQLiteDatabase
}
items:已提交的正式数据;itemsTemp:当前事务中暂存的新增/修改。
写操作时,先写数据库,再根据"当前是否在事务中"决定写进哪个 map:
kotlin
fun append(record: Record): Boolean {
if (!db.insert(table, null, record.toContentValues())) return false
if (db.inTransaction()) itemsTemp[record.id] = record
else items[record.id] = record
return true
}
关键就在 db.inTransaction() 这个判断:如果当前正处于一个数据库事务里,写入就落到 itemsTemp;否则直接落到 items。这样事务外的普通写入和事务内的暂存被自然分流。
提交 :把事务标为成功、结束事务,然后把 itemsTemp 合并进 items,清空暂存区:
kotlin
fun commit() {
db.setTransactionSuccessful()
db.endTransaction()
items.putAll(itemsTemp)
itemsTemp.clear()
}
注意顺序------先 setTransactionSuccessful() 再 endTransaction(),数据库才会真正提交;数据库提交成功之后,才把 itemsTemp 的内容并入 items。此刻所有读者都能立刻看到这 100 条,因为它们已经进了正式的 items。
回滚 :结束事务、不标成功,然后只清空 itemsTemp,items 保持原样:
kotlin
fun rollback() {
db.endTransaction() // 未 setTransactionSuccessful,改动自动丢弃
itemsTemp.clear()
}
因为没调用 setTransactionSuccessful(),数据库那 100 条插入会随事务结束自动丢弃;内存侧只清掉 itemsTemp,items 纹丝不动。两边状态重新一致,没有脏数据残留。
把这三段串起来看:事务进行中的改动只活在 itemsTemp,不会被普通读者(读 items)看到;一旦提交才并入 items,立刻对所有读者可见;一旦回滚,itemsTemp 一清,数据库也随事务丢弃,两边完全一致。 这种"读 / 写隔离"靠的不是锁,而是事务的提交边界------这正是磁盘侧和内存侧最根本的区别。
读取侧:普通遍历读 items 即可;若确实需要"提交 + 暂存"的完整视图,可以合并:
kotlin
fun allItems(): Map<Long, Record> = HashMap<Long, Record>().apply {
putAll(items); putAll(itemsTemp)
}
这个 allItems() 在什么时候用?比如事务内想预览"到目前为止我加了哪些、加上已经提交的一共多少",或者导出时希望连未提交的内容一起输出。它不破坏隔离性,因为 itemsTemp 本来就是"当前事务自己"的暂存,只是把两个视图拼起来看。
Null Object 空事务 :很多数据集其实不需要事务,但调用方写的代码又希望统一调用 commit() / rollback()。与其让每个实现都写一堆空方法,不如在接口里放一个"空事务"单例,什么都不做:
kotlin
interface Transaction {
fun commit() {}
fun rollback() {}
companion object {
val NULL = object : Transaction {} // 空实现,无副作用
}
}
这样,非事务型数据集可以直接返回 Transaction.NULL,调用方无需感知差别,代码统一、无空指针、也无性能损耗。这是一个很小的模式,但能省掉大量 if (tx != null) 的样板代码,也让上层业务不必关心"当下到底是不是在事务里"。
在更大的系统里,这种统一还有连锁好处:单元测试时可以注入 Transaction.NULL,无需真的开启一个 SQLite 事务就能验证上层逻辑;上层业务代码也不再写一堆判空分支,调用 commit() / rollback() 就像调普通方法一样自然。它把一个"有些数据集有事务、有些没有"的不一致,压平成了一套统一接口------这往往比省几行代码更有价值,因为接口一致意味着上层可以无差别地复用同一套导入、导出、编辑流程。
双缓存直观、好懂,但它有一个内在风险:items 与 itemsTemp 是两个状态源,一旦漏掉某条写路径,就会悄悄不一致。 比如某个更新接口只改了 items 没同步 itemsTemp,或者只在 itemsTemp 写、提交时忘记 putAll,都会导致状态分裂。所以双缓存方案对"所有写路径都必须遵守同一套分流约定"要求很高。现代数据库/ORM 通常提供更结构化的替代:真正的内存事务层(copy-on-write 或持久化数据结构)、统一走 SQLite 事务 + 提交后失效缓存、两级缓存 + 可见性回调。选型核心看两点:事务频率(高频事务用内存暂存更顺滑)与一致性强弱(数据出错代价高,宁可多一层保险)。双缓存适合"低频事务 + 需要快速遍历内存"的中间地带。
五、统一判断:两种存储,两套解法,别互相套
把前面两半拼起来看,同一份矢量数据,在内存和磁盘上解决一致性问题的路径完全不同。下面的对照表把差异摆清楚:
| 维度 | 内存矢量数据集 | SQLite 矢量数据集 |
|---|---|---|
| 存储介质性质 | 共享可变状态 | 事务型持久存储 |
| 核心矛盾 | 多线程竞态、读写互相阻塞 | 缓存可见性 vs 事务原子性 |
| 主要手段 | 空间索引 + 双锁(queryLocker / indexLocker) | 双缓存(items / itemsTemp)+ 事务 |
| 隔离粒度 | 临界区(锁) | 提交边界(事务) |
| 脏数据来源 | 改数据没改索引的窗口 | 事务回滚但缓存没退 |
| 惰性优化 | 脏标记延迟重算 bounds | 提交后才把暂存并入正式 |
| 代价 | 锁竞争、实现稍复杂 | 双状态源,写路径须严格分流 |
统一根因一句话:一致性怎么保证,由存储介质的特性决定。 内存是共享可变状态,所以要用锁划定临界区、用索引加速查找;磁盘是事务型的,所以要用双缓存 + 事务把"读"与"写"隔开,避免读到半成品。
常见的几个坑,对照着记:
| 症状 | 根因 | 解法 |
|---|---|---|
| 内存里加锁后查询被增删卡死 | 只用了单一大锁,读写路径没分开 | 数据、索引各上一把锁,缩短临界区 |
| 事务回滚后缓存还残留改动 | 一个 Map 同时装已提交和暂存,回滚退不干净 | 双缓存分离,回滚只清 itemsTemp |
| 索引查出来一堆不相交要素 | 把粗筛当精判,没做二次几何判断 | 索引取候选后再算一次 intersects |
| 事务进行中别人读到半成品 | 改动直接进正式缓存 | 暂存到 itemsTemp,提交才并入 |
| 双缓存两处状态对不上 | 某条写路径没遵守分流约定 | 所有写路径统一经 append 一类入口 |
还有一点值得单独拎出来:不要试图用内存那套"双锁"去解决 SQLite 的可见性问题,也不要指望 SQLite 的事务能替你管好内存里的竞态。前者该靠事务的提交边界,后者该靠锁的临界区,方向反了两边都错。理解这一点,比记住任何一段代码都重要------因为它决定了你在下一个项目里,看到"并发 + 存储"组合时,第一时间该往哪个方向想。
小结
- 空间索引只做"外包矩形粗筛",返回候选集,精确相交必须自己再算一次
intersects。 - 内存数据集用
queryLocker和indexLocker两把锁分离数据/索引的读写,缩短临界区、减少互相阻塞。 - 总范围 bounds 用脏标记 + 惰性重算,把"频繁增删"和"偶尔取范围"两种场景都照顾到。
- SQLite 数据集用
items(已提交)+itemsTemp(暂存)双缓存,事务中写暂存、提交才并入,回滚只清暂存。 - 内存靠锁、磁盘靠事务,两套解法由存储介质特性决定,不能互相套用;双缓存须保证所有写路径严格分流。
系列导航:GIS 系列第 5 篇(共 10 篇)。上一篇《几何层的三个关键算法》,下一篇《异步 IO 流与数据解析管道》。
你手上的矢量数据集,目前是只在内存里跑、还是落了 SQLite?如果两边都要,你是怎么协调那份"已提交 / 暂存"状态的?