Electric 写入模式实战:从在线写入到通过数据库同步的四种本地优先方案(write-patterns 示例深度解析)
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
本指南以仓库中 examples/write-patterns 示例为核心,系统讲解在基于 Electric 的本地优先(local-first)应用中处理「写入」的四种模式:在线写入、组件级乐观更新、共享持久化乐观状态、以及通过嵌入式数据库的自动同步。四种模式共用一个 React 待办应用,在同一页面并排运行,读者读完可以掌握每种模式的实现代码、适用场景、权衡取舍,以及如何在仓库中启动运行做行为对比。
一、示例概览:一个应用,四条写入路径
write-patterns是 Electric 官方仓库中的一个完整 React 示例应用。它的核心思路是:所有模式共享同一条「读路径」(read-path),即通过 Electric 将 Postgres 数据同步进本地应用;区别只在于「写路径」(write-path),即本地写入如何最终回到 Postgres。
主文档 README 明确指出,这四种模式对应 Electric 官方文档中的 Writes 指南,且示例被设计为把四种模式作为同一 React 应用的四个组件同时渲染在页面上,因此你可以并排观察它们的差异,并在不同网络连通性(如 DevTools 切换到 Offline)下验证各自行为。
从目录结构看,示例的代码组织非常清晰:
- 每个模式一个子目录,位于 patterns/ 下:
- 1-online-writes/:在线写入
- 2-optimistic-state/:组件级乐观状态
- 3-shared-persistent/:共享持久化乐观状态
- 4-through-the-db/:通过数据库同步(本地嵌入式 PGlite)
- 共享代码位于 shared/:包括 Express API 服务端 api.js、前端 API 客户端 client.ts、应用配置 config.ts 以及两份数据库迁移脚本。
- 顶层还有 package.json(脚本与依赖)、vite.config.ts、sst.config.ts 等工程配置。
依赖方面,示例使用@electric-sql/client、@electric-sql/react、@electric-sql/experimental(提供matchStream/matchBy等流匹配工具)、@electric-sql/pglite与@electric-sql/pglite-react(模式四),状态管理选用valtio(模式三),服务端使用express、pg、zod,前端框架为 React 19 RC。所有包版本可通过 package.json 查看。
二、共享基础设施:迁移、API 与弹性客户端
在展开四种模式之前,先理解示例的共享部分,因为所有模式都构建在其上。
2.1 数据库迁移:todos表与write_id字段
Postgres 侧的 schema 由两份迁移脚本定义:
- shared/migrations/01-create-todos.sql 创建基础表:
CREATE TABLE IF NOT EXISTS todos ( id UUID PRIMARY KEY, title TEXT NOT NULL, completed BOOLEAN NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL );并插入一条初始数据'Get stuff done',便于启动后立即看到效果。
- shared/migrations/02-add-write-id.sql 为表追加一个可选的
write_id列:
ALTER TABLE todos ADD COLUMN write_id UUID;该迁移的注释解释了它的用途:对较简单的模式并非必需,但为高级模式提供了一个按操作匹配复制流、从而失效本地状态的键。关键洞察在于:按每次操作的write_id而不是仅按行的id匹配,可以在其他用户并发修改同一行时,把本地乐观状态「rebase」到新的数据之上——因为只有当你自己的写入从复制流同步回来时才会清除本地状态,而不是别人的写入。
2.2 API 服务端:REST 写路径与 shape 代理
shared/backend/api.js 是一个 Express 服务(默认端口3001,连接字符串默认postgresql://postgres:password@localhost:54321/electric,均可用环境变量PORT、DATABASE_URL覆盖)。它提供:
GET /todos:不是直接查库,而是把请求代理给 Electric 的 shape 端点。代码中通过ELECTRIC_PROTOCOL_QUERY_PARAMS(来自@electric-sql/client)只透传 Electric 协议参数,服务端固定设置table=todos,并在存在ELECTRIC_SOURCE_ID/ELECTRIC_SOURCE_SECRET时附加认证参数,最后把 Electric 的 Web Stream 转为 Node.js 流透传给客户端。这意味着前端永远只跟自己的 API 打交道,而 Electric 的同步细节被封装在后端——这正是「复用现有 API」这一写路径思路的体现。POST /todos、PUT /todos/:id、DELETE /todos/:id:使用zod校验输入(如idSchema = z.string().uuid()),再执行对应的 SQL(createTodo/updateTodo/deleteTodo)。注意createTodo与updateTodo都会把请求体里的write_id(可选)一并写入数据库,供复制流匹配用。POST /changes:专门为模式四设计,接收按事务分组的变更列表(transactionsSchema校验),在单个 Postgres 事务内逐个应用 insert/update/delete,成功COMMIT、失败ROLLBACK。
2.3 弹性 API 客户端:3 分钟退避重试
shared/app/client.ts 封装了所有写路径共用的请求逻辑,其「弹性」体现在resilientFetch/retryFetch:遇到网络错误时,最多重试maxRetries = 32次,退避延迟按retryCount * backoffMultiplier(1.1) * initialDelayMs(1000)增长,即延迟从 1 秒缓慢增长到 20 秒,总重试时长约 3 分钟。这为离线期间的写入提供了一个基本的“尽力而为”保障(源码注释还提示:若想对 4xx/5xx 也做重试,可在返回前检查 status)。
配置方面,shared/app/config.ts 定义API_URL(默认http://localhost:3001,可用VITE_SERVER_URL覆盖)与TODOS_URL = ${API_URL}/todos,所有模式通过useShape({ url: TODOS_URL, ... })消费 shape 流。
三、模式一:在线写入(Online writes)
代码位于 patterns/1-online-writes/index.tsx。这是最朴素也最简单的方案:读路径用 Electric,写路径直接走 HTTP API,仅在线可用。
3.1 实现要点
组件用useShape订阅 Postgres 中的todos数据:
const { isLoading, data } = useShape<Todo>({ url: TODOS_URL, parser: { timestamptz: (value: string) => new Date(value), }, }) const todos = data ? data.sort((a, b) => +a.created_at - +b.created_at) : []注意parser.timestamptz把 Electric 流中timestamptz类型的字符串解析成Date——四个模式的useShape配置完全一致。
用户事件的处理函数直接await api.request(...),例如创建:
async function createTodo(event: React.FormEvent) { event.preventDefault() const form = event.target as HTMLFormElement const formData = new FormData(form) const title = formData.get(`todo`) as string await api.request(`/todos`, `POST`, { id: uuidv4(), title: title, created_at: new Date(), }) form.reset() }更新与删除同样是对/todos/:id发起PUT/DELETE。在请求完成前,UI 不会出现新数据(客户端有 3 分钟退避重试,但用户界面上仍然要等写入成功)。
3.2 收益与代价
子文档 1-online-writes/README.md 明确给出了它的定位:
收益:
- 实现非常简单;
- 可以直接复用你已有的 API 体系;
- 阅读数据可以很快、且离线可读(因为读路径由 Electric 同步缓存支撑)。
适用场景:实时仪表盘、数据分析和可视化;在云端生成 embedding 的 AI 应用;以及写操作本质上就要求在线的系统,比如支付。
代价:
- 写路径上有网络往返——慢、有延迟、伴随 loading 转圈;
- 交互型应用若要求离线可用,必须升级到带本地乐观状态的方案(模式二)。
仓库中的 Phoenix LiveView 示例(即 examples/phoenix-liveview/README.md)也实现了同一种模式:用 Electric 把数据流式送进 LiveView 客户端,写入则走普通 Phoenix API——如果你主要使用 Elixir 生态,可以对照参考。
四、模式二:乐观状态(Optimistic state)
代码位于 patterns/2-optimistic-state/index.tsx。它在模式一的基础上,用 React 19 内置的useOptimistic钩子,把网络移出写路径:写入立即显示,等 API 成功、再等数据通过 Electric 同步回来之后才丢弃本地临时状态。
4.1 实现要点
组件额外从useShape返回值里取出stream(ShapeStream 实例),用于监听自己的写入何时从服务器同步回来:
const { isLoading, data, stream } = useShape<Todo>({ ... })乐观状态通过useOptimistic定义——第一个参数是「真实」数据(来自同步的sorted),第二个参数是 reducer,把一次本地写操作合并进列表:
const [todos, addOptimisticState] = useOptimistic( sorted, (synced: Todo[], { operation, value }: Write) => { switch (operation) { case `insert`: return synced.some((todo) => todo.id === value.id) ? synced : [...synced, value as Todo] case `update`: return synced.map((todo) => todo.id === value.id ? { ...todo, ...value } : todo ) case `delete`: return synced.filter((todo) => todo.id !== value.id) } } )写入处理函数把api.request与「等待同步」的matchStream放进同一个startTransition:
startTransition(async () => { addOptimisticState({ operation: `insert`, value: data }) const fetchPromise = api.request(path, `POST`, data) const syncPromise = matchStream( stream, [`insert`], matchBy(`id`, data.id) ) await Promise.all([fetchPromise, syncPromise]) })这里matchStream(stream, [operation], matchBy('id', id))来自@electric-sql/experimental,它的作用是把 Promise 挂到 shape 流上,直到出现匹配该id的对应操作(insert/update/delete)才 resolve。注释特别强调:本地乐观状态的生命周期覆盖两个阶段——(1) HTTP 请求进行中,(2) 直到写入经由 Electric shape 流同步回来。这与大多数只等 API 返回的乐观更新示例不同,是本地优先架构下需要额外注意的一点:数据必须「走完一圈」(本地 → API → Postgres → Electric → 本地)才算最终一致。
4.2 收益与代价
子文档 2-optimistic-state/README.md 的结论:
收益:实现简单;可复用现有 API;网络被移出写路径;应用读写都能离线、响应快速、无 loading 转圈。
适用场景:管理类应用与交互式仪表盘;追求「快」、想避免写入转圈的应用;网络状况不稳定场景下的移动应用。
代价(两个关键局限):
- 乐观状态只存在于发起写入的那个组件内部,其他渲染同一份数据的组件看不到它,可能显示陈旧数据;
- 乐观状态不持久化,组件卸载或页面刷新即丢失。
这两点正是模式三要解决的问题。
五、模式三:共享持久化乐观状态(Shared persistent optimistic state)
代码位于 patterns/3-shared-persistent/index.tsx。它用valtio 的可变响应式 store承载乐观状态,并在任何变更时持久化到 localStorage,从而让所有组件都能看到、并在页面刷新后仍保留本地写入。
5.1 实现要点:共享 store 与持久化
模块顶层定义了共享的optimisticState(一个proxyMap),初始化时从 localStorage 恢复,任何变更都写回:
const KEY = `electric-sql/examples/write-patterns/shared-persistent` const optimisticState = proxyMap<string, LocalWrite>( JSON.parse(localStorage.getItem(KEY) || `[]`) ) subscribe(optimisticState, () => { localStorage.setItem(KEY, JSON.stringify([...optimisticState])) })addLocalWrite为每次写入生成uuidv4()作为LocalWrite.id,把{ id, operation, value }存入 store;useSnapshot让组件响应式读取本地写入集合,再通过computeOptimisticStatereducer(结构与模式二的useOptimisticreducer 几乎一致)把同步数据与本地写入合并后渲染。
5.2 实现要点:以write_id匹配的 rebase 逻辑
matchWrite是模式三最值得细读的函数,子文档称其为merge logic:
async function matchWrite(stream, write): Promise<void> { const { operation, value } = write const matchFn = operation === `delete` ? matchBy(`id`, value.id) : matchBy(`write_id`, write.id) try { await matchStream(stream, [operation], matchFn) } catch (_err) { return } optimisticState.delete(write.id) }关键点(3-shared-persistent/README.md 的 Implementation notes):
- insert 和 update 用
write_id匹配,而不是id。请求发送时sendRequest会把write_id: id放进请求体(见下文),服务端写入 Postgres 的write_id列;当该行经 Electric 同步回来时,matchStream用write_id精确匹配到属于自己的那次写入。这样,其他用户对同一行的并发更新不会清除你的乐观状态,本地状态可以被 rebase 到最新数据上。 - delete 仍然按
id匹配,因为删除操作无法更新write_id列。若想支持「可回退的并发删除」,注释建议改用软删除——它本质上是 update。
sendRequest把本地写入发往 API,并在失败/非 2xx 时删除对应的乐观状态条目:
async function sendRequest(path, method, { id, value }) { const data = { ...value, write_id: id } let response: Response | undefined try { response = await api.request(path, method, data) } catch (_err) { /* ignore */ } if (response === undefined || !response.ok) { optimisticState.delete(id) } }写入流程与模式二对称:addLocalWrite→Promise.all([sendRequest(...), matchWrite(...)]),两个 Promise 都完成后,matchWrite内部已把该条目从共享 store 删除,乐观状态自然消失。
5.3 收益与代价
子文档对模式三的评价是:在设计空间中占据了一个很有说服力的位置——提供良好的 UX 和 DX,却不引入太多复杂度或重型依赖。
收益:
- 实现相对简单;
- 乐观状态持久化,让离线写入更有韧性;
- 乐观状态放在共享 store 中,所有组件都能看到并响应,避免了模式二「组件各自为政、显示陈旧数据」的弱点,更适合复杂真实应用;
- 把「不可变的同步状态」与「可变的本地状态」分离,让回滚策略容易推理与实现——回滚入口同时拥有本地写入上下文和共享 store,可以做比较精准的(surgical)回滚。
适用场景:构建本地优先软件;交互式 SaaS 应用;协作与创作类软件。
代价:
- 在读取时合并数据(on-read),本地读取会略微变慢;
- 写入仍需经过 API。这通常是有益且务实的(可复用现有 API),但如果你希望完全不运行 API、追求更纯粹的本地优先,则应考虑模式四。
六、模式四:通过数据库同步(Through-the-database sync)
代码位于 patterns/4-through-the-db/,包含 index.tsx、db.ts、local-schema.sql 与 sync.ts。它把「共享、持久的乐观状态」这一思路推进到底:本地嵌入一个 PGlite 数据库,应用代码直接读写一个统一视图,后台自动检测变更并同步到服务器。
子文档 4-through-the-db/README.md 总结的六步流程:
读路径(read path):
- 把 Electric 同步的数据放进不可变表(
todos_synced); - 把本地乐观状态持久化在影子表(
todos_local); - 用一个视图(
todos)把两者合并,提供统一的读写接口。
写路径(write path): 4.自动检测本地写入; 5. 写入一张变更日志表(changes); 6. 把变更POST给 API 服务器。
6.1 组件层:直接对本地数据库执行 SQL
index.tsx 中,Wrapper负责初始化 PGlite(loadPGlite())、启动ChangeLogSynchronizer(writePathSync.start()),并用PGliteProvider把数据库注入组件树;卸载时调用stop()。
业务组件ThroughTheDB用useLiveQuery查询视图,写入则是纯 SQL,例如创建:
await db.sql` INSERT INTO todos ( id, title, completed, created_at ) VALUES ( ${uuidv4()}, ${title}, ${false}, ${new Date()} ) `更新、删除同样是对todos视图执行UPDATE/DELETE。可以看到:应用代码里完全没有fetch、没有 API 调用、没有乐观合并逻辑——数据读取被useLiveQuery抽象,数据发送被 ChangeLog 机制抽象。这是它最接近「纯本地优先开发体验」的地方。
6.2 本地 schema:视图 + 三类触发器
local-schema.sql 完整定义了本地数据库结构,值得逐个拆解:
两张表 + 一个视图:
-- 不可变的同步状态 CREATE TABLE IF NOT EXISTS todos_synced ( id UUID PRIMARY KEY, title TEXT NOT NULL, completed BOOLEAN NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL, write_id UUID -- 记账列 ); -- 本地乐观状态(影子表) CREATE TABLE IF NOT EXISTS todos_local ( id UUID PRIMARY KEY, title TEXT, completed BOOLEAN, created_at TIMESTAMP WITH TIME ZONE, changed_columns TEXT[], -- 哪些列被本地改过 is_deleted BOOLEAN NOT NULL DEFAULT FALSE, write_id UUID NOT NULL ); -- 统一读写视图 CREATE OR REPLACE VIEW todos AS SELECT COALESCE(local.id, synced.id) AS id, CASE WHEN 'title' = ANY(local.changed_columns) THEN local.title ELSE synced.title END AS title, CASE WHEN 'completed' = ANY(local.changed_columns) THEN local.completed ELSE synced.completed END AS completed, CASE WHEN 'created_at' = ANY(local.changed_columns) THEN local.created_at ELSE synced.created_at END AS created_at FROM todos_synced AS synced FULL OUTER JOIN todos_local AS local ON synced.id = local.id WHERE local.id IS NULL OR local.is_deleted = FALSE;视图用FULL OUTER JOIN合并两表,并通过changed_columns决定每列取本地值还是同步值;is_deleted过滤软删除。这实现了「读取时合并」的全部逻辑,且对上层应用透明。
同步状态清理触发器(把「共享乐观状态」模式中的matchWrite下沉到了数据库层面):
CREATE OR REPLACE FUNCTION delete_local_on_synced_insert_and_update_trigger() RETURNS TRIGGER AS $$ BEGIN DELETE FROM todos_local WHERE id = NEW.id AND write_id IS NOT NULL AND write_id = NEW.write_id; -- 按 write_id 匹配,允许 rebase RETURN NEW; END; $$ LANGUAGE plpgsql;AFTER INSERT OR UPDATE ON todos_synced时触发;delete_local_on_synced_delete_trigger则直接按id删除本地行(删除不可并发回退,匹配id是安全的,若要可回退并发删除仍建议软删除)。这组触发器与模式三的matchWrite逻辑一一对应:当服务端写入同步进todos_synced时,自动清除对应的本地乐观状态。
写入捕获触发器(INSTEAD OF,让应用可以「直接写视图」):
todos_insert_trigger:检查id是否已存在于同步表/本地表(冲突则RAISE EXCEPTION),否则写入todos_local并记录 insert 变更到changes;todos_update_trigger:对比NEW与todos_synced,只把真正变化的列标记进changed_columns(首次更新插入本地行,后续更新则合并列集),并记录 update 变更;todos_delete_trigger:对todos_local做软删除(is_deleted = TRUE),并记录 delete 变更。
每次写入变更都会插入changes表并携带transaction_id(pg_current_xact_id())与本地生成的write_id。变更日志表定义为:
CREATE TABLE IF NOT EXISTS changes ( id BIGSERIAL PRIMARY KEY, operation TEXT NOT NULL, value JSONB NOT NULL, write_id UUID NOT NULL, transaction_id XID8 NOT NULL );最后,changes_notify_trigger在每次AFTER INSERT ON changes时执行NOTIFY changes——这是本地写路径的“事件源”,驱动后台同步。
6.3 后台同步器:ChangeLogSynchronizer
sync.ts 实现了一个最小但完整的同步器,用于说明「监听changes并 POST 给 API」的模式:
start():通过db.listen('changes', handler)(对应 PGlite 的listenAPI)订阅通知,并立即启动首轮process();handle():若正在处理则置位hasChangedWhileProcessing,否则直接process()——避免通知丢失;query():按id > #position拉取尚未同步的变更;send():把变更按transaction_id分组(Object.groupBy)并按事务 id 排序,构造{ id: transaction_id, changes: [...] }数组 POST 到/changes,返回三种结果之一:accepted/rejected/retry(网络错误或 5xx 为retry,其他 4xx 为rejected);proceed():服务端接受后,删除id <= position的已处理变更并推进游标;rollback():子文档特别提醒,这里的回滚策略非常朴素——只要有任何写入被服务端拒绝,就清空全部changes与todos_local。更精细的做法应只清除与失败写入存在因果依赖的本地状态,并向用户说明情况;stop():置shouldContinue = false、中止进行中的请求并取消订阅。
组件中一次离线创建的完整数据流为:INSERT INTO todos(视图)→INSTEAD OF触发器写入todos_local+ 插入changes→NOTIFY changes→ChangeLogSynchronizer拉取 → 分组 POST/changes→ 服务端事务写入 Postgres → Electric 复制回本地todos_synced→ 清理触发器按write_id删除本地乐观状态 → 视图回归纯同步数据。
6.4 收益与代价
收益(子文档原文要点):
- 完整的离线支持、共享乐观状态;
- 组件只与本地数据库交互,无需任何网络编码;
- 数据收发被完全抽象:读由 Electric 同步、写由变更消息日志处理。
适用场景:构建本地优先软件;移动与桌面应用;协作与创作类软件。
代价:
- 本地嵌入式数据库是一个相对重的依赖;
- 影子表与触发器机制使客户端 schema 定义变复杂;
- 后台同步让回滚处理变复杂:模式三能在处理用户输入时、上下文仍在的情况下发现写入被拒绝;而「通过数据库同步」时这个上下文很难重建。文档因此建议:如果这条路径的复杂度超出你的承受范围,可以考虑使用现有的本地优先框架(详见 Electric 官方 Writes 指南的 tools 一节)。
七、如何运行与验证
主文档 README 给出了标准启动流程。需要在仓库根目录先安装依赖并构建全部包:
pnpm install pnpm run -r build然后在示例目录启动 Docker 后端(会拉起 Postgres 与 Electric 同步服务,并自动执行db:migrate应用上述两份迁移):
pnpm backend:up启动开发服务器(package.json 中的dev脚本用concurrently同时运行 Vite 与node shared/backend/api.js):
pnpm dev结束后拆除后端容器:
pnpm backend:down运行后打开页面即可看到四个模式组件并排运行。想验证离线行为,可以在浏览器 DevTools 的 Network 面板把网络切换为 Offline(如示例截图所示),然后观察:模式一写入会一直等待;模式二在组件内立即显示;模式三即使在刷新页面后本地写入仍然保留(localStorage);模式四则完全由本地 PGlite 接管,恢复网络后后台自动补齐同步。仓库还提供 playwright.config.ts 与 Dockerfile,可供端到端验证与容器化部署参考。
八、四种模式对比与选型建议
| 维度 | 1. Online writes | 2. Optimistic state | 3. Shared persistent | 4. Through the DB |
|---|---|---|---|---|
| 写路径 | HTTP API(在线) | API + 组件内乐观状态 | API + 共享持久化乐观状态 | 本地 PGlite + ChangeLog 后台同步 |
| 离线写入 | 不支持(等待重试) | 支持(不持久) | 支持(持久) | 支持(持久) |
| 多组件一致 | 天然一致 | 不一致(陈旧) | 一致 | 一致 |
| 页面刷新保留 | — | 丢失 | 保留(localStorage) | 保留(PGlite) |
| 代码复杂度 | 最低 | 低 | 中 | 高(嵌入式 DB + 触发器 + schema) |
| 回滚能力 | 简单 | 简单 | 较精准 | 困难(建议用更精细策略) |
| 典型场景 | 仪表盘、AI 云端生成、支付类 | 追求响应速度的交互应用、移动端 | 本地优先 SaaS、协作创作 | 纯本地优先、移动/桌面应用 |
从源码结构与各子文档的结论可以看出一条清晰的演进主线:把网络从写路径上逐步移除。模式一把全部信任放在在线 API 上;模式二用useOptimistic把「等待」从用户感知中抹掉;模式三用共享 + 持久化解决了组件一致性与刷新丢失问题,代价仅是引入一个轻量状态库;模式四则用嵌入式数据库把「本地优先」贯彻到底——应用只跟一个数据库视图打交道,但复杂度也转移到本地 schema、触发器和后台同步上。
最终选型建议:能用模式三解决就用模式三(文档称之为设计空间中 UX/DX 的甜点);只有当你确定需要去掉 API、纯本地优先的移动/桌面体验时,才投入模式四,并务必为回滚设计更细致的策略。
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考