单机边缘设备的离线数据同步实战:SQLite + WAL、断电恢复与冲突回放
一、场景:先说清约束,再谈方案
这篇文章的背景是一类很常见的单机边缘设备:门店收银终端、车间巡检平板、野外采集仪。它们平时可能只有间歇网络,甚至一整天都离线,但业务不能停:开单、巡检、采样都必须先落到本地,等网络恢复后再同步到云端。
这类系统有三个硬约束。第一,写入不能丢,断电、闪退、磁盘写满都不能让已经确认的业务凭空消失。第二,同步不能重复扣账、重复开单,网络抖动导致的重发必须被识别为同一笔。第三,设备算力和运维能力有限,不能在上面跑一个重型数据库集群,也不能指望现场人员手工修数据。
基于这三个约束,我的结论是:本地用 SQLite 开启 WAL 模式做唯一事实源,再用一张 outbox 表做可靠的同步载体,而不是直接在业务表上打补丁式地加同步字段。
二、为什么是 SQLite + WAL
SQLite 本身是单文件数据库,部署就是复制一个文件,备份就是复制一个文件,这对边缘设备非常友好。但默认的回滚日志模式在读多写多、且有长查询时容易出现写阻塞。开启 WAL 后,读和写可以走不同的文件,写操作追加到 WAL 文件,读操作看到的是一致快照,这就把“巡检员正在查历史记录,收银员正在开单”这种并发冲突降到了很低的水平。
关键配置并不复杂,但每一项都要有理由:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
synchronous = FULL 是不能省的底线。它保证事务提交返回成功时,数据已经真正落盘,而不是还停留在操作系统缓存里。很多“断电丢单”的故障,根因不是 SQLite 不可靠,而是为了性能把同步级别降到了 NORMAL 甚至 OFF,却又把它当作已持久化来用。边缘设备最怕的不是慢几毫秒,而是用户已经看到“提交成功”,重启后数据却不见了。
WAL 也带来一个运维义务:必须定期做检查点。WAL 文件如果长期不检查点,会越长越大,重启恢复时间也会变长。我的做法是:每次同步成功后,或者写入量达到阈值时,执行一次被动检查点;在设备空闲时段再做一次完整检查点,并记录检查点前后的 WAL 大小,便于发现异常增长。
三、数据模型:业务表与 outbox 表分开
不要直接把同步状态塞进业务表。业务表只表达业务事实,同步状态单独放在 outbox 表里,两者用同一个本地事务提交。这样要么业务和待同步事件一起落盘,要么一起回滚,不会出现“单已开、事件没发”或者“事件已发、单没落盘”的中间态。
CREATE TABLE orders (
id TEXT PRIMARY KEY,
amount_cents INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE outbox (
event_id TEXT PRIMARY KEY,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
cursor INTEGER NOT NULL
);
这里有两个设计要点。第一,event_id 必须全局唯一,建议用设备编号加本地单调序号组合,而不是只依赖随机数,这样服务端去重和事后对账都更容易。第二,cursor 是本地严格递增的序号,同步时按 cursor 顺序拉取,服务端按 cursor 做幂等写入,乱序和重发都不会破坏最终顺序。
四、同步协议:拉取、确认、再删
同步流程我坚持“先确认、后清理”:客户端按 cursor 批量上传,服务端落库并返回已确认的最大 cursor,客户端收到确认后才把对应的 outbox 记录标记为已同步或删除。千万不要在上传前就删除本地事件,否则一次网络超时就会造成永久丢数。
服务端写入必须是幂等的。同一 event_id 重复到达时,第二次应直接返回成功而不产生第二条业务记录。实现上可以用唯一索引加“存在即返回”,也可以用一张已处理事件表做去重。无论哪种方式,都要在上线前用真实的重发做测试:模拟客户端超时重发三次,检查云端订单数仍然是一条。
网络恢复后的第一次同步还要注意流量洪峰。设备离线一天可能攒下几千条事件,一次全量上传既容易超时,也容易把服务端打满。我的做法是分页上传,每页几十到几百条,失败的页单独重试,成功的页继续推进,并把当前 cursor 和失败次数打到本地日志里,便于远程排查。
五、冲突:不要幻想没有冲突
离线系统一定会遇到冲突。典型例子是:同一件商品在门店 A 被改价,在云端也被总部改价;或者同一张巡检单在两台设备上都被编辑过。解决冲突不能靠“后写胜出”一句话带过,因为后写胜出会悄悄丢掉一方的业务意图。
我的分层策略是:先按业务分级。金额、库存、价格这类涉及钱和账的字段,冲突时不自动覆盖,而是生成一条待人工复核记录,并保留双方版本;备注、状态备注这类非关键字段,可以按最后修改时间合并,但也要保留修改历史。所有冲突处理都要可审计:谁改的、旧值是什么、新值是什么、最终采用了哪一方,都要留痕,而不是只留最终结果。
对于确实可以自动合并的场景,建议用字段级合并而不是整行覆盖。例如巡检单的“检查项 A 结果”和“检查项 B 结果”来自不同设备时,字段级合并可以同时保留;整行覆盖则会丢掉其中一台设备的工作量。
六、断电恢复:必须真拔电测一次
上线前最值得做的一次测试,就是模拟断电。我的测试清单包括:在写入高峰期直接断电重启,检查已确认订单是否一条不少;在同步中途断网,检查重发后云端是否没有重复单;在 WAL 文件很大的情况下重启,检查恢复时间是否在可接受范围内。
还有一个容易被忽略的点:不要把 SQLite 文件放在网络文件系统或同步盘目录里。网络文件系统的锁语义和 SQLite 的预期并不一致,容易出现难以复现的损坏。本地盘加定期把数据库文件和 WAL 一起备份,才是稳妥做法。备份时必须先做检查点,或者连同 WAL 文件一起复制,只复制主文件而丢掉 WAL,等于丢掉了最近的写入。
七、上线检查清单
最后把这套方案收敛成一份可执行的清单:开启 WAL 并把同步级别设为 FULL;业务写入与 outbox 写入同事务;同步按 cursor 分页、服务端幂等、确认后清理;冲突分级处理且全程留痕;定期检查点并监控 WAL 大小;上线前做断电、断网重发、重复提交三项故障演练。
离线同步的难点从来不是某一个 API,而是把写入、同步、冲突、恢复当作一个整体来设计。把本地数据库当作事实源,把同步当作可重放、可确认、可审计的事件流,系统在断网时才能继续营业,在恢复后才能干净地对上账。
- 点赞
- 收藏
- 关注作者
评论(0)