硬件异步事件下的业务一致性:智能柜领取/归还流程的竞态设计

硬件异步事件下的业务一致性:智能柜领取/归还流程的竞态设计

场景:智能柜终端通过 TCP 与柜门控制板通信,用户刷脸后自动分配箱门领/还物品。硬件有两个异步上报通道------门磁(门开/关)和在位传感器(物品在/不在),且传感器有约 300ms 的物理延迟。业务流程(领取/归还)完全由这些异步事件驱动。这类"软件状态机 + 不可靠异步硬件事件"的系统,最棘手的就是各种竞态。本文分享项目中沉淀下来的五个一致性设计模式。

〇、基础设施:事件先汇聚,再串行处理

所有设计建立在一个前提上:硬件事件先在入口处收敛 。TCP 接收线程只做分拣入队------每个门一个 ConcurrentLinkedQueue,消费协程批量取尽后按消息类型合并(同类型只留最后一条),再用一个全局 Semaphore(1) 串行处理:

kotlin 复制代码
private val tcpMessageScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val doorMessageSemaphore = Semaphore(1)   // 所有门事件处理全局互斥

串行化是有意的取舍:直接用串行消灭业务层并发,换取业务逻辑里可以不设防地读写数据库。门控业务的吞吐量远低于串行处理的能力上限,这个交换非常划算。

另外,主动查询的回复(GET_STATUS)和硬件主动推送(ON/CLOSE)路由进同一组汇聚函数处理------推送和补查共用一个状态机入口,这是后面"窗口内拿到即回写"能成立的前提。

一、授权窗口:传感器延迟不应改变业务语义

问题 :用户领取开门 → 取走物品 → 关门。由于传感器有 ~300ms 延迟,关门的瞬间传感器可能还报"在位"。而"归还"的判定恰好是"关门 + 在位"。单看硬件事件序列,"领取后关门"和"归还放入后关门"不可区分------会被误判成归还,生成错误的归还单。

方案 :把"业务意图"显式注入状态机。归还认证通过后写入一个 30 秒授权窗口(MainViewModel.kt:142-146):

kotlin 复制代码
/**
 * 归还授权标记:key="$m3Id-$pindex-$index",value=授权时间戳
 * 只有经过 backTld() 归还认证后的门关闭/在位上报,才允许触发订单完成
 */
private val returnAuthorizedMap = ConcurrentHashMap<String, Long>()

关门事件的处理逻辑变成(MainViewModel.kt:3334-3384):

kotlin 复制代码
if (detailDb.isCabinetClosed() && detailRelativeDb.isPositionIn()) {
    val authTime = returnAuthorizedMap[key]
    if (authTime != null && TimeUtil.currentTimeMillis() - authTime <= 30000L) {
        // 有授权:走归还流程(另有防重复检查,最新记录已是归还则不再创单)
        ...
        returnAuthorizedMap.remove(key)
    } else {
        // 无授权:判定为"领取后关门",转入空领取检测(见下节)
    }
}

配套两个清除点:领取时反向清除该门的归还授权(防领取被误判),归还授权时清除空领取检测任务(两个机制互斥)。

本质是把分布式系统里"以事件定状态"改成"以意图 + 事件定状态"------硬件事件只提供观测,业务语义由用户的认证动作锚定。

二、延迟仲裁:5 秒空领取检测

问题:用户领取开门后没取物就直接关门(空领取)。此刻门已关、传感器在位(物还在),如果什么都不做,箱门会被长期占用、订单卡死。但关门瞬间又不能立刻断定是空领取------也许传感器还没刷新。

方案 :延迟 5 秒二次读库仲裁(MainViewModel.kt:3386-3448):

