Local-First 架构实战:从 API 到数据库同步的范式迁移

2026-05-26 22:12 Local-First 架构实战:从 API 到数据库同步的范式迁移已关闭评论

Local-First 架构实战:从 API 到数据库同步的范式迁移

用 CRDT 做本地优先数据库同步,离线体验能追上 Web 版,但冲突处理策略选错会让整个架构白做。

背景:为什么我要放弃"纯云端"?

去年我做了一个团队协作工具,初期架构很"标准"——React + Express + PostgreSQL,所有状态走 API 请求。上线后被吐槽最多的不是功能不够,而是"切换任务要转圈 2 秒"和"地铁里完全没法用"。

我一开始觉得是网络问题,上了缓存、加了 Optimistic Update,体验有改善,但治标不治本。核心矛盾是:应用需要毫秒级响应,但网络延迟是物理极限。

直到读到 Martin Kleppmann 那篇《Local-First Software》,才意识到该换的不是缓存策略,而是架构范式。

什么是 Local-First?一句话说清楚

核心就一条:用户设备是数据的"一等公民",云端是同步的"副本",不是数据源头。

传统架构:用户操作 → API → 数据库 → 响应回客户端

Local-First:用户操作 → 本地数据库(立即响应)→ 后台同步到其他设备

差异不在技术实现,而在信任模型——你相信本地设备还是相信服务器?

技术选型对比:我踩过哪些坑

我调研了主流的 Local-First 方案:

方案 同步引擎 冲突处理 学习成本
Automerge CRDT 自动合并
Yjs CRDT 自动合并 低(文档协作)
SQLite + sync 自定义同步 需手动处理
ElectricSQL PG 逻辑复制 基于 LSN 有序同步

我最终选了 ElectricSQL + SQLite (PGLite),原因是:

  1. 我需要关系型数据(不是纯文档协作),Yjs 不合适
  2. 团队熟悉 SQL,不想为了同步学一套新数据模型
  3. ElectricSQL 的 "Shape-based sync" 粒度刚好——按查询订阅数据,不是全表同步

如果你做的是文档类应用(Notion 竞品),选 Yjs。如果你做的是数据密集型应用(表格、看板、CRM),选 ElectricSQL 或 PowerSync。

实战第一步:搭建本地优先的数据层

先看项目结构:

src/
  db/
    client.ts        # 本地数据库客户端
    schema.ts        # Drizzle ORM 表定义
    sync-rules.ts    # ElectricSQL Shape 定义
  hooks/
    useLiveQuery.ts  # 实时查询 hook
  components/
    TaskCard.tsx
    TaskBoard.tsx

安装依赖

npm install electric-sql @electric-sql/pglite drizzle-orm
npm install -D drizzle-kit @electric-sql/cli

定义本地数据表(Drizzle ORM)

// src/db/schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const tasks = pgTable('tasks', {
  id: uuid('id').defaultRandom().primaryKey(),
  title: text('title').notNull(),
  status: text('status', { enum: ['todo', 'in_progress', 'done'] })
    .default('todo'),
  assigneeId: text('assignee_id'),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

标准的 PostgreSQL 表定义——没有 Local-First 特有的东西。数据模型不需要为同步妥协,这正是 ElectricSQL 的卖点。

配置 Shape(订阅哪些数据)

// src/db/sync-rules.ts
import { ShapeStream } from 'electric-sql/client';

// Shape = 你感兴趣的查询结果集
// 服务端会持续推送这个查询的变更
export const taskShape = new ShapeStream({
  url: `${import.meta.env.VITE_ELECTRIC_URL}/v1/shape`,
  params: {
    table: 'tasks',
    // 只同步当前用户可见的任务
    where: `assignee_id = '${currentUserId}' OR assignee_id IS NULL`,
  },
  // 关键:首次加载后保持连接
  live: true,
});

