陌上人如玉
公子世无双

7.12 离线优先与请求队列

7.12 离线优先与请求队列

一句话:离线优先的本质是「把『本地数据库』当成唯一读写入口,把『网络同步』当成后台异步过程」——所以真正要设计的不是「缓存」,而是「队列的可靠性(重试、顺序、幂等、死信)、冲突解决、以及同步状态的可见性」。

📌 与已讲内容的分工:10.11 PWA 讲「Service Worker 的缓存与离线壳」;3.14 跨标签页同步 讲「多标签页一致」;本篇讲「数据层视角的离线队列与本地库」。

一、三种策略:先选清楚

⭐⭐⭐ 三种「数据策略」(不是「缓存策略」):

① 【网络优先(Network-first)】
   读:先请求网络,失败再用缓存
   写:直接请求网络,失败就报错
   ✅ 实现最简单;✅ 数据最新
   ⚠️ 弱网下「每次都要等超时」;离线时「完全不可用」
   ⭐ 适合:**绝大多数管理后台**(在线是常态)

② 【缓存优先(Cache-first)】
   读:先用缓存(可配「后台刷新」)
   写:⭐ **仍然要网络**
   ✅ 读很快、离线可读
   ⚠️ 数据可能陈旧;写操作离线仍然失败
   ⭐ 适合:**读多写少、容忍陈旧**(新闻、文档、商品列表)

③ 【离线优先(Offline-first)】⭐ 本篇主题
   读:**先读本地库**(本地库是「真相」),后台同步
   写:⭐ **先写本地库 + 入队**,联网后自动同步
   ✅ 离线完全可用;✅ 体感最快(写操作「零延迟」)
   ⚠️ **代价很大**:
     · 需要「本地数据库 + 队列 + 同步引擎」
     · 需要「冲突解决」(多设备/多标签页)
     · 需要「同步状态可见」(否则用户以为「已经生效了」)
   ⭐ 适合:**现场作业类应用**(外勤、盘点、医院、工地、销售拜访、离线笔记)
     —— 这些场景「网络不可靠」是常态,且「丢掉用户输入」的代价很高

⭐ 判据(一句话):「**用户会不会在『没网』的环境里使用,并且『丢掉他输入的数据』是不可接受的?**」
   会 → 离线优先;不会 → 网络优先 + 一点缓存就够。

二、离线优先的四个能力

⭐⭐⭐ 四个必须都做到的能力(缺一个就会「半吊子」):

① 【本地库是「唯一读写入口」】⭐
   ❌ 组件里「有时读网络、有时读本地」→ 状态来源混乱、逻辑分叉
   ✅ 组件只读「本地库」(内存 store / IndexedDB);
     网络同步「只负责把本地库与服务端对齐」
   ⭐ 好处:UI 逻辑只有一条路径(不用写「在线/离线」两套分支)

② 【写操作「先落本地 + 入队」】
   用户点「保存」→ ① 立刻写本地库(UI 立刻反映)→ ② 入队 → ③ 后台尝试同步
   ⭐ 关键:「入队」与「写本地」必须「原子」(要么都成功,要么都不做)
     ⚠️ 否则会出现「本地有数据但队列里没有」→ 永远不同步

③ 【队列要「可靠」】⭐ 最容易被做残的地方
   重试(指数退避)· 顺序(同一实体的操作要保序)· 幂等(重复提交安全)
   · 死信(永久失败的怎么办)· 持久化(刷新后队列还在)
   · 取消/合并(用户撤销了,队列里的操作也要撤)

④ 【同步状态「可见」】⭐
   用户必须知道:「这条还在同步」「这条同步失败了」「这条可能是旧的」
   ⚠️ 不可见的后果:用户以为「已经保存了」,结果关掉标签页就丢了(⭐ 信任崩塌)

三、本地库:为什么是 IndexedDB

⭐⭐⭐ 本地库选择的四条判据(与 3.15 的介质对比同源):

   · **容量**:「几百条待同步记录 + 本地业务数据」可能几 MB~几十 MB
     → ❌ `localStorage` 只有 5MB 且**同步阻塞**(会卡 UI)
     → ✅ **IndexedDB**(容量大、异步、支持索引与事务)
   · **查询**:需要「按状态筛」「按时间排序」「按实体查」
     → ✅ IndexedDB 的**索引**
   · **事务**:需要「写本地 + 写队列」原子
     → ✅ IndexedDB 的**事务**(把两个 store 放进同一个事务)
   · **结构化克隆**:能直接存 `Date`/`Blob`/`ArrayBuffer`
     → ✅ IndexedDB 原生支持(⭐ 避免了 3.15 里的「JSON 陷阱」)
// ✅ 用 Dexie 定义本地库(比裸 IndexedDB 好写得多)
import Dexie, { type Table } from 'dexie';

/** ⭐ 本地业务表 */
interface LocalNote {
  id: string;
  title: string;
  content: string;
  updatedAt: number;
  /** ⭐⭐ 同步元数据(每条记录都要有) */
  sync: {
    /** 'synced' | 'pending' | 'failed' */
    state: 'synced' | 'pending' | 'failed';
    /** 服务端版本号(乐观锁,见 7.11) */
    serverVersion: number | null;
    /** ⭐ 本地最后修改时间(用于「冲突时谁赢」的判断) */
    localUpdatedAt: number;
    /** 最后同步尝试的时间与错误 */
    lastTriedAt: number | null;
    lastError: string | null;
    /** ⭐ 重试次数(指数退避 + 上限) */
    attempts: number;
  };
}

