KMP WasmJS 避坑指南:SQLDelight 并发事务导致的“幽灵”数据丢失
1. 问题背景与现象 (The Problem)
在开发 Kotlin Multiplatform (KMP) 项目时,我们使用 SQLDelight 配合 WebWorkerDriver 在 WasmJS (Web) 端实现本地数据库。
为了加快 App 的初始化速度,我们通常会使用协程并发请求多个网络接口,并将其存入本地数据库:
1 | coroutineScope.launch { updateTips() } // 批量写入 Tips |
诡异的现象:
- 在 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 指令被交叉合并了:
- 协程A (Tips) 发送:
BEGIN TRANSACTION - 协程A 挂起,等待 Worker 响应。
- 协程B (Dishes) 抢占线程,发送:
BEGIN TRANSACTION👈 致命冲突! - 协程A 发送:
INSERT... - 协程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 | expect class DbTransactionLock() { |
第二步:在 Web 端实现真正的 Mutex
在 wasmJsMain (或 jsMain) 中:
1 | import kotlinx.coroutines.sync.Mutex |
第三步:在 Native 端实现空壳 (No-op)
在 androidMain, iosMain, jvmMain 中:
1 | actual class DbTransactionLock actual constructor() { |
第四步:在 DataSource 中应用
重构我们的数据库操作类,提取高阶函数,不仅解决了问题,还消除了满屏幕的 withContext 样板代码:
1 | class DishLocalDataSourceImpl( |
4. 💡 总结与黄金法则 (Golden Rules)
在开发 KMP Web 数据库时,牢记以下三条法则:
- 查询 (SELECT) 绝对自由:不要给查询加锁,让它们尽情并发。
- 单条操作 (INSERT/UPDATE 单行) 绝对安全:它们不会维持持续的状态,不用加锁。
- 只要写了
transaction {},在 Web 端就必须套上Mutex:这是为了防止事件循环 (Event Loop) 切片导致 SQL 指令在单线程 Worker 里打架。