kotlin 复制代码
val runnable = Runnable {
    val currentDb = /* 重新查箱门记录 */
    val currentPos = /* 重新查在位传感器记录 */
    if (currentDb?.isCabinetClosed() == true && currentPos?.isPositionIn() == true) {
        // 5 秒后门仍关且传感器仍在位 → 确认空领取,自动归还闭环:
        // 关旧领取单 + 生成归还单 + 清空占用
        ...
    }
    emptyTakeChecks.remove(key)
}
emptyTakeChecks[key]?.let { mHeartbeatHandler.removeCallbacks(it) }  // 先取消同门旧任务
emptyTakeChecks[key] = runnable
mHeartbeatHandler.postDelayed(runnable, 5000L)

关键在提前完成通道 :如果在位传感器在这 5 秒内报了"不在位"(用户其实取走了物,只是传感器慢),立即取消检测任务(MainViewModel.kt:3497-3503):

kotlin 复制代码
if (!inposition && relativeIndex >= 0) {
    // 用户取走了物品,取消对应箱门的空领取检测
    emptyTakeChecks.remove(doorKey)?.let {
        mHeartbeatHandler.removeCallbacks(it)
    }
}

这是典型的"deadline + 提前完成"双触发定时器模式:证据先到先采信,证据不到就到点仲裁。

三、慢硬件的最终一致:60 秒窗口 + 立即回写 + 兜底

需求:每条领取/归还记录要带上"关门后物品最后的在位状态"(审计用:还了没有?)。但关门后板卡刷新慢,什么时候能拿到最新状态不确定。

三层方案

  1. 主动补查:关门后立即查一次门状态,1500ms 后再查一次在位状态(错开避免同刻多指令导致板卡丢回复);
  2. 窗口内拿到即写:60 秒窗口内,任何在位状态更新(推送或补查回复,哪怕值没变只是确认)到达,立即取消兜底任务并回写:
kotlin 复制代码
private fun triggerLastPositionWriteBackIfPending(m3Id: String, pindex: String, doorIndex: Int) {
    val doorKey = "$m3Id-$pindex-$doorIndex"
    val task = lastPositionWriteBackTasks.remove(doorKey) ?: return   // 无任务=空操作
    mHeartbeatHandler.removeCallbacks(task)                           // 取消 60s 兜底
    mHeartbeatHandler.post { writeBackLastPosition(...) }             // 同线程立即执行
}
  1. 60s 兜底:板卡一直不响应,到点写入本地最后已知值(为空则记录"未知")。

正常链路下关门 ~1.5 秒补查回复到达即完成回写;板卡故障时 60 秒兜底保证字段不为空。

窗口拉长带来的新竞态 :60 秒内同一门连续操作,"新登记覆盖旧登记,旧任务把状态写错订单"。防御是执行前做快照等值比对(MainViewModel.kt:4073-4090):

kotlin 复制代码
// 捕获本次登记的待回写记录;任务执行时若该门已被新操作覆盖登记,则放弃本次回写
val expected = pendingLastPositionOrders[doorKey]
val runnable = Runnable {
    lastPositionWriteBackTasks.remove(doorKey)
    if (expected != null && pendingLastPositionOrders[doorKey] == expected) {
        writeBackLastPosition(doorKey, m3Id, pindex, index)
    }
}
mHeartbeatHandler.postDelayed(runnable, 60_000L)

同时登记侧保证"任务存在 ⇒ 对应当前登记":新登记覆盖时同步取消旧任务。两个方向都把竞态堵死。

另外这个回写还联动了上传层:如果记录已经上传过后台,回写时标记 position_uploaded="0",由补传通道携带同一 recordCode 再传一次------本地最终一致 + 后台最终一致两层闭环。

四、不可靠通道上的幂等指令下发:灯控三段式

问题:门事件和在位事件往往成对到达,每个事件都可能触发一次灯控计算,朴素实现会连发多条灯指令打满板卡。但如果只做去重,又有新风险:上次发的指令板卡没执行成功,会被去重逻辑吞掉,灯就永远停在错误状态。

方案:防抖 + 去重 + 确认重试三段式MainViewModel.kt:4241-4366):