/** ⭐ 队列项(⭐ 独立的表,便于「按顺序取」与「按状态筛」) */
interface QueueItem {
  /** ⭐⭐ 自增主键(保证「入队顺序」稳定) */
  seq?: number;
  /** 幂等键(⭐ 服务端据此去重,见 10.11) */
  idempotencyKey: string;
  /** 操作类型(用「联合类型」表达,便于穷举处理) */
  op:
    | { kind: 'note.create'; payload: LocalNote }
    | { kind: 'note.update'; payload: LocalNote }
    | { kind: 'note.delete'; payload: { id: string } };
  /** ⭐「依赖」:必须等某个 seq 完成后才能执行(保证同一实体的操作顺序) */
  dependsOnSeq?: number;
  /** ⭐ 实体 id(用于「合并同实体的操作」与「保序」) */
  entityId: string;
  createdAt: number;
  attempts: number;
  /** ⭐ 下一次可尝试的时间(指数退避) */
  nextAttemptAt: number;
  status: 'pending' | 'inflight' | 'failed' | 'dead';
  lastError: string | null;
}

/** ⭐ 冲突记录(让用户「手动决定」比「静默覆盖」好得多) */
interface Conflict {
  id: string;
  entityId: string;
  local: LocalNote;
  remote: LocalNote;
  detectedAt: number;
}

export class AppDatabase extends Dexie {
  notes!: Table<LocalNote, string>;
  queue!: Table<QueueItem, number>;
  conflicts!: Table<Conflict, string>;

  constructor() {
    super('app-db');

    // ⭐⭐ 版本迁移(与 3.15 同样的纪律:每个版本一个 upgrade 函数)
    this.version(1).stores({
      notes: 'id, updatedAt, sync.state',            // ⭐ 索引:主键 + 常用查询字段
      queue: '++seq, status, nextAttemptAt, entityId',
      conflicts: 'id, entityId',
    });

    this.version(2)
      .stores({
        // ⭐ v2:给 notes 加「标签」索引(旧数据自动迁移,新字段为 undefined)
        notes: 'id, updatedAt, sync.state, tags',
        queue: '++seq, status, nextAttemptAt, entityId, dependsOnSeq',
        conflicts: 'id, entityId',
      })
      .upgrade(async (tx) => {
        // ⭐ 显式迁移(给旧数据补默认值)
        await tx.table<LocalNote>('notes').toCollection().modify((note) => {
          (note as LocalNote & { tags?: string[] }).tags ??= [];
        });
      });
  }
}

export const db = new AppDatabase();

四、队列:可靠性的七个细节

⭐⭐⭐ 队列「做残」的七种方式(每一条都对应一个真实故障):

① 【只存内存】→ 刷新/关闭标签页后「待同步操作丢失」
   ✅ 队列必须**持久化**(IndexedDB 表)

② 【没有幂等键】→ 网络超时后重试 → **服务端执行两次**
   (⭐ 典型:「重复下单」「重复退款」)
   ✅ 每个队列项带 `idempotencyKey`,**服务端按它去重**(见 10.11)

③ 【没有顺序保证】→ 「先创建后更新」可能变成「更新一个还不存在的实体」
   ✅ 两种做法:
     · **按实体保序**:同一 `entityId` 的操作串行(用 `dependsOnSeq` 串成链)
     · **操作合并**:把同一实体的多次修改「合并成一个」(⭐ 更高效,见下)

④ 【没有重试退避】→ 断网时「每秒重试一次」→ 打爆电量与电池
   ✅ **指数退避**(1s → 2s → 4s → … 上限 60s)+ 抖动

⑤ 【不区分「可重试」与「永久失败」】→ 「参数错误」被无限重试
   ✅ 按错误类型分流:`4xx`(除 408/429)→ **死信**;`5xx`/网络 → 重试

⑥ 【没有死信处理】→ 永久失败的操作「卡在队列里」→ 阻塞后续(如果保序)
   ✅ 把永久失败的移到 `dead` 状态 + ⭐ **通知用户**(让他决定「重试/丢弃/导出」)

⑦ 【没有「取消/合并」】→ 用户「删了刚建的笔记」,队列里还有「创建 + 删除」两条
   ✅ 入队时「合并同实体的操作」(⭐ 见下文的「合并规则表」)
// ✅ 操作合并规则(⭐ 「减少队列长度」与「避免无意义请求」的关键)
/**
 * 同一实体的「连续操作」可以合并:
 *
 *   create → update   ⇒  create(把 update 的字段并进 payload)
 *   create → delete   ⇒  ⭐ 「什么都不做」(直接移除两条)—— 净效果是「没发生」
 *   update → update   ⇒  update(合并字段,只保留最后一次)
 *   update → delete   ⇒  delete(丢弃 update)
 *   delete → create   ⇒  update(「重建」等价于「更新」,⭐ 但要保留 id)
 *
 * ⚠️ 但有几条「不能合并」的情形:
 *   · 操作「有服务端副作用」(如「发送通知」「扣款」)→ 必须逐条执行
 *   · 操作「依赖前一步的返回值」(如「创建拿 id 再更新」)→ 不能合并
 *   · 「幂等键语义」要求「每次都是一个独立请求」(如「埋点上报」)
 */
