LiveView 聊天室下半场:在线用户、多房间、部署,这 3 关一来,demo 才算开始像项目

📅 2026/7/9 16:55:24
LiveView 聊天室下半场:在线用户、多房间、部署,这 3 关一来,demo 才算开始像项目
前言上篇写完以后我短暂地得意了十分钟。原因也很朴素我开两个浏览器窗口互发消息能实时看到自己发的消息不重复消息列表也没再走那种偷偷全量重查的歪路。按理说这时候已经很容易脑补一句“行了聊天室我会了。”结果后面我一加在线用户列表这句豪言壮语当场塌方。再往后我又把多房间切换和部署环境补进去塌得更彻底。我这才意识到聊天室这个题最坏的地方就在这儿它前半段特别容易让人产生一种“已经站稳了”的幻觉后半段却会非常残忍地告诉你你站稳的可能只是单房间、单节点、本地双窗口自娱自乐版平静生活。真正卡人的往往不是消息收发本身而是下面这几件事在线状态到底算业务数据还是连接状态房间切换到底算页面换皮还是订阅迁移部署上线之后WebSocket、节点广播、重连恢复还能不能维持同一种状态真相这三件事一旦没想清楚聊天室就会进入一种很微妙的状态代码看着没崩页面看着能动但你心里会一直虚。1. 我第一次觉得“消息发通了不等于聊天室做完了”上篇那套最小闭环我现在依然认为非常值。先把消息这条链收紧本身就是一件很重要的事。因为如果连“创建消息 - 广播 - 页面更新”都没站稳后面任何扩展都只是在一堆松土上加家具。但在线用户、多房间、部署这几个点一进来事情就突然变味了原因也不复杂。1.1 在线用户把“谁在房间里”这个问题抬上桌了消息这件事有个天然优势它可以落库。只要消息进了数据库你掉线也好、刷新也好、部署重启也好历史记录总归还在。这种感觉很踏实像你把东西放进了抽屉哪怕屋里停电了抽屉也不会自己蒸发。可在线状态不是这样。在线状态天生就是一种会抖、会掉、会随连接生灭的东西。你只要开始显示“当前在线 7 人”就等于主动把一个瞬时状态搬到了台面上。它没有消息那种“写进去就算数”的安全感。1.2 多房间让“当前页面”这件事不再单纯单房间聊天室有个巨大的幻觉优势你几乎不用认真面对“边界迁移”。你订阅一个 topic拉一批消息后面就等广播。逻辑很顺甚至顺得有点容易让人膨胀。可一旦你开始做多房间事情就完全不是“多一个列表”这么简单了。你会被迫面对这些问题现在显示的到底是哪一个房间当前 LiveView 进程到底订阅了哪个 topic从 A 房切到 B 房时旧房间的订阅和在线状态怎么处理消息流怎么重置才不会把两个房间的数据搅成一锅这时候你会发现聊天室不是“页面里多一个侧边栏”这么轻松它本质上是在逼你把房间边界说清楚。1.3 部署则会把本地的“岁月静好”全部掀翻本地开发时很多问题其实都藏着。你一个节点一个浏览器或者最多两个窗口网络也稳定部署也不存在WebSocket 一直连着像温室花朵一样被照顾得很好。一上线这种好日子就结束了。代理层会不会正确升级 WebSocket节点之间的广播能不能互通服务重启时页面是不是优雅重连Presence 会不会抖一下就把人数刷成 0这些都开始从“以后再说”变成“你现在就得面对”。所以我后来对“聊天室写完没”有了个很主观的新标准不是消息能飞起来就算写完而是切房、断线、重连、上线之后它还能不像草台。2. 在线用户这件事我一开始差点写成“数据库考古”这块我得先承认一个很土的歪念头。我最早第一反应真的是想在users表上加个online字段。甚至那一瞬间我还觉得自己挺务实登录了标记online: true退出了标记online: false进房间了再带个current_room_id看起来多合理像极了那种“先把需求顶住明天再优雅”的工程直觉。后来我冷静下来越看越觉得不对劲。2.1online字段看着踏实实际上会把你带沟里问题不是这个字段不能加而是它表达的根本不是你真正想要的东西。聊天室里的“在线”至少有三层含义这个用户是不是登录态存在这个用户是不是当前还连着 WebSocket这个用户是不是正在某个具体房间里这三层一旦混成一个布尔值后面一定会乱。举个很现实的例子我同一个账号开了 3 个标签页其中 2 个在房间 A1 个在房间 B我关掉其中 1 个标签页这时候数据库里那一个online你打算怎么解释它根本解释不了。更别提部署重启、网络闪断、浏览器睡眠恢复这种场景。你会发现数据库里那个“在线”特别像古代户籍庄严、正式、更新缓慢但跟眼前这个人到底站不站在屋里关系其实不大。所以我后来给自己定的第一条规矩就是在线状态是连接态不是持久业务事实。它可以展示可以参与 UI 判断但别把它当成数据库真相去供着。2.2 后来我老老实实把这件事交给PresencePhoenix 给聊天室这种场景留的工具其实很实在Presence就是干这个的。它最让我舒服的一点不是“它很高级”而是它承认了一个现实在线这件事本来就应该跟连接活着没有、在哪个 topic 里有关。我后来比较顺手的一种写法是在房间建立连接后再去track当前用户把用户的轻量信息挂进meta里。大概像这样defp join_room(socket, room_id) do topic Chat.room_topic(room_id) user socket.assigns.current_user Phoenix.PubSub.subscribe(MyApp.PubSub, topic) MyAppWeb.Presence.track( self(), topic, to_string(user.id), %{username: user.username, joined_at: System.system_time(:second)} ) socket | assign(:room_id, room_id) | assign(:topic, topic) | assign(:online_users, list_online_users(topic)) end这段代码真正值钱的不是track/4这个 API 本身而是它让我终于不再试图把“房间里谁在线”塞回数据库了。在线状态就留在实时系统里连接断了它自然掉进了另一个房间它也跟着迁走。这个味道就对了。2.3 这块最容易忽略的坑不是人数而是“一个人开三个标签页算几个”这事我一开始真没太当回事后来才发现它特别像产品需求里的小地雷。Presence.list/1给你的结果天然是按 key 聚合过的但每个 key 下面会有metas。也就是说一个用户可能不止一个连接。所以“在线用户数”到底怎么算其实不是技术问题而是产品语义问题。我后来给自己分了两种口径如果页面文案写的是“在线用户”那我倾向于按用户去重如果页面文案写的是“在线连接”那就可以把多个标签页都算进去处理时我通常会先做一层整理defp list_online_users(topic) do topic | MyAppWeb.Presence.list() | Enum.map(fn {user_id, %{metas: metas}} - %{ user_id: user_id, username: metas | hd() | Map.get(:username), connections: length(metas) } end) end这个connections我挺喜欢保留下来。因为很多时候用户列表看着是“3 个人”你自己心里却知道其实是“2 个人 3 个连接”。这种信息对排查现场很有用尤其你自己一边写一边开着多个标签页压测时心里不会老觉得“是不是 Presence 又抽风了”。2.4 我后面真正学乖的地方是不再把 Presence 当审计系统这句话很重要我自己是后面才彻底想清楚的。Presence 很适合做当前房间在线列表顶部在线人数谁正在某个页面里但它不适合直接承担最后登录时间谁今天来过这个房间精确到分钟的审计留痕原因很简单它是为了反映“现在”不是为了解释“过去”。把这两类东西混起来后面就特别容易出现一种荒诞感你明明想做实时 UI最后却在拿 Presence 硬扛统计系统的活。这个力一旦使歪整套设计会越来越别扭。3. 多房间这块真正让我清醒的不是功能变多而是mount/3不再兜得住上篇单房间版本里我对mount/3的体感其实挺好的。参数进来订阅 topic拉历史消息页面 ready逻辑一条线走到底像极了那种“虽然不复杂但还挺完整”的小幸福。然后多房间一来这点幸福感迅速结束。3.1 我第一次切房间时消息直接串台了这个 bug 的现场特别有喜感。我从房间 A 切到房间 B页面标题已经是 B 了结果 A 房间有人一发消息B 页面也跟着蹦一条。那一刻的感觉非常像你把电视切到体育频道结果新闻联播的字幕还在底下飘。问题后来排出来其实并不玄学旧 topic 没退订新 topic 订了但旧订阅还活着消息列表也没 reset两个房间的数据开始同屏共舞所以我后来对“切房间”这件事的理解彻底变了。它根本不是“换个room_id显示一下内容”而是一次完整的上下文迁移旧房间订阅要退旧房间 Presence 要 untrack新房间订阅要建新房间在线列表要重拉消息流要重置一项都不能装看不见。3.2 我后来把房间切换的主战场放到了handle_params/3这个选择跟 LiveView 的路由模型很有关。如果你在同一个 LiveView 里用push_patch切换房间那么很多时候进程并不会重建。也就是说mount/3不会像你想象中那样重新兜底一遍。这时候如果你还把所有切房逻辑都押在mount/3上十有八九会开始怀疑人生。我后来更顺手的一种做法是让handle_params/3明确承担“房间变了”的处理def handle_params(%{id room_id}, _uri, socket) do {:noreply, switch_room(socket, room_id)} end defp switch_room(socket, room_id) do old_topic socket.assigns[:topic] new_topic Chat.room_topic(room_id) user socket.assigns.current_user if connected?(socket) and old_topic old_topic ! new_topic do Phoenix.PubSub.unsubscribe(MyApp.PubSub, old_topic) MyAppWeb.Presence.untrack(self(), old_topic, to_string(user.id)) end if connected?(socket) and old_topic ! new_topic do Phoenix.PubSub.subscribe(MyApp.PubSub, new_topic) MyAppWeb.Presence.track( self(), new_topic, to_string(user.id), %{username: user.username, joined_at: System.system_time(:second)} ) end socket | assign(:room_id, room_id) | assign(:topic, new_topic) | assign(:online_users, list_online_users(new_topic)) | stream(:messages, Chat.list_messages(room_id), reset: true) end我很喜欢这里的reset: true。因为它的态度很明确既然房间上下文已经变了旧消息就别再赖着不走。很多 bug 其实不是 API 不会用而是你脑子里对“切换”这件事还抱着一种过于温柔的幻想总想着也许只改一部分状态就够了。聊天室会非常无情地告诉你不够。3.3 我后来特别反感一种写法所有房间共用一个大 topic这类写法我不是没见过甚至我自己一开始也差点往这条路拐。思路一般是这样的所有消息都广播到一个总 topic页面收到后再靠message.room_id自己判断要不要处理它不是完全不能跑但我后来越来越不喜欢这种味道。因为它会把“房间边界”从系统结构层偷偷降级成“页面收到后自己懂点事”。这就很别扭。我更认同的方式还是一个房间一个 topic谁该听谁就订谁不该到的消息结构上就别让它到原因很简单边界最好在入口处收而不是在消费端补救。这套想法其实跟我上篇把广播收回 Context 是一个路数别等事情散开了再靠 if/else 补纪律。3.4 还有一个很阴的坑Presence 跟着房间走但别忘了 UI 也得跟着一起清醒我一开始把订阅和untrack都补了以后以为这事差不多稳了。结果切房时又遇到一个很烦人的小问题右侧在线用户列表偶尔会闪一下旧房间的人。后来回头看原因也不复杂。房间切换时消息流、在线列表、房间标题本来就是三块不同状态。如果你不明确在同一个切换动作里把它们一起更新页面就会在很短一段时间里露出一种“灵魂已经过去了肉体还留在原地”的分裂感。所以我后面特别在意一个小原则切房不是更新一两个 assign而是把“这个房间的页面上下文”整体切过去。这个原则听着有点大其实说白了就是别零敲碎打。实时页面一旦状态分叉用户肉眼是能看出来的。4. 部署这块我本来以为只是运维话题后来发现它直接决定聊天室到底真不真我以前多少有点轻视“部署注意事项”这一节。总觉得这种内容特别容易写得像年终运维总结看着重要读着离我很远。但聊天室这个题不一样它太依赖长连接和实时广播了。你只要把它放进真实网络环境里那些“本地开发里根本看不见的问题”会立刻跑出来排队。4.1 我后来对代理层的第一要求很简单先别把 WebSocket 吃了这个坑属于那种不一定报得很响但就是能把人耗死。页面初始渲染是能开的普通 HTTP 请求也没问题你甚至会误以为应用一切正常。结果一到实时更新这块就开始表现得像个性格忽冷忽热的人有时候行有时候不行。后来我基本会先去看代理层是不是把/live/websocket这条链打通了。以 Nginx 为例常见配置大概是这个意思location /live/websocket { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 60s; proxy_pass http://phoenix_upstream; }这段配置本身不花哨但它很像进场门票。门票没拿好后面什么 Presence、PubSub、在线人数聊了都白聊。4.2 我后来不太爱听“加个负载均衡就好了”这种轻飘飘的话单节点聊天室和多实例聊天室根本不是一个难度等级。单节点时消息广播、Presence 同步、LiveView 进程都生活在同一个小宇宙里。你稍微写顺一点体验通常不会太差。多节点以后事情立刻变严肃起来连接可能落在不同节点广播必须跨节点传播Presence 也得知道别的节点上谁在线这时候如果节点之间不互通你会看到一种很诡异的现场A 用户明明发了消息B 用户也明明在线但两边像活在平行宇宙里互相都收不到所以我后来有个很强的个人偏见别把“聊天室本地跑通了”误认为“聊天室可以横向扩了”。真要多实例至少得先确认一件事你的 PubSub 和 Presence是不是已经按跨节点的方式站稳了。否则加机器不一定是扩容也可能只是把 bug 复制到更多地方。4.3 我后来也不再迷信 sticky session 了我更在意“重连以后还能不能自洽”这个点挺有意思我以前会下意识觉得实时应用嘛负载均衡肯定得 sticky不然人一漂移就完蛋。后来我反而觉得sticky 当然有时能省事但它不是核心矛盾。聊天室真正该扛住的是连接断了LiveView 能不能正常重连重连以后页面能不能重新拉回当前房间的历史消息Presence 能不能重新track短暂抖动时用户看到的是“自然恢复”而不是“整页灵魂出窍”换句话说我后来更关心的是系统是否接受一个现实掉线和重连在真实网络里不是事故是日常。你要是把聊天室写成一种“只要不断线就很好一断线就心态爆炸”的结构那上线以后基本只会被现实教育。4.4 这也是为什么我后来越来越认同“消息能落库在线状态别硬持久化”这两者在部署阶段的差异会一下变得特别清楚。服务重启了在线人数归零再慢慢回来我能接受因为那是连接态。但如果服务一重启最近消息也没了或者页面重连后靠一堆本地内存状态拼历史那就不叫抖动那叫基础结构没站住。所以我后来其实越来越喜欢这种分工消息数据库负责兜底在线状态Presence 负责反映当前页面恢复LiveView 重连后主动把当前房间重新拼起来这三样各干各的反而很稳。最怕的是你拿一种机制去假装承担另外一种机制的活。5. 我最后给自己收成了 3 条死规矩真正值钱的不是功能又多了三个而是这三个判断必须收硬。5.1 第一条在线状态是连接事实不是数据库人格如果一个状态会随着页面关闭、网络闪断、进房退房瞬间变化那它就更像连接事实。这种东西适合交给 Presence不适合被我写进数据库然后再祈祷它永远跟现实一致。5.2 第二条切房间不是 UI 行为是订阅和上下文的迁移只把标题从 A 改成 B这不叫多房间。真正的多房间是消息流、Presence、topic、页面状态全都认认真真地切过去。聊天室最讨厌那种“看起来切了实际上没全切”的半成品它会用串房消息这种方式当场打你脸。5.3 第三条生产可用的标准不是本地两个窗口互发消息我现在已经很难再被“本地演示成功”轻易打动了。真正该拿来验收的是这些场景同一个人开多个标签页房间频繁切换网络抖一下再恢复应用重启后页面自动回魂多实例下广播还能对得上这些东西一旦过得去聊天室才开始有一点“不是 demo”的样子。总结消息链路解决的是实时能不能跑起来边界解决的是它能不能长期不串味。在线用户在考验你有没有把连接态和业务态分开多房间在考验你有没有把页面上下文整体迁移部署则在考验你到底是在写一个本地看着很顺的小玩具还是在写一个能接受真实网络世界脾气的实时页面。我这次最大的感受是LiveView 真正厉害的地方不是把实时做出来而是它逼你认真面对状态到底归谁。前一个问题是“消息成功后谁来宣布页面更新”再往后就会追到“谁算在线”“谁属于哪个房间”“掉线回来以后谁负责把现场重新拼起来”。这几个问题一旦答顺了聊天室就会从“挺能演示的 demo”慢慢长成“有点项目味儿的东西”。如果你也在写 LiveView 聊天室而且刚好写到在线状态、多房间、部署这几块开始头皮发紧那太正常了。我自己就是在这儿才真正意识到前面只是热身。真正见功力的往往不是让消息飞起来而是让这些边界在复杂一点的场景里依然不串味。