第二期 · 存储引擎与数据访问层:300 行代码撑起「本地优先」

0 阅读12分钟

关键词:分域存储 · 写锁串行 · 防抖持久化 · 版本迁移 · 适配器门面 · 损坏自愈 目标读者:前端 / 架构


一、业务目标

一期证明了「愿意记」,二期要证明「记得住、丢不了、还能长」。

一期的存储是散落的 wx.setStorageSync 调用,随着三期登录、四期同步的到来,会遇到四个致命问题:

  1. key 混乱:会话、设置、记录、同步状态混在一起,改一处要 grep 全工程;
  2. 并发覆盖:打卡 + 同步同时写 em_mood,后写的覆盖先写的;
  3. 无法演进:数据结构一改,老用户直接崩;
  4. 无法扩展:页面直接依赖存储实现,加云同步要改所有页面。

二期的目标就是:在不引入任何框架的前提下,把这四件事一次性解决,并把「换数据源」的能力做成一等公民。


二、核心功能范围

能力说明
分域存储7 个域:meta mood period settings session sync guest
写锁串行Promise 队列串行化所有写,杜绝并发覆盖
防抖持久化500ms 防抖批量落盘;session sync 例外,同步直写
校验与迁移结构校验 + schemaVersion 迁移 + 高版本库只读保护
事件广播极简发布订阅:settings:changed session:changed sync:changed mood:changed period:changed
损坏自愈校验失败 → dump 到 em_corrupted_backup → 空库启动,不白屏
适配器门面Api.request(module, action, payload) 屏蔽 Local/Remote 差异
deviceId 兜底缺失即生成并强制回写,否则 (userId, deviceId) 唯一约束失效(四期同步命脉)

三、涉及的技术模块

utils/
├── storage.js        ★ L4 本地存储引擎(约 300 行)
├── data-adapter.js   ★ Api 门面(单例)
├── adapters/
│   ├── data-adapter.js   契约:Result + DataAdapter 抽象类
│   ├── local-adapter.js  ★ 本地实现(含校验、派生重算、导入导出)
│   └── remote-adapter.js (四期实现,本期预留)
└── constants.js      SCHEMA_VERSION、KEY 前缀等

四、关键技术选型与实现要点

4.1 分域 key 设计

// utils/storage.js:7-17
const KEYS = {
  meta:     'em_meta',          // { schemaVersion, deviceId, ... }
  mood:     'em_mood',          // { records, streak, lastCheckInDate, totalRecords }
  period:   'em_period',        // { records, cache }
  settings: 'em_settings',      // { storageMode, theme, reminder*, customTags, ... }
  session:  'em_session',       // { userId, accessToken, refreshToken, expiresAt, refreshExpiresAt }
  sync:     'em_sync',          // { pullCursor, lastSyncAt, status, pendingOps }
  guest:    'em_guest_backup'   // 游客快照(合并前备份)
};

为什么按「域」而不是按「功能」分?

  • 一个域 = 一次 setStorageSync = 一次序列化。域太大(如把 settings 塞进 meta)会导致每次改主题都要序列化全部记录;
  • 域太小(如每条记录一个 key)会导致遍历成本高、无事务性;
  • 7 个域对应 7 种生命周期mood/period 防抖写、session/sync 立即写、settings 中等频率、meta 几乎不变、guest 一次性。

4.2 写锁 + 防抖:两条正交的机制

// utils/storage.js:90-126
let writeQueue = Promise.resolve();   // 串行队列
let flushTimer = null;
const dirty = { meta: false, mood: false, period: false, settings: false };

function enqueueWrite(domain) {        // ① 防抖:500ms 内的多次写合并成一次
  dirty[domain] = true;
  if (flushTimer) clearTimeout(flushTimer);
  flushTimer = setTimeout(flush, 500);
}

function lockWrite(task) {             // ② 串行:Promise 链把并发写排队
  const run = writeQueue.then(task, task);   // then(task, task):前一个失败不阻塞后续
  writeQueue = run.catch(() => {});
  return run;
}