export function mergeQueueOps(existing: QueueItem | undefined, next: QueueItem): QueueItem | null {
  if (!existing) return next;

  const a = existing.op;
  const b = next.op;

  // ⭐ create → update:合并成 create(并把 idempotencyKey 保留「最初的」)
  if (a.kind === 'note.create' && b.kind === 'note.update') {
    return { ...existing, op: { kind: 'note.create', payload: { ...a.payload, ...b.payload } } };
  }

  // ⭐ create → delete:净效果「什么都没发生」→ 移除两条(返回 null)
  if (a.kind === 'note.create' && b.kind === 'note.delete') {
    return null;
  }

  // ⭐ update → update:合并字段
  if (a.kind === 'note.update' && b.kind === 'note.update') {
    return { ...existing, op: { kind: 'note.update', payload: { ...a.payload, ...b.payload } } };
  }

  // update → delete:只留 delete
  if (a.kind === 'note.update' && b.kind === 'note.delete') {
    return { ...existing, op: b };
  }

  // 其余情况:「追加」(不合并)
  return next;
}
/** ============ 队列引擎(250 行里的核心部分) ============ */
const BASE_DELAY_MS = 1_000;
const MAX_DELAY_MS = 60_000;
const MAX_ATTEMPTS = 6;

function backoffDelay(attempts: number): number {
  // ⭐ 指数退避 + 抖动(⭐ 抖动很重要:避免「多个客户端同时重试」造成尖峰)
  const exp = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempts);
  const jitter = Math.random() * 0.3 * exp;
  return Math.round(exp + jitter);
}

export class SyncQueue {
  private running = false;
  private listeners = new Set<() => void>();

  /** ⭐ 入队(⭐ 与「写本地」放在同一个 IndexedDB 事务里——保证原子) */
  async enqueue(item: Omit<QueueItem, 'seq' | 'attempts' | 'nextAttemptAt' | 'status' | 'lastError'>) {
    return db.transaction('rw', db.queue, db.notes, async () => {
      // ⭐ 找「同一实体的待处理项」看能否合并
      const pending = await db.queue
        .where('entityId')
        .equals(item.entityId)
        .filter((q) => q.status === 'pending' || q.status === 'failed')
        .last();                                     // ⭐ 取「最后一条」(顺序合并)

      const merged = mergeQueueOps(pending, {
        ...item,
        attempts: 0,
        nextAttemptAt: Date.now(),
        status: 'pending',
        lastError: null,
      });

      if (merged === null) {
        // ⭐ 「create → delete」→ 移除已有那条(净效果「什么都没发生」)
        if (pending?.seq !== undefined) await db.queue.delete(pending.seq);
        return;
      }

      if (pending?.seq !== undefined && merged !== pending) {
        // ⭐ 合并进已有项(⚠️ 不新增队列项 → 队列不会无限增长)
        await db.queue.update(pending.seq, {
          op: merged.op,
          createdAt: Date.now(),
          attempts: 0,
          nextAttemptAt: Date.now(),
          status: 'pending',
        });
        return;
      }

      await db.queue.add(merged);
    });
  }

  /** ⭐ 取「下一个可执行」的项(⭐ 保证「同一实体的顺序」) */
  private async pickNext(): Promise<QueueItem | undefined> {
    const now = Date.now();

    const candidates = await db.queue
      .where('status')
      .anyOf('pending', 'failed')
      .filter((q) => q.nextAttemptAt <= now)
      .toArray();

    if (candidates.length === 0) return undefined;

    // ⭐⭐ 顺序保证:对「同一实体」,只执行「seq 最小」的那条
    const minSeqByEntity = new Map<string, number>();
    for (const item of candidates) {
      const seq = item.seq!;
      const cur = minSeqByEntity.get(item.entityId);
      if (cur === undefined || seq < cur) minSeqByEntity.set(item.entityId, seq);
    }

    const runnable = candidates.filter((item) => minSeqByEntity.get(item.entityId) === item.seq);
    // ⭐ 再按 seq 全局排序(先来先做)
    runnable.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
    return runnable[0];
  }

  /** ⭐⭐ 执行一项(⭐ 核心:区分「可重试」与「永久失败」) */
  private async execute(item: QueueItem): Promise<void> {
    await db.queue.update(item.seq!, { status: 'inflight' });

    try {
      await performRequest(item);                    // ⭐ 真正发请求(带幂等键)

      await db.transaction('rw', db.queue, db.notes, async () => {
        await db.queue.delete(item.seq!);

        // ⭐ 标记「已同步」(⭐ 如果「期间又有新的本地修改」,就保持 pending)
        const local = await db.notes.get(item.entityId);
        if (local && local.sync.localUpdatedAt <= item.createdAt) {
          await db.notes.update(item.entityId, {
            sync: { ...local.sync, state: 'synced', serverVersion: (local.sync.serverVersion ?? 0) + 1, lastError: null, attempts: 0 },
          });
        }
      });

      this.notify();
    } catch (err) {
      const classified = classifyQueueError(err);    // ⭐ 分类(见下)

      const attempts = item.attempts + 1;

      if (classified.kind === 'permanent' || attempts >= MAX_ATTEMPTS) {
        // ⭐⭐ 死信:永久失败 → 不再重试,但「保留在队列里 + 标红」(让用户决定)
        await db.queue.update(item.seq!, {
          status: 'dead',
          attempts,
          lastError: classified.message,
          nextAttemptAt: Number.MAX_SAFE_INTEGER,    // ⭐ 永不自动执行
        });

        // ⭐ 本地标记「失败」(UI 显示「同步失败,点击重试」)
        await db.notes.update(item.entityId, {
          sync: { ...(await db.notes.get(item.entityId))!.sync, state: 'failed', lastError: classified.message, attempts },
        });
      } else if (classified.kind === 'conflict') {
        // ⭐⭐ 冲突:不重试,而是「记录下来 + 让用户决定」(见第五节)
        await db.queue.delete(item.seq!);
        await this.recordConflict(item, classified.remote);
      } else {
        // ⭐ 可重试:指数退避
        await db.queue.update(item.seq!, {
          status: 'failed',
          attempts,
          lastError: classified.message,
          nextAttemptAt: Date.now() + backoffDelay(attempts),
        });
      }

      this.notify();
    }
  }

