一、结论订正 (第二次, 前两次均被测量方式误导) 初次把 ~14s 延迟归因于 nginx 未关 proxy_buffering —— 实测被推翻: 加了 proxy_buffering off / proxy_read_timeout 1h 并热重载, nginx -T 确认生效, 延迟毫无变化。 (该配置已回滚: advanced_config 清空、生效 conf 还原、nginx 已重载。保留它反而会让 OTA 大包 下载失去缓冲保护, 属净负收益。) 随后把 ~15-30s 延迟归因于客户端 db.watch/渲染滞后 —— 同样被推翻: 错误在于测量方法, 用 CDP 反复读 document.body.innerText (长列表页开销大) 去轮询, 且把 ssh + docker exec 的 ~6s 开销算进了延迟。 改为**页面内自计时**后 (50ms 轮询本地库 + MutationObserver 观察 DOM, 各自记录 performance.now), 一次干净测量的结果: - psql 命令总耗时 (ssh + docker exec + UPDATE): 2138ms - 数据出现在客户端本地库: 2251ms (≈ 命令返回后 113ms) - 界面更新: 2580ms - 同步数据到达 -> 渲染完成: 329ms 即端到端约 1~2s, 其中 UI 渲染只占 ~0.33s, useDbSignal 的表级 watch 工作正常, 无需改用 useQuery。服务端侧的 powersync 日志也一致 (checkpoint_complete 与改库同一秒, stream_ms 达 42 分钟、close_reason: client closing stream, 长连接稳定)。 二、补回滚开关 文档验收标准里的"回滚开关"此前并未实现。useDbSignal 现在读取 localStorage['iboard_realtime'] === '0' 时完全不订阅, 退回手动刷新, 用于线上出问题时的快速止损 (重启应用生效)。 三、文档 §7.0 改为"端到端约 1~2s (达标)", 写入可复现的页面内自计时测量方法, 并留档两次被推翻的结论及各自错因, 避免后人重复踩坑。§6.2 验收标准同步更新。
15 KiB
任务模块「即时同步」方案
状态:方案设计,未实施(本文只描述做法,不含代码改动) 范围:任务模块(Project / BoardColumn / Task / TaskMember / TaskTag) 相关文档:0.2.1 实施方案、PowerSync 可行性研究报告 编写时间:2026-10-27
1. 问题与现状
1.1 现象
多端(Windows 外壳 / Android / Web)同时打开同一项目时:
- A 端点完成或改状态,B 端不会自动更新;
- 必须切到别的页面再切回来(组件重新挂载触发重新查询)才能看到;
- 看板分组(按
columnId)与详情状态(按status)也会出现一段时间的显示不一致。
1.2 根因:不是"同步没到",而是"到了没人通知界面"
PowerSync 的下行链路本身是推送式的(服务端 PG 逻辑复制 → sync service → WebSocket → 客户端写入本地 SQLite),延迟通常在亚秒级。问题出在消费端:
| 层 | 现状 | 结论 |
|---|---|---|
| 传输层 | PowerSyncProvider 已在 src/App.jsx 挂载,连接正常 |
✅ 正常 |
| 数据层 | 变化已写入本地 SQLite | ✅ 正常 |
| UI 层 | 全部是命令式读取:db().getAll(...),读完就不再看 |
❌ 断点在这 |
具体地:
src/api/task.js的projectApi.list/detail、taskApi.list都是一次性getAll(), 且在 JS 里做二次装配(子任务、成员、标签、列),没有任何订阅;src/lib/powersync/react.jsx虽然 re-export 了useQuery/useStatus, 但全项目一次都没用过(grep useQuery src只命中该文件的导出语句);src/pages/task/TaskPage.jsx、ProjectView.jsx只在useEffect(..., [load])里加载一次;- 唯一的刷新机制是
src/hooks/useRefreshOnVisible.js(回前台/窗口聚焦时重查), 而且任务模块这两个页面根本没有用它(只有打卡MergedView/ListView与宏观各页在用)。
所以「切面板能刷新」只是因为它重新挂载组件 → 重新执行一次 load()。
1.3 现状刷新机制盘点
| 页面 | 首次加载 | 回前台刷新 | 数据变化实时反映 |
|---|---|---|---|
TaskPage |
useEffect |
❌ 无 | ❌ |
ProjectView |
useEffect |
❌ 无 | ❌(仅本地操作后手动 load()) |
TaskDetail |
打开时传入快照 | — | ❌(快照可能过期) |
MergedView(打卡) |
useEffect |
✅ | ❌(且打卡走 REST,非 PowerSync) |
2. 目标与非目标
2.1 目标
- 跨端即时:一端修改,其它端在 ≤1s 内自动呈现(不含网络中断场景);
- 不打断操作:正在输入的详情表单、正在拖拽的卡片不被远端变更打断;
- 离线可用:断网期间照常读写本地库,恢复后自动补齐并刷新;
- 改动可控:优先复用现有装配逻辑(
taskApi的 shape),不做大规模重写; - 可回滚:通过开关或逐步替换,出问题能退回"手动刷新"。
2.2 非目标
- 不做多人协同编辑(同一字段并发写仍按 LWW 后到覆盖);
- 不解决打卡/清单模块的实时性(它们走 REST,见 §7);
- 不引入 WebSocket 之外的额外实时通道(如 SSE)。
3. 方案选型
| 方案 | 做法 | 优点 | 缺点 | 结论 |
|---|---|---|---|---|
| A. 轮询 | 定时(如 3s)重跑 load() |
改动最小 | 无谓查询、电量/流量、仍不"即时" | ❌ |
| B. 全量响应式 | 把 taskApi.list 整体改成一条大 SQL + useQuery |
最"正统",细粒度 | 现有装配(子任务/成员/标签)要在 SQL 里重写;改动面大、回归风险高 | ⏳ 后续 |
| C. 变更信号 + 重算(推荐) | 用 db.watch() 订阅一个轻量"信号查询",信号变化时重跑现有 load() |
复用现有装配逻辑;改动集中且小;天然合并多次变更 | 每次重算一次列表(数据量小,可接受) | ✅ P0 采用 |
| D. 表级广播 | 监听"某表有变更"事件触发重查 | 实现最简单 | 无法区分项目/范围,易全页面重查 | 作为 C 的退化实现 |
结论:P0 用方案 C;跑稳后再评估把高频视图(看板)迁到 B 的增量查询。
4. 推荐方案详设(方案 C)
4.1 核心思路
本地 SQLite ←(PowerSync 下行写入)— 任一端/服务端变更
│
└─▶ db.watch(<信号查询>) ── 变化 ──▶ 节流合并 ──▶ 重跑 load()
└─▶ setState 渲染
"信号查询" 是一段廉价但能覆盖所有相关表的 SQL:只要任务域的任一相关行变化, 其结果就会变;它不承担业务语义,只负责触发。
4.2 订阅范围:用 SDK 的表级过滤(已实施)
初版设计是手写一段"信号 SQL"(count / sum / max(updatedAt) 汇总), 实施时发现
db.watch()原生支持tables表名过滤 与throttleMs节流, 由 SDK 判断"结果是否真的变了",比自己做汇总更可靠,因此改为表级过滤。
CommonPowerSyncDatabase.watch(sql, params, handler, options) 的 options:
| 选项 | 用途 |
|---|---|
tables: string[] |
只关心这些表的变更,SDK 内部按表失效 |
throttleMs |
两次回调之间的最小间隔,天然合并同一批同步写入 |
signal: AbortSignal |
卸载时取消订阅 |
订阅内容(src/lib/powersync/useDbSignal.js):
db.watch('SELECT 1 AS s', [], { onResult, onError }, {
tables: ['Task', 'BoardColumn', 'TaskMember', 'TaskTag'],
throttleMs: 250,
signal: ac.signal,
})
查询本身(SELECT 1)没有业务含义,只借 watch 的变更通知能力。表名集合定义在
src/api/task.js:
| 常量 | 内容 | 用于 |
|---|---|---|
TASK_TABLES |
Task, BoardColumn, TaskMember, TaskTag |
项目内任务列表 / 看板 |
PROJECT_TABLES |
Project, ProjectMember |
项目列表、成员 |
之所以不需要 max(updatedAt):表级过滤关心的是"行有没有被写过",
而不是"结果集内容是否变化",所以改标题、改状态这类不增删的写入同样会触发。
4.3 UI 层接入
提供两个小工具,避免每个页面各写一套:
| 工具 | 位置(建议) | 作用 |
|---|---|---|
useDbSignal(sql, params, onChange) |
src/lib/powersync/useDbSignal.js(新增) |
封装 db.watch() + 节流 + 卸载清理 |
useTaskListSignal(projectId, onChange) |
src/api/task.js 或同目录(新增) |
拼好上述信号 SQL,页面只关心"变了要干嘛" |
接入示意(ProjectView):
// 现状
useEffect(() => { setTasks(null); load() }, [load])
// 改造后(示意)
useEffect(() => { setTasks(null); load() }, [load])
useDbSignal(TASK_LIST_SIGNAL_SQL, [projectId, projectId, projectId, projectId, projectId, projectId], () => {
if (dragActiveRef.current) return // 见 §4.4
load()
})
TaskPage 同理,信号为项目表。
4.4 变更合并与"不打断"策略
这是本方案的关键,否则会出现「打字打到一半表单被重置」「拖到一半卡片跳走」。
| 场景 | 策略 |
|---|---|
| 连续多次变更(一次同步写多行) | useDbSignal 内部 150~300ms 防抖,合并成一次 load() |
| 看板/列表拖拽进行中 | 用 dragActiveRef 跳过本次刷新;拖拽结束后补一次 load() |
| 任务详情页打开中 | 不自动刷新表单;改为监听该任务是否变化,变化时在详情顶部显示「该任务已在他端更新,点击刷新」 |
| 正在编辑标题/描述 | 同上传:不覆盖输入框,只提示 |
本地乐观更新进行中(updateTask 未完成) |
以本地 patch 为准,load() 延后到请求结束(现有 updateTask 已有 snapshot 回滚,保留) |
4.5 同步状态指示
用已导出的 useStatus() 在任务页顶部显示一个极轻量的状态点:
| 状态 | 展示 |
|---|---|
connected && dataFlowStatus.connected |
不显示(无干扰) |
| 正在下载/上传 | 细小的同步图标 |
| 离线 / 连接失败 | 「离线,改动将在联网后同步」 |
说明:当前外壳里 PowerSync 会打
Multiple tab support is not enabled的提示, 属预期(已在 0.2.1 关闭多标签以避免 Windows reload 后本地库挂起),不影响本方案。
4.6 离线与重连
- 断网:
db.watch()仍监听本地库,本地写入照常触发刷新(体验一致); - 恢复:PowerSync 自动重连并回放,写入本地库后信号触发刷新,无需额外逻辑;
- 应用回到前台:保留
useRefreshOnVisible作为兜底(WebView 可能挂起渲染线程), 与db.watch()并存不冲突(两者都只是调load())。
5. 改造清单(按文件)
| 文件 | 改动 | 状态 |
|---|---|---|
src/lib/powersync/useDbSignal.js |
新增:db.watch + 表过滤 + 节流 + 卸载清理 |
✅ P0 已完成 |
src/api/task.js |
新增 TASK_TABLES / PROJECT_TABLES 常量 |
✅ P0 |
src/pages/task/ProjectView.jsx |
订阅任务域;拖拽中延后刷新;回前台兜底 | ✅ P0 |
src/pages/task/TaskPage.jsx |
订阅项目域;回前台兜底 | ✅ P0 |
src/pages/task/BoardView.jsx / ListView.jsx |
上报 onDragActiveChange |
✅ P0 |
src/pages/task/TaskDetail.jsx |
「已在他端更新」提示(不覆盖表单) | ⏳ P1 |
| 同步状态指示 | 用 useStatus() 显示离线/同步中 |
⏳ P1 |
不需要改动:server/、sync-config.yaml、数据库结构。
6. 分期与验收
6.1 分期
| 阶段 | 内容 | 预估 |
|---|---|---|
| P0 | useDbSignal + ProjectView/TaskPage 接入 + 拖拽保护 |
0.5~1 天 |
| P1 | 详情页「已在他端更新」提示 + 同步状态指示 | 0.5 天 |
| P2 | 看板改增量查询(方案 B);打卡/清单的实时性另议 | 按需 |
6.2 验收标准(可测)
- 跨端延迟:A 端改状态/拖列/改标题,B 端自动更新。实测 约 1~2s(数据进本地库约 1s,渲染 +0.3s,见 §7.0);
- 不打断:B 端正在输入标题时,A 端改同任务描述 → B 端输入框内容不被清空,仅出现提示;
- 拖拽安全:B 端拖拽中收到远端变更 → 卡片不跳位,松手后列表更新;
- 离线:断网期间本地操作正常,恢复后自动补齐并刷新;
- 回滚:
localStorage['iboard_realtime'] = '0'后重启应用,退回"手动刷新",无副作用; - 性能:单项目 100 条任务、连续 20 次远端写入,页面无卡顿、无重复渲染风暴(可用 Performance 面板核对)。
6.3 度量
- 在
load()内打点(可临时console.time),统计重算耗时与触发次数; - 目标:一次远端变更只触发 1 次 重算;100 条任务时单次重算 < 60ms。
7. 已知边界与后续
7.0 实测:端到端约 1~2s(达标)
P0 落地后在真实 Windows 外壳上实测,服务端直接改库模拟另一端:
| 项 | 结果 |
|---|---|
| 是否还需要切页面 | 不需要 —— 服务端改完,页面在无任何操作的情况下自动更新 ✅ |
| 端到端延迟 | 约 1~2s(数据进本地库约 1s,渲染再 +0.3s)✅ |
测量方法(关键:早期结论曾被测量方式误导两次,务必按此方法复测)
测量必须在页面内自计时,不能用 CDP 轮询 DOM:
// 页面内: 50ms 轮询本地库 + MutationObserver 观察 DOM, 各自记录 performance.now()
const P = { startedAt: performance.now(), dbAt: null, domAt: null }
setInterval(async () => {
if (P.dbAt !== null) return
const row = await window.__psdb.get('SELECT title FROM Task WHERE number = ?', [16])
if (row && row.title.includes(MARK)) P.dbAt = performance.now() - P.startedAt
}, 50)
new MutationObserver(check).observe(document.body, { childList: true, subtree: true })
一次干净测量的结果(已重载页面、无残留定时器):
| 度量 | 数值 |
|---|---|
psql 命令总耗时(ssh + docker exec + UPDATE) |
2138ms |
| 数据出现在客户端本地库(相对计时起点) | 2251ms → ≈ 命令返回后 113ms |
| 界面更新(相对计时起点) | 2580ms |
| 同步数据到达 → 渲染完成 | 329ms |
结论:服务端改库到界面呈现约 1~2s(含 SSH/psql 开销在内的保守上界),其中 UI 渲染只占 ~0.33s,
useDbSignal 的表级 watch 工作正常,无需改 useQuery。
两次被推翻的中间结论(留档,避免重复踩坑)
| 曾经的判断 | 为何是错的 |
|---|---|
① "nginx proxy_buffering 导致按批下发" |
加了 proxy_buffering off 等配置并热重载(nginx -T 确认生效),延迟毫无变化。该配置已回滚(advanced_config 清空、生效 conf 还原、nginx 已重载)—— 保留它反而会让 OTA 大包下载失去缓冲保护 |
② "同步数据早在服务端发出,客户端 db.watch/渲染滞后 15~30s" |
错在测量:用 CDP 反复读 document.body.innerText(长列表页上开销很大)轮询,且把 ssh + docker exec 的 ~6s 开销算进了延迟。改为页面内自计时后,实测只有 |
服务端侧的旁证(与上述一致):powersync 日志 checkpoint_complete 与改库同一秒完成;
stream_ms 达 42 分钟、close_reason: client closing stream,长连接稳定,非频繁重连。
| 项 | 说明 |
|---|---|
| 打卡 / 清单 | 走 REST(src/api/checkin.js),不经过本地库,本项目方案不覆盖;如需实时,可复用现有 useRefreshOnVisible + 定时轮询,或后续把这些模块也迁到 PowerSync |
| 并发写冲突 | 仍为 LWW(updatedAt 后到覆盖);本文不引入人工合并 |
| 排序并发 | taskApi.reorder 会整批重写 sortOrder,两端同时拖拽可能相互覆盖;建议 P2 再引入"版本号/操作序列" |
| 大列表 | 方案 C 每次全量重算,任务数极大(>1000)时需转方案 B 的增量查询 |
| WebView 后台节流 | 应用在后台时 watch 回调可能被延后,回前台由 useRefreshOnVisible 兜底 |
8. 附:关键 API 备忘
| API | 来源 | 用途 |
|---|---|---|
db.watch(sql, params, options) |
@powersync/common CommonPowerSyncDatabase |
订阅查询结果,返回 AsyncIterable |
useQuery(sql, params, options) |
@powersync/react |
React 版订阅;options.rowComparator 可做增量更新(P2 用) |
useStatus() |
@powersync/react |
连接/同步状态 |
getPowersyncDb() |
src/lib/powersync/db.js |
本地库单例(当前唯一入口) |
注:
src/lib/powersync/react.jsx已 re-exportuseQuery/useStatus/usePowerSync, 本次改造无需新增依赖。
9. 结论
当前"必须切面板才刷新"不是同步没到,而是 UI 从未订阅本地库变化。 采用「变更信号 + 复用现有装配重算」(方案 C)可在改动面很小的情况下获得亚秒级跨端同步, 并把"不打断编辑/拖拽"作为硬性要求一并纳入。P0 约半天到一天即可上线验证。