弱网环境离线优先:Android 终端数据可靠同步架构实战

弱网环境离线优先: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)换取数据时效性。

七、下行同步的冲突防御

上行讲完了,下行(后台 → 终端的人员/特征同步)同样有坑:

  1. 防自回环 :本设备刚注册待上传的人员(update_level="1"),同步时跳过远端覆盖------否则后台把本设备自己刚上报的数据回传回来,可能把本地更新的数据冲掉;
  2. 本地特征优先:本地已有有效人脸特征时保留本地特征(本地摄像头提取的特征与本地识别引擎最匹配),只更新有效期等元数据;
  3. 时间戳整轮成功后才推进 :增量同步游标 feature_last_sync_time 在整轮分页全部拉完才落库,中途失败时间戳不前进,下轮自然重拉当前区间------游标推进与数据处理构成一个"全或无"单元;
  4. 错峰与降级:第一页确认有数据后才一次性预加载本地人员成 Map(避免空轮询白查 5000 条),预加载失败降级为逐条查库。

八、可观测性:没有远程日志,就把日志留在现场

工业内网环境没有 APM、没有远程日志平台。两个替代设计:

  • FileLog 落盘日志:所有同步 Manager 的关键节点(入队、成功、失败、code/msg、幂等键)都写本地日志文件,现场拷文件即可诊断;
  • 心跳即探针 :设备信息 10 秒轮询同步接口,响应 code=200 即 deviceOnlineStatus.postValue(true),异常即 false,直接驱动 UI 上的在线状态圆点------同步通道本身复用为在线探针,零额外成本。

九、总结

这套架构没有用到任何高大上的组件,全是朴素的工程决策,但组合起来非常稳固:

  1. 状态存库里------持久化是唯一事实来源,重启恢复是扫描库而不是恢复内存;
  2. 墓碑软删除------删除也要走同步确认,警惕同唯一键复活;
  3. 幂等键先行------会重试就必须幂等,recordCode/bindCode 在数据创建时生成、全链路沿用;
  4. 重试分层------按数据价值定节奏,业务失败熔断、网络异常重试,区别对待;
  5. 两阶段补传------时效性数据先传,富数据后补,幂等键把它们粘起来;
  6. 下行防自回环------同步是双向的,本地未确认的变更必须免疫远端覆盖。
相关推荐
古法安卓2 小时前
Android-休眠唤醒后onLocationChanged没有数据问题排查
android·java·android studio
Htr_5 小时前
Creem 2.0 使用指南:面向 AI 构建时代的资金平台
android·数据库·人工智能·ui·photoshop
wardenlzr6 小时前
车机看门狗(Watchdog)为何会让整机硬重启
android·优化·车机
IT毕设实战小研7 小时前
基于大数据的商场商铺数据分析与可视化的设计与实现
android·java·大数据·django·课程设计
aqi007 小时前
一文读懂 HarmonyOS 7.0 带来的十大API重要升级
android·华为·harmonyos·鸿蒙·harmony
是店小二呀8 小时前
鸿蒙PC开源移植:CodeLite原生IDE与Remote Agent适配
android·智能手机·远程桌面
终端安全笔记9 小时前
iOS 27 给了租赁一个新工具,但它只认受监督的设备
android·网络·安全·ios
爱笑鱼9 小时前
Android 系统启动机制(八):系统服务怎样从创建走到可用?SystemServiceManager 和 Boot Phase 各管什么?
android
hai_android9 小时前
Android 组件化开发实践
android·java·kotlin