  /** ⭐ 主循环(⭐ 单实例:用「running 标志」防并发执行) */
  async flush(): Promise<void> {
    if (this.running) return;                       // ⚠️ 防重入(否则同一条会被执行两次)
    if (!navigator.onLine) return;                  // ⭐ 离线直接跳过

    this.running = true;
    try {
      // ⭐ 最多连续执行 20 条(避免「一次同步几千条」阻塞)
      for (let i = 0; i < 20; i++) {
        const item = await this.pickNext();
        if (!item) break;
        await this.execute(item);
      }
    } finally {
      this.running = false;
    }
  }

  /** ⭐ 订阅「队列状态」(供 UI 显示「待同步 N 条」) */
  subscribe(fn: () => void) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
  private notify() { this.listeners.forEach((fn) => fn()); }

  /** ⭐ 统计(UI 用) */
  async stats() {
    const all = await db.queue.toArray();
    return {
      pending: all.filter((q) => q.status === 'pending' || q.status === 'inflight').length,
      failed: all.filter((q) => q.status === 'failed').length,
      dead: all.filter((q) => q.status === 'dead').length,
    };
  }

  /** ⭐ 手动重试死信 */
  async retryDead(seq: number) {
    await db.queue.update(seq, { status: 'pending', attempts: 0, nextAttemptAt: Date.now(), lastError: null });
    void this.flush();
  }

  private async recordConflict(item: QueueItem, remote: LocalNote) {
    const local = await db.notes.get(item.entityId);
    if (!local) return;
    await db.conflicts.put({
      id: item.idempotencyKey,
      entityId: item.entityId,
      local,
      remote,
      detectedAt: Date.now(),
    });
  }
}
/** ============ 错误分类(⭐ 决定「重试 / 死信 / 冲突」的关键) ============ */
type QueueError =
  | { kind: 'retryable'; message: string }                  // 5xx / 超时 / 网络
  | { kind: 'permanent'; message: string }                  // 4xx(除 408/429)
  | { kind: 'conflict'; message: string; remote: LocalNote }; // 409(乐观锁失败)

function classifyQueueError(err: unknown): QueueError {
  const status = (err as { status?: number })?.status;
  const message = (err as { message?: string })?.message ?? '未知错误';

  if (status === 409) {
    // ⭐ 冲突:带「服务端当前版本」(让用户能对比)
    const remote = (err as { remote?: LocalNote })?.remote;
    if (remote) return { kind: 'conflict', message, remote };
    return { kind: 'permanent', message };                  // 「没有 remote」时降级为「永久失败」
  }

  // ⭐ 「可重试」:网络层错误(无 status)/ 5xx / 408 / 429
  if (status === undefined || status >= 500 || status === 408 || status === 429) {
    return { kind: 'retryable', message };
  }

  // ⭐ 「永久失败」:其余 4xx(400/401/403/404/422)
  return { kind: 'permanent', message };
}

/** ⭐ 真正发请求(⭐ 带幂等键 —— 这是「重试安全」的全部依据) */
async function performRequest(item: QueueItem): Promise<void> {
  const res = await fetch('/api/sync', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // ⭐⭐ 幂等键:服务端按它去重(「第二次到达」直接返回「第一次的结果」)
      'Idempotency-Key': item.idempotencyKey,
    },
    body: JSON.stringify(item.op),
  });

  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    // ⭐ 把「冲突时的服务端数据」带出来(供用户对比)
    throw Object.assign(new Error(body.message ?? `HTTP ${res.status}`), { status: res.status, remote: body.remote });
  }
}

五、冲突解决:三条路线

⭐⭐⭐ 冲突(多设备/多标签页改同一条)只有三条路线:

① 【最后写入赢(LWW)】⭐ 最简单
   用「时间戳/版本号」决定谁赢,输的一方被覆盖
   ✅ 实现简单;⚠️ **静默丢数据**(用户不知道自己的修改没了)
   ⭐ 仅适合「后写覆盖前写」语义的字段(备注、状态)

② 【乐观锁 + 提示冲突(⭐ 推荐)】
   客户端提交「我读到的版本号」→ 服务端不匹配 → **409**
   → 前端「记录下来 + 提示用户」(⭐ 让用户选择「用我的 / 用服务端的 / 合并」)
   ✅ **不丢数据**;✅ 用户有掌控感
   ⚠️ 需要「冲突 UI」(多写一点前端代码,但值得)

