弱网环境离线优先:Android 终端数据可靠同步架构实战
场景:智能柜终端部署在工业内网,网络时断时续,设备可能断电重启,但人员、操作记录、绑定关系等数据必须可靠地与后台双向同步------不能丢、不能重、不能因为重启漏传。本文分享这套"离线优先"同步架构的几个核心设计:状态存库里、墓碑软删除、幂等键、分层重试节奏、两阶段补传、下行冲突防御。
一、总原则:状态存库里,不存内存里
整套架构只有一条铁律:所有待同步状态都以 SQLite 行级字段持久化,内存队列只是加速层,数据库才是唯一事实来源。
对应的字段散落在各张表上:
| 表 | 状态字段 | 语义 |
|---|---|---|
| 人员表 | update_level |
0 已同步 / 1 待新增 / 2 待更新 / 3 待删除(墓碑) |
| 人员表 | finger_sync_status |
指纹模板是否已推送后台 |
| 订单表 | uploaded + position_uploaded |
记录是否已上传 / 在位状态是否待补传 |
| 绑定记录表 | uploaded + failCount |
是否已上传 / 连续业务失败次数 |
App 重启后,各同步 Manager 在 Application.onCreate 统一 start(),第一件事就是扫描数据库恢复未完成任务。例如指纹上传的恢复逻辑(FingerprintPushManager.kt:115-160):
kotlin
private suspend fun restoreTasksFromDb() {
val unsyncedList = LitePal.where(
"(finger_sync_status != ? or finger_sync_status is null) and (...)", "1", ...
).find(PersonDbBean::class.java)
for (person in unsyncedList) {
pendingTasks[rp] = PushTask(rp, base64List)
if (processingRpSet.add(rp)) { // 防重复创建协程
GlobalScope.launch(Dispatchers.IO) {
try { processTask(rp) } finally { processingRpSet.remove(rp) }
}
}
}
}
断电、杀进程、升级覆盖安装------任何一种中断都不会丢任务,因为任务列表本身就在库里。
二、墓碑软删除:解决"本地删了但后台没收到"
人员删除是个经典两难:本地直接删,如果删除请求没到达后台,下次全量同步会把这个人重新拉回来(幽灵复活);不删又影响本地使用。
方案是 update_level 状态机 + 墓碑(PersonDbManager.kt:688-703):
kotlin
fun deleteUserByUserId(userId: String?) {
queryEnablePersonByUserId(userId)?.let {
if ("1" == it.is_user_sync) {
it.update_level = "3" // 后台已有此人 → 打墓碑,等后台确认后才真正删
it.save()
} else {
it.delete() // 后台本来就没有 → 直接硬删
}
}
}
配套规则:
- 所有业务查询统一过滤
update_level != "3"------墓碑对本地业务不可见; - 上传时墓碑随待同步队列一起上报,后台按
successUserRpList逐人确认后 ,墓碑才真正硬删、同步标志才清 0(UserRequest.java:228-254)------只处理后台明确确认成功的,没确认的下轮继续。
墓碑的代价与对策 :查询过滤墓碑导致一个隐蔽 bug------同一 RP 的"墓碑 + 新记录"可以共存。对策是在 5 个新增人员入口保存前统一调用 deleteDuplicatePersonByRp(rp),硬删同 RP 全部记录(含墓碑)并清理人脸缓存。软删除方案一定要考虑"同唯一键复活"问题。
三、幂等键:重试安全的前提
只要会重试,就必须保证重复上传不产生重复数据。两条链路各有一个幂等键:
操作记录:recordCode 。首次上传前生成 UUID 落库,之后的补传沿用同一编码(OperateRecordUploadManager.kt:94-141):
kotlin
if (order.record_code.isNullOrEmpty()) {
order.record_code = UUID.randomUUID().toString().replace("-", "")
}
一个值得注意的细节:归还单是通过 copyOrderInfo() 从领取单复制生成的,而 copyOrderInfo() 故意不复制 record_code------归还单必须有自己独立的编码,否则两单同码会被服务端幂等去重掉一条。
绑定关系:bindCode。绑定时生成 UUID 存到箱门记录上,解绑记录沿用同一 bindCode(服务端据此幂等关联绑定/解绑两个事件);本地同 bindCode 的绑定记录只生成一次:
kotlin
if (detail.bindCode.isEmpty()) {
detail.bindCode = UUID.randomUUID().toString()
detail.save()
}
// 同一 bindCode 的绑定记录只生成一次(绑定与上架两个时机都会触发到这里)
val exists = LitePal.where("bindCode = ? and recordType = ?", detail.bindCode, "0")
.count(BindRecordDbBean::class.java) > 0
if (exists) return
四、分层重试节奏:按数据价值定策略
同一个项目里四种数据四种重试策略,这是有意为之的分层:
| 数据 | 策略 | 理由 |
|---|---|---|
| 指纹模板 | 指数退避无限重试(30s→60s→120s→240s→300s 封顶) | 关键数据,必须到;退避避免打挂刚恢复的后台 |
| 操作记录 | 5s 匀速轮询,每轮一条 | 审计数据,量大,匀速控制流量 |
| 绑定记录 | 60s 轮询 + 业务失败 3 次熔断弃传 | 对账数据,容忍延迟 |
| 特征下发 | 5 分钟增量 + 每日 0~2 点随机分钟全量 | 下行大流量,随机错峰防多终端同时打满后台 |
指纹的指数退避实现(FingerprintPushManager.kt:214-241):
kotlin
private suspend fun processTask(rp: String) {
while (true) {
val task = pendingTasks[rp] ?: return
val success = doUpload(task)
if (success) {
if (pendingTasks.remove(rp, task)) { return } // remove 失败说明有新任务覆盖,继续处理新的
continue
}
// 30s * 2^retryCount,封顶 5 分钟
val multiplier = 1L shl minOf(task.retryCount, 10)
val delayMs = (INITIAL_RETRY_INTERVAL_MS * multiplier).coerceAtMost(MAX_RETRY_INTERVAL_MS)
task.retryCount++
delay(delayMs)
}
}
每日全量同步的随机错峰 也值得借鉴:0 点到 2 点之间随机选一个分钟执行(FeatureSyncRequest.java 调度逻辑),避免同一站点的多台终端在整点同时全量拉取打满后台------这就是分布式里的"防惊群"。
五、失败分类:业务失败和网络异常不是一回事
绑定记录上传区分两类失败(BindRecordUploadManager.kt:104-137):
kotlin
if (result.isSuccess()) {
record.uploaded = "1"
record.save()
} else {
// 请求正常返回但业务失败才计数,连续失败达到上限后删除记录不再重试
record.failCount++
if (record.failCount >= MAX_FAIL_COUNT) { // 3 次
record.delete()
} else {
record.save()
}
}
// catch (e: Exception) ------ 网络异常不计数、不中断本轮,继续下一条
语义很清晰:网络异常是暂时的,总会恢复,所以无限重试;业务失败(code≠200)说明数据本身后台不认,无限重试没有意义,3 次熔断弃传。 弱网工程里这条经验非常值钱------不加区分地无限重试,会让一条脏数据永远卡在队列里反复消耗资源。
六、两阶段补传:审计记录不被传感器时延阻塞
操作记录需要带"最后在位状态",但这个状态关门后最长 60 秒才能从板卡拿到。如果等它齐了再上传,弱网下审计记录的及时性就没了。方案是拆成两阶段(OperateRecordUploadManager.kt:74-193):
kotlin
private suspend fun uploadPendingRecords() {
uploadOneRecord() // reportType=0 首次上传
uploadOnePositionSupplement() // reportType=1 在位状态补传
}
- 首传 :上传时已有在位状态就携带
inPlaceStatus;没有就不传,成功后标记position_uploaded="0"(待补传); - 补传 :轮询查
uploaded="1" && position_uploaded="0" && last_position 非空的记录,用同一 recordCode 再调同一接口,以reportType="1"补带在位状态; - 如果记录已上传后本地才回写到在位状态,回写时反向标记
position_uploaded="0"触发补传。
先保证"有记录",再补全"完整记录"------用接口层面的幂等(同 recordCode)换取数据时效性。
七、下行同步的冲突防御
上行讲完了,下行(后台 → 终端的人员/特征同步)同样有坑:
- 防自回环 :本设备刚注册待上传的人员(
update_level="1"),同步时跳过远端覆盖------否则后台把本设备自己刚上报的数据回传回来,可能把本地更新的数据冲掉; - 本地特征优先:本地已有有效人脸特征时保留本地特征(本地摄像头提取的特征与本地识别引擎最匹配),只更新有效期等元数据;
- 时间戳整轮成功后才推进 :增量同步游标
feature_last_sync_time在整轮分页全部拉完才落库,中途失败时间戳不前进,下轮自然重拉当前区间------游标推进与数据处理构成一个"全或无"单元; - 错峰与降级:第一页确认有数据后才一次性预加载本地人员成 Map(避免空轮询白查 5000 条),预加载失败降级为逐条查库。
八、可观测性:没有远程日志,就把日志留在现场
工业内网环境没有 APM、没有远程日志平台。两个替代设计:
- FileLog 落盘日志:所有同步 Manager 的关键节点(入队、成功、失败、code/msg、幂等键)都写本地日志文件,现场拷文件即可诊断;
- 心跳即探针 :设备信息 10 秒轮询同步接口,响应 code=200 即
deviceOnlineStatus.postValue(true),异常即 false,直接驱动 UI 上的在线状态圆点------同步通道本身复用为在线探针,零额外成本。
九、总结
这套架构没有用到任何高大上的组件,全是朴素的工程决策,但组合起来非常稳固:
- 状态存库里------持久化是唯一事实来源,重启恢复是扫描库而不是恢复内存;
- 墓碑软删除------删除也要走同步确认,警惕同唯一键复活;
- 幂等键先行------会重试就必须幂等,recordCode/bindCode 在数据创建时生成、全链路沿用;
- 重试分层------按数据价值定节奏,业务失败熔断、网络异常重试,区别对待;
- 两阶段补传------时效性数据先传,富数据后补,幂等键把它们粘起来;
- 下行防自回环------同步是双向的,本地未确认的变更必须免疫远端覆盖。