function flush() {                     // 一次 flush 落盘所有脏域
  flushTimer = null;
  if (!dirty.meta && !dirty.mood && !dirty.period && !dirty.settings) return;
  lockWrite(() => {
    const jobs = [];
    if (dirty.meta)     jobs.push(() => wx.setStorageSync(KEYS.meta, store.meta));
    if (dirty.mood)     jobs.push(() => wx.setStorageSync(KEYS.mood, store.mood));
    if (dirty.period)   jobs.push(() => wx.setStorageSync(KEYS.period, store.period));
    if (dirty.settings) jobs.push(() => wx.setStorageSync(KEYS.settings, store.settings));
    jobs.forEach(j => { try { j(); } catch (e) { console.error('persist error', e); } });
    dirty.meta = dirty.mood = dirty.period = dirty.settings = false;
  });
}

function flushNow() {                  // 关键写(打卡、登录)立即落盘
  if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
  flush();
}

设计取舍:

决策理由
防抖 500ms连续打卡、连点标签时避免每次都序列化全量记录
session sync 不防抖登录态与离线队列丢不得,必须同步直写
then(task, task)单个域落盘失败(如超限)不应阻塞其他域
提供 flushNow()打卡后立刻落盘,防止用户秒退小程序导致丢失

⚠️ 已知缺陷flushNow() 只是「取消防抖立即执行 flush()」,而 flush 内部仍走 lockWrite 异步队列。因此 flushNow() 返回后,数据不保证已落盘。严格场景(如 logout 后立即 wx.reLaunch)应增加 await 版本。

4.3 内存镜像 + 引用语义

// utils/storage.js:216-217
function getDomain(name)  { return store[name]; }
function setDomain(name, value, immediate) {
  store[name] = value;
  enqueueWrite(name);
  if (immediate) flushNow();
}

getDomain() 返回的是内存镜像引用,不是拷贝。两个 Adapter 都依赖「getDomain → 原地 mutate → setDomain 回写」的模式。

优点:零拷贝,大数据量下性能好。 风险:调用方绕过 setDomain 直接 mutate 引用,会导致内存与磁盘不一致(改动在内存里生效,但没标脏、没落盘,重启即丢)。

落地约束(写进团队规范)

// ✅ 正确
const mood = storage.getDomain('mood');
mood.records.push(newRecord);
storage.setDomain('mood', mood, true);

// ❌ 错误:改动不会持久化
const mood = storage.getDomain('mood');
mood.records.push(newRecord);   // 就此结束,没有 setDomain

4.4 初始化与损坏自愈

// utils/storage.js:147-213  initStore()
// ① session / sync 独立加载,不参与结构校验
//    refreshExpiresAt 已过期 → clearSession() 回游客态
// ② meta/mood/period/settings 任一缺失 → 建默认结构 + flushNow()
// ③ validateStructure 不过 → throw STRUCTURE_INVALID
//    schemaVersion 不同 → migrate()
// ④ deviceId 缺失 → 生成并强制回写 meta(否则四期 (userId,deviceId) 唯一约束失效)
// ⑤ catch:dump 到 em_corrupted_backup,空库启动,返回 { ok:false, error }
function validateStructure(db) {                  // L129
  return db.meta && db.mood && db.period && db.settings
    && Array.isArray(db.mood.records)
    && Array.isArray(db.period.records);
}

function migrate(db, from) {                      // L135
  if (from === SCHEMA_VERSION) return db;
  if (from > SCHEMA_VERSION) throw new Error('SCHEMA_AHEAD');  // 高版本库只读,绝不回写
  // v1 → v2 → ... 逐级升级分支
  db.meta.schemaVersion = SCHEMA_VERSION;
  return db;
}

SCHEMA_AHEAD 是保命设计:用户从新版本回退到旧版本时,旧版本若强行写库会破坏数据。宁可只读 + 提示升级。

