题干与适用场景
Node.js 24 的 node:sqlite 提供同步 API。DatabaseSync 代表单个 SQLite 连接,createSession() 可追踪会话开始后的变化,session.changeset() 返回二进制 changeset,目标数据库可用 applyChangeset() 应用并在冲突处理器中选择中止、忽略或替换。请设计一个离线客户端与服务端之间的增量同步协议。
题目重点是“数据库变更如何安全传播”,包括主键、旧值校验、冲突策略、幂等、事务边界和权限。changeset 不是可直接执行的 SQL,也不等于跨数据库自动合并。
面试官考察点
强回答会区分源库生成 changeset、传输层可靠投递、目标库原子应用和业务级冲突决策。面试官会追问 Session 生命周期、changeset 与 patchset 的取舍、SQLITECHANGESETDATA 等冲突类型、重复应用、schema 演进,以及 DatabaseSync 同步执行对事件循环的影响。
只说“序列化数据库再覆盖”会丢失并发更新;只返回 SQLITECHANGESETREPLACE 会静默覆盖目标数据,无法证明业务正确。
回答前需要澄清的问题
同步拓扑与权威副本
确认是单向上行、双向同步还是多端汇聚,谁是每个实体的权威副本。若两端都能编辑同一行,需要版本、设备 ID 或业务事件来裁决,不能把 SQLite 的默认冲突回调当成业务规则。
版本与 schema 生命周期
确认 source 和 target 是否保证相同 schema、主键和列类型。changeset 依赖表结构;迁移必须先完成并标记协议版本,否则应用旧 changeset 可能失败或产生错误映射。
延迟与安全边界
确认 changeset 大小、重试窗口、是否允许离线数天以及数据是否含敏感字段。二进制 payload 需要完整性校验、身份认证、重放保护和加密;不能把它当作可信 SQL。
30 秒回答框架
“我为每个同步批次分配 ID 和基于源库版本的游标。源库创建 Session,完成本地事务后导出 changeset;服务端先校验 schema、签名、批次顺序和幂等状态,再在目标数据库事务中调用 applyChangeset。冲突回调默认中止并记录行、列和原因,只有业务明确允许时才忽略或替换。成功后持久化批次状态,失败使用指数退避重试,重复批次返回已处理结果。同步 API 放在线程或队列边界之外,避免长时间同步 SQLite 阻塞 Node.js 事件循环。”
分步骤深入解答
第一步:建立可追踪的批次
创建 Session 后只把一个明确的本地事务范围纳入批次。记录 batchId、源设备、起始游标、schema 版本、changeset 哈希和创建时间。调用 session.changeset() 得到 Uint8Array 后立即关闭或复用 Session,避免一个长期 Session 包含无法重放的过大历史。
第二步:选择 changeset 或 patchset
changeset 包含用于判断冲突的旧值信息,适合需要校验目标行是否仍符合预期的同步;patchset 更紧凑,但提供的旧值信息更少。先按带宽和冲突审计要求选择,不能只因为 payload 更小就牺牲可诊断性。
第三步:验证与幂等投递
传输层使用 TLS、设备身份和 payload 签名;服务端校验大小、哈希、schema 版本和批次来源。以 batchId 和源游标建立唯一记录,已成功应用的批次重复到达时返回已处理结果。后续批次必须按游标顺序应用,缺失批次进入等待队列而不是跳过。
第四步:在事务中应用并处理冲突
目标端开启事务并调用 applyChangeset。冲突处理器应把表、主键、冲突类型和目标值写入审计缓冲区;默认返回 SQLITECHANGESETABORT,让整个批次回滚。只有经过业务规则确认的字段才允许 OMIT 或 REPLACE,并把决策写入可重放日志。
第五步:设计业务合并
字段级可合并数据可以按时间戳、单调版本或集合并集处理;金额、库存和权限等不可盲合并数据应生成人工队列或补偿事件。冲突解决后不要直接修改原 changeset,生成带父批次和决策原因的新批次,保证审计链完整。
第六步:处理类型与整数精度
Node.js 与 SQLite 的类型集合不同。超出 JavaScript 安全整数范围的 SQLite INTEGER,在未启用 readBigInts 时读取可能抛出 ERROUTOF_RANGE;协议应统一使用 BigInt、字符串或明确范围。BLOB 使用 Uint8Array,不能把任意对象直接写入 SQLite。
第七步:控制同步执行成本
DatabaseSync 的 API 同步执行,长事务、巨大 changeset 或频繁 applyChangeset 会阻塞事件循环。把同步工作放到 worker、独立进程或受控队列,限制 payload 大小和每批行数,监控应用耗时、冲突率、回滚率和待处理批次年龄。
高质量示范回答
我会把同步批次视为不可变事件。设备为每批生成 ID、游标、schema 版本和哈希;Session 只覆盖已提交的本地事务,导出 changeset 后通过认证且可重放保护的通道发送。服务端验证结构与顺序,以批次 ID 做幂等,然后在目标 SQLite 事务中调用 applyChangeset。
冲突回调默认中止,让批次原子回滚并记录主键、冲突类型和目标值。库存、金额和权限不使用通用替换,交给业务合并或补偿队列;可安全覆盖的字段才允许忽略或替换。成功批次写入状态表后才确认游标推进。因为 DatabaseSync 会同步阻塞,我会把大批次放入 worker,并测试重复投递、缺失批次、schema 迁移、整数溢出和进程崩溃恢复。
常见错误
- 错误表现: 每次同步都覆盖目标数据库。→ 失败原因: 会删除目标端的独立更新。→ 修正方法: 传递 changeset,基于旧值检查冲突并记录决策。
- 错误表现: 所有冲突都返回
REPLACE。→ 失败原因: 业务数据被静默覆盖。→ 修正方法: 默认中止,按字段和业务不变量授权替换。 - 错误表现: 重试时生成新批次而没有幂等键。→ 失败原因: 重复应用或游标跳跃会造成重复副作用。→ 修正方法: 用 batch ID、源游标和应用状态去重。
- 错误表现: 在主事件循环中应用超大 changeset。→ 失败原因:
DatabaseSync同步执行,HTTP 和定时任务会被阻塞。→ 修正方法: worker 或队列隔离,并设置批次上限。
追问及应对
追问一:什么时候用 patchset?
当带宽受限且目标端已有足够上下文、冲突审计要求较低时可以用 patchset。需要比较旧值、解释冲突或做可审计合并时选择 changeset,并接受更大的 payload。
追问二:目标端 schema 少了一列怎么办?
先拒绝批次并返回兼容错误,不能让应用层猜测列映射。执行目标端迁移,确认 schema 版本与主键一致后再重放;若必须兼容多个版本,服务端按协议版本转换并保留原始 payload。
追问三:如何避免冲突回调中的副作用?
回调只收集结构化冲突信息并返回常量决策,不调用外部服务、不修改其他表。事务提交后再异步写审计或通知;这样回滚不会留下与数据库状态不一致的外部副作用。
追问四:为什么不直接传 SQL?
SQL 缺少源行旧值、表结构和批次边界,重试时难以判断是否已经应用,也容易把未授权语句传到目标端。changeset 由 SQLite 生成并可在目标端逐项处理冲突,更适合受控同步协议。
追问五:如何验证整数和 BLOB 的三语协议一致?
建立跨语言固定样例:最大安全整数、超范围整数、负值、NULL、UTF-8 文本和二进制 payload。对每个样例记录 SQLite 类型、Node.js 读写选项和序列化表示,确保重放前后字节和数值相同。