翻车现场
我当时写的是个多房间聊天室。
界面不复杂,但动态东西很多:
- 左侧房间列表
- 中间消息流
- 右侧在线用户
- 顶部关键字搜索
- 输入框实时校验
- 未读数、在线状态、最新消息都得跟着动
一开始我对这套东西的满意度还挺高。
因为 LiveView 给人的第一印象太容易让人放松警惕了。事件写在服务端,页面跟着变,少了很多前端自己接 WebSocket、自己管 store、自己防状态打架的活。那段时间我甚至有点飘,觉得这玩意儿简直像"服务端替我把麻烦都扛了"。
然后房间里的消息一多,页面开始阴阳怪气。
不是那种一眼就能定位的报错,而是更烦的那种:
- 输入框偶尔有点黏
- 搜索框打快了,日志像开了机关枪
- 新消息明明到了,界面却会顿一下
- 浏览器标签页挂久一点,内存也不太讲武德
这种问题最折磨人的地方就在这儿。
它不是不能用,它只是一直在暗示你:哥们,你现在这套写法有点虚。
我第一反应:这锅八成是数据库的
我第一眼看到"页面越来越喘",脑子里条件反射就是数据库。
毕竟消息列表、房间统计、在线人数,这些词摆在一起,怎么看都像 SQL 要出来背锅。我那几天排查思路也非常传统:
- 看慢查询
- 补索引
- 合并重复读取
- 把一些顺手查的统计拆掉
折腾完不是完全没效果,确实顺了一点,但那股奇怪的卡顿感还在。
这时候我心态有点开始歪了。
因为最烦的不是"完全没优化到",而是"优化了一点,但核心问题根本没动"。这就像你以为自己治的是发烧,结果退了半度,真正的病灶还在后面冲你招手。
后来我去看浏览器 DevTools 里的 WebSocket 帧,整个人当场清醒。
我本来以为新来一条消息,服务端最多推一个很小的增量回来,结果一看消息体大小,我脑子里只剩一句话:
这哪是增量,这不是搬家吗。
也就是在那一刻我第一次意识到,我查数据库的方向不算错,但只查数据库根本不够。因为问题不只是"查得快不快",还有一层更隐蔽的账:
- 你让 LiveView 重新算了多少状态
- 这些状态最后生成了多大的 diff
- 这份 diff 又通过 WebSocket 往前端搬了多少东西
数据库只是锅边料,真正的大锅在我自己的状态管理习惯上。
我第二反应:那我把列表换成 streams,这事不就结了
我接着盯住了消息列表。
很明显,聊天消息这种东西本来就不该每来一条都整段重刷。于是我开始改 streams。当时我的心态还挺自信,甚至有点"终于抓到主犯了"的爽感。
初始化只放最近一段消息:
elixir
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
新消息来了只插一条:
elixir
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":
elixir
<div id="messages" phx-update="stream">
<div :for={{dom_id, message} <- @streams.messages} id={dom_id}>
<p>{message.user_name}: {message.body}</p>
</div>
</div>
这一步改完,效果确实立刻有。
消息来了不再是"整段列表重发",而是老老实实补一个增量。那一刻我还真开心了一下,心想这回总算打到七寸了。
结果高兴得太早。
页面是顺了不少,但还是没有彻底顺。我当时还纳闷,甚至一度怀疑是不是 streams 用法有问题,或者是模板哪儿还在偷偷重渲染。后来我把代码翻得更细,才发现问题有点好笑:
我虽然把列表渲染改成了 streams,但我还把完整消息列表继续放在 assigns 里。
也就是说,我表面上在减肥,背地里夜宵一顿没落。
比如我当时很容易写出这种代码:
elixir
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 虽然瘦了一点,进程状态还是胖的,很多额外计算也还是会照做。
我还顺手踩了一个不大但很气人的坑。
我一开始往头部补历史消息,直接写了:
elixir
stream(socket, :messages, history_messages, at: 0)
然后 UI 顺序反了。
我当场还愣了一下,以为自己排序 SQL 写错了。后面才反应过来,批量 at: 0 本质上是逐条往头部插,所以顺序会倒。这种坑很典型,伤害不大,但特别适合发生在你最自信的时候。要么先 Enum.reverse/1,要么换别的追加思路,不然你会对着页面怀疑半天人生。
我第三次打脸:真正把 WebSocket 喂胖的,不止消息列表
消息列表这条线捋顺以后,我本来以为差不多可以收工了。
结果继续抓 WebSocket 帧,发现它还是不算瘦。然后我才发现,自己当时的代码风格其实很危险,说白了就一句:
只要这个页面可能会用到,我就先塞进 assigns 再说。
比如新消息来了,我顺手会把这些东西一起更新:
elixir
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。刚上线那会儿我还挺喜欢这种"所见即所得"的实时感,觉得很高级。可一旦手速稍微快一点,日志马上就开始狂刷。
那时候我的心态特别像:
我写的不是实时交互,我写的是服务端陪打字。
一次输入一个事件,一次事件一轮处理,一轮处理里可能还带查询、过滤、模板更新。单看每一步都不重,可频率一上来,整个链路就开始烦人。
后来我把搜索和输入校验拆开处理,才正常一点:
heex
<form phx-change="search" phx-submit="send">
<input
type="text"
name="keyword"
value={@keyword}
phx-debounce="300"
/>
<input
type="text"
name="message[body]"
value={@body}
phx-debounce="blur"
/>
</form>
这块我后面有个特别朴素的判断标准:
phx-debounce是给输入框留口气phx-throttle是防我自己把页面写成压力测试入口
尤其是搜索、校验、keydown 这种高频交互,我现在第一反应已经不是"能不能做得更实时",而是:
这事真值得我每敲一个字都往服务端发一次吗?
很多时候答案其实没那么热血。
我最后才承认:LiveView 本质上就是一堆还活着的进程
最后一个坑来得最阴。
LiveView 写顺手以后,人真的很容易忘记一件事:页面背后是进程,不是魔法。
BEAM 很能扛进程,这当然没问题。问题从来不是"进程能不能多",而是"每个进程里到底装了多少东西"。
我那段时间为了省事,习惯性把这些玩意儿全挂进 socket:
- 完整消息列表
- 搜索结果缓存
- 在线用户详情
- 房间统计
- 临时表单状态
- 某些异步任务也会用到的大对象
一两个页面看不出什么味道,多开几个标签页、房间里再来点活跃用户,内存就开始给我上脸色。
更扎心的是,我还一度这么写过异步任务:
elixir
assign_async(socket, :summary, fn ->
{:ok, %{summary: build_summary(socket.assigns.room_id, socket.assigns.messages)}}
end)
表面看起来没什么,实际是在偷偷把一个很胖的上下文复制给异步任务。那一刻我真的有种"代码写得很顺,内存死得很冤"的感觉。
后面我改成先把必要字段抠出来,再交给异步任务:
elixir
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 最容易踩的性能坑,不是它不够快,而是它太容易让我产生一种错觉:代码写得这么顺,性能应该也会自动很顺。结果最后发现,真正下手把页面喂胖的人,一直是我自己。