kotlin 复制代码
// 1. 防抖:300ms 内状态反复变化只发最后一条
lightUpdateDelays.remove(key)?.let { lightControlHandler.removeCallbacks(it) }
val runnable = Runnable {
    val newState = when {
        门开 -> LightState.FLASH              // 呼吸灯
        门关且在位 -> LightState.ON            // 常亮
        else -> LightState.OFF                 // 关灯
    }
    // 2. 去重 + 3. 失败重发:状态变了,或上次命令失败了,才发送
    if (lastLightStates[key] != newState || lastLightCommandSuccess[key] == false) {
        lastLightStates[key] = newState
        lastLightCommandSuccess.remove(key)    // 标记 pending,等待板卡响应
        发送灯控指令()
        scheduleLightCommandTimeout(key)       // 启动超时检测
    }
}
lightControlHandler.postDelayed(runnable, 300L)

lastLightCommandSuccess 是三态设计:true 成功 / false 失败 / 不存在 = pending 。板卡回 code=500 或 5 秒无响应即标 false,下次状态评估时突破去重重新发送;重试上限 3 次防板卡过载时无限循环。

这把"at-least-once 的指令下发"收敛为"灯最终与期望状态一致 "。一个容易踩的坑:灯控指令实际发给传感器地址(9-16),响应里的 door_address 也是传感器地址,必须映射回箱门索引(address - 8)才能匹配状态表的 key。

五、资源清理:长连接 App 的防泄漏清单

所有定时任务统一用 ConcurrentHashMap<String, Runnable> 管理,取消一律 map.remove(key)?.let { handler.removeCallbacks(it) }onCleared() 里是一份教科书式的清理清单:20+ 个 map 逐一清空、Handler 回调全移除、协程作用域 cancel、两个独立 HandlerThread quitSafely()。硬件长连接 App 里,僵尸回调和泄漏的定时任务是稳定性头号杀手,这份清单值得照搬。

六、总结:五个可复用的模式

模式 解决的问题 一句话概括
授权窗口 事件乱序/观测歧义 业务意图显式注入状态机,事件只做触发器
延迟仲裁 观测不确定性 deadline + 提前完成,证据先到先采信
窗口回写 + 兜底 慢硬件的最终一致 拿到即写,拿不到到点写最后已知值
防抖+去重+确认重试 不可靠通道的指令下发 at-least-once 收敛为最终状态一致
入口串行化 业务层并发 信号量在入口消灭并发,业务代码零设防

最后一点心得:和硬件打交道,"状态"永远是观测值而不是事实。传感器有延迟、板卡会丢指令、响应可能永远不来。接受这个前提,把每个机制都设计成"观测驱动 + 意图锚定 + 超时兜底"的三段式,系统的鲁棒性会好得多。

相关推荐
Godikov1 小时前
不用 Netty:Android 手写 TCP 客户端实战——自定义协议帧、粘包处理与可靠重连
android
事圆则缓1 小时前
Java 面向对象如何落到 Android View 体系
android
iPad协议个微协议2 小时前
# 微信自动营销系统如何基于 WechatApi 做事件驱动设计
android·微信·企业微信·个人开发·wechatapi·微信个人号开发
AirDroid_cn2 小时前
隐私沙盒:第三方应用读取剪切板内容如何拦截?
android·gitee
2501_915918414 小时前
怎么把 Python 写的 Flet 应用打包成 iOS App 并上架 App Store?
android·ios·小程序·https·uni-app·iphone·webview
邪修king6 小时前
Re:Linux系统篇(二十六):文件系统(二):Ext 文件系统底层详解:从 inode、块组到软硬链接,结合 Windows 讲透文件管理本质
android·java·linux
又见情义6 小时前
RK3568 Android 13 版本号管理实战:基于 ROCKCHIP_BUILD_NUMBER 的定制化方案
android
终端安全笔记6 小时前
iOS 27 强制 TLS 1.2:租赁设备的注册链路会在哪一环断
android·网络·安全·ios·智能手机
mmsx7 小时前
MapLibre 实战 11|用户说"我的地块丢了":一个 sealed class 图层模型,和四个让我重构三版的坑
android·前端·开源