搞物联网平台这几年,我们在设备交互上踩过的坑

Admin 64 次阅读 本文阅读量 加载中… IoT
搞物联网平台这几年,我们在设备交互上踩过的坑

这篇东西不是文档,也不是汇报材料。就是想把这几年做能源管理平台时,跟那些"不听话"的设备打交道的心得记下来。如果你也在做 IoT 平台,可能有些地方会心一笑——原来你们也这样。


一、用户点一下"召读",背后的故事远比你想象的多

做能源管理的都懂,运营人员最喜欢干的事,就是在页面上选中一块电表,勾几个复选框——电量、变比、费率时段、购电信息、表计时间——然后点"召读"。在他们眼里,这就是一个按钮。但在平台这边,这往往是五六次独立的设备通信。

这些通信还不是并行的。很多表计,尤其是老式的有线采集终端或者 NB 水表,根本扛不住并发。你同时塞给它好几条指令,它要么装死,要么给你返回一堆错乱的报文,你都不知道该信哪一段。

所以我们内部早期吃过一个亏:当时为了"快",把这几项召读并行发下去了。结果用户反馈说,"经常召出来数据对不上,电量和变比明明是同一时刻的,数值却不匹配"。后来查了半天,才发现是设备侧响应顺序乱了,平台把 A 请求的回复错配给了 B 请求。

从那以后,我们做了一个现在回头看很朴素的决定:同一台设备,同一时间,只干一件事。后台给每个终端地址维护了一条自己的队列,命令一个个来,做完了这件,再拿下一件。听起来慢?其实用户体验反而变好了——因为结果准了,用户不再怀疑平台"造假",也不再反复点按钮重试。


二、"部分成功"有时候比"全不成功"强得多

早期我们的召读接口设计得很"洁癖":电量、变比、费率时段,必须全部成功,才给用户返回数据。只要有一项超时或者设备否认,整个请求返回"召读失败"。

听起来很严谨对吧?但用户炸了。

运营人员最常遇到的场景是:电量能读出来,费率时段读不出来——因为那个时段表太大,设备响应慢,刚好超时了。按照我们原来的逻辑,用户等了十几秒,最后看到两个字:失败。前面读出来的电量也白白扔了。

用户的原话是:"我知道费率时段有时候读不出来,但你先把电量给我啊,我等着抄数呢!"

这句话点醒了我们。物联网平台跟普通 Web 系统不一样,通信链路上每一个环节都可能掉链子,你不能用数据库事务那种"全成功或全回滚"的思维来设计。

后来我们改了策略:逐项执行,逐项累积。电量出来了,先记下来;变比出来了,再拼上去;费率时段超时了,就在结果里写一句"费率时段:召读超时",但前面已经拿到的数据照样返回。

前端展示也跟着改了——每项数据旁边有个小状态:成功是绿的,失败是红的,用户一眼就知道谁靠谱谁不靠谱。运营人员满意度直线上升,因为对他们来说,能看到一部分真实数据,比看一个冷冰冰的"失败"有用一百倍


三、排队这事的玄学:既要防堆积,又要防饿死

前面说了给每台设备搞个队列。但队列这个东西,搞不好就是灾难。

我们最早没设队列长度上限。结果有一次某广场的网络出问题了,几十个设备离线,但运营人员不知道,还在不停地发起召读。命令全堆在 Redis 里,等网络恢复的那一刻,几百条命令同时涌向设备——设备直接崩了。

从那以后我们给每个设备的队列加了个盖子,最多存 100 条。满了之后新来的命令直接拒绝,告诉用户"设备任务繁忙,请稍后重试"。用户一开始有点懵,"我没发几条啊怎么就满了?"后来我们加了一句更友好的提示:"该设备当前有 X 条任务在处理中",用户就理解了——哦,是前面的人堵住了。

还有个问题是超时。队列里的命令不能无限等下去,不然就变成僵尸了。我们给每条命令设了 30 秒的寿命。进入队列时打时间戳,轮到他执行的时候,先看一眼:"你超了吗?"超了就直接标记失败,赶紧给后面的人让路。

