如何在 DataStore 中实现原子性更新操作?

解读

国内面试场景下,这道题通常出现在“本地持久化”专题,面试官想确认两点:

  1. 你是否真的用过 Jetpack DataStore,而不是只会背“替代 SharedPreferences”的口号;
  2. 面对并发读写,你能否保证“读-改-写”整包逻辑要么全部成功、要么全部失败,且不会把半写数据暴露给其他协程/线程。
    回答时切忌只说“用 edit{} 就行”,必须点出“事务隔离、异常回滚、重试策略”三板斧,才能体现资深水平。

知识点

  1. DataStore 事务 API
    • PreferencesDataStore:edit{ } 块内部是 Mutex 锁 + 版本号校验,天然原子。
    • ProtoDataStore:updateData{ } 块内部同样是 Mutex 锁,但要求你返回新对象,框架用 CAS 比较并替换。
  2. 原子性保障机制
    • 内存级:kotlinx.coroutines.sync.Mutex 保证同一进程内只有一个协程进入事务块。
    • 磁盘级:先写临时文件,fsync 成功后原子 rename,即使断电也不会出现半写。
  3. 异常与重试
    • 抛出 IOException 时,DataStore 自动重试一次;仍失败则抛给调用方,不会残留脏数据。
    • 业务层异常(如 JSON 解析失败)会导致事务回滚,旧数据保持不动。
  4. 并发策略
    • 单进程:Mutex 已够用;
    • 多进程:DataStore 本身不支持,需要额外用文件锁或 ContentProvider 代理,面试提到即可。
  5. 性能注意
    • 事务块里只做纯内存计算,杜绝 I/O、网络、大循环;
    • 高频计数器场景用“内存缓存 + 周期性批量提交”模式,避免每次 +1 都走事务。

答案

在 PreferencesDataStore 中,用 edit() 函数把整个“读-改-写”包进协程块即可实现原子性:

dataStore.edit { prefs ->
    val old = prefs[KEY_COUNTER] ?: 0
    prefs[KEY_COUNTER] = old + 1        // 即使此时其他协程也在读,它们看到的仍是旧快照
}

edit{} 内部流程:

  1. 获取 Mutex 锁 → 2. 拿到 Immutable 快照 → 3. 执行你的转换逻辑 → 4. 写回内存缓存 → 5. 异步落盘(临时文件 + fsync + rename)。
    任何一步抛异常,内存缓存不会应用新值,磁盘也不会出现半写文件,从而保证“全有或全无”。

若是 ProtoDataStore,则使用 updateData():

val newCounter = dataStore.updateData { prefs ->
    prefs.toBuilder().setCounter(prefs.counter + 1).build()
}

框架通过 CAS 比较版本号,若发现并发修改会自旋重试,同样满足原子性。

拓展思考

  1. 高频计数器优化:
    直播 App 的“点赞”每秒上千次,直接走 DataStore 事务会拖慢主线程。国内主流方案是“内存 AtomicLong + 500 ms 防抖批量提交”,仅把最终值写 DataStore,即保证落地又不掉帧。
  2. 多进程共享配置:
    车载场景里 HUD 与车机主进程需共享夜间模式开关,DataStore 原生不支持多进程。可在 AIDL 接口里暴露一个 IContentProvider,内部仍用 DataStore 做单进程存储,其他进程通过 ContentProvider 触发事务,既利用原子性又规避文件锁。
  3. 灾难恢复:
    断电时临时文件可能残留,虽然 DataStore 启动时会自动清理,但国内 ROM 定制较多,建议首次初始化时主动调用 context.dataStoreFile.delete() 作为兜底,防止某些魔改系统 rename 语义不一致导致一直读旧文件。