③ 【自动合并(CRDT / 字段级合并)】
   · **字段级合并**:不同字段的修改自动合并(⭐ 多数冲突其实都是「改了不同字段」)
   · **CRDT**:把「状态」改成「可交换的操作」(计数器、集合、文本)
   ✅ 最先进;⚠️ 复杂度最高(文本 CRDT 尤其)
   ⭐ 实践建议:**先做「字段级合并」**(能解决大部分冲突),
     真的需要「同一段文本并发编辑」再上 CRDT 库(Yjs/Automerge)。
/** ============ 字段级合并(⭐ 性价比最高的冲突策略) ============ */
export interface FieldMergeResult {
  /** 能否自动合并 */
  canAutoMerge: boolean;
  /** 合并后的结果 */
  merged: LocalNote;
  /** ⭐ 真正冲突的字段(需要用户决定) */
  conflictFields: Array<keyof LocalNote>;
}

/** ⭐⭐ 用「字段级的最后修改时间」做三方合并(base / local / remote) */
export function mergeNoteChanges(
  base: LocalNote,          // 上次同步时的版本
  local: LocalNote,         // 本地当前(可能被改过)
  remote: LocalNote         // 服务端当前
): FieldMergeResult {
  const merged: LocalNote = { ...remote };
  const conflictFields: Array<keyof LocalNote> = [];

  // ⭐ 只比较「业务字段」(不含 sync 元数据)
  const businessKeys: Array<keyof LocalNote> = ['title', 'content'];
  // ⭐ 「标签」用「集合并集」合并(⭐ 两边加的标签都保留 —— 这是「无冲突」的)
  const localTags = new Set((local as LocalNote & { tags?: string[] }).tags ?? []);
  const remoteTags = new Set((remote as LocalNote & { tags?: string[] }).tags ?? []);
  (merged as LocalNote & { tags?: string[] }).tags = [...new Set([...localTags, ...remoteTags])];

  for (const key of businessKeys) {
    const baseVal = base[key];
    const localVal = local[key];
    const remoteVal = remote[key];

    // ⭐ 情况 A:只有本地改了 → 用本地
    if (localVal !== baseVal && remoteVal === baseVal) {
      (merged[key] as unknown) = localVal;
      continue;
    }
    // ⭐ 情况 B:只有远端改了(或都没改)→ 用远端(已经是 merged 的初值)
    if (remoteVal !== baseVal && localVal === baseVal) continue;

    // ⭐⭐ 情况 C:两边都改了同一个字段 → 真冲突(⚠️ 需要用户决定)
    if (localVal !== remoteVal) conflictFields.push(key);
  }

  return { canAutoMerge: conflictFields.length === 0, merged, conflictFields };
}
/** ============ 冲突 UI(让用户决定,而不是静默覆盖) ============ */
function ConflictResolver({ conflictId }: { conflictId: string }) {
  const [conflict, setConflict] = useState<Conflict | null>(null);

  useEffect(() => {
    void db.conflicts.get(conflictId).then((c) => setConflict(c ?? null));
  }, [conflictId]);

  if (!conflict) return null;

  const localOnly = conflict.local.content;
  const remoteOnly = conflict.remote.content;

  return (
    <div role="dialog" aria-modal="true" aria-label="修改冲突">
      <h2>这条笔记在别处也被修改了</h2>

      {/* ⭐ 并排对比(⭐ 让用户「看得懂」比「自动决定」重要得多) */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
        <section>
          <h3>你的版本({new Date(conflict.local.updatedAt).toLocaleString('zh-CN')})</h3>
          <pre style={{ whiteSpace: 'pre-wrap' }}>{localOnly}</pre>
          <button onClick={() => void resolveConflict(conflict.id, 'local')}>保留我的</button>
        </section>

        <section>
          <h3>服务端版本({new Date(conflict.remote.updatedAt).toLocaleString('zh-CN')})</h3>
          <pre style={{ whiteSpace: 'pre-wrap' }}>{remoteOnly}</pre>
          <button onClick={() => void resolveConflict(conflict.id, 'remote')}>保留服务端的</button>
        </section>
      </div>

      {/* ⭐ 或者「都要」(把本地版本作为「新笔记」保留,避免丢内容) */}
      <button onClick={() => void resolveConflict(conflict.id, 'both')}>两个都保留</button>
    </div>
  );
}

async function resolveConflict(conflictId: string, choice: 'local' | 'remote' | 'both') {
  const conflict = await db.conflicts.get(conflictId);
  if (!conflict) return;

  if (choice === 'remote') {
    // ⭐ 用服务端版本覆盖本地
    await db.notes.put({ ...conflict.remote, sync: { ...conflict.remote.sync, state: 'synced' } });
  } else if (choice === 'local') {
    // ⭐ 用本地版本「重新提交」(带「服务端最新版本号」做乐观锁,⭐ 避免再次冲突)
    await db.notes.update(conflict.entityId, {
      sync: { ...conflict.local.sync, state: 'pending', serverVersion: conflict.remote.sync.serverVersion },
    });
    await queue.enqueue({
      idempotencyKey: crypto.randomUUID(),
      op: { kind: 'note.update', payload: conflict.local },
      entityId: conflict.entityId,
      createdAt: Date.now(),
    });
  } else {
    // ⭐ 「都保留」:服务端版本保存在原记录,「本地版本」作为新笔记
    await db.notes.put({ ...conflict.remote, sync: { ...conflict.remote.sync, state: 'synced' } });
    const newId = crypto.randomUUID();
    await db.notes.put({
      ...conflict.local,
      id: newId,
      title: `${conflict.local.title}(冲突副本)`,
      sync: { ...conflict.local.sync, state: 'pending', serverVersion: null },
    });
    await queue.enqueue({
      idempotencyKey: crypto.randomUUID(),
      op: { kind: 'note.create', payload: { ...conflict.local, id: newId } },
      entityId: newId,
      createdAt: Date.now(),
    });
  }

  await db.conflicts.delete(conflictId);
}

六、本篇特有的坑

// ① 「写本地」与「入队」不在同一个事务(本地有数据但队列没有)
await db.notes.put(note);
await db.queue.add(item);                                  // ⚠️ 中间崩溃 → 永不同步
// ✅ db.transaction('rw', db.notes, db.queue, async () => { ...两者一起... })

// ② 队列只存内存(刷新后丢失)
const queue = [];                                          // ❌
// ✅ 持久化到 IndexedDB

// ③ 没有幂等键(重试导致「重复下单」)⭐
// ✅ 每个队列项带 `idempotencyKey`,服务端按它去重

// ④ 同一实体的操作「乱序执行」(更新一个还不存在的实体)
// ⚠️ 并发执行队列项时最容易出现
// ✅ 「同一 entityId 串行」(或「操作合并」)

// ⑤ 并发执行「同一条队列项」(重入)
flush(); flush();                                          // ⚠️ 同一条被执行两次
// ✅ `running` 标志防重入

// ⑥ 「4xx 也无限重试」(用户等到第 6 次才看到「参数错误」)
// ✅ 分类:4xx(除 408/429)→ 死信

// ⑦ 没有指数退避(断网时「每秒重试」→ 耗电)
// ✅ 退避 + 抖动

// ⑧ 没有死信处理(永久失败卡住队列)
// ✅ `dead` 状态 + UI 提示 + 手动重试入口

// ⑨ 队列「无限增长」(用户狂改一条笔记 → 几百条队列)
// ✅ 入队时「合并同实体的操作」

// ⑩ 「create → delete」在队列里两条都留着(净效果是「没发生」)
// ✅ 合并规则里「直接移除两条」

// ⑪ 「删除」用「物理删除」(本地直接删掉,无法「回滚」)
// ⚠️ 同步失败时「数据没了」
// ✅ 用「软删除」(`deletedAt` 标记)+ 同步成功后再物理删

// ⑫ 「同步状态不显示」(用户以为已保存)
// ✅ ⭐ UI 上「每条记录的状态徽标」+「全局待同步数」

// ⑬ 「离线改动」在「重新联网」时没有自动触发同步
// ✅ 监听 `online` 事件 + `visibilitychange`(恢复可见时)+ `Leader 选举`(只让主标签页同步,见 3.14)

// ⑭ 「多个标签页各自同步」(重复提交)
// ⭐ 两个标签页同时 flush → 同一条队列项被执行两次(虽然有幂等键兜底,但浪费)
// ✅ Leader 选举(Web Locks)+「队列项加 inflight 状态 + 超时回收」

// ⑮ 「inflight 状态」在「标签页崩溃」后残留(永远卡在 inflight)
// ✅ 启动时「把超过 N 分钟的 inflight 重置为 pending」

// ⑯ 「服务端返回的 id」与「本地临时 id」不一致(关联丢失)
// ⭐ 典型:「先本地创建(temp id)→ 服务端返回真实 id」→ 本地要「替换 id 并更新引用」
// ✅ 让服务端「接受客户端生成的 id」(⭐ 最省心:客户端用 uuid,服务端照存)
//    或「在响应里返回 id 映射」,前端做一次「引用改写」(⭐ 要处理「其他记录引用了它」)

// ⑰ 「大数据量」一次性读取(内存爆)
const all = await db.notes.toArray();                      // ⚠️ 几万条会卡
// ✅ 分页 + 索引查询 + 虚拟列表(见 5.5)

// ⑱ 「同步时」全量拉取(每次同步都拉全表)
// ✅ 「增量同步」(带 `updatedSince` 或 cursor,⭐ 或服务端推送变更)

// ⑲ 「离线时」用户看到「数据不一致但没提示」
// ✅ 全局「离线」状态条 + 「这条可能是旧的」标记(见 10.11)

// ⑳ 「时钟不可靠」(用 `Date.now()` 做「最后修改时间」)
// ⚠️ 用户改系统时间 / 多设备时钟偏差 → LWW 判断错
// ✅ 用「服务端时间」或「逻辑时钟(Lamport)」/「版本号」而非墙钟

// ㉑ 「冲突」静默覆盖(LWW)→ 用户数据丢失且无感知 ⭐
// ✅ 乐观锁 + 冲突 UI(或至少「字段级合并」)

// ㉒ 「IndexedDB 被清理」(浏览器存储压力下会清掉「best-effort」存储)
// ✅ `navigator.storage.persist()` 申请「持久存储」+「关键数据在联网时尽快同步」

// ㉓ 「隐私模式」下 IndexedDB 不可用(或打开失败)
// ✅ 降级到「内存模式」+ 明确提示「当前无法离线保存」

// ㉔ 「同步引擎」写在组件里(随组件卸载而停)
// ✅ 独立于 React 的「服务模块」(在应用启动时初始化)
// ⑮ 的完整处理(回收「卡住的 inflight」)
async function recoverStaleInflight(timeoutMs = 2 * 60_000) {
  const cutoff = Date.now() - timeoutMs;
  const stale = await db.queue
    .where('status')
    .equals('inflight')
    .filter((q) => q.nextAttemptAt <= cutoff)      // ⭐ 用 nextAttemptAt 记「开始执行的时间」
    .toArray();

  for (const item of stale) {
    await db.queue.update(item.seq!, {
      status: 'pending',
      nextAttemptAt: Date.now(),
      lastError: '上次执行被中断(可能标签页崩溃)',
    });
  }
}
// 应用启动时调用一次
// ⑬ 的完整处理(同步的触发时机)
export function startSyncEngine(queue: SyncQueue) {
  // ⭐ ① 网络恢复
  window.addEventListener('online', () => void queue.flush());

  // ⭐ ② 恢复可见(后台标签页可能错过事件、浏览器也会节流)
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') void queue.flush();
  });

  // ⭐ ③ 定时兜底(⚠️ 后台会被节流,只作为「尽力而为」)
  setInterval(() => void queue.flush(), 30_000);

  // ⭐ ④ 启动时先恢复卡住的项,再 flush
  void recoverStaleInflight().then(() => queue.flush());

  // ⭐⑤ 用 Leader 选举「只让主标签页同步」(避免多标签页重复提交,见 3.14)
  // leader.onBecomeLeader(() => void queue.flush());
}