这里有个细节:Shape 的 where 条件决定了每个客户端能看到多少数据。 我一开始不用 where,全表同步,结果用户 A 看到了用户 B 的草稿——权限漏了。

实战第二步:处理同步与冲突

这是最核心的部分。Local-First 的"魔鬼"都在这里。

第一个坑:乐观写入 → 本地生效 → 服务端确认

// src/db/client.ts
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';

const pg = await PGlite.create({
  dataDir: 'idb://my-app-db', // 持久化到 IndexedDB
  extensions: { live },
});

// 写入本地——立刻生效,不等待网络
export async function updateTaskStatus(taskId: string, newStatus: string) {
  // 1. 本地立即写入
  await pg.sql`UPDATE tasks SET status = ${newStatus}, updated_at = NOW()
               WHERE id = ${taskId}`;

  // 2. ElectricSQL 自动捕获这个变更并推送到服务端
  // 3. 如果服务端冲突(比如别人也改了这条),服务端会推送覆盖
}

看起来简单对吧?问题是如果你写入了本地不允许的数据——比如把 status 设成了 'deleted' 但枚举不允许——服务端会拒绝并推回原值。客户端会看到一个"闪现":先变成 deleted,再被拉回原值。

用户体验就是:按钮点了,好像是成功了,0.5 秒后状态又回去了,没有任何提示。

第二个坑:冲突处理的三种策略(选错就翻车)

我测试了三种冲突策略:

策略 1:Last-Write-Wins (LWW)——最简单但最危险

// 服务端策略配置
{
  conflict: 'lww',
  timestampField: 'updated_at',
}

谁最后写谁赢。听起来合理?实测场景:

  • 用户 A 离线状态下把任务标题改成"修复登录 Bug"
  • 用户 B 同时把同一个任务改成了"修复支付 Bug"
  • 两人都在线后,后同步的人覆盖先同步的人

用户 C 看到的结果取决于 A 和 B 谁先写完谁后同步——完全是运气。 我们接到 3 个工单说"数据丢了"。

策略 2:CRDT 自动合并(Automerge Core)——数据不丢了但逻辑可能错

ElectricSQL 底层用 PG 逻辑复制 + CRDT 做行级合并。对于同一条记录的不同字段,CRDT 可以合并:

A 改了 title = "修复 Bug"
B 改了 assignee = "张三"
合并结果:title = "修复 Bug", assignee = "张三" ✅

但如果两个人改了同一个字段,CRDT 会按版本向量合并。大多数情况下结果合理,但不是所有合并结果在业务上都是合法的

策略 3:操作转换 + 业务校验(OT + Validation)——我最终用的方案

// 服务端:收到本地变更后做业务校验
async function handleRemoteChange(change: Change) {
  const { table, rowId, data, meta } = change;

  // 业务规则校验
  if (table === 'tasks' && data.status === 'done') {
    const task = await db.select().from(tasks).where(eq(tasks.id, rowId));
    if (!task.subtask.every(s => s.status === 'done')) {
      // 拒绝变更,推回旧值
      await pushBackOldValue(rowId);
      await notifyClient(rowId, 'REJECTED',
        '子任务未完成不能标记为 done');
      return;
    }
  }

  // 通过校验,接受变更
  await applyChange(change);
}

关键点:服务端不是无脑合并,而是要加上业务规则校验。 Local-First 不等于"放弃服务端控制"。

实战第三步:离线体验——真正的分水岭

联网状态下 Local-First 和传统架构差距不大。一旦断网,差距才真正显现。

实现离线队列

// 内置——ElectricSQL 自动管理操作队列
// 离线时写入堆积在本地,恢复连接后按序推送到服务端

// 监听同步状态,给用户反馈
const { electric } = useElectricProvider();

