门禁说走了,柜子说在,盘点说没有:资产"在位"状态的冲突收敛设计
季度盘点那天出了件很难解释的事。
系统里三件涉密载体显示"在柜",管控柜的轮询也确实读到了它们的标签;但门禁日志里,其中一件在前一天下午被带出过一次,没有对应的外带审批单;另外两件,同事拿着手持机在保密室里来回扫了三遍,一次都没读到。
三个人给了我三个答案。柜子的结论是在位,门禁的结论是出去了,手持机的结论是找不到。追问"它到底在不在",其实问错了——三个设备都没有说谎,它们只是在回答三个不同的问题。
这件事之后我们把"在位"这个字段从系统里拆掉了。不是因为难维护,是因为它本身就不成立。
一、"在位"不是一个布尔值
三种采集设备的观测语义完全不同。
柜内天线是持续驻留采样:每十秒轮询一轮,读到就是 present,读不到就是 absent。它回答的是"此刻这枚标签是否在柜内天线的覆盖范围内"。
通道门禁是穿越瞬时事件:标签经过读取面的那几百毫秒里被读到,配合前后天线的触发顺序给出 enter 或 leave。它回答的是"这枚标签刚刚穿过这扇门,往哪个方向"。
手持盘点是人为发起的时刻快照:同事走过哪些区域、天线朝哪个角度、有没有被金属文件柜挡住,都会影响结果。它回答的是"在人走过的这条路径上,这一刻读到了什么"。
一个布尔字段要同时承载"十秒粒度的驻留"、“毫秒粒度的穿越"和"不定期的抽样”,这本身就是设计错误。所以第一步不是修数据,是把状态位换掉,换成观测事实。
每一路只上报自己亲眼看到的事,不解释,不裁决。
二、把三路降维成同一种事件
统一成四元组:谁、在哪、什么动作、什么时候看到的。
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class Observation:
tag: str # 标签 EPC
source: str # cabinet / gate / handheld
node: str # 天线编号或门禁编号
phase: str # enter / leave / present / absent
event_time: datetime # 设备本地采集时刻,不是上云时刻
rssi: int
def edge_from_poll(tag, node, seen, ts, rssi):
"""柜内轮询结果转成边沿事件:只在状态翻转时产出"""
prev = last_poll_state.get((tag, node))
last_poll_state[(tag, node)] = seen
if prev is None or prev == seen:
return None # 无翻转,不产事件,避免刷屏
return Observation(tag, "cabinet", node,
"present" if seen else "absent", ts, rssi)
两个细节值得说。
第一,柜内轮询必须做边沿化。十个柜子每十秒一轮,一天就是八万多条记录,其中百分之九十九点九是"还是那样"。只在上报值翻转时产事件,日事件量从八万降到几百,后面所有重排和消费的计算量跟着降两个数量级。
第二,event_time 必须是设备本地采集时刻,不能用云端收到消息的接收时刻。这条在断网场景下是致命的,下一节会看到。
三、方向要靠事件配对,不能靠单次读数
门禁读到一次标签,只能说明"它在这一刻出现在这扇门的读取面附近"。人抱着文件在门口站一会儿、标签贴在金属文件柜侧板上被反射读到,都可能产生单次读数。
所以方向不来自读数本身,来自配对:同一枚标签在同一次穿越里,先 a 侧天线读到、后 b 侧天线读到,中间间隔在 1.5 秒以内,才合成一个 leave(或 enter)。落单的读数不进事件流,只进调试日志。
柜内同理,present → absent 是离开座位,absent → present 是回到座位,两次翻转之间的时间差就是它在柜外的时长。
四、乱序和迟到:允许窗口,但别改写历史
边缘侧断网是常态。保密室那个 IoT Edge 节点上个月因为交换机重启断了四十分钟,恢复后一次性补传了两千多条事件。这些事件的 event_time 全部落在四十分钟之前,到达却是最后。
如果按到达时间消费,状态机会先看到"四十分钟后的 enter",再看到"四十分钟前的 leave",最后状态停在"已离开"——和事实完全相反。
处理办法是两条:窗口内重排,窗口外补偿。
ALLOW_LATE = timedelta(minutes=5)
def consume(obs, watermark):
if obs.event_time >= watermark - ALLOW_LATE:
buffer.append(obs) # 进重排缓冲,按 event_time 排序后消费
buffer.sort(key=lambda o: o.event_time)
while buffer and buffer[0].event_time <= watermark:
state_machine.apply(buffer.pop(0))
else:
correction_log.append(obs) # 超窗:不改历史,记一次补偿
state_machine.correct(obs) # 修正当前结论,并留下修正痕迹
关键在最后两行。超过迟到窗口的事件不去覆盖已经写下的历史结论,而是作为一条 correction 追加进去,状态机的当前值按它调整,但"当时系统认为是 X"这个事实保留。
审计的时候查的往往不是"现在是什么状态",而是"那天下午系统为什么判断它在柜"。改写历史会让这个问题永远答不出来。
五、冲突不裁决,降级成"待核"
状态机只有四个值:在柜、在途、在用、待核。
leave 且没有对应外带单,进在途并立刻告警;enter 回在柜;有领用单的进在用。
真正难判的是另一种:门禁说出了门,柜子却还在读到它。两个源冲突,谁对?
我们的选择是不裁决。落进"待核",同时把两边证据都附在记录上:门禁的穿越时间、天线编号、RSSI 序列,柜内最近三轮的轮询结果。等人工确认。
理由很实际。硬判丢失,误报率会让人不再相信告警;硬判在位,真出事时系统等于没说话。待核是一个诚实的中间态,它承认系统不知道,并且把"不知道"这件事本身变成一条待办。超过二十四小时没人处理,自动升级到部门负责人。
六、落到云上的那一段
设备侧在华为云 IoTDA 里定义一个标准物模型,属性只报三样:node_id、phase、event_time,RSSI 作为可选扩展字段。不上报任何业务含义,业务含义全部留给云端算——设备固件升级的周期比业务规则长得多,把判断逻辑放在端上,改一次规则就要动一批设备。
边缘节点用 IoT Edge 承接本地缓存,断网期间事件落本地库,链路恢复后按 event_time 升序批量上报。规则引擎把设备属性流转到 DIS,再由 FunctionGraph 消费,状态表落 GaussDB,用版本号做乐观锁,避免同一枚标签的并发事件互相覆盖。
对接第三方系统时,适配器统一在启动时装配:
SINKS = {
"ledger": LedgerAdapter("首码资产管理系统"),
"alarm": AlarmAdapter(channel="app+mail"),
}
结语
做完这套之后,盘点再出现"三个答案不一致",我们不再急着修数据。
不一致本身就是信息。它说明某一路采集出了问题,或者某一次带出没有走审批。系统诚实地把三个答案并排放在一起,让人去看那个矛盾,比强行合成一个看似干净的状态位要有用得多。
台账的价值不在于给出一个确定的答案,而在于当答案不确定时,它还记得自己是怎么不确定的。
- 点赞
- 收藏
- 关注作者
评论(0)