七、面试延伸

  1. 「什么是『离线优先』?和『缓存』有什么区别?」
    缓存是「网络优先 + 兜底」——读可以走缓存,但写操作仍然必须联网(离线时写失败);离线优先是「本地库是唯一读写入口,网络同步是后台异步过程」——⭐ 读先读本地库(本地是「真相」),⭐ 写先写本地库 + 入队,联网后自动同步。离线优先的代价明显更大:需要「本地数据库 + 可靠队列 + 冲突解决 + 同步状态可见」。选型判据:⭐ 「用户会不会在『没网』的环境里使用,并且『丢掉他输入的数据』不可接受?」——会(外勤、盘点、工地、离线笔记)→ 离线优先;不会(绝大多数管理后台)→ 网络优先 + 一点缓存就够。

  2. 「离线队列要可靠,必须做到什么?」
    七个细节:① ⭐ 持久化(IndexedDB 表,不能只在内存);② ⭐ 幂等键(每个队列项带 idempotencyKey,服务端按它去重——否则「网络超时后重试」会重复下单/重复退款);③ ⭐ 顺序保证(「同一实体」的操作必须串行,否则会出现「更新一个还不存在的实体」);④ ⭐ 指数退避 + 抖动(断网时别每秒重试,也别让所有客户端同时重试);⑤ ⭐ 区分「可重试」与「永久失败」(5xx/网络/408/429 → 重试;其余 4xx → 死信);⑥ ⭐ 死信处理(永久失败不能卡住队列 → 移到 dead 状态 + 通知用户让他决定);⑦ ⭐ 取消/合并(用户「建了又删」→ 队列里不该留两条)。另外还有两个容易被忽略的:「写本地」与「入队」必须原子(同一事务)、「inflight 状态」要能回收(标签页崩溃后不能永远卡住)。

  3. 「队列项为什么要『合并』?」
    两个目的:① ⭐ 避免队列无限增长——用户「狂改一条笔记」会产生几十条 update,合并后只剩一条(⭐ 而且「只发最后一次」比「发几十次」快得多);② ⭐ 避免无意义请求——「create → delete」净效果是「什么都没发生」,两条都发就是「建了又删」。合并规则:create→update ⇒ create(字段并入);create→delete ⇒ 移除两条;update→update ⇒ update(合并字段);update→delete ⇒ delete。⚠️ 不能合并的情形:① 操作有服务端副作用(「发通知」「扣款」必须逐条);② 操作依赖前一步的返回值;③ 幂等键语义要求「每次都是独立请求」(如埋点上报)。

  4. 「离线优先里,「冲突」怎么处理?」
    三条路线:① LWW(最后写入赢)——用时间戳/版本号决定,⚠️ 静默丢数据(用户不知道自己的修改没了),⭐ 只适合「后写覆盖前写」的字段;② ⭐ 乐观锁 + 冲突 UI(推荐)——客户端提交「我读到的版本号」,服务端不匹配就返回 409 → 前端记录下来 + 并排展示「我的」与「服务端的」+ 让用户选(保留我的 / 保留服务端的 / 两个都保留)——✅ 不丢数据、用户有掌控感;③ ⭐ 自动合并——字段级合并(⭐ 性价比最高:多数冲突其实是「改了不同字段」或「加了不同标签」,都能自动合)与 CRDT(最先进但最复杂)。实践建议:先做「字段级合并」,实在需要「同一段文本并发编辑」再上 CRDT 库(Yjs/Automerge)——⚠️ 不要为了「技术优雅」手写文本 CRDT。

  5. 「本地库为什么用 IndexedDB 而不是 localStorage?」
    四个判据:① ⭐ 容量——「几百条待同步记录 + 本地业务数据」可能几十 MB,而 localStorage 只有 5MB;② ⭐ 不能阻塞主线程——localStorage 是同步 API(写大对象会卡 UI),IndexedDB 是异步的;③ ⭐ 事务——需要「写本地 + 入队」原子(把两个 store 放进同一个事务),localStorage 没有事务;④ ⭐ 结构化克隆——IndexedDB 能直接存 Date/Blob/ArrayBuffer(避免 3.15 里的 JSON 陷阱),而 localStorage 只能存字符串。另外:IndexedDB 有索引(按状态/时间/实体查询,⭐ 队列的「取下一个可执行项」就靠它)。实践上用 Dexie(idb 也行)——它把「版本迁移」也变简单了(⭐ 与 3.15 的迁移纪律一致:每个版本一个 upgrade 函数)。

  6. 「同步状态怎么让用户看到?」
    ⭐ 至少三层:① 每条记录的状态徽标(「已同步 / 待同步 / 同步失败」)——用户能知道「我刚改的这条是不是保存了」;② ⭐ 全局的待同步计数(如「3 条待同步」,⭐ 并配「正在同步…」的进度);③ ⭐ 离线/失败的状态条(「当前离线,改动会在联网后同步」/「2 条同步失败,点击查看」)。为什么必须做:⚠️ 「不可见的同步」的后果是「用户以为已经保存了,结果关掉标签页就丢了」——⭐ 一旦发生一次,用户就再也不信这个应用了。另外:① 离线时明确标注「数据可能是旧的」(见 10.11);② 「同步失败」要给用户出路(重试 / 导出 / 丢弃)。

  7. 「离线优先有什么坑?」
    六个高频:① ⭐ 「本地临时 id」与「服务端 id」不一致——最省心的做法是「让服务端接受客户端生成的 uuid」;否则要「做一次 id 映射 + 引用改写」(⚠️ 处理「别的记录引用了它」很麻烦);② ⭐ 多标签页重复同步——两个标签页同时 flush → 同一条被执行两次(虽然有幂等键兜底,但浪费)→ ✅ Leader 选举(Web Locks,见 3.14)+ inflight 状态;③ ⭐ inflight 卡死——标签页崩溃后项永远停在 inflight → ✅ 启动时「把超过 N 分钟的 inflight 重置为 pending」;④ 时钟不可靠——用墙钟做「最后修改时间」时,用户改系统时间或多设备偏差会导致 LWW 判断错 → ✅ 用服务端时间或逻辑时钟/版本号;⑤ IndexedDB 被浏览器清理(存储压力下 best-effort 存储会被清)→ ✅ navigator.storage.persist() + 「联网时尽快同步」;⑥ 隐私模式下 IndexedDB 不可用 → ✅ 降级到内存模式 + 明确提示「当前无法离线保存」。

  8. 「如果让你给一个应用加离线能力,会怎么做?」
    ⭐ 分阶段,不要一步到位:第一步(成本最低、收益最高)——① Service Worker 缓存应用壳(⭐ 至少「离线能打开」而不是白屏,见 10.11);② 只读数据的缓存(「最近一次成功的数据」+ 明确标注「离线数据,可能不是最新」);③ 离线状态条(让用户知道当前离线)。第二步——④ 写操作的「乐观 UI + 队列 + 幂等键」(⭐ 这一步就开始有「离线优先」的味道了,且能解决「用户填了半天点保存却失败」这个最痛的点);⑤ 同步状态可见(待同步数 + 每条的状态)。第三步(只有真的需要才做)——⑥ 本地库成为唯一读写入口(完整的离线优先);⑦ 字段级合并 + 冲突 UI;⑧ 多标签页 Leader 选举 + 增量同步。⭐ 判据:「先解决『丢数据』的痛,再考虑『完全离线可用』」——很多场景「第一步 + 队列」就够了,不必上完整的离线优先进阶。