function SyncIndicator() {
  const [status, setStatus] = useState('synced');

  useEffect(() => {
    const unsub = electric.subscribeToSyncStatus((event) => {
      switch (event.status) {
        case 'connected':
          setStatus('synced');
          break;
        case 'connecting':
          setStatus('connecting...');
          break;
        case 'disconnected':
          setStatus('offline');
          break;
      }
    });
    return () => unsub();
  }, []);

  return (
    <div className={`sync-indicator ${status}`}>
      {status === 'offline' ? '⚡ 离线模式' : '✓ 已同步'}
    </div>
  );
}

实测数据:

  • 4G 网络:本地写入 2ms vs API 写入 180ms,快了 90 倍
  • 断网场景:功能完全可用,恢复连接后 3-5 秒完成同步
  • 冲突率:团队使用中约 2.3% 的操作发生冲突(主要是同一任务被多人同时修改状态)

最头疼的问题:首次加载

第一次打开应用时,本地数据库是空的。需要从服务端拉全量数据。

# ElectricSQL 的 Shape 首次同步
# 我的生产数据:5000 条任务记录
# 首次同步耗时:约 8 秒(WebSocket)
# 数据体积:~2.3MB

8 秒的白屏没法接受。我的解法:

// 提前预置"骨架数据"到本地
// 用 Service Worker 在后台预热缓存
self.addEventListener('install', async () => {
  // 提前初始化 PGLite 并拉取常用数据
  const initDb = await PGlite.create({ dataDir: 'idb://my-app-db' });
  await initDb.sql`CREATE TABLE IF NOT EXISTS tasks (...)`;
  self.skipWaiting();
});

加上 Service Worker 预热后,首次打开从 8 秒降到 1.2 秒——用户体验可接受。

踩坑总结

1. 不要信任 CRDT 能自动合并所有场景

CRDT 保证数据不丢,但不保证合并结果有业务意义。状态机类字段(如 status: todo→in_progress→done)必须有服务端校验。

2. 权限必须在服务端,不在客户端

本地数据可以随便读,但同步到服务端时必须过权限校验。否则用户改了本地 SQL 就能看到别人数据。

3. 同步延迟是必然的,UI 要预留状态

即使 ElectricSQL 声称"实时同步",实测从写入到其他客户端看到更新,平均延迟 200-500ms。不要假设所有设备在同一时刻看到相同数据。

4. 离线数据膨胀问题

用户离线 3 天,本地积压了 2000 条操作记录。恢复连接时同时往服务端推送,压力瞬间飙升。我加了批量提交操作压缩才解决:

// 批量提交,而不是逐条推送
const BATCH_SIZE = 50;
const pendingOps = getPendingOps();

for (let i = 0; i < pendingOps.length; i += BATCH_SIZE) {
  const batch = pendingOps.slice(i, i + BATCH_SIZE);
  await electric.syncBatch(batch);
}

我现在怎么选架构?

做这个项目之前,我所有新项目都是"缺省 API 优先"。现在决策标准变了:

  • 单用户工具(笔记、记账):直接 Local-First,同步是 bonus
  • 多人协作(项目管理、文档):Local-First 值得,但要算冲突处理成本
  • 金融/合规类:别碰 Local-First,服务器权威模型更适合

Local-First 不是银弹,但对"用户期望像原生应用一样快"的产品,它是目前最现实的答案。


延伸思考: 如果 WebSocket 同步不可靠怎么办?我下一轮实验打算试试 ElectricSQL 的 HTTP 长轮询 fallback + 端到端加密。另一个没解决的问题是——多设备同时离线编辑同一份数据,冲突率会从 2% 飙升到多少?有结论了再来汇报。

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4178.html ,谢谢支持!

资源分享

分类:Android 标签:
php详细介绍正则表达式实际用法 php详细介绍正则表达式实际用法
Ubuntu更新python3 Ubuntu更新python3
python去掉目录最后一个斜杠几种方法 python去掉目录最后一个斜杠几种
浅谈json的封装和解析 浅谈json的封装和解析

评论已关闭!