4.5 事件总线:极简但要够用

// utils/storage.js:30-42
const listeners = {};
function on(event, cb)  { (listeners[event] = listeners[event] || []).push(cb); }
function off(event, cb) { /* ... */ }
function emit(event, payload) {
  (listeners[event] || []).forEach(cb => {
    try { cb(payload); } catch (e) { console.error('store listener error', e); }
  });
}

踩坑提醒emit 里必须 try-catch 包裹每个回调——一个订阅者抛异常会阻断其余订阅者,也会让写流程失败。

现状盘点:全工程只有 pages/index/index.js:78 注册了 mood:changedperiod:changedsync:changedsettings:changed 目前无订阅者。这意味着同步状态变化不会自动反映到 UI,只能靠设置页手动拉取。属于二期未完成项。

4.6 适配器门面:本期最重要的设计

// utils/data-adapter.js:17-36
class ApiFacade {
  init(force) {
    if (this.adapter && !force) return;         // 懒加载 + 幂等
    const logged = !!storage.getSession();      // 登录态决定数据源
    this.adapter = logged ? new RemoteAdapter() : new LocalAdapter();
    this.mode = this.adapter.mode;
  }
  request(module, action, payload) {
    if (!this.adapter) this.init();
    return this.adapter.request(module, action, payload);
  }
  isRemote() { return this.adapter instanceof RemoteAdapter; }
}
const Api = new ApiFacade();
// utils/adapters/data-adapter.js:4-18  契约
class Result {
  constructor(ok, code, data, error) {}
  // code: 'OK' | 'VALIDATION' | 'NOT_FOUND' | 'STORAGE' | 'NETWORK'
}
class DataAdapter {
  get mode() { throw new Error('not implemented'); }
  request(module, action, payload) { throw new Error('not implemented'); }
}

切换触发点(Api.init(true) 强制重建):

场景位置
冷启动app.js:36
复用会话app.js:50
登录成功 / 合并完成app.js:127
登出app.js:202
设置页手动切换存储模式pages/settings/settings.js:53

为什么值得? 四期引入 RemoteAdapter 时,8 个页面、几十处调用一行未改。这是「依赖倒置」在小项目里最划算的一次实践。

4.7 LocalAdapter 的分发与派生

// utils/adapters/local-adapter.js
request(module, action, payload) {
  return Promise.resolve()
    .then(() => { /* mood | period | stats | data | setting 分发 */ })
    .catch(e => Result.fail('STORAGE', e.message));
}
  • 写操作后 必须 触发派生重算:
    recalcStreak();    // L106-114  重算 streak / lastCheckInDate / totalRecords
    recalcPredict();   // L170-174  重算 period.cache
    storage.setDomain('mood', mood, true);   // immediate = true,立即落盘
    storage.emit('mood:changed');
    
  • data.importoverwrite 全量替换;merge 按 id 去重。
  • stats.* 全部调 calc.* 实时计算(local-adapter.js:177-206)。

五、数据流转与接口设计

5.1 完整数据流

┌── 页面层 ──┐
│ page.js    │  Api.request('mood','create',{...})
└─────┬──────┘
      ▼
┌── 门面层 ─────────────────────────────────────────┐
│ ApiFacade: 选适配器 → adapter.request(...)         │
└─────┬────────────────────────────────┬────────────┘
      ▼ 未登录                          ▼ 已登录(四期)
┌─ LocalAdapter ─┐              ┌─ RemoteAdapter ─┐
│ ① validate     │              │ ① http.post     │
│ ② mutate 镜像  │              │   /sync/push    │
│ ③ recalc 派生  │              │ ② 失败 → pending│
│ ④ emit 事件    │              │   Ops 入队      │
└─────┬──────────┘              └────────┬────────┘
      ▼                                  ▼
┌── storage 引擎 ───────────────────────────────────┐
│ dirty 标记 → 防抖 500ms → lockWrite 串行落盘        │
│ session / sync:同步直写,不防抖                    │
└─────┬─────────────────────────────────────────────┘
      ▼
  wx.setStorageSync('em_*')