一句话速记

离线优先的本质是「本地库是唯一读写入口,网络同步是后台异步过程」——读先读本地、写先写本地 + 入队;⭐ 选型判据:「用户会不会在没网环境下使用、且丢掉输入不可接受」(外勤/盘点/工地/离线笔记 → 离线优先;绝大多数后台 → 网络优先够);本地库用 IndexedDB(容量大 / 异步不阻塞 / 有事务(「写本地 + 入队」必须原子) / 结构化克隆存 Date 不用怕 JSON 陷阱 / 有索引),配 Dexie 并每个版本一个 upgrade(同 3.15 纪律);⭐ 队列可靠的七个细节:持久化、幂等键(防重复下单)、顺序保证(同实体串行)、指数退避 + 抖动、区分可重试(5xx/网络/408/429)与永久失败(其余 4xx → 死信)、死信可见可处理、操作合并(create→delete ⇒ 直接移除两条;update→update ⇒ 合并字段;⚠️ 有服务端副作用/依赖返回值/埋点语义的不能合并);⭐ 冲突三条路线:LWW(简单但静默丢数据,只适合「后写覆盖前写」)→ ⭐ 乐观锁 + 冲突 UI(推荐,并排展示 + 让用户选「我的/服务端的/都保留」)→ 自动合并(⭐ **先做「字段级合并」,真要「同段文本并发编辑」再上 CRDT);⭐ **同步状态必须可见(每条的状态徽标 + 全局待同步数 + 离线状态条——⚠️ 不可见的后果是「用户以为保存了、结果丢了」,信任崩塌);高频坑:本地临时 id 与服务端 id 不一致(✅ 让服务端接受客户端 uuid)、多标签页重复同步(✅ Leader 选举 + inflight)、inflight 卡死(✅ 启动时回收)、时钟不可靠(✅ 用服务端时间/逻辑时钟)、IndexedDB 可能被清(✅ persist())、隐私模式降级;落地分三步:离线壳 + 只读缓存 + 状态条 → 乐观 UI + 队列 + 幂等键 + 同步可见 → 完整离线优先 + 冲突 UI。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.12 离线优先与请求队列

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