最麻烦的是超时之后的清理。不是简单地把命令删了就算完——你还得通知那个在 HTTP 接口上干等的人,还得把设备状态重置为"空闲",好让队列继续往下走。有一版代码这里漏了一个状态重置的调用,结果设备命令执行完以后,队列再也不动了。用户反馈说"这块表怎么点什么都没反应了"。一查,队列头那个命令早就执行完了,但状态还是"忙",后面的全堵死。修复之后我们还加了守护逻辑:每次处理完命令,不管成功失败还是异常,都强制把状态刷一遍,确保通道不锁死。


四、同步接口包异步逻辑,是一门妥协的艺术

我们内部对这个问题争论了很久。设备通信本质上是异步的:你发一条命令下去,设备什么时候回,回不回,你说了不算。但前端同学不干了:"你让我发完请求再轮询?或者搞 WebSocket?我们页面排期很紧,没人力做这套。"

最后折中的方案是:对外暴露同步 HTTP 接口,前端直接调用,像调普通 API 一样;但内部完全走消息队列,HTTP 线程只在本地的缓存上轮询结果。

轮询的间隔设计也有讲究。最开始我们固定每秒查一次缓存,结果设备响应快的时候没问题,设备一慢,日志里全是"查缓存、没结果、查缓存、没结果",CPU 白忙活。后来改成了退避策略:第 1 秒查,第 2 秒查,然后隔 2 秒、3 秒、5 秒……越往后查得越懒。这样既不会漏掉快响应,又不会在慢设备上浪费资源。

不过这里有个隐藏的坑:HTTP 超时时间。我们设的是 30 秒。但有时候设备确实能在 35 秒回,HTTP 已经断掉了,设备回复写进了缓存,却没人来取。后来我们在产品逻辑上做了补偿:HTTP 返回"超时",不代表命令失败了,只是"目前还没收到回复"。用户可以在页面上看到一个"任务记录"列表,过一会儿刷新,可能状态就从"执行中"变成了"成功"。用户慢慢也接受了这个设定——物联网本来就不是秒回的事


五、多协议接入:别让新设备变成全员的噩梦

我们平台接过的设备类型,说起来能写半页纸:走国网 376.1 的有线采集器、走天翼 AEP 的 NB 水表、走 OneNET 的 NB 电表、走 LoRaWAN 的温湿度传感器……每种设备的接入方式、报文格式、认证逻辑、响应模式全都不一样。

最早期的时候,我们傻乎乎地把这些差异往上层透传。前端调个召读接口,得传个参数告诉后端"这块表是 AEP 的还是 OneNET 的",前端简直疯掉。更可气的是,每新增一种协议,前端要改,业务层要改,测试要重新测一遍全量功能。

后来我们彻底重构了这一层,搞了一个协议适配枢纽。上层业务只管说:"我要给设备 X 下发命令 Y。"至于这个设备是 NB 的还是有线的、要走运营商云 API 还是走 Netty 长连接,上层一概不问。适配层根据设备档案里的协议类型字段,自己路由到对应的处理策略。

这里面的门道不少。有些协议是"请求-响应"模式,下发之后等回复就行。但有些 NB 平台是"下发即成功"——它只保证把消息推到运营商,设备到底收没收到,它不管。对于这种,我们内部要额外维护一个状态机:运营商确认收到了,算"已发送";设备真的回执了,才算"执行成功"。如果设备一直没回执,还得走重试或者告警逻辑。

但无论如何,前端和业务层是无感的。新增一种协议,只是适配层多一个分支的事。前端同学后来跟我说,"你们又接了新设备?完全没感觉啊,页面不用动。"这就是我们想听到的。


六、超时不是异常,是一种常态

刚开始做这行的时候,每次看日志里出现"超时"两个字都很难受,觉得是自己的 bug。后来做得久了才明白:在物联网里,超时是日常,设备秒回才是意外。

NB 表计为了省电,动不动进入 PSM 休眠,你发命令的时候它可能在睡觉,等它醒了轮询网络,可能已经十几秒过去了。地下室的采集器信号只有一格,报文丢包重传,一来一回半分钟很正常。这些都不是平台能控制的。

所以我们对超时的态度,从"尽量避免"变成了"优雅处理"。

所谓优雅,不是说把超时藏起来不让用户看见,而是让用户知道发生了什么,以及接下来会怎样。我们现在超时的提示语是这样的:"设备响应超时(可能是网络延迟或设备休眠),平台已保留任务,设备上线后会自动执行。您也可以在任务记录中查看最新状态。"

用户看到这段话,基本就安心了。他知道不是平台挂了,也不是自己操作错了,只是设备暂时没理他。更重要的是,他知道平台没放弃,还在帮他重试

