连上 WebSocket 发一条消息,半小时就能跑通。我以为最难的部分已经过去了,实际上真正的工作量全在「连接会断」这件事上,而且我一开始完全低估了它有多难察觉。四个问题。
一是连接已经死了,但双方都不知道。我的重连是监听连接关闭事件触发的。但我手动切到飞行模式测的时候发现,客户端很长时间都收不到关闭事件——它以为连接还在,照常往里写消息,而那些消息全部静默消失了。没有报错、没有回调、发送的时候一切正常。这个现象让我第一次理解:「连接对象还在」和「连接真的通」是两件事。
二是我加了重连之后,服务端一重启就被打满。我的重连是「断了立刻重试,失败了继续立刻重试」。我用脚本开了上百个连接,然后杀掉服务端进程再启动——结果是上百个客户端在同一瞬间全部重连,而且失败了马上又重连,服务端刚起来就被这波请求压住。
三是重连成功了,但断线期间的消息没了。我最初的重连只是「把连接建起来」。断开的那段时间对方发的消息,服务端推的时候我不在线,重连之后也不会再推一次——这些消息就永远看不到了,除非我手动下拉刷新。
四是应用切到后台后维持着一个假连接。安卓上应用切后台会被冻结,心跳停了、连接实际已经不通,但客户端的状态还是「已连接」。用户切回前台之后以为能正常收发,实际上要等到下一次心跳超时才会重连。
所以四个改动:加双向心跳与超时判定、重连改为指数退避加随机抖动、重连后用本地最后一条消息的位置补拉、切后台主动断开、回前台立即重连并补拉。
这个模块最想说的一句话是:长连接最危险的状态不是「断开」,而是「已经断了但你以为还连着」。断开是可以被处理的(有事件、有回调);而「假连接」没有任何信号,你发出去的消息就像扔进了黑洞。心跳的全部意义就是把这种沉默的失效变成一个可以被察觉的事件——想清楚这一点之后,心跳超时次数、退避策略这些参数该怎么定就都有依据了。
做心跳,而不是只依赖连接关闭事件。只依赖事件的实现最省事、也不产生额外流量。但它对「假连接」完全无效——而假连接恰恰是最危险的状态,因为它没有任何信号。心跳的代价是持续的流量和耗电(移动端上这个代价是真实的)。判据是「这个失效如果发现不了,后果有多严重」:消息静默丢失、用户以为发出去了,这个后果足够严重,值得付心跳的成本。而心跳间隔的取值就是在「发现速度」和「流量耗电」之间找一个点,我是在真机上试的。
退避加抖动,而不是固定间隔。固定间隔实现最简单、恢复也最快。但它在「服务端重启」这个场景下会形成同步的重试洪峰——所有客户端同时断开、同时重试、同时失败、同时再试。抖动这一步特别容易被省略,但没有它,即使有退避,大家的重试时刻仍然是对齐的。判据是「会不会有很多客户端在同一时刻做同一件事」:会,那就必须打散。
重连后补拉而不是靠服务端重推。让服务端记住「谁没收到什么」然后重连时重推,听起来更省客户端的事。但那要求服务端为每个离线用户维护待推队列,而队列要处理堆积、过期、多设备各自的进度——复杂度明显高于让客户端说一句「我最后收到的是这条,之后的给我」。判据是「谁最知道自己缺什么」:客户端最知道,那就由它发起。这个选择也让服务端保持了无状态的推送逻辑,我一个人维护得住。
切后台主动断开,而不是尽力维持。维持连接能让消息在后台也及时收到。但移动端会冻结后台应用,维持只是维持了一个客户端自己的状态标记,实际早就不通了——而这个假状态比明确的断开更糟。主动断开的代价是后台期间完全收不到消息(要靠系统级推送补,那超出了我这个项目的范围),但换来的是状态真实。
踩过的坑一:切飞行模式后客户端长时间收不到关闭事件,消息静默丢失。发送的时候没有任何报错。这个现象让我第一次理解「连接对象还在」和「连接真的通」是两件事。教训是:任何「保持中的状态」都需要一个主动的探测手段来确认它还有效——不能等对方来通知你,因为通知本身也依赖那条已经断了的链路。
踩过的坑二:杀掉服务端再启动,上百个客户端同时重连把它压住。我是用脚本开了上百个连接才复现的。教训是:客户端的重试策略要考虑「所有客户端一起重试」的场景——单个客户端看起来很合理的「立刻重试」,乘以客户端数量之后就是一次自制的洪峰。
踩过的坑三:重连成功了但断线期间的消息没补。我当时以为「连接恢复了就好了」。教训是:重连不等于恢复——连接是通道,而通道断开期间流经它的数据需要单独补。我后来把「重连成功」和「数据同步完成」分成了两个状态,界面上也分开显示。
没做的部分:没做多进程下的连接寻址(用户连在哪台机器上、消息怎么转发过去)。因为我只有一台服务器、单个进程,这个问题不存在——硬做一个用不上的方案不如说清「单进程下不需要,多进程时需要解决连接与用户的映射问题」。我认为能说清这个边界,比假装自己处理过多机部署要好。
假连接的发现时间是这个模块最核心的数字:报「手动切到飞行模式后,从连接实际不通到客户端判定失效并开始重连的时间」,并说明这个时间由「心跳间隔 × 超时次数」决定。改造前这个时间是不确定的(要等系统级的关闭事件,实测很长),改造后是可控的——「从不确定变成可控」才是心跳的核心价值。
心跳参数的取值要报实测过程:「在真机上分别试几组心跳间隔,记录发现时间与耗电、流量变化,取一个平衡点」。直接给一个数字而不说怎么来的就是拍的。
退避效果用脚本压测:「用脚本开 N 个连接,杀掉服务端进程再启动,记录重连请求在时间轴上的分布」。报法是「无退避时 N 个重连集中在启动瞬间;加退避与抖动后被打散到若干秒内」——分布比总数更能说明问题。
断线补拉是断言型用例:断开连接、期间由另一个连接发送 M 条消息、恢复连接,断言这 M 条消息全部出现在会话里、顺序正确、且没有重复。
前后台切换:切后台若干分钟(期间对方发消息)、切回前台,断言立即重连并补齐了这些消息;反复切前后台,断言不产生重复消息、不产生多余连接。
服务端连接清理:用脚本建立 N 个连接后直接掐断(不发关闭帧),断言服务端在超时后清理了这些连接、内存中的连接数回落。
身份校验:不带凭据或带错误凭据发起握手,断言连接被拒绝。
不要报什么:不要报「支持 N 万长连接」——我只用脚本开到上百个,而且单进程的上限受机器配置影响。该报的是「切飞行模式后失效发现时间从不确定变为可控的若干秒」「N 个连接同时重连时的时间分布对比」「断线期间的 M 条消息全部补齐且不重复」「掐断连接后服务端超时清理生效」这几件带条件的、可核对的事。
发消息这件事我最初的实现是:把消息塞进连接、同时把它加到界面的消息列表里、显示成已发送。看起来很顺,而且在网络正常时确实没问题。四个问题都是我人为破坏条件之后才出现的。
一是界面显示已发出,服务端根本没收到。我把「写入连接」当成了「发送成功」。但写入连接只是把数据交给了操作系统的缓冲区,它离到达服务端还差很远——我掐断连接之后再发消息,界面上照样显示成已发送,而服务端什么都没收到。用户完全不知道对方没收到。
二是我加了重发之后,出现了重复消息。场景是:服务端已经收到并存了消息,但回执在返回路上丢了。客户端等不到回执、判定失败、用户点了重发——服务端就存了第二条一样的消息。而这种情况我是靠人为丢掉回执才构造出来的,正常网络下极难碰到。
三是对话顺序错乱。我用消息的本地时间排序。但我在两台设备上测的时候,其中一台的系统时间快了几分钟——结果是它发的消息全部排到了对方消息的前面,整个对话读起来是乱的。而这不是极端情况,用户的设备时间本来就不保证准确。
四是同一条消息出现两次。断线重连后我会补拉消息。而有些消息既通过推送到过客户端、又被包含在补拉结果里——我直接把补拉的结果追加到列表,于是出现了两条一模一样的。
所以四个改动:发送改为三态并由服务端回执驱动、用客户端生成的消息标识做幂等键、排序改用服务端分配的会话内递增序号、补拉与推送的消息按标识合并去重。
这个模块最想说的一句话是:「我把数据交出去了」和「对方收到了」之间隔着一整条不可靠的链路,而界面上那个「已发送」的对勾必须由对方的回执来点亮,不能由自己点亮。我最初就是自己点亮的,所以它表达的其实是「我发出去了」,而用户理解的是「对方收到了」——这两者之间的落差就是所有问题的来源。想清楚之后我去看了主流聊天产品,发现它们的消息状态确实是分阶段的(转圈、单勾、双勾),原来那不是装饰。
消息标识由客户端生成,而不是服务端。服务端生成标识更符合直觉(它是权威)。但幂等要求「重发时服务端能认出这是同一条消息」——如果标识是服务端给的,那客户端重发时手上还没有标识,服务端只能当成新消息。所以幂等键必须在客户端就确定下来。判据是「幂等键必须在第一次请求之前就存在」——这一条在任何「客户端可能重试」的场景里都成立(提交表单、支付、发布内容),不只是聊天。
顺序用服务端序号而不是本地时间,代价是「发送中」的消息位置会跳变。用本地时间排序的话,消息一上屏就在最终位置、不会跳。但设备时间不保证准确,我实测到一台设备快几分钟就让整个对话乱了。判据是「哪个错误用户更不能容忍」:展示时间偏差几分钟他能容忍(甚至注意不到),顺序错乱会让对话读不通,完全不能接受。所以我接受位置跳变这个小代价。
状态做成三态而不是两态。只做「已发送 / 失败」更简单。但弱网下从发出到收到回执可能有好几秒,这段时间用户需要知道「正在发」而不是看到一个已经点亮的对勾——提前点亮就是在骗他。三态的代价是要处理超时判定和状态流转,但它让界面表达的东西是真实的。做完之后我去看主流聊天产品,发现它们的状态确实是分阶段的(转圈、单勾、双勾),原来那不是装饰。
不做「已读」状态。已读需要接收方回传阅读事件、还要处理多设备、以及「已读但没真看」这类语义问题。而我判断它对验证「消息可靠投递」这条链路没有增量价值——送达和已读是两个不同层面的问题。取舍依据是「先把送达这条链路做对,再谈更上层的状态」,而且我能说清已读要额外解决什么,这比硬做一个半成品好。
踩过的坑一:界面显示已发送,服务端根本没收到。我把「写入连接」当成了「发送成功」。掐断连接后发消息,写入照样成功。教训是:「我把数据交出去了」和「对方收到了」之间隔着一整条不可靠的链路——界面上那个对勾必须由对方的回执来点亮,不能由自己点亮。这一条推广开来就是:任何表示「对方已经怎样了」的界面状态,都不能由本地行为触发。
踩过的坑二:重发导致消息重复。场景是服务端已收到并落库、但回执在返回路上丢了。这种情况我是人为丢掉回执才构造出来的,正常网络下极难碰到——而它一旦发生,用户看到的是自己发了两条一样的消息。教训是:只要存在「客户端会重试」这件事,服务端就必须能识别重复请求,而识别的依据必须是客户端在第一次请求时就带上的标识。
踩过的坑三:设备时间不一致导致对话顺序错乱。我是在两台真机上测的时候发现的,其中一台时间快了几分钟。教训是:不要用客户端提供的时间做任何有正确性要求的判断——它不是权威、也不受你控制。需要顺序就让服务端分配序号。
没做的部分:没做消息的本地持久化(离线也能看历史)。它需要本地数据库、以及本地与服务端的增量同步和冲突处理,复杂度高出一个量级;而我目前的补拉机制已经能在联网后拿到全部消息。取舍依据是「离线看历史」这个需求在我的场景里不强,而且我能说清它要额外解决什么。
假成功是断言型用例,也是这个模块最重要的一条:用工具掐断连接,然后发送消息,断言消息状态在超时后变为「发送失败」而不是停留在「已发送」;恢复连接后点击重发,断言送达。
幂等性要靠人为构造回执丢失来测:在服务端落库成功之后丢弃回执,断言客户端重发后服务端不产生第二条消息、且返回的是原来的序号。这条必须打桩,正常网络下等不到。
顺序正确性用「改设备时间」来测:把一台设备的系统时间调快若干分钟,两台互发消息,断言两端看到的对话顺序完全一致。这个测法很便宜,而且它是踩坑的直接回归。
去重要覆盖两条来源:构造一条「既被推送到、又在补拉结果里」的消息,断言会话里只出现一次;多设备场景下自己发的消息被推回来,断言不重复。
发送中超时:断言「发送中」状态超过设定时间后转为失败,不会无限转圈。
断线时的发送拦截:断言连接断开时发送框被禁用或有明确提示,用户无法产生「以为发出去了」的消息。
并发发送:用脚本从同一会话并发发送 N 条消息,断言全部落库、序号连续无重复、两端看到的顺序一致。
不要报什么:不要报「消息送达率 100%」——弱网下不可能,而且这个数字无法自证。该报的是「掐断连接后消息正确进入失败态并可重发」「人为丢弃回执后重发不产生重复」「设备时间相差若干分钟时两端顺序一致」「推送与补拉重叠的消息只出现一次」这几件可核对的事。
会话列表看起来只是「把我参与的对话列出来、每条显示最后一句话和未读数」。我最初的实现完全没有独立的会话概念,全靠从消息表里算。四个问题。
一是会话列表随消息量增长越来越慢。我的查询是「从消息表里找出所有和我有关的消息,按会话分组,每组取最新一条」。灌了几万条消息之后,这个查询明显慢下来了——而它是打开应用第一眼就要看到的东西。
二是未读数在三个地方显示三个数字。底部角标、会话列表、聊天页顶部,三处各自算各自的。刷新时机不同,于是角标显示 5、列表加起来是 3、进聊天页又是另一个数。用户一眼就能看出对不上。
三是进入会话就清未读,但消息没加载出来。我在进入聊天页时就调了清除未读的接口。结果是网络失败、消息一条都没显示出来,但未读已经被清掉了——用户退出去看到未读没了,以为自己看过了,那些消息就真的被漏掉了。
四是历史消息向上翻页会重复。我用偏移量向上翻。而聊天页在翻页期间可能有新消息进来,偏移量整体错位,翻上去就出现重复的消息。
所以四个改动:建独立的会话表并维护最后一条消息摘要与未读数、未读数收敛为会话表上的单一来源、已读用「已读到的序号」表达、清除时机改为消息实际渲染后、历史消息改按序号游标翻页。
这个模块最想说的一句话是:把「已读」从一个布尔标记改成「已读到的消息序号」,一下解决了三个原本各自要单独处理的问题。它让「部分已读」可以表达(只清除已经渲染出来的那部分)、让多设备的已读位置可以简单地取最大值合并、也让未读数变成一个可以随时从序号差算出来、因此可以被对账校验的派生值。一个数据结构的选择带来这么多连带收益,是我在这个项目里比较意外的收获。
已读用「序号」而不是布尔标记,这是这个模块里最有价值的一个决定。布尔标记(这个会话已读 / 未读)实现最简单。但它表达能力太弱:无法表达「我看了前面几条但后面没看」、多设备的已读状态无法合并(谁覆盖谁?)、未读数也无法从它重算出来(因此无法对账)。换成序号之后这三件事都变得简单:部分已读就是序号停在中间、多设备合并就是取最大值、未读数就是最新序号减已读序号。判据是「这个字段需要表达的是一个开关还是一个位置」——是位置,就不要用开关去存。一个数据结构的选择带来这么多连带收益,是我在这个项目里比较意外的收获。
建独立会话表,接受「同一份信息存两处」的冗余。从消息表算列表的好处是永远不会不一致(只有一份数据)。但它的耗时随消息总量增长,而会话列表是打开应用第一眼就要看到的东西。冗余的代价是要保证一致性——我的做法是把「更新会话」和「消息落库」放在同一个事务里,再加未读数的定时对账兜底。判据是「读的频率和数据增长的速度」:读得最频繁、而底层数据一直在涨,那就必须把结果预先算好存下来。
清除未读的时机绑定「消息渲染完成」而不是「进入页面」。绑定进入页面实现最简单。但它把「已读」这个语义和「打开了容器」混为一谈了——而用户可能什么都没看到(网络失败)。代价是要能判断「消息真的渲染出来了」,实现上麻烦一点。判据是「这个状态变更代表的是用户的什么行为」:已读代表「他看到了」,那就必须等到真的显示出来。这一条我觉得比技术实现更重要——很多状态设计错就错在语义对不上。
未读数本地先清但要能回滚。等服务端返回再清的话,弱网下红点要几百毫秒甚至更久才消失,用户会以为没点到。本地先清体验好,但必须处理失败回滚——只做一半反而更糟(红点消失又出现,像坏了)。这一条和任何「本地先行」的乐观更新都一样:本地先行和失败回滚是一套,不能只做前一半。
踩过的坑一:进页面就清未读,网络失败时消息一条没显示但未读没了。用户退出去看到未读为零,以为自己看过了。教训是:「已读」的语义应该对应用户真的看到了内容,而不是他打开了某个页面——这两者在网络失败时会完全分叉。
踩过的坑二:未读数三处显示三个数字。因为角标、列表、聊天页各自统计。教训是:同一个数字在界面上出现多次时,它必须只有一个来源——多来源的不一致是必然的,因为它们的刷新时机不可能完全同步。而且这类不一致用户一眼就能看到,特别伤信任。
踩过的坑三:向上翻历史消息出现重复。我用的是偏移量。教训和帖子列表那次一样:偏移量分页隐含「数据集不变」,而聊天页在翻页期间随时可能有新消息进来。改成序号游标之后,新消息追加在底部完全不影响向上翻页的游标——两个方向互不干扰。
没做的部分:没做群聊。群聊的未读和已读要按成员维度维护,消息的扩散量也完全不同(一条消息要更新所有成员的未读数),它不是「把一对一放大」而是另一个问题。取舍依据是我想先把一对一这条链路做扎实;而且我能说清群聊要额外解决什么(成员维度的未读、扩散写的量级、已读回执的数量),这比硬做一个不完整的群聊要好。
会话列表耗时必须报清消息量:「消息表 N 条数据时,会话列表接口耗时从 X 降到 Y,且改造后耗时与消息总量无关」。「与总量无关」是这个改动的核心收益,要明确写出来。我的数据是用脚本灌的几万条消息,要说明这一点。
未读数一致性用断言型用例:断言底部角标、会话列表各行之和、聊天页顶部三处数字始终相等,并且角标是由各会话未读数加出来的而不是单独取的。
未读对账:人为把某个会话的未读数改错,断言定时对账能用「最新序号 - 已读序号」重算并修正、且记录一条差异。
清除时机是踩坑的直接回归:让拉取消息的请求失败,断言未读数没有被清除、退出后仍显示原来的未读;请求成功且消息渲染后,断言未读被清除。
本地先清与回滚:让上报已读的请求失败,断言未读数回滚到原值并重试,而不是停在零。
时序误清:在上报已读的请求发出后、到达服务端前注入一条新消息,断言这条新消息的未读没有被清掉。这条要靠打桩制造时序。
向上翻页去重:向上翻页期间持续插入新消息,断言翻上去的历史消息没有重复、也没有跳过。
多设备已读合并:用两个连接分别把已读位置推进到不同的序号,断言最终已读位置是两者的最大值。
不要报什么:不要报「未读准确率」——没有明确的分母。该报的是「消息量 N 条时列表耗时且与总量无关」「三处未读数字始终相等」「拉取失败时未读不被清除」「上报在途期间的新消息不被误清」「多设备已读取最大值」这几件可核对的事。
我做完文字消息之后,以为图片消息只是「多一个上传步骤」。实际上它把我在文字消息里建立的那套模型全打破了——因为发一张图不是一次操作,而是一个可能在中间任何一步断掉的流程。四个问题。
一是点了发送之后很久看不到任何东西。我的实现是「上传完成、拿到地址、再把消息发出去、然后上屏」。限速之后一张图要传十几秒,这段时间会话里什么都没有——用户不知道自己到底点没点上,很多人会再点一次,于是发出了两张一样的图。
二是消息顺序错乱。用户先发了一张图,图还在传的时候又发了一行文字。文字消息瞬间就发出去了、先上屏;图片十几秒后才上屏,排在了文字后面——而用户的操作顺序是先图后文,会话里看到的却是反的。
三是上传成功但发送失败时,重试又把图重新上传了一遍。我把「上传 + 发送」当成一个整体来重试。结果是那张已经在服务器上的图,又被完整地传了一次——在限速条件下这意味着用户又等了十几秒,而这次等待完全是浪费的。
四是接收方要等原图下载完才能看到内容。我只存了原图。接收方在弱网下打开会话,图片位置是一片空白,要等好几秒才出来——而他其实只需要先看到「这是一张什么图」。
所以四个改动:选图即用本地路径生成占位消息上屏、上传与发送拆成两段、各自可重试、上传成功的凭据缓存复用、生成缩略图,接收方先出缩略图再换原图。
这个模块最想说的一句话是:图片消息不是「文字消息加一个上传」,而是一个多步骤流程——而多步骤流程的关键是「每一步的结果都要能被单独保留和复用」。我最初把上传和发送当成一个原子操作来重试,所以上传成功的成果被白白丢掉了;拆成两段之后,失败重试的代价从「十几秒」降到了「一次消息发送」。
把上传与发送拆成两段、各自可重试,这是这个模块最核心的决定。当成一个整体最简单(一个方法里做完两件事、失败就整体重来)。但它的代价在弱网下极其昂贵:上传成功、发送失败时,重试会把那张已经在服务器上的图完整地再传一次——用户白等十几秒。判据是「这个流程里有没有某一步的成果值得单独保留」:上传的成果(几秒到十几秒的传输)显然值得,那就必须把它的结果缓存下来、让后续步骤可以直接复用。这一条和我在图文社区发布器那边的「图片凭据复用」是同一个模式,只是这里的每一条消息都要单独维护自己的凭据。
选图就立刻上屏,而不是等发送成功。等成功才上屏的好处是界面上不会出现「最终失败的消息」。但它在弱网下让用户十几秒看不到任何反馈——而这个反馈缺失直接导致了重复发送。代价是要处理占位消息的各种中间态(上传中、上传失败、发送中、发送失败)。判据是「用户能不能确认自己的操作被接收了」:不能确认,他就会重复操作。而立刻上屏还顺带解决了顺序问题,这是我做的时候没预料到的额外收益。
生成缩略图,接收方先看缩略图。只存原图省掉生成和存储。但接收方在弱网下要等好几秒才能看到内容,而他真正需要的只是先知道「这是一张什么图」。代价是每张图多存一份、上传时多传一份(虽然缩略图很小)。判据是「先看到一个粗略版本有没有价值」:聊天场景下价值很大——你需要立刻知道对方发了什么,而不是等图完全清晰。
图片消息的发送完全复用文字消息那套机制,而不是单独实现。单独实现会更直接(图片有自己的特殊流程)。但那意味着文字消息已经解决的问题(幂等键防重复、回执驱动状态、服务端序号排序、断线补拉去重)要在图片消息上重新解决一遍——而我一定会漏掉某几个。所以我的做法是:把「上传」做成发送前的一个准备步骤,准备完成后走的是完全相同的发送通道。判据是「这两种东西在发送这个环节上有没有本质差异」:没有(都是一条消息,只是内容不同),那就不该有两套发送逻辑。
踩过的坑一:上传成功、发送失败,重试把图重新传了一遍。我是在限速环境下测重试时发现的——看着进度条从零开始,我才意识到自己把两件事绑在一起了。教训是:一个多步骤流程里,已经完成的昂贵步骤必须能被后续重试复用——而「昂贵」在这里指的是耗时和流量,不是计算量。
踩过的坑二:先发的图排在了后发的文字之后。因为图上屏晚了十几秒。教训是:消息的顺序应该由「用户操作的时刻」决定,而不是由「它完成的时刻」决定——所以占位必须在操作那一刻就插进列表。这一点和我用服务端序号排序并不冲突:占位消息还没有序号,它临时排在末尾;但因为它是在操作时刻插入的,相对顺序就是对的。
踩过的坑三:用户切出会话后,正在上传的图白传了。因为我把上传流程绑在了页面的生命周期上。教训是:一个已经开始的、用户预期会完成的操作,不应该因为界面被销毁而中断——用户点了发送就认为它会发出去,他切走只是不想等。
没做的部分:没做图片的秒传(相同图片已存在则跳过上传)。它需要先算文件指纹再向服务端确认,而在移动端算大文件指纹本身也要时间;而且聊天场景里重复发同一张图的比例不高(和头像、表情那种复用场景不同)。取舍依据是「命中率低而前置成本确定存在」——如果是表情包这类高复用场景,我的结论会反过来。
所有数字必须带限速条件:我报的是「把上行限到几十 KB 每秒」。不限速的话上传瞬间完成,这个模块的四个问题一个都不会出现。
反馈延迟是最直观的一个数字:报「从点击发送到会话里出现这条消息的时间,从『整张图的上传耗时(限速下十几秒)』降到『接近零』」。要说清这不是上传变快了,而是占位提前上屏了——诚实说明这一点比夸大更可信。
顺序正确性是断言型用例:先发一张图、在上传过程中再发一行文字,断言会话里图在文字之前(改造前是反的)。这条用例很便宜但直接对应一个真实的坑。
凭据复用是踩坑的直接回归:让上传成功、发送失败,然后点重试,断言不产生新的上传请求(可以在网络面板里数请求数为零)、消息最终发送成功。
两段各自重试:让上传失败,断言占位消息显示「上传失败」且重试只重传;让发送失败,断言重试只重发。两条一起测才说明拆分生效。
缩略图效果报体积与出图时间:「缩略图体积约为原图的几分之一;限速条件下接收方看到内容的时间从 X 降到 Y」。并说明原图是在点开查看时才加载的。
布局不跳:接收一条图片消息,断言图片加载完成时消息列表位置不发生跳动(因为用了消息里带的宽高预留占位)。
退出会话不中断:在上传过程中退出会话,断言消息最终仍然发送成功。这条要真的切走再回来看。
幂等:在弱网下对同一张图连续点两次发送,断言只产生一条消息(复用文字消息那套幂等键)。
不要报什么:不要报「图片发送成功率」——弱网下不可能是满的,而且这个数字取决于限速设成多少。该报的是「限速值」「从点击到上屏的时间对比及原因说明」「图文混发时顺序正确」「发送失败重试不产生新上传请求」「缩略图体积与接收方出图时间对比」这几件带条件的、可核对的事。
服务端这一侧我最初写得非常简单:一个映射表,键是用户标识、值是他的连接;来消息了就查一下对方在不在,在就推过去。单客户端测完全正常。我用脚本开了多个客户端、又模拟了几种断线情况之后,四个问题出来了。
一是同一个用户开第二台设备,第一台就收不到消息了。因为我的映射是「一个用户一个连接」——第二个连接进来时直接覆盖了第一个。而多设备同时在线是聊天产品的基本能力(手机和电脑同时登着)。
二是刚登录就收不到消息,这个问题非常隐蔽。场景是:用户网络切换,新连接建立成功了,而旧连接过了一会儿才被系统判定超时并触发关闭事件——我的注销逻辑是「按用户标识把他从映射里移除」,于是旧连接的关闭把刚建立的新连接一起注销掉了。表现是用户明明显示已连接,却收不到任何消息,重连一次又好了。
三是我先推送再落库,导致消息丢失。我当时想「先推过去让对方尽快看到」。但如果推送成功、落库因为某个原因失败了,那这条消息就只存在于接收方的界面上——他刷新一下、或者断线补拉的时候,那条消息就消失了。而发送方那边显示的是发送成功。
四是一个卡住的连接影响了同一批的其他接收方。我在一个循环里依次往每个连接写数据。其中一个连接是「已死但还没被判定关闭」的状态,向它写入会阻塞——循环卡在那里,后面的接收方全都没收到。
所以四个改动:改为一个用户对应一个连接集合、注销时校验是否为当前这一个连接、顺序改为先落库再推送、向单个连接的写入失败或阻塞与其他连接隔离,另外补了死连接的超时清理。
这个模块最想说的一句话是:「用户」和「连接」是两个不同的东西,我一开始把它们当成一对一,后面所有的问题都源自这个建模错误。一个用户可以有多个连接(多设备)、一个连接在任何时刻可能已经死了但服务端还不知道、而同一个用户的新旧连接会在一段时间里同时存在。把这三件事想清楚之后,多设备、误注销、死连接清理这几个问题的解法就都是自然的了。
先落库再推送,而不是先推送再落库。先推送的直觉理由是「让对方尽快看到」。但它把「消息已被系统接受」这件事的证据顺序搞错了:推送成功而落库失败时,这条消息只存在于接收方的界面上——他刷新一下就没了,而发送方显示的是发送成功。代价是消息到达接收方会晚一次数据库写入的时间(几毫秒),完全可以接受。判据是「哪一步才算这条消息真的存在」:落库才算,那就必须先落库。这个顺序确定之后还带来一个简化:推送失败不需要任何重试或补偿机制,因为补拉已经覆盖了它。
一个用户对应连接集合,而不是单个连接。单连接模型简单得多(映射表直接覆盖就行)。但它直接否掉了多设备同时在线这个基本能力;而且它掩盖了一个更隐蔽的问题——同一个用户的新旧连接会在一段时间里同时存在(网络切换时旧连接还没被判定超时)。代价是注销、推送、清理都要处理集合而不是单个对象。判据是「这个关系在现实中是一对一吗」:不是,那就不能按一对一建模——我后面所有的问题都源自这个建模错误。
注销时校验连接标识,这是一个成本极低但必须有的判断。不校验只是一行代码的差别。但它导致的现象非常难查:用户显示已连接却收不到消息、重连一次又好了——因为它只在「网络切换、新旧连接短暂共存」这个特定时序下出现。判据是「移除操作的目标是否唯一确定」:按用户标识移除时目标不唯一(他可能有多个连接),那就必须补上一个能唯一确定的条件。
向每个连接的写入相互隔离,接受「实现比一个循环复杂」。循环同步写入是最直接的写法。但一个已死但未被判定关闭的连接会让写入阻塞,把整个循环卡住——而它影响的是同一批的其他接收方,他们和这个坏连接毫无关系。判据是「一个参与方出问题会不会影响其他参与方」:会,那就必须隔离。而隔离之后加上写入超时,单个坏连接的影响就被限制在它自己身上了。
踩过的坑一:同一用户第二台设备登录,第一台就收不到消息。因为映射是一对一、直接覆盖。教训是:建模阶段就要问「这个关系在现实中是一对一吗」——我当时想的是「一个用户就一个连接」,而这个假设从一开始就是错的。
踩过的坑二:刚登录就收不到消息,重连又好了,这是我在这个模块里查得最久的问题。现象太诡异,我一开始怀疑心跳、怀疑握手。后来打日志才看到时序:新连接建立成功、几秒后旧连接的关闭事件到达、而我的注销按用户标识把新连接一起移除了。教训是:清理类操作必须能唯一确定自己要清理的那一个对象——而「按标识删除」这种写法在「同一标识可能对应多个对象」时就是错的。
踩过的坑三:一个卡住的连接让同一批的其他接收方都没收到消息。我是用脚本开多个客户端、掐断其中一个之后复现的。教训是:对一组对象依次执行可能失败或阻塞的操作时,必须假设其中任何一个都可能卡住——而不是假设它们都会正常返回。
没做的部分:没做多进程或多机部署下的连接寻址(用户连在哪台机器上、消息怎么转发过去)。因为我只有一台服务器、单个进程,这个问题不存在——硬做一个用不上的方案不如说清「单进程下用内存结构即可,多进程时需要一个能查询『用户连在哪』的共享结构,以及跨进程的消息转发」。我认为能说出这个边界和它要解决的问题,比假装自己处理过多机部署要好。
多设备在线是断言型用例:同一用户建立两个连接,向他发消息,断言两个连接都收到;关闭其中一个,断言另一个仍然收得到、且该用户仍被视为在线。
误注销是踩坑的直接回归,而且要靠打桩制造时序:先建立连接 A,再建立连接 B,然后触发 A 的关闭事件,断言 B 仍在连接集合中、仍能收到消息。这条用例价值很高,因为这个 bug 只在特定时序下出现、手测极难复现。
先落库再推送:人为让落库失败,断言接收方没有收到这条消息、发送方收到的是失败而不是成功。这条验证的是「顺序对了之后不会出现『对方看到了但库里没有』」。
在线离线分流:接收方在线时断言直接推送到达;接收方离线时断言消息落库、未读数加一、且他上线后能补拉到。
多端同步:用同一用户的两个连接,从其中一个发消息,断言另一个也收到了这条消息。
写入隔离用脚本构造:建立 N 个连接,把其中一个掐断(不发关闭帧,让它处于「已死但未判定关闭」的状态),然后向这一批推送,断言其余 N-1 个全部收到。报法是「N 个接收方中含 1 个死连接时,其余全部正常收到」——这条是坑的直接回归。
死连接清理:建立 N 个连接后直接掐断,断言超时后连接集合中的数量回落到零;并报「当前在线连接数」这个指标的变化曲线——它是判断清理是否生效最直接的依据。
握手校验:不带凭据或带错误凭据发起握手,断言连接被拒绝。
不要报什么:不要报「支持多少万连接」——我只用脚本开到上百个,而单进程上限受机器配置影响,说了会被追问细节。该报的是「同一用户多连接均可收到消息」「旧连接关闭不影响新连接」「落库失败时接收方不会收到消息」「N 个接收方含 1 个死连接时其余全部正常」「掐断后连接数超时回落」这几件可核对的事。
这个模块的起因是我做完搜索之后想加一个「点击搜索结果跳到那条消息」的功能。我以为它只是「滚动到某一条」,实际上它要求的加载方式和正常的聊天列表完全不同。四个问题。
一是跳转要翻很多页才能找到目标。我的历史消息只支持「从最新往上翻」。要跳到几个月前的一条消息,就得一页一页往上翻直到命中——我灌了几万条历史消息测试,跳转一条较早的消息要发十几次请求,耗时完全不可控。而用户点搜索结果时的预期是立刻到达。
二是跳转之后回不到最新消息。我的列表只支持向上加载。用户跳到几个月前那条消息之后,想回到最新的对话,只能一路往下滑——但往下没有加载逻辑,滑到底就是空的。他只能退出会话再重新进。
三是跳过去之后不知道该看哪一条。我只做了「滚动到那个位置」。但屏幕上有十几条消息,用户不知道自己搜的是哪一条——尤其是搜索命中的关键词在消息中间时。
四是「以下为新消息」分割线在阅读过程中不断上移。我的实现是「根据当前未读数,从最后一条往上数」来定位分割线。而进入会话后未读数会被逐步清掉,于是分割线跟着往上跑——用户还没读完就找不到自己读到哪了。
所以四个改动:按目标消息序号一次取出前后上下文、列表支持双向续翻、定位后滚动到目标并短暂高亮、分割线在进入会话时一次性确定并固定。
这个模块最想说的一句话是:「跳到某条消息」和「翻看最新消息」是两种不同的加载模式,我一开始试图用同一套逻辑覆盖,所以怎么改都别扭。正常聊天是「从最新往上单向翻」,而跳转是「以某个点为中心向两侧展开」——后者需要服务端支持「取某条消息前后各若干条」这个查询,而这个查询在前一种模式下根本不需要。想清楚这是两种模式之后,接口和前端状态都变得清楚了。
为定位跳转单独设计一个「取前后上下文」的接口,而不是复用正常的翻页接口。复用的好处是接口少、前端逻辑统一。但它把「定位」变成了「不断翻页直到命中」——请求次数取决于目标消息离最新有多远,完全不可控。代价是多一个接口、前端多一套状态(双向游标)。判据是「这两种需求的访问模式是否相同」:一个是单向连续、一个是随机定位后双向展开,本质不同,那就该有各自的接口。我在这上面绕了一阵,就是因为一开始想「用一套逻辑覆盖」。
跳转后不清未读,与正常进入区分开。统一都清最简单。但用户从搜索结果跳过去,他的意图只是「看一条老消息」——把未读清掉意味着他真正没读的那些消息被当成已读了。代价是要在会话页里区分两种进入方式,状态多了一个分支。判据是「这个动作代表用户读了什么」:跳转只代表他读了那一条附近的内容,不代表他读完了全部未读。这一条和我在会话未读那块的结论一致——已读的语义应该对应他真的看到了什么。
新消息分割线一次性确定并固定,而不是实时重算。实时重算听起来更「准确」。但它的参照物(未读数)在用户阅读过程中一直在变,于是分割线不断上移——用户还没读完就找不到自己读到哪了。固定的代价是它可能和「当前真实未读」不一致(本次进入后又来了新消息)。但分割线的语义本来就是「我上次读到这里」,它是一个进入会话那一刻的快照。判据是「这个标记描述的是哪个时刻的状态」:进入那一刻,那它就不该随后续变化而动。
滚动到目标消息时居中而不是置顶,并且加短暂高亮。置顶实现更简单(滚动到那个偏移量就行)。但用户看一条历史消息通常是想看它的上下文,置顶会让上文全部在屏幕外;而不加高亮的话,屏幕上十几条消息他不知道该看哪一条。这两个都是很小的改动,但它们决定了这个功能是「能用」还是「好用」。
踩过的坑一:跳转靠一页页往上翻,几万条历史下要发十几次请求。我是灌了几万条消息之后才发现的——之前只有几百条消息时,翻两三次就命中了,我完全没觉得有问题。教训是:「循环请求直到满足条件」这种写法的次数由数据分布决定,而不是由你控制——小数据量会让它看起来完全正常。
踩过的坑二:跳转之后回不到最新消息,用户只能退出重进。因为我的列表只有向上加载。教训是:一旦支持了「从中间某个位置开始展示」,列表就必须是双向的——而我最初的单向设计隐含了「入口永远是最新消息」这个假设,跳转功能一加进来这个假设就不成立了。
踩过的坑三:新消息分割线在阅读过程中不断上移。因为我用实时未读数去反推它的位置。教训是:一个「标记某个历史时刻」的东西,不能用一个实时变化的量去计算它——参照物在变,标记就会跟着跑。
没做的部分:没做消息内容的全文搜索(只做了从引用消息跳转)。它需要为消息内容建全文索引并处理分词,而聊天消息的量级增长很快、索引维护成本比帖子内容高得多;而且消息内容比公开帖子更敏感,搜索能力放开之前要想清楚权限边界。取舍依据是「定位能力先做扎实,搜索是它的一个入口而不是前提」——而我做的定位接口正好是搜索功能落地时需要的那一块,所以这个顺序我认为是对的。
请求次数是这个模块最有说服力的数字:报「在灌入几万条历史消息的会话里,跳转到一条较早的消息,请求次数从『十几次(一页页往上翻)』降到 1 次」。并说明「十几次」这个数字取决于目标离最新有多远——也就是它本来就是不可控的,这个说明比数字本身更能说明问题。
定位耗时要报清历史消息总量:「历史消息 N 条时,定位接口耗时 X」,并说明它与目标消息的位置无关——「与位置无关」才是改造后的核心性质。
双向续翻是断言型用例:跳转到一条较早的消息后,断言可以继续向上翻、也可以继续向下翻;一路向下翻到没有更多时,断言恢复了「新消息自动追加到底部」的行为。
分割线固定是踩坑的直接回归:带着若干未读进入会话,断言分割线位置在整个停留期间不变;期间收到新消息,断言新消息追加在底部而分割线不动;退出再进入,断言分割线按新的已读位置重新计算。
跳转不清未读:带着若干未读,从搜索结果跳转进入会话,断言未读数没有被清除;正常进入时断言未读被清除。两条一起测才说明区分生效。
目标消息已删除:删掉目标消息后再跳转,断言返回明确状态、界面给出「该消息已删除」而不是空白列表。
视觉反馈:断言目标消息滚动后处于屏幕中部(上下文可见)、短暂高亮后淡出。
跳转态下的新消息:在跳转态收到新消息,断言列表不自动滚动、底部出现「有新消息」提示。
不要报什么:不要报「跳转速度提升 N 倍」——倍数取决于目标消息离最新有多远,本来就不是一个固定值。该报的是「历史消息总量」「请求次数从十几次降到 1 次且原次数不可控」「定位耗时与目标位置无关」「跳转后可双向续翻」「分割线在停留期间固定」这几件带条件的、可核对的事。
没有匹配的内容,换个关键词试试。
项目拆解 · 即时聊天(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据