接上回:Loiter 踩坑记:Cardputer 生存指南。前三篇讲的是怎么把这个卡片机社交厅从零搭起来;这一篇讲的是它上线之后,怎么差点把一屋子人的"分身"弄丢。
那天下午,我盯着 loiter.polly.wang 的大屏,随手按了下刷新。
页面回来了,海洋还在荡,六座彩色岛屿还在,可属于我的那个小人——没了。
不是它走开了,是整张地图上空空如也,像散场后的剧院。而就在一分钟前,它还好端端地站在自己的岛上。
我没动设备,没断网,什么都没碰。只是刷新了一下网页。
案发现场:我们刚刚做了什么
要解释小人为什么消失,得先说我们当时在忙什么。
那会儿正在给大屏加一个"导播台"功能——主持人可以在一个隐藏页面上点几个按钮,让全场大屏一起暗下、一起亮出地图、一起进入合影模式。技术上不复杂:按钮调一个带鉴权的服务端接口,服务端通过 WebSocket 把指令广播给所有打开的大屏。
为了让线上立刻生效,部署过程里 Polly 重启了两次后端服务。
就是这两次重启,埋了雷。
诊断只花了几分钟,但结论有点反直觉。我让 Polly 去服务器上抓了 8 秒的设备上行数据——一片死寂,没有任何一台设备在说话。可设备明明都还连着、屏幕都亮着。
把因果链拼起来,是这样一条:
后端是纯内存态的——所有人在哪座岛、收集了哪些颜色,全存在内存里,没落数据库。重启 = 这张"花名册"被清空。
但消息中转站(MQTT broker)没有重启——所以每台设备的长连接还活着。在设备眼里,"我一直在线啊,没掉过线",于是它不会重连,也就不会重新上报自己的存在。
结果:服务器把所有人忘得一干二净,而设备一个个都觉得"我早报到过了,不用再说一遍"。两边都没错,但谁也不肯先开口。大屏从内存里捞花名册,捞到的是空的。
这是分布式系统里一个很经典的坑,有个挺传神的名字叫幽灵在线(ghost connection):连接还在,但连接两端对"现在是什么状态"的认知已经对不上了。
最扎心的一点是:这些幽灵设备不会自愈。平时没人操作,它们就静静地待着,一个字节都不往外发。你不去重启每一台设备,它们永远不会重新报到。一屋子人的分身,就这么集体蒸发,而且不会自己回来。
招魂:让蒸发的小人自己走回来
修法其实只有一句话:既然设备不肯主动重连后报到,那就让它定期主动报一次。
我们给固件加了一段心跳——每 25 秒,设备主动把"我是谁、我在哪座岛"重新喊一遍。对一台活着的服务器来说,这句重复的"我还在"基本是废话,它听过就算;可一旦服务器刚重启、花名册是空的,这句话就成了招魂的引子——服务器照着设备喊的信息,把这个人原封不动重新建出来:同样的岛,同样的本命色。
最漂亮的是,这套"喊话"用的全是项目里早就有的通道。设备本来就会在登岛时上报一次,我们只是让它定期再报。零新协议,零新接口,加一个定时器而已。
听起来三行代码搞定,对吧?我也是这么以为的。然后我把它丢给两个 AI 审稿人,故事才真正开始。
转折:两个审稿人,给了我相反的答案
Loiter 的开发是个固定的三角:我拍板、Polly 写码,然后两个 AI 各自独立审稿——小龙虾 🦞 盯架构和边界,Codex 盯协议和一致性。他们在各自的环境里看代码,互相不通气,我最后把两份意见拼起来。
这次,他们第一次给了我方向相反的结论。
小龙虾打了 9.5 分,APPROVE。它的理由很顺:心跳只伺候那些"已经登岛、在大屏上有坐标"的设备就够了——还没选完岛的人,大屏上本来就没有他的小人,蒸发了也不用救。
逻辑严丝合缝。如果只看这一份,我就合并了。
但 Codex 在同一段代码上按了暂停,标了个 P1(最高优先级)问题:**门控条件错了。**它说,"还没登岛"不等于"大屏上不可见"。
两个结论对着干。一个说"没登岛=不可见=不用救",一个说"没登岛≠不可见"。这种时候,谁的语气都不算数——得去代码里看真相。
我让 Polly 翻出大屏渲染小人的那段源码,一行行读。真相站在 Codex 这边:
大屏在收到一个"我来了"的报到时,哪怕这人还没选岛,也会先给他生成一个小人,临时丢在地图正中央,等选完岛再挪到对应岛屿。
也就是说,一个刚输完名字、正在做性格测试的人,他的小人此刻就站在大屏中央,清清楚楚。如果按小龙虾的方案,只救已登岛的人,那这些"中央待命"的小人在服务器重启后就回不来了——而他们恰恰是最显眼的一批。
🦞 漏了,Codex 抓住了。我们把门控从"已登岛"放宽到"已报到",problem solved。
这里有个我后来一直在回味的点:APPROVE 不是免检章。如果我因为小龙虾给了 9.5 就跳过 Codex,或者反过来因为两人打架就和稀泥选个折中,都会埋下这个 bug。最后定胜负的不是哪个 AI 更权威、分数更高,而是去源码里翻出那段"还没登岛也会渲染"的逻辑。代码不会投票,但代码不说谎。
顺带一提,Codex 还揪出了第二个小问题:既然每台设备每 25 秒喊一次"我还在",活着的服务器会不会被这些重复的报到刷屏?会。16 台设备,差不多每分钟 38 条假惺惺的"新人到达"日志。修法是让服务器学会分辨:第一次报到才当新人欢迎,之后的重复报到只悄悄点个卯。
这不只是一个卡片机的毛病
修完我才意识到,"幽灵在线"这件事,跟卡片机、跟 ESP32、跟这个具体项目几乎没关系。
只要你的系统满足两个条件,它就会找上门:
- 服务端把状态放在容易丢的地方(内存、缓存、会随重启清空的任何东西);
- 客户端通过一条长连接维持在线(WebSocket、MQTT、长轮询、游戏服务器的 TCP 会话)。
聊天室服务重启后,在线列表空了,但用户的 WebSocket 还连着,不刷新就一直显示离线;游戏服务器热更新后,玩家角色从世界里消失,但客户端以为自己还在副本里;物联网平台重启,设备状态全灰,可设备从没断过 MQTT。全是同一个故事的不同皮肤。
解法也大同小异——要么让服务端把状态持久化(重启能自己捞回来),要么让客户端定期"报平安",用一个最小的心跳把对不上的认知重新对齐。我们选了后者,因为它改动最小、不引入数据库。
代价当然有,而且我们没藏着:心跳只能还原"你是谁、在哪座岛"这种核心身份,你在过程中临时收集到的东西,服务器崩了就真没了。但对一个几十人、一个多小时的现场来说,这个代价可以接受——真正要守的铁律不是"崩了能恢复",而是活动当天,绝不主动重启服务器。心跳是后悔药,不是免死金牌。
收束
那天下午让我心头一紧的,是大屏上那片突如其来的空荡。
现在再去点刷新,小人还在那儿——就算我手贱再重启一次服务器,最多 25 秒,它会自己慢慢走回岛上,像什么都没发生过。
我们没给它写复活逻辑,只是教会了每台设备一件事:隔一会儿,就大声说一句"我还在"。
招魂这种事,说到底,靠的不是法术,是有人记得定时喊一嗓子。