后台的实现上,我们做了三层超时防护。HTTP 等缓存的超时是一层;队列里命令的过期时间是一层;真正下发到设备后的响应等待又是一层。每层超时之后,都要做清理:写失败结果、释放资源、触发下一个命令。有一阵子我们日志里经常出现某个设备队列卡住不动的现象,追查后发现是某层超时之后忘了调状态重置。这种 bug 很阴险,因为系统没报错,一切看起来都正常,就是设备不响应了。后来我们给每个超时出口都加了强制清理的 finally 逻辑,才算根治。


七、别让运营人员学通信协议

这可能是我们做过最值的一个改动。

早期的召读结果,我们直接把解析后的原始字段返回给前端。比如费率时段,返回的是一组十六进制时段表,或者一个 JSON 数组,每个元素有 startHour、startMinute、tariffType 之类的字段。运营人员看得一脸懵,跑过来问:"这个 tariffType 等于 2 是什么意思?"

我们内部有个通信协议专家,他能一眼看出来 2 代表"峰时段"。但运营人员不是工程师,他们不应该学这个。

后来我们专门做了一层结果格式化。电量值乘以变比,保留两位小数,后面跟个"kWh"。费率时段格式化成:"当前运行第 1 套时段方案\n尖时段:08:00-12:00\n峰时段:12:00-18:00……"购电信息直接显示"剩余金额:156.80 元"。

前端展示也配合做了调整。每项数据一个卡片,成功了的显示数值,失败了的显示红色提示和重试按钮。运营人员终于不用拿着返回结果去问研发了,他们自己就能看懂,就能判断,就能干活。

这个改动技术上没什么高难度,就是一堆字符串拼接和字典映射。但用户体验的提升是巨大的。有一次一个广场的运营主管专门发消息说:"你们最近是不是升级了什么?召读结果现在看得特别清楚。"

你看,有时候最好的技术,就是让别人感受不到技术的存在。


八、一些零碎但真实的心得

关于线程池

命令消费千万别用无界队列。我们吃过亏,高峰期命令堆积,内存爆了,服务重启,队列里的命令全丢了。后来改成有界队列,拒绝策略用 CallerRuns——意思就是,如果我忙不过来了,谁提交的任务谁自己跑。听起来粗暴,但其实是最好的背压机制。

关于设备影子

我们维护了一套设备在线状态的缓存,本质上就是 AWS 说的 Device Shadow。命令入队之前先瞄一眼:这设备最近一次心跳是什么时候?如果已经离线超过阈值,直接返回"设备离线",别往队列里塞了。这能给用户省掉大量无意义的等待。但这个心跳时间也不能设太短,NB 设备本来心跳就稀疏,设太短了会误判。

关于发布上线

物联网服务重启是最头疼的。你这边一重启,那边可能有几百条命令正在处理中,线程池一关,命令可能就丢了。我们后来加了 ShutdownHook,停服务的时候先拒绝新任务,然后等已提交的任务执行完(最多等 10 秒),等不完的才强制中断。配合 K8s 的优雅停机策略,基本能把损失降到最低。当然,我们也没 K8s,就是裸机部署,但思路是一样的。

关于日志

物联网平台的日志一定要能串起来。一条命令从 HTTP 进来,生成 cmdId,进 MQ,被消费,下发设备,等响应,写缓存,HTTP 取到结果——这整个链路必须能用同一个 cmdId 追踪。我们有一次排查一个问题,查了三个服务的日志,因为 cmdId 没传全,中间断了一环,差点没查出来。后来强制要求:cmdId 一旦生成,全程携带,日志里每条都打印。


写在最后

做物联网平台,本质上是在跟"不确定性"共处。设备会失联,网络会抖动,协议会千奇百怪,用户会 impatient。你没法消灭这些问题,只能学会怎么跟它们相处,然后把确定性的体验留给用户。

用户不在乎你后台用了多少线程、队列有多长、协议有多复杂。他在乎的是:我点了一下,有没有反馈?反馈快不快?我看不看得懂?出了问题我知道该怎么办?

如果你能把这四件事做好,你的 IoT 平台就已经超过了市面上大半的同类产品。

希望这些踩坑经历对你有用。如果你也有类似的故事,欢迎交流——做这行的人,谁还没被几个设备折腾过呢。