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),原因是:
- 我需要关系型数据(不是纯文档协作),Yjs 不合适
- 团队熟悉 SQL,不想为了同步学一套新数据模型
- 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% 飙升到多少?有结论了再来汇报。

评论已关闭!