LiveView 页面越写越卡,我最后抓到的凶手不是信息库,是我自己乱塞 `assigns`
LiveView 页面越写越卡,我最后抓到的凶手不是信息库,是我自己乱塞 `assigns` 翻车现场 我当时写的是个多房间聊天室。 界面不复杂,但动态东西很多: 左侧房间列表 中间消息流 右侧在线使用者 顶部关键字搜索 输入框实时校验 未读数、在线状态、最新消息都得跟着动 一开始我对这套东西的满意度还挺高。 因为 LiveView 给人的第一印象太容易让人放松警惕了。事件写在服务端,页面跟着变,...
LiveView 页面越写越卡,我最后抓到的凶手不是信息库,是我自己乱塞 `assigns` 翻车现场 我当时写的是个多房间聊天室。 界面不复杂,但动态东西很多: 左侧房间列表 中间消息流 右侧在线使用者 顶部关键字搜索 输入框实时校验 未读数、在线状态、最新消息都得跟着动 一开始我对这套东西的满意度还挺高。 因为 LiveView 给人的第一印象太容易让人放松警惕了。事件写在服务端,页面跟着变,少了很多前端自己接 WebSocket、自己管 store、自己防状态打架的活。那段时间我甚至有点飘,觉得这玩意儿简直像“服务端替我把麻烦都扛了”。 然后房间里的消息一多,页面开始阴阳怪气。 不是那种一眼就能定位的报错,而是更烦的那种: 输入框偶尔有点黏 搜索框打快了,日志像开了机关枪 新消息明明到了,界面却会顿一下 浏览器标签页挂久一点,内存也不太讲武德 这种问题最折磨人的地方就在这儿。 它不是不能用,它只是一直在暗示你: 哥们,你现在这套写法有点虚。 我第一反应:这锅八成是数据库的 我第一眼看到“页面越来越喘”,脑子里条件反射就是数据库。 毕竟消息列表、房间统计、在线人数,这些词摆在一起,怎么看都像 SQL 要出来背锅。我那几天排查思路也非常传统: 看慢查询 补索引 合并重复读取 把一些顺手查的统计拆掉 折腾完不是完全没效果,确实顺了一点,但那股奇怪的卡顿感还在。 这时候我心态有点开始歪了。 因为最烦的不是“完全没改进到”,而是“优化了一点,但核心问题根本没动”。这就像你以为自己治的是发烧,结果退了半度,真正的病灶还在后面冲你招手。 后来我去看浏览器 DevTools 里的 WebSocket 帧,整个人当场清醒。 我本来以为新来一条消息,服务端最多推一个很小的增量回来,结果一看消息体大小,我脑子里只剩一句话: 这哪是增量,这不是搬家吗。 也就是在那一刻我第一次意识到,我查数据库的方向不算错,但只查数据库根本不够。因为问题不只是“查得快不快”,还有一层更隐蔽的账: 你让 LiveView 重新算了多少状态 这些状态最后生成了多大的 diff 这份 diff 又通过 WebSocket 往前端搬了多少东西 数据库只是锅边料,真正的大锅在我自己的状态管理习惯上。 我第二反应:那我把列表换成 streams ,这事不就结了 我接着盯住了消息列表。 很明显,聊天消息这种东西本来就不该每来一条都整段重刷。于是我开始改 streams 。当时我的心态还挺自信,甚至有点“终于抓到主犯了”的爽感。 初始化只放最近一段消息: def mount(%{"room_id" => room_id}, _session, socket) do messages = Chat.list_recent_messages(room_id, limit: 50) {:ok, socket |> assign(:room_id, room_id) |> assign(:message_count, Chat.count_messages(room_id)) |> stream(:messages, messages, limit: -50)} end 新消息来了只插一条: def handle_info({:new_message, message}, socket) do {:noreply, socket |> update(:message_count, &(&1 + 1)) |> stream_insert(:messages, message, limit: -50)} end 模板也改成 phx-update="stream" :
{message.user_name}: {message.body}
这一步改完,效果确实立刻有。 消息来了不再是“整段列表重发”,而是老老实实补一个增量。那一刻我还真开心了一下,心想这回总算打到七寸了。 结果高兴得太早。 页面是顺了不少,但还是没有彻底顺。我当时还纳闷,甚至一度怀疑是不是 streams 用法有问题,或者是模板哪儿还在偷偷重渲染。后来我把代码翻得更细,才发现问题有点好笑: 我虽然把列表渲染改成了 streams ,但我还把完整消息列表继续放在 assigns 里。 也就是说,我表面上在减肥,背地里夜宵一顿没落。 比如我当时很容易写出这种代码: def handle_info({:new_message, _message}, socket) do room_id = socket.assigns.room_id messages = Chat.list_recent_messages(room_id) {:noreply, socket |> assign(:messages, messages) |> assign(:message_count, length(messages)) |> stream(:messages, messages, reset: true)} end 现在回头看,这段代码简直像“我知道 streams 很关键,但我只学会了它的皮”。 streams 真正有用,不是模板里出现 @streams.messages 就算完事,而是你别再把那份大集合长期挂在 socket 上当纪念品。否则 diff 虽然瘦了一点,进程状态还是胖的,很多额外计算也还是会照做。 我还顺手踩了一个不大但很气人的坑。 我一开始往头部补历史消息,直接写了: stream(socket, :messages, history_messages, at: 0) 然后 UI 顺序反了。 我当场还愣了一下,以为自己排序 SQL 写错了。后面才反应过来,批量 at: 0 本质上是逐条往头部插,所以顺序会倒。这种坑很典型,伤害不大,但特别适合发生在你最自信的时候。要么先 Enum.reverse/1 ,要么换别的追加思路,不然你会对着页面怀疑半天人生。 我第三次打脸:真正把 WebSocket 喂胖的,不止消息列表 消息列表这条线捋顺以后,我本来以为差不多可以收工了。 结果继续抓 WebSocket 帧,发现它还是不算瘦。然后我才发现,自己当时的代码风格其实很危险,说白了就一句: 只要这个页面可能会用到,我就先塞进 assigns 再说。 比如新消息来了,我顺手会把这些东西一起更新: def handle_info({:new_message, message}, socket) do room_id = socket.assigns.room_id {:noreply, socket |> assign(:messages, Chat.list_recent_messages(room_id)) |> assign(:message_count, Chat.count_messages(room_id)) |> assign(:online_users, Presence.list_online_users(room_id)) |> assign(:search_result, Chat.search_preview(room_id, socket.assigns.keyword)) |> assign(:stats, build_room_stats(room_id))} end 这段代码的问题,不在于某一行单独看有多离谱,而在于它特别符合一种“写起来很顺手、跑起来很费劲”的坏习惯。 我本来只是想更新消息列表,结果顺手把: 消息总数 在线用户 搜索预览 房间统计 全都带着重新算了一遍。 这时候我才彻底反应过来,LiveView 性能问题和我之前写 SPA 时的那种焦虑,只是形式不同。 以前我最怕的是前端组件树乱重渲染。到了 LiveView,我一度以为不用盯这些了。结果只是换了战场: SPA 常见问题是浏览器端重渲染太频繁 LiveView 常见问题是服务端状态太胖,diff 太大,事件太密 浏览器那边安静了,服务器这边开始替我加班。 我第四次打脸: phx-change 太勤快,用户打字都像在帮我压测 消息流这块收拾得差不多以后,我本来真的觉得自己快赢了。 结果搜索框又开始作妖。 我当时给房间搜索、输入校验都绑了 phx-change 。刚上线那会儿我还挺喜欢这种“所见即所得”的实时感,觉得很高级。可一旦手速稍微快一点,日志马上就开始狂刷。 那时候我的心态特别像: 我写的不是实时交互,我写的是服务端陪打字。 一次输入一个事件,一次事件一轮处理,一轮处理里可能还带查询、过滤、模板更新。单看每一步都不重,可频率一上来,整个链路就开始烦人。 后来我把搜索和输入校验拆开处理,才正常一点:
这块我后面有个特别朴素的判断标准: phx-debounce 是给输入框留口气 phx-throttle 是防我自己把页面写成压力测试入口 尤其是搜索、校验、 keydown 这种高频交互,我现在第一反应已经不是“能不能做得更实时”,而是: 这事真值得我每敲一个字都往服务端发一次吗? 很多时候答案其实没那么热血。 我最后才承认:LiveView 本质上就是一堆还活着的进程 最后一个坑来得最阴。 LiveView 写顺手以后,人真的很容易忘记一件事:页面背后是进程,不是魔法。 BEAM 很能扛进程,这当然没问题。问题从来不是“进程能不能多”,而是“每个进程里到底装了多少东西”。 我那段时间为了省事,习惯性把这些玩意儿全挂进 socket: 完整消息列表 搜索结果缓存 在线用户详情 房间统计 临时表单状态 某些异步任务也会用到的大对象 一两个页面看不出什么味道,多开几个标签页、房间里再来点活跃用户,内存就开始给我上脸色。 更扎心的是,我还一度这么写过异步任务: assign_async(socket, :summary, fn -> {:ok, %{summary: build_summary(socket.assigns.room_id, socket.assigns.messages)}} end) 表面看起来没什么,实际是在偷偷把一个很胖的上下文复制给异步任务。那一刻我真的有种“代码写得很顺,内存死得很冤”的感觉。 后面我改成先把必要字段抠出来,再交给异步任务: room_id = socket.assigns.room_id message_ids = Enum.map(socket.assigns.visible_message_ids, & &1) socket = assign_async(socket, :summary, fn -> {:ok, %{summary: build_summary(room_id, message_ids)}} end) 这种改动看起来不性感,但很值。 因为 LiveView 这套模型最容易给人的错觉就是:状态都在服务端,好像放哪儿都行。真到工程复杂起来,我才发现服务端状态不是仓库,它更像背包。能装,不代表该全装。 根因说人话就一句 我后来把这几轮翻车串起来看,根因其实就一条: 我把 LiveView 当成了“少写前端代码的 SPA”,却没把它当成“服务端长期持有状态的实时系统”。 这两种理解看起来只差几个字,写出来的代码味道完全不一样。 在 SPA 里,很多性能问题最后都会落到浏览器: 组件重渲染 状态扩散 DOM 更新过频 到了 LiveView,这些问题没有消失,只是换了个壳: assigns 太胖,服务端 diff 会跟着胖 列表没用对,WebSocket 会替你搬整车数据 事件太密,连接层会被你自己聊爆 每个 LiveView 进程都背一堆状态,内存迟早不装傻 所以我后来再看 LiveView 性能,基本就只问自己三件事: 我到底让服务端长期记住了什么 我到底让 WebSocket 频繁传了什么 我到底让每个页面进程背了多少本来不该它背的东西 这三个问题一问清楚,很多“玄学卡顿”立刻就没那么玄了。 我最后怎么把这摊子收拾回来的 我最后没搞什么特别炫的优化,更多是把写法掰正。 1. 不再把 assigns 当杂物间 我后面会更克制地放状态。 能只放 ID,就不放整对象;能现算的小东西,就不长期挂着;那种“顺手放一下吧,反正以后可能用到”的内容,我现在会下意识警惕,因为这类东西最后十有八九会变成 diff 和内存的隐形税。 有些只是渲染一下就够的内容,我也会认真考虑要不要走 temporary_assigns 这类思路,而不是让它在 socket 里常驻。 2. 长列表从一开始就按 streams 设计 我现在看到消息流、通知流、活动日志、长表格,脑子里的第一反应已经不是“先 assign 一个列表再说”,而是: 这玩意儿是不是从第一天起就该按 streams 来。 而且不是只改模板那半套,是真把状态模型一起改掉: 模板用 phx-update="stream" 新增用 stream_insert/4 删除用 stream_delete/3 或 stream_delete_by_dom_id/3 想控前端长度,后续插入时持续带上 limit 首次 mount 也别贪多,因为初次渲染不会帮你自动瘦身 3. 高频事件先怀疑自己是不是发太勤了 现在我写 phx-change 、 phx-keydown 、 phx-click 这种东西,会先怀疑自己。 输入校验就用 phx-debounce ,狂点按钮就上 phx-throttle ,不值得实时的交互就别硬装实时。LiveView 的强项是事件闭环写起来顺,不是让我把每次手抖都变成一次服务端往返。 4. 把页面当进程系统看,不当语法糖看 我后来会更主动地盯这些东西: LiveDashboard 里的内存和连接情况 页面长时间挂着时到底背了多少状态 异步任务有没有复制不必要的数据 某些空闲页面要不要靠 hibernate_after 之类的机制安静一点 这一步很关键。 因为 LiveView 越顺手,越容易让人忘了它本质上还是运行中的系统,不是什么“反正写上去就有人替我兜底”的模板魔法。 最后一句人话 这波坑踩完以后,我对 LiveView 的滤镜反而健康了很多。 我现在还是很喜欢它,因为它确实能把很多 SPA 里又碎又烦的状态同步活抹平掉。但我已经不再把它理解成“自动省性能”的开发框架了。 它更像一辆很好开的车。 方向盘很顺,起步也轻,结果我一上头,把后备箱、后座、车顶架全塞满,还一路地板油,最后下车问它为什么费油。 真相其实很朴素: assigns 不是收纳箱,塞多了 diff 一定胖 streams 不是点缀,长列表不用它早晚还债 WebSocket 不是不要钱,事件发太勤一样会把自己聊累 LiveView 进程不是黑洞,状态放进去都得算内存 如果非要我用一句话总结这篇的核心感受,那就是: LiveView 最容易踩的性能坑,不是它不够快,而是它太容易让我产生一种错觉:代码写得这么顺,性能应该也会自动很顺。结果最后发现,真正下手把页面喂胖的人,一直是我自己。