5.2 storage 对外 API 契约

API行号语义
initStore()147冷启动初始化,返回 {ok, error}
getDomain(name)216返回镜像引用(非拷贝
setDomain(name, value, immediate)217写镜像 + 标脏,immediate 时立即落盘
getSettings() / updateSettings(patch, immediate)222 / 223settings 浅合并 + emit
flushNow()123取消防抖立即 flush
getDeviceId()229返回内存 _deviceId
getSession / setSession / clearSession / isLogged232-243会话管理
getSync() / updateSync(patch)246 / 250同步状态(懒建默认值)
backupGuest / getGuestBackup / clearGuestBackup257-270游客快照
on / off / emit31-42事件总线
_internals282暴露 store 与工厂,供测试/迁移

5.3 与后端的数据契约(RecordMapper 同构)

// mood-backend/.../common/util/RecordMapper.java:22-78
moodToMap(m)   // id 用 clientId(保证端云同一主键)
moodFromMap(m) // clientId 取 clientId 或 id 首个非空

容错解析(RecordMapper.java:102-167):

  • listVal:支持 JSON 数组或逗号分隔字符串
  • instVal:先试 ISO_OFFSET_DATE_TIME,再 Instant.parse
  • intVal / boolVal:空值兜底。

这套容错是必须的:端上历史数据格式不统一(早期版本 tagIds 存过字符串),服务端必须能兼容才不会在导入时大面积失败。


六、技术难点分析

难点 1:如何做到「写不坏」

三层防护:

  1. 写前validateMood / validatePeriod 拒绝非法数据;
  2. 写中lockWrite 串行化,避免并发覆盖;
  3. 写后/读时initStore 校验失败 → 备份 + 空库启动。

残留风险getDomain 的引用语义让「绕过 setDomain 的 mutate」成为可能。建议二期收尾时给开发版加一个 Object.freeze 版镜像(生产关闭),一运行就暴露违规调用。

难点 2:deviceId 的稳定性

docs/deviceId-稳定性对刷新链路的影响分析-v1.0.md 专门讨论了这个问题。核心矛盾:

  • refresh_tokens 上有 uk_user_device {userId, deviceId} 唯一索引;
  • 如果 deviceId 每次冷启动都变(例如用了不稳定的生成源),每次登录都会 upsert 出新文档,集合单调膨胀;
  • 如果 deviceId 意外丢失/重置,会撞唯一索引导致用户被踢下线

落地结论(写进代码注释)

// storage.js:185-192
// deviceId 缺失时必须回写 meta 并立即持久化,
// 否则 (userId, deviceId) 唯一约束失效

同时 AuthService.tryRotate 第 208-210 行注释明确:轮换时不更新 deviceId,否则会撞唯一索引。

难点 3:版本迁移的前向兼容

migrate 目前只有 v1 分支,但骨架已就绪:

if (from > SCHEMA_VERSION) throw new Error('SCHEMA_AHEAD');   // 只读保护
// 逐级:if (from <= 1) { /* v1→v2 */ }  if (from <= 2) { /* v2→v3 */ }

规范:每次结构变更必须 ①SCHEMA_VERSION += 1 ②补一个升级分支 ③在 e2e 里加一条「老版本数据 → 新版本」的用例。

现存瑕疵:VERSION_KEY = 'em_version'(storage.js:17)声明后从未使用,真实版本在 meta.schemaVersion。建议删除该常量避免误导。

难点 4:session 与 sync 的特殊性

这两个域不能防抖不能参与结构校验

  • session 丢了 = 用户被登出(体验灾难);
  • sync.pendingOps 丢了 = 离线写的数据永久丢失(数据灾难)。

因此它们走同步 setStorageSync 直写(L235、L252)。代价是写入频率高,但这两个域数据量极小(<1KB),完全可接受。


七、方案对比与推荐

7.1 存储方案

方案包体事务迁移结论
自研 L4 引擎(本项目)0 KB域级串行内置✅ 300 行换全部能力
直接散写 Storage0 KB❌ 不可演进
引入 Redux + persist+20 KB需插件❌ 心智负担重
小程序 wx.cloud.database0 KB❌ 违背本地优先

7.2 跨层通信

方案结论
极简事件总线(本项目)✅ 够用,30 行
全局 getCurrentPages() 手动刷新❌ 耦合页面栈,易漏
状态管理库(Pinia-like)端上无必要

7.3 数据访问抽象

方案结论
门面 + 适配器(本项目)✅ 页面零改动切换数据源
页面里 if (isLogged) http else storage❌ 每个页面都要写分支,必然腐化
统一走 HTTP,本地用 mock server❌ 离线不可用

八、性能与安全考量

性能

现状优化建议
冷启动7 次同步 getStorageSync可接受;如需优化可合并 meta+settings
写入防抖 500ms + 串行已足够
大数据量getDomain 零拷贝✅ 天然优势
序列化域级全量记录 >1 万条时考虑分页域

安全

  • 本地明文wx.setStorageSync 不加密,手机被物理接触即可读出。健康数据属敏感个人信息,设置页应如实告知;
  • 会话落盘风险accessToken/refreshToken 存在本地。缓解:refresh 30 天 TTL + 服务端可吊销(logout-all);
  • 备份残留em_guest_backupem_corrupted_backup 在成功后必须清理(clearGuestBackup),否则旧数据长期驻留;
  • deviceId 不可用于指纹追踪:仅作设备维度的会话标识,不与用户身份绑定对外输出。

九、风险与应对

风险影响应对
绕过 setDomain 的 mutate内存/磁盘不一致,重启丢数据开发版 Object.freeze 镜像 + 代码评审清单
flushNow() 非真正同步极端时序下丢数据提供 flushSync()(同步 setStorageSync 版)用于登出/退出前
迁移分支缺失老用户升级后异常强制 SCHEMA_VERSION +1 与迁移分支同 PR
Storage 超限(10MB)写入静默失败flush 内 try-catch + 上报;设置页展示占用
事件订阅者泄漏页面销毁未 off,重复刷新页面 onUnload 统一 off;或改用一次性事件
em_guest_backup 只写不读合并失败无自动回滚四期补「恢复备份」入口

十、验收标准与交付物

验收标准

  • 连续快速打卡 10 次,最终记录数与内容正确,无丢失、无重复;
  • 手动清空 em_mood 后冷启动,自动建默认结构且功能正常;
  • 写入非法 JSON 到 em_period 后冷启动:备份到 em_corrupted_backup,空库启动不白屏;
  • schemaVersion 高于当前版本时,只读不回写,提示用户升级;
  • 删除 deviceId 后冷启动:自动生成并已持久化(杀掉重进不变);
  • 通过 Api.init(true) 模拟切换适配器,页面代码零改动即可工作;
  • 离线连续操作后杀进程重启,数据完整;
  • 所有写路径最终都调用了 setDomain / updateSettings / updateSync(静态检查)。

交付物

类型内容
代码utils/storage.jsutils/data-adapter.jsutils/adapters/*
契约Result 错误码体系、DataAdapter 抽象
文档详细设计文档 v5.0(本地优先版)
诊断docs/deviceId-稳定性对刷新链路的影响分析-v1.0.md

十一、后续优化方向

  1. flushSync():为登出、退出、导入等「最后一步写」提供真同步 API;
  2. 事件订阅治理:补齐 period:changed / sync:changed 订阅者,让同步状态实时反映到 UI;
  3. 容量面板:设置页展示各域占用与剩余,接近 10MB 时引导导出/上云;
  4. 加密域(可选):对 mood / period 落盘前做 AES-GCM,密钥由登录态派生或系统生物识别保护——见第五期;
  5. 删除未使用的 VERSION_KEY,避免误导后人。