KMP WasmJS 避坑指南:SQLDelight 并发事务导致的“幽灵”数据丢失

1. 问题背景与现象 (The Problem)

在开发 Kotlin Multiplatform (KMP) 项目时,我们使用 SQLDelight 配合 WebWorkerDriver 在 WasmJS (Web) 端实现本地数据库。

为了加快 App 的初始化速度,我们通常会使用协程并发请求多个网络接口,并将其存入本地数据库:

1
2
coroutineScope.launch { updateTips() }   // 批量写入 Tips
coroutineScope.launch { updateDishes() } // 批量写入 Dishes

诡异的现象:

  • Android / iOS / JVM 平台上,这段代码运行完美,数据瞬间写入,UI (通过 Flow 监听) 立刻刷新。
  • WasmJS (Web) 平台上,后台日志显示网络请求成功且执行了写入,但 UI 毫无反应(Flow 没有发射新数据)。更神奇的是,如果把代码改成顺序执行(写在同一个 launch 里),问题就消失了。

2. 深入排查:究竟发生了什么?(The Root Cause)

这个问题的根本原因,是 Kotlin 协程的并发机制JS 的单线程模型 以及 SQLite 的事务限制 三者发生的“连环车祸”。

💥 原因一:Web 端的单连接与 SQLite 事务限制

在 Native 平台(Android/iOS/JVM),底层 SQLite 驱动拥有连接池(Connection Pool),并且支持真正的多线程。协程 1 和 协程 2 会分配到不同的线程和连接,天然被操作系统隔离。

但在 Web (WasmJS) 端,由于浏览器环境的限制,SQLDelight 通过 WebWorkerDriver 与隐藏在 Web Worker 中的 sql.js 通信。此时只有一条单通道(Single Connection)
SQLite 规定:同一个连接,绝对不允许同时开启两个事务。 否则会报 cannot start a transaction within a transaction 错误。

💥 原因二:JS 的单线程与协程挂起 (Suspend & Yield)

因为 JS 是单线程的,两个并发的 launch 会在主线程上交替执行(挂起和恢复)。这导致它们向 Web Worker 发送的 SQL 指令被交叉合并了:

  1. 协程A (Tips) 发送:BEGIN TRANSACTION
  2. 协程A 挂起,等待 Worker 响应。
  3. 协程B (Dishes) 抢占线程,发送:BEGIN TRANSACTION 👈 致命冲突!
  4. 协程A 发送:INSERT...
  5. 协程B 发送:INSERT...

当 Web Worker 收到第二个 BEGIN 时,SQLite 直接报错。这导致事务在 Worker 内部崩溃或回滚,SQLDelight 用于通知 UI 的表失效信号(Invalidation Notification)被直接丢弃。UI 的 Flow 永远等不到更新通知。

(注:SQLDelight 早期使用 Java 的 ThreadLocal 来追踪事务状态,在单线程的 JS 环境下这会导致严重的全局状态污染。虽然官方通过 PR #5967 引入了基于 CoroutineContext 的追踪修复了 Kotlin 侧的代码,但依然无法违背底层 SQLite “单连接不可重叠事务” 的物理定律。)

3. 终极解决方案:平台感知的互斥锁 (Platform-Aware Mutex)

既然在 Web 端不能让事务并发,我们就必须在代码层加锁,强制事务排队执行。但如果直接加 Mutex,会拖慢原生平台(Android/iOS)的性能,因为它们原本是可以完美并发的。

最优雅的设计是使用 Kotlin 的 expect/actual 机制,实现“仅在 Web 端生效的锁”。

第一步:定义跨平台锁

commonMain 中:

1
2
3
expect class DbTransactionLock() {
suspend fun <T> withLock(block: suspend () -> T): T
}

第二步:在 Web 端实现真正的 Mutex

wasmJsMain (或 jsMain) 中:

1
2
3
4
5
6
7
8
9
10
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

actual class DbTransactionLock actual constructor() {
private val mutex = Mutex()
actual suspend fun <T> withLock(block: suspend () -> T): T {
// Web 端只有单连接,强制所有事务排队,防止指令交叉
return mutex.withLock { block() }
}
}

第三步:在 Native 端实现空壳 (No-op)

androidMain, iosMain, jvmMain 中:

1
2
3
4
5
6
actual class DbTransactionLock actual constructor() {
actual suspend fun <T> withLock(block: suspend () -> T): T {
// Native 端有连接池和多线程保护,直接放行,追求最高性能
return block()
}
}

第四步:在 DataSource 中应用

重构我们的数据库操作类,提取高阶函数,不仅解决了问题,还消除了满屏幕的 withContext 样板代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class DishLocalDataSourceImpl(
private val dishQueries: DishQueries,
private val defaultDispatcher: CoroutineDispatcher,
) : DishLocalDataSource {

private val transactionLock = DbTransactionLock()

// 辅助方法 1:普通写入(无需加锁)
private suspend inline fun <T> write(crossinline block: suspend () -> T): T =
withContext(defaultDispatcher) { block() }

// 辅助方法 2:批量事务写入(跨平台智能加锁)
private suspend inline fun <T> transactionWrite(crossinline block: suspend () -> T): T =
withContext(defaultDispatcher) {
transactionLock.withLock { block() }
}

// ❌ 读取操作:依然保持自由并发
override fun getAllDishes() = dishQueries.getAll().asFlow().mapToList(defaultDispatcher)

// ❌ 单条插入:不需要锁
override suspend fun upsertDish(dish: DishLocalEntity) = write {
dishQueries.upsertDish(dish)
}

// ✅ 批量事务插入:必须使用 transactionWrite 加锁!
override suspend fun upsertDishes(dishes: List<DishLocalEntity>) = transactionWrite {
dishQueries.transaction {
dishes.forEach { dishQueries.upsertDish(it) }
}
}
}

4. 💡 总结与黄金法则 (Golden Rules)

在开发 KMP Web 数据库时,牢记以下三条法则:

  1. 查询 (SELECT) 绝对自由:不要给查询加锁,让它们尽情并发。
  2. 单条操作 (INSERT/UPDATE 单行) 绝对安全:它们不会维持持续的状态,不用加锁。
  3. 只要写了 transaction {},在 Web 端就必须套上 Mutex:这是为了防止事件循环 (Event Loop) 切片导致 SQL 指令在单线程 Worker 里打架。