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

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

场景:智能柜终端通过 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 收敛为最终状态一致
入口串行化 业务层并发 信号量在入口消灭并发,业务代码零设防

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

相关推荐
martindelophy4 小时前
用自然语言剪视频:我们在 Timeline Studio 中实现了 ChatCut 对话剪辑
android·音视频
ao-weilai4 小时前
MySQL数据库:复合查询
android·数据库·mysql
三少爷的鞋17 小时前
AI 时代,我们都将成为通才型开发者:只懂 Android,已经不够了
android
Dovis(誓平步青云)21 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
晚风叙码1 天前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer