硬件异步事件下的业务一致性:智能柜领取/归还流程的竞态设计
场景:智能柜终端通过 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 秒窗口 + 立即回写 + 兜底
需求:每条领取/归还记录要带上"关门后物品最后的在位状态"(审计用:还了没有?)。但关门后板卡刷新慢,什么时候能拿到最新状态不确定。
三层方案:
- 主动补查:关门后立即查一次门状态,1500ms 后再查一次在位状态(错开避免同刻多指令导致板卡丢回复);
- 窗口内拿到即写: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(...) } // 同线程立即执行
}
- 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 收敛为最终状态一致 |
| 入口串行化 | 业务层并发 | 信号量在入口消灭并发,业务代码零设防 |
最后一点心得:和硬件打交道,"状态"永远是观测值而不是事实。传感器有延迟、板卡会丢指令、响应可能永远不来。接受这个前提,把每个机制都设计成"观测驱动 + 意图锚定 + 超时兜底"的三段式,系统的鲁棒性会好得多。