怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

无实习这一档是给谁的 完全没有实习经历时,简历上唯一能写的就是自己做的项目。但「图书馆座位预约」「学生成绩管理」这类选题面试官一年看几百份,而硬套企业项目(微服务、分布式事务、消息中间件)会被一问就穿——他很清楚学生不可能有那种环境。这一档收的是复刻主流产品的核心链路、一个人真能做出来、技术栈朴素但有真实难点的项目。这一档的模块比企业档多——因为企业项目里你只被分到一个模块,而个人项目是从连接保活到历史消息定位全都你自己写的,能讲的面本来就宽。下面六个模块彼此独立——只做过哪几个就只写哪几个。
怎么讲才不像课设 聊天这个方向特别适合这一档,因为它的难点全在「连接不可靠」这件事上,而这件事不需要任何高大上的技术栈就能遇到、也能解决。含金量不在「我用 WebSocket 发了条消息」,而在你知道连接会断、断了你怎么知道、断的期间的消息怎么补、补的时候怎么不重复能把这条链路讲完整,比会用十个中间件更能说明你懂分布在两端的状态一致性。
只写你当场证得出来的 个人项目最容易穿的不是技术,是那些用来撑场面、但一追问就散的外部背书:「有几百个注册用户」会被问用户从哪来、怎么收集反馈、留存多少;「开源项目」「有人 star」「参赛获奖」同样如此——面试时你拿不出来,说了等于给对方一个突破口正确的立足点只有一条:我没有真实流量,但我能复现问题、能讲清每个数字的来路。这一页所有问题都是靠手动切飞行模式、用工具直接掐断连接、把服务端进程杀掉重启复现的——这些动作面试时你能一句话说清,它比任何用户量都硬。
三条自检 一、你踩的坑能不能复现——能说清「我怎么断网、怎么杀进程,它就必然出现」;二、能说出坑的根因,尤其是「连接好的时候完全正常」的那种;三、每个数字都能回答「这个数是怎么来的」(造了多少消息、断多久、几个并发连接)。三条都有就能写。
项目背景设定 我做了一个一对一私信功能(会话列表、聊天窗口、未读数、历史消息翻页),Spring Boot + WebSocket + MySQL + Redis,前端是 uni-app,一台服务器、单个进程,没有消息中间件。
数据和测试条件先说清楚 这是个人项目,没有真实用户量。所有问题都是我自己构造出来的:用脚本开几十到上百个 WebSocket 连接互相发消息手动切飞行模式和用工具直接掐断连接模拟断线、直接杀掉服务端进程再重启模拟发版、灌了几万条历史消息验证翻页与未读计算。这些条件我能当场复述,也能现场再跑一遍。
为什么这六块值得写 聊天这个场景把「连接不可靠」这件事逼到了台面上。前三块(保活重连、消息可靠投递、会话与未读)是这条主线:连接会断、断了双方都不一定马上知道、断的期间消息会丢、补消息的时候又容易重复;而这些问题在我本机连本机、网络一切正常的时候一个都不出现——我是把网线拔了、把进程杀了才看清整条链路该长什么样。后三块(图片消息、服务端连接管理、历史消息定位)是一个人做完整聊天功能绕不开的部分发一张图和发一行文字完全是两件事(要先上传再发送,中间任何一步都可能断)、服务端得知道谁连在哪个连接上以及他有几台设备而「点一条搜索结果跳到那条消息」需要加载它周围的上下文而不是从最新往上翻六块都是我自己写的,所以每一块我都能讲到根因;这也是个人项目相比「企业里被分到一个模块」的唯一优势——面比较宽,别浪费。

模块一:长连接的保活与断线重连

  1. 长连接的保活与断线重连(心跳解决「连接已死但双方都不知道」+ 退避重连避免雪崩式重试 + 重连后的状态重建 + 前后台切换的主动断开)★★★
    简历这样写 即时聊天(个人项目)(Spring Boot + WebSocket + Redis + uni-app):初版仅在连接关闭事件触发时重连,实测发现切换到飞行模式后客户端长时间收不到关闭事件(连接处于「已死但未通知」状态),期间发出的消息全部静默丢失;改为双向心跳 + 超时判定(客户端定时发心跳,连续若干次未收到响应即主动判定连接失效并重连),服务端同样对长时间无心跳的连接做清理;重连由固定间隔立即重试改为指数退避加随机抖动,避免服务端重启瞬间大量客户端同时重连;重连成功后不是简单恢复,而是用本地最后一条消息的位置向服务端补拉断线期间的消息;应用切到后台时主动断开并停止心跳,回到前台立即重连并补拉,避免在后台被系统冻结后维持一个假连接。以上现象均通过手动断网与杀进程复现。
    展开完整拆解
    为什么要这么设计

    连上 WebSocket 发一条消息,半小时就能跑通。我以为最难的部分已经过去了,实际上真正的工作量全在「连接会断」这件事上,而且我一开始完全低估了它有多难察觉。四个问题。

    一是连接已经死了,但双方都不知道。我的重连是监听连接关闭事件触发的。但我手动切到飞行模式测的时候发现,客户端很长时间都收不到关闭事件——它以为连接还在,照常往里写消息,而那些消息全部静默消失了没有报错、没有回调、发送的时候一切正常。这个现象让我第一次理解:「连接对象还在」和「连接真的通」是两件事。

    二是我加了重连之后,服务端一重启就被打满。我的重连是「断了立刻重试,失败了继续立刻重试」。我用脚本开了上百个连接,然后杀掉服务端进程再启动——结果是上百个客户端在同一瞬间全部重连,而且失败了马上又重连,服务端刚起来就被这波请求压住。

    三是重连成功了,但断线期间的消息没了。我最初的重连只是「把连接建起来」。断开的那段时间对方发的消息,服务端推的时候我不在线,重连之后也不会再推一次——这些消息就永远看不到了,除非我手动下拉刷新。

    四是应用切到后台后维持着一个假连接。安卓上应用切后台会被冻结,心跳停了、连接实际已经不通,但客户端的状态还是「已连接」用户切回前台之后以为能正常收发,实际上要等到下一次心跳超时才会重连。

    所以四个改动:加双向心跳与超时判定重连改为指数退避加随机抖动重连后用本地最后一条消息的位置补拉切后台主动断开、回前台立即重连并补拉

    这个模块最想说的一句话是:长连接最危险的状态不是「断开」,而是「已经断了但你以为还连着」。断开是可以被处理的(有事件、有回调);而「假连接」没有任何信号,你发出去的消息就像扔进了黑洞心跳的全部意义就是把这种沉默的失效变成一个可以被察觉的事件——想清楚这一点之后,心跳超时次数、退避策略这些参数该怎么定就都有依据了。

    整体链路
    连接建立 │ ├─ 握手时携带身份凭据,服务端校验后才接受连接 │ 不校验的后果:任何人都能连上来收发消息 │ ├─ 服务端维护「用户 → 连接」的映射(单进程用内存结构即可) │ 我只有一个进程,所以不需要跨进程的连接寻址 │ 但要处理同一用户多设备:一个用户可能对应多个连接 │ └─ 连接建立后立即补拉一次(见下方「重连后的状态重建」) 心跳(把沉默的失效变成可察觉的事件) │ ├─ 客户端定时发心跳帧,服务端收到后回响应 │ ├─ 客户端连续若干次未收到响应 → 主动判定连接失效 → 关闭并重连 │ 只靠连接关闭事件的后果 │ 切飞行模式后客户端很久收不到关闭事件 │ 它以为连接还在、照常写消息,而消息全部静默消失 │ 没有报错、没有回调 —— 这是最危险的状态 │ ├─ 服务端也要判定:长时间没收到某连接的心跳 → 清理该连接 │ 不清理的后果:服务端内存里堆积大量已死连接 │ ├─ 心跳间隔与超时次数要权衡 │ 太短 → 流量与耗电增加(移动端敏感) │ 太长 → 发现失效太慢,这段时间的消息都会丢 │ └─ 心跳本身要走和消息相同的通道 单独开一个探测通道的话,探测通了不代表消息通道通 重连(退避 + 抖动) │ ├─ 首次断开 → 短暂延迟后重试 ├─ 连续失败 → 间隔按指数增长,并设一个上限 ├─ 每次间隔加一个随机抖动 │ 固定间隔立即重试的后果(我实测过) │ 杀掉服务端进程再启动 │ 上百个客户端在同一瞬间全部重连、失败了马上又重连 │ 服务端刚起来就被这波请求压住 │ 抖动的作用:把同时到达的重连打散开 │ ├─ 重连成功 → 重置退避计数 │ └─ 达到最大重试次数 → 停止自动重连,界面提示并给手动重连入口 无限重试的后果:用户在没网的地方,手机一直在耗电 重连后的状态重建(这一步最容易漏) │ ├─ 不能只是「把连接建起来」 │ 断开期间对方发的消息,服务端推的时候我不在线 │ 重连后不会再推一次 → 这些消息永远看不到 │ ├─ 重连成功后带上「本地最后一条消息的位置」向服务端补拉 │ 服务端返回该位置之后的全部消息 │ ├─ 补拉结果与本地已有消息按消息标识去重(见模块二) │ └─ 补拉期间界面要有明确状态(连接中 / 同步中),不要显示成正常 前后台切换 ├─ 切到后台:主动断开连接并停止心跳 │ 不断开的后果:应用被系统冻结,心跳停了、连接实际已不通 │ 但客户端状态还是「已连接」= 假连接 ├─ 回到前台:立即重连 + 补拉,不等下一个心跳周期 └─ 整个恢复流程要幂等(用户会反复切前后台) 连接状态要对用户可见 ├─ 顶部显示「连接中 / 已断开 / 同步中」 ├─ 断开时发送框要禁用或明确提示,不要让他以为发出去了 └─ 不显示状态的后果:他在断线状态下发了一堆消息,全都没出去
    分步拆解
    1. 握手时必须校验身份凭据。不校验的后果是任何人都能连上来收发消息——这是很容易漏的一处,因为本地测试时不会有人来连。
    2. 要处理同一用户多设备:一个用户可能对应多个连接。用「用户 → 连接集合」而不是「用户 → 单个连接」,否则后连上的会把前一个挤掉。
    3. 必须做心跳,不能只依赖连接关闭事件。切飞行模式后客户端很久收不到关闭事件,它以为连接还在、照常写消息,而消息全部静默消失。
    4. 客户端连续若干次未收到心跳响应就主动判定失效并重连。次数不能只设 1(偶发丢包会误判),也不能太多(发现太慢)。
    5. 服务端也要清理长时间无心跳的连接。不清理的后果是内存里堆积大量已死连接。
    6. 心跳必须走和消息相同的通道。单独开一个探测通道的话,探测通了不代表消息通道通。
    7. 心跳间隔要权衡流量与发现速度。太短增加流量与耗电(移动端敏感),太长则失效发现得慢、这段时间的消息都会丢。
    8. 重连必须用指数退避,不能固定间隔立即重试。我实测过:杀掉服务端再启动,上百个客户端同一瞬间全部重连、失败了马上又重连,服务端刚起来就被压住。
    9. 退避间隔要加随机抖动。抖动的作用是把同时到达的重连打散开——没有抖动的话,即使有退避,大家的重试时刻也是对齐的。
    10. 退避要设上限,重连成功要重置计数。不设上限会导致间隔无限增长,不重置则下次断开时直接用很长的间隔。
    11. 达到最大重试次数要停止自动重连并给手动入口。无限重试的后果是用户在没网的地方手机一直在耗电。
    12. 重连之后必须补拉断线期间的消息,这一步最容易漏。只把连接建起来的话,断开期间对方发的消息永远看不到——因为服务端推的时候我不在线,重连后不会再推。
    13. 补拉要带「本地最后一条消息的位置」。不带位置就只能全量拉,而全量拉在几万条历史消息下是不可行的。
    14. 补拉结果要和本地消息去重。因为可能有消息既被推送过又被补拉到(见模块二)。
    15. 补拉期间界面要显示「同步中」。显示成正常状态的后果是用户以为消息已经全了。
    16. 切到后台要主动断开并停止心跳。不断开的后果是应用被系统冻结、连接实际已不通,但客户端状态还是「已连接」——这就是假连接。
    17. 回到前台要立即重连并补拉,不等下一个心跳周期。等周期意味着用户回来后还有一段时间收不到消息。
    18. 连接状态必须对用户可见。断开时发送框要禁用或明确提示——不显示的后果是他在断线状态下发了一堆消息,全都没出去。
    关键决策与取舍

    做心跳,而不是只依赖连接关闭事件。只依赖事件的实现最省事、也不产生额外流量。但它对「假连接」完全无效——而假连接恰恰是最危险的状态,因为它没有任何信号心跳的代价是持续的流量和耗电(移动端上这个代价是真实的)。判据是「这个失效如果发现不了,后果有多严重」:消息静默丢失、用户以为发出去了,这个后果足够严重,值得付心跳的成本。而心跳间隔的取值就是在「发现速度」和「流量耗电」之间找一个点,我是在真机上试的。

    退避加抖动,而不是固定间隔。固定间隔实现最简单、恢复也最快。但它在「服务端重启」这个场景下会形成同步的重试洪峰——所有客户端同时断开、同时重试、同时失败、同时再试抖动这一步特别容易被省略,但没有它,即使有退避,大家的重试时刻仍然是对齐的。判据是「会不会有很多客户端在同一时刻做同一件事」:会,那就必须打散。

    重连后补拉而不是靠服务端重推。让服务端记住「谁没收到什么」然后重连时重推,听起来更省客户端的事。但那要求服务端为每个离线用户维护待推队列,而队列要处理堆积、过期、多设备各自的进度——复杂度明显高于让客户端说一句「我最后收到的是这条,之后的给我」判据是「谁最知道自己缺什么」:客户端最知道,那就由它发起。这个选择也让服务端保持了无状态的推送逻辑,我一个人维护得住。

    切后台主动断开,而不是尽力维持。维持连接能让消息在后台也及时收到。但移动端会冻结后台应用,维持只是维持了一个客户端自己的状态标记,实际早就不通了——而这个假状态比明确的断开更糟主动断开的代价是后台期间完全收不到消息(要靠系统级推送补,那超出了我这个项目的范围),但换来的是状态真实。

    踩过的坑一:切飞行模式后客户端长时间收不到关闭事件,消息静默丢失。发送的时候没有任何报错。这个现象让我第一次理解「连接对象还在」和「连接真的通」是两件事教训是:任何「保持中的状态」都需要一个主动的探测手段来确认它还有效——不能等对方来通知你,因为通知本身也依赖那条已经断了的链路。

    踩过的坑二:杀掉服务端再启动,上百个客户端同时重连把它压住。我是用脚本开了上百个连接才复现的。教训是:客户端的重试策略要考虑「所有客户端一起重试」的场景——单个客户端看起来很合理的「立刻重试」,乘以客户端数量之后就是一次自制的洪峰。

    踩过的坑三:重连成功了但断线期间的消息没补。我当时以为「连接恢复了就好了」。教训是:重连不等于恢复——连接是通道,而通道断开期间流经它的数据需要单独补我后来把「重连成功」和「数据同步完成」分成了两个状态,界面上也分开显示。

    没做的部分:没做多进程下的连接寻址(用户连在哪台机器上、消息怎么转发过去)。因为我只有一台服务器、单个进程,这个问题不存在——硬做一个用不上的方案不如说清「单进程下不需要,多进程时需要解决连接与用户的映射问题」我认为能说清这个边界,比假装自己处理过多机部署要好。

    数字是怎么测的

    假连接的发现时间是这个模块最核心的数字:「手动切到飞行模式后,从连接实际不通到客户端判定失效并开始重连的时间」,并说明这个时间由「心跳间隔 × 超时次数」决定。改造前这个时间是不确定的(要等系统级的关闭事件,实测很长),改造后是可控的——「从不确定变成可控」才是心跳的核心价值。

    心跳参数的取值要报实测过程:「在真机上分别试几组心跳间隔,记录发现时间与耗电、流量变化,取一个平衡点」。直接给一个数字而不说怎么来的就是拍的。

    退避效果用脚本压测:「用脚本开 N 个连接,杀掉服务端进程再启动,记录重连请求在时间轴上的分布」。报法是「无退避时 N 个重连集中在启动瞬间;加退避与抖动后被打散到若干秒内」——分布比总数更能说明问题。

    断线补拉是断言型用例:断开连接、期间由另一个连接发送 M 条消息、恢复连接,断言这 M 条消息全部出现在会话里、顺序正确、且没有重复。

    前后台切换:切后台若干分钟(期间对方发消息)、切回前台,断言立即重连并补齐了这些消息;反复切前后台,断言不产生重复消息、不产生多余连接。

    服务端连接清理:用脚本建立 N 个连接后直接掐断(不发关闭帧),断言服务端在超时后清理了这些连接、内存中的连接数回落。

    身份校验:不带凭据或带错误凭据发起握手,断言连接被拒绝。

    不要报什么:不要报「支持 N 万长连接」——我只用脚本开到上百个,而且单进程的上限受机器配置影响。该报的是「切飞行模式后失效发现时间从不确定变为可控的若干秒」「N 个连接同时重连时的时间分布对比」「断线期间的 M 条消息全部补齐且不重复」「掐断连接后服务端超时清理生效」这几件带条件的、可核对的事。

    面试追问
    Q:WebSocket 有关闭事件,为什么还要自己做心跳? A:因为关闭事件解决不了最危险的那种情况——连接已经断了,但双方都不知道。我是手动切飞行模式测出来的:客户端很长时间都收不到关闭事件,它以为连接还在、照常往里写消息,而那些消息全部静默消失了——没有报错、没有回调,发送的时候一切正常这个现象让我第一次理解「连接对象还在」和「连接真的通」是两件事。关闭事件依赖的是「链路能把关闭这件事通知到你」,而链路本身已经断了,这个通知也就到不了(要等系统级的超时,实测时间很长且不确定)。心跳的全部意义就是把这种沉默的失效变成一个可以被察觉的事件:客户端定时发心跳,连续若干次没收到响应就主动判定失效并重连。这样失效发现时间从「不确定」变成了「心跳间隔 × 超时次数」这个可控的值——我认为这才是心跳的核心价值。几个实现细节值得说超时次数不能设 1(偶发丢包会误判),也不能太多(发现太慢);心跳必须走和消息相同的通道——单独开一个探测通道的话,探测通了不代表消息通道通;服务端也要清理长时间无心跳的连接,否则内存里堆积大量已死连接。代价是持续的流量和耗电,移动端上这个代价是真实的,所以心跳间隔我是在真机上试了几组、看发现时间和耗电的平衡才定的。我总结的判据是:任何「保持中的状态」都需要一个主动探测手段来确认它还有效,不能等对方来通知你——因为通知本身也依赖那条已经断了的链路。
    Q:断线重连就是重新连上去,还有什么好设计的? A:我一开始也这么想,「断了就立刻重连」,结果踩了两个坑。第一个是重连把服务端打崩。我的策略是「断了立刻重试,失败了继续立刻重试」。我用脚本开了上百个连接,然后杀掉服务端进程再启动——上百个客户端在同一瞬间全部重连,失败了马上又重连,服务端刚起来就被这波请求压住教训是:客户端看起来很合理的「立刻重试」,乘以客户端数量之后就是一次自制的洪峰。修法是指数退避加随机抖动——抖动这一步特别容易被省略,但没有它,即使有退避,所有客户端的重试时刻仍然是对齐的(因为它们是同时断开的)。另外要设间隔上限、重连成功后重置计数、达到最大次数后停止自动重连并给手动入口——无限重试会让用户在没网的地方手机一直耗电。第二个坑更本质:我重连成功了,但断线期间的消息没了。因为服务端推送的时候我不在线,重连之后它不会再推一次——那些消息就永远看不到,除非手动下拉刷新。教训是:重连不等于恢复。连接只是通道,而通道断开期间流经它的数据需要单独补。所以重连成功后要带上「本地最后一条消息的位置」向服务端补拉,并和本地已有消息去重。为什么让客户端发起补拉而不是服务端记住谁没收到什么?因为那要求服务端为每个离线用户维护待推队列,还要处理堆积、过期、多设备各自的进度——而「谁最知道自己缺什么」的答案是客户端,让它说一句「我最后收到的是这条」简单得多,服务端的推送逻辑也能保持无状态。我后来把「重连成功」和「数据同步完成」分成了两个状态,界面上也分开显示。

模块二:消息的可靠投递与去重

  1. 消息的可靠投递与去重(客户端生成标识实现幂等 + 发送中与失败的三态展示 + 有序性靠服务端序号而非本地时间 + 推送与补拉的合并去重)★★★
    简历这样写 消息可靠投递(Spring Boot + WebSocket + MySQL):初版发送即认为成功,弱网下出现消息在界面上显示已发出但服务端从未收到;改为三态模型(发送中、已送达、发送失败)并由服务端回执驱动状态流转,失败可点击重发;重发引入客户端生成的消息标识作为幂等键,解决「服务端已收到但回执丢失、客户端重发导致消息重复」的问题;消息顺序原按客户端本地时间排列,实测两端设备时间不一致时对话顺序错乱,改为以服务端分配的会话内递增序号排序,本地时间仅用于展示;断线重连后的补拉结果与推送到的消息按消息标识合并去重,避免同一条消息出现两次;发送框在连接断开时禁用并提示,不再让用户以为消息已发出。上述问题通过掐断连接、修改设备时间、构造回执丢失等方式复现。
    展开完整拆解
    为什么要这么设计

    发消息这件事我最初的实现是:把消息塞进连接、同时把它加到界面的消息列表里、显示成已发送看起来很顺,而且在网络正常时确实没问题。四个问题都是我人为破坏条件之后才出现的。

    一是界面显示已发出,服务端根本没收到。我把「写入连接」当成了「发送成功」。但写入连接只是把数据交给了操作系统的缓冲区,它离到达服务端还差很远——我掐断连接之后再发消息,界面上照样显示成已发送,而服务端什么都没收到。用户完全不知道对方没收到。

    二是我加了重发之后,出现了重复消息。场景是:服务端已经收到并存了消息,但回执在返回路上丢了客户端等不到回执、判定失败、用户点了重发——服务端就存了第二条一样的消息而这种情况我是靠人为丢掉回执才构造出来的,正常网络下极难碰到。

    三是对话顺序错乱。我用消息的本地时间排序。但我在两台设备上测的时候,其中一台的系统时间快了几分钟——结果是它发的消息全部排到了对方消息的前面,整个对话读起来是乱的而这不是极端情况,用户的设备时间本来就不保证准确。

    四是同一条消息出现两次。断线重连后我会补拉消息。而有些消息既通过推送到过客户端、又被包含在补拉结果里——我直接把补拉的结果追加到列表,于是出现了两条一模一样的。

    所以四个改动:发送改为三态并由服务端回执驱动用客户端生成的消息标识做幂等键排序改用服务端分配的会话内递增序号补拉与推送的消息按标识合并去重

    这个模块最想说的一句话是:「我把数据交出去了」和「对方收到了」之间隔着一整条不可靠的链路,而界面上那个「已发送」的对勾必须由对方的回执来点亮,不能由自己点亮。我最初就是自己点亮的,所以它表达的其实是「我发出去了」,而用户理解的是「对方收到了」——这两者之间的落差就是所有问题的来源。想清楚之后我去看了主流聊天产品,发现它们的消息状态确实是分阶段的(转圈、单勾、双勾),原来那不是装饰。

    整体链路
    发送(三态,状态由回执驱动) │ ├─ 客户端生成消息标识(本地唯一即可)→ 立即上屏,状态「发送中」 │ 立即上屏是必要的:不然弱网下用户以为没点上 │ ├─ 通过连接发出(携带消息标识) │ 注意:写入连接成功 ≠ 服务端收到 │ 写入只是交给了操作系统缓冲区 │ 掐断连接后发消息,写入照样成功,服务端什么都没收到 │ ├─ 服务端收到 → 落库 → 分配会话内序号 → 回执(带消息标识与序号) │ ├─ 客户端收到回执 → 状态改「已送达」,并用服务端序号归位 │ └─ 超时未收到回执 → 状态改「发送失败」,提供重发 不做失败态的后果:界面显示已发出,实际对方没收到 幂等(重发不能产生重复) │ ├─ 消息标识由客户端生成并在重发时保持不变 │ ├─ 服务端以消息标识做唯一约束 │ 已存在 → 不重复入库,直接返回原来的回执(带原序号) │ ├─ 为什么必须这样 │ 服务端已收到并落库,但回执在返回路上丢了 │ 客户端判定失败、用户点重发 → 服务端存了第二条一样的消息 │ 这种情况我是人为丢掉回执才构造出来的,正常网络极难碰到 │ └─ 标识不能由服务端生成 —— 那样重发时服务端认不出是同一条 顺序(用服务端序号,不用本地时间) │ ├─ 服务端为每条消息分配会话内递增序号 ├─ 客户端排序以序号为准,本地时间只用于展示 │ ├─ 用本地时间排序的后果(我在两台设备上实测到的) │ 其中一台系统时间快了几分钟 │ 它发的消息全部排到对方消息前面,整个对话读起来是乱的 │ 而设备时间本来就不保证准确,这不是极端情况 │ └─ 「发送中」的消息还没有序号 → 临时排在末尾 收到回执后按真实序号归位(位置可能发生跳变,这是正常的) 接收与去重 │ ├─ 推送到达 → 按消息标识检查本地是否已有 → 没有才插入 │ ├─ 断线重连补拉 → 结果与本地列表按消息标识合并去重 │ 直接追加的后果:既被推送过又在补拉结果里的消息出现两次 │ ├─ 自己发的消息也会被推回来(多设备场景)→ 同样靠标识去重 │ └─ 去重要在数据层做,不要在渲染层用「看起来一样」判断 界面状态要如实 ├─ 连接断开时发送框禁用或明确提示 │ 不提示的后果:用户在断线状态下发一堆消息,全都没出去 ├─ 「发送中」超过一定时间要变成「发送失败」,不要一直转圈 └─ 失败的消息要留在原位并可重发,不要消失
    分步拆解
    1. 消息标识必须由客户端生成,并在重发时保持不变。由服务端生成的话,重发时服务端认不出这是同一条消息,幂等就无从实现。
    2. 发送后立即上屏、状态为「发送中」。不立即上屏的后果是弱网下用户以为没点上,会重复点。
    3. 要明确「写入连接成功」不等于「服务端收到」。写入只是交给了操作系统缓冲区——掐断连接后发消息,写入照样成功、服务端什么都没收到。
    4. 状态必须由服务端回执驱动,不能自己点亮。自己点亮的「已发送」表达的是「我发出去了」,而用户理解的是「对方收到了」——这个落差就是问题的来源。
    5. 回执要带上消息标识和服务端分配的序号。客户端靠标识找到对应的那条消息,靠序号把它归位。
    6. 超时未收到回执要转为「发送失败」,不要一直转圈。一直转圈的后果是用户不知道到底成没成。
    7. 服务端要以消息标识做唯一约束。已存在时不重复入库、直接返回原来的回执(带原序号)——这样重发是安全的。
    8. 顺序必须用服务端分配的会话内递增序号。用本地时间的后果我实测过:一台设备系统时间快了几分钟,它发的消息全部排到对方前面,整个对话读起来是乱的。
    9. 本地时间只用于展示,不参与排序。展示上偏差几分钟用户能容忍,顺序错乱不能。
    10. 「发送中」的消息还没有序号,临时排在末尾。收到回执后按真实序号归位——位置可能跳变,这是正常的,不要为了避免跳变而用本地时间排序。
    11. 推送到达时要按标识检查本地是否已有。没有才插入,否则多设备场景下自己发的消息被推回来会重复。
    12. 补拉结果必须与本地列表按标识合并去重。直接追加的后果是既被推送过又在补拉结果里的消息出现两次。
    13. 去重要在数据层做,不要在渲染层用「看起来一样」判断。两条内容相同的消息可能是用户真的发了两次,只有标识能区分。
    14. 连接断开时发送框要禁用或明确提示。不提示的后果是用户在断线状态下发一堆消息,全都没出去。
    15. 失败的消息要留在原位并可重发,不要消失。消失的话用户不知道哪条没发出去。
    关键决策与取舍

    消息标识由客户端生成,而不是服务端。服务端生成标识更符合直觉(它是权威)。但幂等要求「重发时服务端能认出这是同一条消息」——如果标识是服务端给的,那客户端重发时手上还没有标识,服务端只能当成新消息所以幂等键必须在客户端就确定下来。判据是「幂等键必须在第一次请求之前就存在」——这一条在任何「客户端可能重试」的场景里都成立(提交表单、支付、发布内容),不只是聊天。

    顺序用服务端序号而不是本地时间,代价是「发送中」的消息位置会跳变。用本地时间排序的话,消息一上屏就在最终位置、不会跳。但设备时间不保证准确,我实测到一台设备快几分钟就让整个对话乱了判据是「哪个错误用户更不能容忍」:展示时间偏差几分钟他能容忍(甚至注意不到),顺序错乱会让对话读不通,完全不能接受。所以我接受位置跳变这个小代价。

    状态做成三态而不是两态。只做「已发送 / 失败」更简单。但弱网下从发出到收到回执可能有好几秒,这段时间用户需要知道「正在发」而不是看到一个已经点亮的对勾——提前点亮就是在骗他三态的代价是要处理超时判定和状态流转,但它让界面表达的东西是真实的。做完之后我去看主流聊天产品,发现它们的状态确实是分阶段的(转圈、单勾、双勾),原来那不是装饰。

    不做「已读」状态。已读需要接收方回传阅读事件、还要处理多设备、以及「已读但没真看」这类语义问题。而我判断它对验证「消息可靠投递」这条链路没有增量价值——送达和已读是两个不同层面的问题取舍依据是「先把送达这条链路做对,再谈更上层的状态」,而且我能说清已读要额外解决什么,这比硬做一个半成品好。

    踩过的坑一:界面显示已发送,服务端根本没收到。我把「写入连接」当成了「发送成功」。掐断连接后发消息,写入照样成功教训是:「我把数据交出去了」和「对方收到了」之间隔着一整条不可靠的链路——界面上那个对勾必须由对方的回执来点亮,不能由自己点亮。这一条推广开来就是:任何表示「对方已经怎样了」的界面状态,都不能由本地行为触发。

    踩过的坑二:重发导致消息重复。场景是服务端已收到并落库、但回执在返回路上丢了。这种情况我是人为丢掉回执才构造出来的,正常网络下极难碰到——而它一旦发生,用户看到的是自己发了两条一样的消息。教训是:只要存在「客户端会重试」这件事,服务端就必须能识别重复请求,而识别的依据必须是客户端在第一次请求时就带上的标识。

    踩过的坑三:设备时间不一致导致对话顺序错乱。我是在两台真机上测的时候发现的,其中一台时间快了几分钟教训是:不要用客户端提供的时间做任何有正确性要求的判断——它不是权威、也不受你控制。需要顺序就让服务端分配序号。

    没做的部分:没做消息的本地持久化(离线也能看历史)。它需要本地数据库、以及本地与服务端的增量同步和冲突处理,复杂度高出一个量级而我目前的补拉机制已经能在联网后拿到全部消息取舍依据是「离线看历史」这个需求在我的场景里不强,而且我能说清它要额外解决什么。

    数字是怎么测的

    假成功是断言型用例,也是这个模块最重要的一条:用工具掐断连接,然后发送消息,断言消息状态在超时后变为「发送失败」而不是停留在「已发送」;恢复连接后点击重发,断言送达。

    幂等性要靠人为构造回执丢失来测:在服务端落库成功之后丢弃回执,断言客户端重发后服务端不产生第二条消息、且返回的是原来的序号这条必须打桩,正常网络下等不到。

    顺序正确性用「改设备时间」来测:把一台设备的系统时间调快若干分钟,两台互发消息,断言两端看到的对话顺序完全一致这个测法很便宜,而且它是踩坑的直接回归。

    去重要覆盖两条来源:构造一条「既被推送到、又在补拉结果里」的消息,断言会话里只出现一次;多设备场景下自己发的消息被推回来,断言不重复。

    发送中超时:断言「发送中」状态超过设定时间后转为失败,不会无限转圈。

    断线时的发送拦截:断言连接断开时发送框被禁用或有明确提示,用户无法产生「以为发出去了」的消息。

    并发发送:用脚本从同一会话并发发送 N 条消息,断言全部落库、序号连续无重复、两端看到的顺序一致。

    不要报什么:不要报「消息送达率 100%」——弱网下不可能,而且这个数字无法自证。该报的是「掐断连接后消息正确进入失败态并可重发」「人为丢弃回执后重发不产生重复」「设备时间相差若干分钟时两端顺序一致」「推送与补拉重叠的消息只出现一次」这几件可核对的事。

    面试追问
    Q:消息发出去了界面就显示已发送,有什么问题? A:问题是「我把数据交出去了」和「对方收到了」完全是两件事,而我最初用前者去点亮了表示后者的对勾。具体来说:我把「写入连接」当成了「发送成功」,但写入连接只是把数据交给了操作系统的缓冲区,它离到达服务端还差很远——我用工具掐断连接之后再发消息,写入照样成功、界面照样显示已发送,而服务端什么都没收到。用户完全不知道对方没收到。所以状态必须由服务端的回执来驱动,我改成了三态:客户端生成消息标识、立即上屏显示「发送中」(不立即上屏的话弱网下用户以为没点上,会重复点);服务端收到、落库、分配序号、回执;客户端收到回执才改成「已送达」;超时没收到回执就转「发送失败」并提供重发——不要一直转圈,那样用户不知道到底成没成。做完之后我去看主流聊天产品,发现它们的消息状态确实是分阶段的(转圈、单勾、双勾),原来那不是装饰。但改成可重发之后我又踩了第二个坑:重复消息。场景是服务端已经收到并落库了,但回执在返回路上丢了——客户端判定失败、用户点重发,服务端就存了第二条一样的消息这种情况我是人为丢掉回执才构造出来的,正常网络下极难碰到,但它一旦发生用户就看到自己发了两条。解法是消息标识由客户端生成、重发时保持不变,服务端以它做唯一约束:已存在就不重复入库、直接返回原来的回执。关键点是标识不能由服务端生成——那样客户端重发时手上还没有标识,服务端只能当成新消息。我总结的是:幂等键必须在第一次请求之前就存在,这一条在任何「客户端可能重试」的场景里都成立。
    Q:消息按时间排序不是最自然的吗?为什么要服务端发号? A:因为客户端的时间不可信,我在两台真机上实测撞到了这个问题。我用消息的本地时间排序,结果其中一台设备的系统时间快了几分钟——它发的消息全部排到了对方消息的前面,整个对话读起来是乱的而这不是极端情况,用户的设备时间本来就不保证准确(手动改过、时区不对、同步有偏差都很常见)。所以我改成由服务端为每条消息分配会话内的递增序号,客户端排序以序号为准,本地时间只用于展示。判据是「哪个错误用户更不能容忍」:展示时间偏差几分钟他能容忍、甚至注意不到;顺序错乱会让对话读不通,完全不能接受这个选择有一个代价我想说清楚:「发送中」的消息还没有序号,只能临时排在末尾,收到回执后按真实序号归位——所以它的位置可能会跳一下。用本地时间排序的话就不会跳。但我认为这个小跳变比顺序错乱好接受得多,所以接受了它。更一般的教训是:不要用客户端提供的时间做任何有正确性要求的判断——它不是权威、也不受你控制。凡是需要顺序、需要判定过期、需要计费的地方,时间都应该由服务端给另外这个测法我觉得挺值得提:把一台设备的系统时间调快几分钟、两台互发消息、断言两端看到的顺序完全一致——成本几乎为零,但它能稳定打出这类问题,我把它留成了回归用例。

模块三:会话列表与未读数

  1. 会话列表与未读数(会话表消除按用户扫全表 + 未读数的唯一来源与清除时机 + 已读位置以序号表达 + 历史消息按游标向上翻页)★★
    简历这样写 会话列表与未读(Spring Boot + MySQL + Redis + uni-app):会话列表初版由消息表分组取每个会话最新一条,消息量增长后成为慢查询,改为独立维护会话表(双方、最后一条消息摘要、更新时间、各自未读数),列表查询变为按用户与更新时间的索引直接取,耗时不再随消息总量增长;未读数原由各页面自行统计导致列表、角标、聊天页三处数字不一致,收敛为会话表上的单一来源,所有位置读同一个值;已读状态用「已读到的消息序号」表达而非布尔标记,从而支持「进入会话时只清除已实际展示的那部分」,也让多设备的已读位置可以取最大值合并;清除未读的时机从进入页面改为消息实际渲染后,修复了「网络失败但未读已被清掉」的问题;历史消息改为按序号游标向上翻页,替代偏移量分页。
    展开完整拆解
    为什么要这么设计

    会话列表看起来只是「把我参与的对话列出来、每条显示最后一句话和未读数」。我最初的实现完全没有独立的会话概念,全靠从消息表里算。四个问题。

    一是会话列表随消息量增长越来越慢。我的查询是「从消息表里找出所有和我有关的消息,按会话分组,每组取最新一条」。灌了几万条消息之后,这个查询明显慢下来了——而它是打开应用第一眼就要看到的东西。

    二是未读数在三个地方显示三个数字。底部角标、会话列表、聊天页顶部,三处各自算各自的刷新时机不同,于是角标显示 5、列表加起来是 3、进聊天页又是另一个数。用户一眼就能看出对不上。

    三是进入会话就清未读,但消息没加载出来。我在进入聊天页时就调了清除未读的接口。结果是网络失败、消息一条都没显示出来,但未读已经被清掉了——用户退出去看到未读没了,以为自己看过了,那些消息就真的被漏掉了。

    四是历史消息向上翻页会重复。我用偏移量向上翻。而聊天页在翻页期间可能有新消息进来,偏移量整体错位,翻上去就出现重复的消息。

    所以四个改动:建独立的会话表并维护最后一条消息摘要与未读数未读数收敛为会话表上的单一来源已读用「已读到的序号」表达、清除时机改为消息实际渲染后历史消息改按序号游标翻页

    这个模块最想说的一句话是:把「已读」从一个布尔标记改成「已读到的消息序号」,一下解决了三个原本各自要单独处理的问题。它让「部分已读」可以表达(只清除已经渲染出来的那部分)、让多设备的已读位置可以简单地取最大值合并也让未读数变成一个可以随时从序号差算出来、因此可以被对账校验的派生值一个数据结构的选择带来这么多连带收益,是我在这个项目里比较意外的收获。

    整体链路
    会话表(不要从消息表里算列表) │ ├─ 一条会话记录:双方标识 · 最后一条消息摘要 · 更新时间 │ 我方已读到的序号 · 未读数 │ 注意:每个参与者各自持有自己的已读位置与未读数 │ ├─ 收发消息时更新会话:摘要 · 更新时间 · 对方未读数加一 │ 和消息落库放在同一个事务里,避免列表与消息不一致 │ ├─ 列表查询:按(用户, 更新时间)索引直接取,游标分页 │ 从消息表分组取最新一条的后果 │ 消息量涨到几万条后明显变慢 │ 而它是打开应用第一眼就要看到的东西 │ └─ 会话表的行数只与「聊过天的人数」相关,增长很慢 而消息表是持续增长的 —— 这是两者的本质区别 未读数:只有一个来源 │ ├─ 唯一来源 = 会话表上的未读数字段 │ ├─ 所有展示位置都读它 │ 底部角标 = 各会话未读数之和 │ 列表每行 = 该会话的未读数 │ 各页面自行统计的后果 │ 三处刷新时机不同 → 角标 5、列表加起来 3、聊天页又一个数 │ 用户一眼就能看出对不上 │ └─ 未读数是派生值:可以随时用「最新序号 - 已读到的序号」重算 所以它可以被对账校验(定时重算比对,差异告警) 已读位置:用序号而不是布尔 │ ├─ 存「我在这个会话里已读到的消息序号」 │ ├─ 这一个改动带来三个连带收益 │ 能表达「部分已读」→ 只清除已经渲染出来的那部分 │ 多设备合并简单 → 取两个位置的最大值即可 │ 未读数可重算 → 变成可对账的派生值 │ └─ 用布尔标记「这个会话已读」的话,上面三件事都做不到 清除未读的时机(不是进入页面) │ ├─ 进入聊天页 → 拉取消息 → 消息实际渲染出来 → 才上报已读位置 │ ├─ 进页面就清的后果(我踩过) │ 网络失败、消息一条都没显示,但未读已经被清掉 │ 用户退出去看到未读没了,以为自己看过了 → 消息真的被漏掉 │ ├─ 上报的是「已读到的序号」而不是「全部已读」 │ 传「全部已读」的问题:上报到达服务端之间的新消息会被误清 │ ├─ 本地先把未读置零(界面立刻响应) │ 上报失败 → 回滚未读数并重试 │ 只做本地清除不做回滚的后果:红点消失又出现,像坏了 │ └─ 用户在会话中途退出 → 上报已渲染到的位置,不是最新位置 历史消息翻页(向上翻) ├─ 按「小于某个序号」的游标向上取一页 ├─ 用偏移量的后果:翻页期间有新消息进来,偏移量整体错位 → 重复 ├─ 新消息追加在底部,不影响向上翻页的游标 └─ 翻到最顶部要有明确的「没有更多了」,不要一直转圈
    分步拆解
    1. 必须有独立的会话表,不要从消息表分组算列表。消息表是持续增长的,而会话表的行数只与「聊过天的人数」相关、增长很慢——这是两者的本质区别。
    2. 会话表要存最后一条消息摘要与更新时间。这样列表查询不需要再去碰消息表。
    3. 每个参与者各自持有自己的已读位置与未读数。不能一条会话只存一份——双方的已读进度是不同的。
    4. 更新会话与消息落库要在同一个事务里。分开做的后果是列表显示的最后一句话和实际最新消息不一致。
    5. 列表查询按(用户, 更新时间)索引取,并用游标分页。耗时不再随消息总量增长。
    6. 未读数必须只有一个来源。各页面自行统计的后果是角标、列表、聊天页三处数字对不上,用户一眼就能看出来。
    7. 底部角标由各会话未读数之和算出,不要单独取一个总数。单独取的话总数和各行加起来会不一致。
    8. 已读状态用「已读到的消息序号」表达,不要用布尔标记。这一个改动同时让部分已读可表达、多设备可取最大值合并、未读数可重算对账——用布尔的话这三件事都做不到。
    9. 未读数要能用「最新序号 - 已读到的序号」重算,并做定时对账。因为它是增量维护的派生值,一定会因为某条路径漏更新而漂移。
    10. 清除未读的时机是「消息实际渲染出来之后」,不是进入页面。进页面就清的后果是网络失败、消息一条都没显示,但未读已经被清掉——用户以为自己看过了,消息真的被漏掉。
    11. 上报的是「已读到的序号」而不是「全部已读」。传「全部已读」的问题是上报到达服务端之间的新消息会被误清。
    12. 本地先把未读置零让界面立刻响应,但上报失败要回滚。只做本地清除不回滚的后果是红点消失又出现,用户觉得功能是坏的。
    13. 用户在会话中途退出时,上报的是已渲染到的位置。而不是这个会话的最新位置——他确实没看到后面那些。
    14. 历史消息向上翻页要用序号游标。用偏移量的后果是翻页期间有新消息进来、偏移量整体错位,翻上去出现重复。
    15. 新消息追加在底部,不影响向上翻页的游标。这也是用序号游标的好处——两个方向互不干扰。
    16. 翻到最顶部要有明确的「没有更多了」。不要一直转圈让用户以为在加载。
    关键决策与取舍

    已读用「序号」而不是布尔标记,这是这个模块里最有价值的一个决定。布尔标记(这个会话已读 / 未读)实现最简单。但它表达能力太弱:无法表达「我看了前面几条但后面没看」、多设备的已读状态无法合并(谁覆盖谁?)、未读数也无法从它重算出来(因此无法对账)换成序号之后这三件事都变得简单:部分已读就是序号停在中间、多设备合并就是取最大值、未读数就是最新序号减已读序号。判据是「这个字段需要表达的是一个开关还是一个位置」——是位置,就不要用开关去存。一个数据结构的选择带来这么多连带收益,是我在这个项目里比较意外的收获。

    建独立会话表,接受「同一份信息存两处」的冗余。从消息表算列表的好处是永远不会不一致(只有一份数据)。但它的耗时随消息总量增长,而会话列表是打开应用第一眼就要看到的东西冗余的代价是要保证一致性——我的做法是把「更新会话」和「消息落库」放在同一个事务里,再加未读数的定时对账兜底判据是「读的频率和数据增长的速度」:读得最频繁、而底层数据一直在涨,那就必须把结果预先算好存下来。

    清除未读的时机绑定「消息渲染完成」而不是「进入页面」。绑定进入页面实现最简单。但它把「已读」这个语义和「打开了容器」混为一谈了——而用户可能什么都没看到(网络失败)代价是要能判断「消息真的渲染出来了」,实现上麻烦一点。判据是「这个状态变更代表的是用户的什么行为」:已读代表「他看到了」,那就必须等到真的显示出来。这一条我觉得比技术实现更重要——很多状态设计错就错在语义对不上。

    未读数本地先清但要能回滚。等服务端返回再清的话,弱网下红点要几百毫秒甚至更久才消失,用户会以为没点到。本地先清体验好,但必须处理失败回滚——只做一半反而更糟(红点消失又出现,像坏了)这一条和任何「本地先行」的乐观更新都一样:本地先行和失败回滚是一套,不能只做前一半。

    踩过的坑一:进页面就清未读,网络失败时消息一条没显示但未读没了。用户退出去看到未读为零,以为自己看过了。教训是:「已读」的语义应该对应用户真的看到了内容,而不是他打开了某个页面——这两者在网络失败时会完全分叉。

    踩过的坑二:未读数三处显示三个数字。因为角标、列表、聊天页各自统计。教训是:同一个数字在界面上出现多次时,它必须只有一个来源——多来源的不一致是必然的,因为它们的刷新时机不可能完全同步。而且这类不一致用户一眼就能看到,特别伤信任。

    踩过的坑三:向上翻历史消息出现重复。我用的是偏移量。教训和帖子列表那次一样:偏移量分页隐含「数据集不变」,而聊天页在翻页期间随时可能有新消息进来。改成序号游标之后,新消息追加在底部完全不影响向上翻页的游标——两个方向互不干扰。

    没做的部分:没做群聊。群聊的未读和已读要按成员维度维护,消息的扩散量也完全不同(一条消息要更新所有成员的未读数)它不是「把一对一放大」而是另一个问题取舍依据是我想先把一对一这条链路做扎实;而且我能说清群聊要额外解决什么(成员维度的未读、扩散写的量级、已读回执的数量),这比硬做一个不完整的群聊要好。

    数字是怎么测的

    会话列表耗时必须报清消息量:「消息表 N 条数据时,会话列表接口耗时从 X 降到 Y,且改造后耗时与消息总量无关」。「与总量无关」是这个改动的核心收益,要明确写出来。我的数据是用脚本灌的几万条消息,要说明这一点。

    未读数一致性用断言型用例:断言底部角标、会话列表各行之和、聊天页顶部三处数字始终相等,并且角标是由各会话未读数加出来的而不是单独取的。

    未读对账:人为把某个会话的未读数改错,断言定时对账能用「最新序号 - 已读序号」重算并修正、且记录一条差异

    清除时机是踩坑的直接回归:让拉取消息的请求失败,断言未读数没有被清除、退出后仍显示原来的未读;请求成功且消息渲染后,断言未读被清除。

    本地先清与回滚:让上报已读的请求失败,断言未读数回滚到原值并重试,而不是停在零。

    时序误清:在上报已读的请求发出后、到达服务端前注入一条新消息,断言这条新消息的未读没有被清掉这条要靠打桩制造时序。

    向上翻页去重:向上翻页期间持续插入新消息,断言翻上去的历史消息没有重复、也没有跳过。

    多设备已读合并:用两个连接分别把已读位置推进到不同的序号,断言最终已读位置是两者的最大值。

    不要报什么:不要报「未读准确率」——没有明确的分母。该报的是「消息量 N 条时列表耗时且与总量无关」「三处未读数字始终相等」「拉取失败时未读不被清除」「上报在途期间的新消息不被误清」「多设备已读取最大值」这几件可核对的事。

    面试追问
    Q:已读状态存个布尔值不就行了?为什么要存序号? A:布尔值最简单,但它的表达能力不够,我换成序号之后一下解决了三个原本各自要单独处理的问题。第一,它能表达「部分已读」。用户进入会话、消息渲染出来一部分他就退出了,已读位置应该停在他真正看到的那一条,而不是整个会话都算已读——布尔值做不到这个区分。第二,多设备的已读位置可以简单合并:取两个位置的最大值就行。而如果是布尔值,两台设备一个说已读一个说未读,你没法判断谁应该覆盖谁第三,未读数变成了一个可以随时重算的派生值——「最新序号减已读序号」因此它可以被对账校验(定时重算比对,差异告警);用布尔值的话未读数只能靠增量维护,而增量维护一定会因为某条路径漏更新而漂移,还没有办法发现我总结的判据是:这个字段需要表达的是一个开关还是一个位置?是位置,就不要用开关去存。顺带说一个和它配套的、我踩过的坑:清除未读的时机。我最初是进入聊天页就调清除接口结果网络失败、消息一条都没显示出来,但未读已经被清掉了——用户退出去看到未读没了、以为自己看过了,那些消息就真的被漏掉了。改成「消息实际渲染出来之后才上报已读位置」教训是「已读」的语义应该对应用户真的看到了内容,而不是他打开了某个页面——这两者在网络失败时会完全分叉。而且上报的是「已读到的序号」而不是「全部已读」,否则上报到达服务端之间的新消息会被误清。
    Q:会话列表直接从消息表里查最新一条不行吗?为什么要单独一张表? A:能查,我一开始就是这么做的,但它的耗时随消息量增长——而会话列表是打开应用第一眼就要看到的东西。我的原实现是「从消息表里找出所有和我有关的消息、按会话分组、每组取最新一条」,用脚本灌了几万条消息之后就明显慢下来了关键在于两张表的增长速度完全不同消息表是持续增长的(聊得越久越多),而会话表的行数只与「聊过天的人数」相关、增长非常慢。所以把结果预先算好存在会话表上,列表查询就变成按(用户, 更新时间)索引直接取,耗时和消息总量无关了代价是同一份信息存了两处(最后一条消息既在消息表里、摘要又在会话表上),需要保证一致——我的做法是把「更新会话」和「消息落库」放在同一个事务里,分开做的话会出现「列表显示的最后一句话和实际最新消息不一致」;未读数再加一层定时对账兜底(用「最新序号 - 已读序号」重算比对)。判据是「读的频率和底层数据的增长速度」:读得最频繁、而底层数据一直在涨,那就必须把结果预先算好存下来,接受冗余、再用事务和对账去保证一致另外会话表的结构有一个容易做错的地方已读位置和未读数必须是每个参与者各自一份,不能一条会话只存一份——因为双方的已读进度本来就是不同的。还有一个连带的收益:有了会话表之后,列表本身也可以用游标分页(按更新时间),而不是又回到偏移量分页的老问题上。

模块四:图片消息的两段式发送

  1. 图片消息的两段式发送(本地占位先上屏保住顺序 + 上传与发送拆成两步且各自可重试 + 已上传结果复用不重传 + 接收方先出缩略图再换原图)★★★
    简历这样写 图片消息(Spring Boot + WebSocket + 本地文件存储 + uni-app):图片消息初版为上传完成后才在界面上出现,弱网下用户点了发送后长时间看不到任何反馈、且与其后发出的文字消息顺序错乱;改为选图即用本地路径生成占位消息上屏(立刻占住它在会话中的位置),再执行「上传得到凭据 → 携带凭据发送消息」的两段式流程,两步各自记录状态并可单独重试——上传成功但发送失败时直接复用已上传的凭据,不重新上传;上传前在客户端压缩并生成缩略图,接收方先显示缩略图再替换为原图,弱网下也能立刻看到内容;对上传成功但用户已退出会话的情况做了处理(消息仍然发出,不因页面销毁而中断)。相关问题均通过限速与人为掐断连接复现。
    展开完整拆解
    为什么要这么设计

    我做完文字消息之后,以为图片消息只是「多一个上传步骤」。实际上它把我在文字消息里建立的那套模型全打破了——因为发一张图不是一次操作,而是一个可能在中间任何一步断掉的流程。四个问题。

    一是点了发送之后很久看不到任何东西。我的实现是「上传完成、拿到地址、再把消息发出去、然后上屏」。限速之后一张图要传十几秒,这段时间会话里什么都没有——用户不知道自己到底点没点上,很多人会再点一次,于是发出了两张一样的图。

    二是消息顺序错乱。用户先发了一张图,图还在传的时候又发了一行文字。文字消息瞬间就发出去了、先上屏;图片十几秒后才上屏,排在了文字后面——而用户的操作顺序是先图后文,会话里看到的却是反的。

    三是上传成功但发送失败时,重试又把图重新上传了一遍。我把「上传 + 发送」当成一个整体来重试。结果是那张已经在服务器上的图,又被完整地传了一次——在限速条件下这意味着用户又等了十几秒,而这次等待完全是浪费的。

    四是接收方要等原图下载完才能看到内容。我只存了原图。接收方在弱网下打开会话,图片位置是一片空白,要等好几秒才出来——而他其实只需要先看到「这是一张什么图」。

    所以四个改动:选图即用本地路径生成占位消息上屏上传与发送拆成两段、各自可重试上传成功的凭据缓存复用生成缩略图,接收方先出缩略图再换原图

    这个模块最想说的一句话是:图片消息不是「文字消息加一个上传」,而是一个多步骤流程——而多步骤流程的关键是「每一步的结果都要能被单独保留和复用」。我最初把上传和发送当成一个原子操作来重试,所以上传成功的成果被白白丢掉了拆成两段之后,失败重试的代价从「十几秒」降到了「一次消息发送」。

    整体链路
    选图之后立刻上屏(先占住位置) │ ├─ 用本地文件路径生成一条「发送中」的图片消息,立刻插入会话 │ 不立刻上屏的后果 │ 限速下一张图要传十几秒,这段时间会话里什么都没有 │ 用户不知道点没点上 → 再点一次 → 发出两张一样的图 │ ├─ 立刻上屏还解决了顺序问题 │ 先发图、图还在传时又发了文字 │ 文字瞬间发出并上屏,图十几秒后才上屏 → 排在文字后面 │ 而用户的操作顺序是先图后文,会话里看到的却是反的 │ 占位消息在选图那一刻就占住了它的位置,顺序就对了 │ └─ 占位消息要带本地生成的消息标识(和文字消息同一套幂等键) 第一段:上传 │ ├─ 客户端先压缩,并生成一张缩略图 │ 压缩要留下限,压坏了图片消息本身就没意义 │ ├─ 上传原图与缩略图,得到「图片凭据」 │ 同时上报图片的宽高(接收方要用它预留占位,避免布局跳动) │ ├─ 上传进度显示在占位消息上(覆盖一层进度) │ ├─ 上传失败 → 占位消息标记为「上传失败」,提供重试 │ 重试只重传,不重新走发送 │ └─ 上传成功 → 把凭据缓存在这条占位消息上(关键) 第二段:发送 │ ├─ 携带图片凭据、宽高、缩略图信息,走和文字消息完全一样的发送流程 │ 也就是说:三态、回执驱动、服务端序号排序、幂等键 │ 复用文字消息那套机制,不要另写一套 │ ├─ 发送失败 → 重试时直接用已缓存的凭据,不重新上传 │ 当成一个整体重试的后果(我踩过) │ 那张已经在服务器上的图又被完整传了一次 │ 限速下用户又等十几秒,而这次等待完全是浪费的 │ └─ 发送成功 → 占位消息替换为服务端返回的正式消息 本地路径换成服务端地址,但可以先继续用本地图渲染(更快) 上传成功但用户已退出会话 ├─ 上传与发送不绑定在页面生命周期上 ├─ 页面销毁不中断流程,消息照常发出 └─ 不这么做的后果:用户切走了,图白传了、消息也没发出去 接收方的渲染 │ ├─ 先用缩略图渲染(体积小,弱网下也能很快出来) │ 只存原图的后果:接收方弱网下图片位置一片空白,要等好几秒 │ ├─ 原图在用户点开查看时才加载 │ ├─ 用消息里带的宽高预留占位,避免图片加载完成时消息列表跳动 │ 这一条和图文社区那边的瀑布流是同一个道理 │ └─ 加载失败要有占位与重试,不要显示破图 其他 ├─ 图片消息在会话列表里的摘要显示为「[图片]」而不是空白 ├─ 上传大小与类型要在服务端二次校验(不信任客户端) └─ 长时间未被任何消息引用的上传文件要定时清理
    分步拆解
    1. 选图之后立刻用本地路径生成占位消息上屏。不立刻上屏的后果是限速下用户十几秒看不到任何反馈、以为没点上、再点一次发出两张一样的图。
    2. 立刻上屏同时解决了顺序问题。占位消息在选图那一刻就占住了位置——否则先发的图会因为传得慢而排在后发的文字之后。
    3. 占位消息要带本地生成的消息标识,和文字消息用同一套幂等键。不要为图片另造一套。
    4. 客户端先压缩,并额外生成一张缩略图。压缩要留下限——压坏了图片消息本身就没意义。
    5. 上传时要一并上报图片宽高。接收方用它预留占位,避免图片加载完成时消息列表跳动(和瀑布流是同一个道理)。
    6. 上传进度要显示在占位消息上。让用户知道在传、传到哪了。
    7. 上传与发送必须拆成两段,各自记录状态。当成一个整体重试的后果是已经在服务器上的图又被完整传一次,限速下用户白等十几秒。
    8. 上传成功的凭据要缓存在这条占位消息上。这是「发送失败时不重传」的实现基础。
    9. 第二段发送要完全复用文字消息那套机制。三态、回执驱动、服务端序号排序、幂等键——不要另写一套,否则图片消息会缺掉文字消息已经解决过的那些保障。
    10. 发送成功后把占位消息替换为正式消息。但可以先继续用本地图渲染——比重新下载自己刚发的图快得多。
    11. 上传与发送不要绑定在页面生命周期上。不这么做的后果是用户切走了,图白传了、消息也没发出去。
    12. 接收方先用缩略图渲染,原图在点开查看时才加载。只存原图的后果是接收方弱网下图片位置一片空白,要等好几秒。
    13. 接收方要用消息里带的宽高预留占位。不预留的话图片加载完成时整个消息列表会跳一下。
    14. 加载失败要有占位与重试,不要显示破图。
    15. 会话列表里的摘要要显示为「[图片]」。显示空白的话用户不知道最后一条是什么。
    16. 上传的大小与类型要在服务端二次校验。不信任客户端——请求可以被直接构造。
    17. 长时间未被任何消息引用的上传文件要定时清理。用户选完图放弃发送的情况很常见。
    关键决策与取舍

    把上传与发送拆成两段、各自可重试,这是这个模块最核心的决定。当成一个整体最简单(一个方法里做完两件事、失败就整体重来)。但它的代价在弱网下极其昂贵:上传成功、发送失败时,重试会把那张已经在服务器上的图完整地再传一次——用户白等十几秒判据是「这个流程里有没有某一步的成果值得单独保留」:上传的成果(几秒到十几秒的传输)显然值得,那就必须把它的结果缓存下来、让后续步骤可以直接复用这一条和我在图文社区发布器那边的「图片凭据复用」是同一个模式,只是这里的每一条消息都要单独维护自己的凭据。

    选图就立刻上屏,而不是等发送成功。等成功才上屏的好处是界面上不会出现「最终失败的消息」。但它在弱网下让用户十几秒看不到任何反馈——而这个反馈缺失直接导致了重复发送代价是要处理占位消息的各种中间态(上传中、上传失败、发送中、发送失败)判据是「用户能不能确认自己的操作被接收了」:不能确认,他就会重复操作。而立刻上屏还顺带解决了顺序问题,这是我做的时候没预料到的额外收益。

    生成缩略图,接收方先看缩略图。只存原图省掉生成和存储。但接收方在弱网下要等好几秒才能看到内容,而他真正需要的只是先知道「这是一张什么图」代价是每张图多存一份、上传时多传一份(虽然缩略图很小)判据是「先看到一个粗略版本有没有价值」:聊天场景下价值很大——你需要立刻知道对方发了什么,而不是等图完全清晰。

    图片消息的发送完全复用文字消息那套机制,而不是单独实现。单独实现会更直接(图片有自己的特殊流程)。但那意味着文字消息已经解决的问题(幂等键防重复、回执驱动状态、服务端序号排序、断线补拉去重)要在图片消息上重新解决一遍——而我一定会漏掉某几个所以我的做法是:把「上传」做成发送前的一个准备步骤,准备完成后走的是完全相同的发送通道。判据是「这两种东西在发送这个环节上有没有本质差异」:没有(都是一条消息,只是内容不同),那就不该有两套发送逻辑。

    踩过的坑一:上传成功、发送失败,重试把图重新传了一遍。我是在限速环境下测重试时发现的——看着进度条从零开始,我才意识到自己把两件事绑在一起了教训是:一个多步骤流程里,已经完成的昂贵步骤必须能被后续重试复用——而「昂贵」在这里指的是耗时和流量,不是计算量。

    踩过的坑二:先发的图排在了后发的文字之后。因为图上屏晚了十几秒。教训是:消息的顺序应该由「用户操作的时刻」决定,而不是由「它完成的时刻」决定——所以占位必须在操作那一刻就插进列表。这一点和我用服务端序号排序并不冲突:占位消息还没有序号,它临时排在末尾;但因为它是在操作时刻插入的,相对顺序就是对的。

    踩过的坑三:用户切出会话后,正在上传的图白传了。因为我把上传流程绑在了页面的生命周期上。教训是:一个已经开始的、用户预期会完成的操作,不应该因为界面被销毁而中断——用户点了发送就认为它会发出去,他切走只是不想等。

    没做的部分:没做图片的秒传(相同图片已存在则跳过上传)。它需要先算文件指纹再向服务端确认,而在移动端算大文件指纹本身也要时间而且聊天场景里重复发同一张图的比例不高(和头像、表情那种复用场景不同)。取舍依据是「命中率低而前置成本确定存在」——如果是表情包这类高复用场景,我的结论会反过来。

    数字是怎么测的

    所有数字必须带限速条件:我报的是「把上行限到几十 KB 每秒」。不限速的话上传瞬间完成,这个模块的四个问题一个都不会出现。

    反馈延迟是最直观的一个数字:「从点击发送到会话里出现这条消息的时间,从『整张图的上传耗时(限速下十几秒)』降到『接近零』」要说清这不是上传变快了,而是占位提前上屏了——诚实说明这一点比夸大更可信。

    顺序正确性是断言型用例:先发一张图、在上传过程中再发一行文字,断言会话里图在文字之前(改造前是反的)。这条用例很便宜但直接对应一个真实的坑。

    凭据复用是踩坑的直接回归:让上传成功、发送失败,然后点重试,断言不产生新的上传请求(可以在网络面板里数请求数为零)、消息最终发送成功

    两段各自重试:让上传失败,断言占位消息显示「上传失败」且重试只重传;让发送失败,断言重试只重发。两条一起测才说明拆分生效。

    缩略图效果报体积与出图时间:「缩略图体积约为原图的几分之一;限速条件下接收方看到内容的时间从 X 降到 Y」。并说明原图是在点开查看时才加载的。

    布局不跳:接收一条图片消息,断言图片加载完成时消息列表位置不发生跳动(因为用了消息里带的宽高预留占位)。

    退出会话不中断:在上传过程中退出会话,断言消息最终仍然发送成功。这条要真的切走再回来看。

    幂等:在弱网下对同一张图连续点两次发送,断言只产生一条消息(复用文字消息那套幂等键)。

    不要报什么:不要报「图片发送成功率」——弱网下不可能是满的,而且这个数字取决于限速设成多少。该报的是「限速值」「从点击到上屏的时间对比及原因说明」「图文混发时顺序正确」「发送失败重试不产生新上传请求」「缩略图体积与接收方出图时间对比」这几件带条件的、可核对的事。

    面试追问
    Q:图片消息不就是先上传再发一条消息吗?有什么特别的? A:流程确实是这两步,但我最初把它们当成一个整体来处理,结果打破了我在文字消息里已经建好的那套模型。三个问题。第一,点了发送之后很久看不到任何东西——我的实现是「上传完成、拿到地址、再发消息、然后上屏」,限速下一张图要传十几秒,这段时间会话里什么都没有,用户不知道点没点上,很多人会再点一次,于是发出了两张一样的图第二,消息顺序错乱:用户先发图、图还在传时又发了一行文字,文字瞬间发出并上屏、图十几秒后才上屏,排在了文字后面——而用户的操作顺序是先图后文。这两个问题的解法是同一个:选图那一刻就用本地文件路径生成一条「发送中」的占位消息插入会话,立刻给反馈、同时占住它的位置。教训是:消息顺序应该由「用户操作的时刻」决定,而不是由「它完成的时刻」决定。第三个问题最浪费:上传成功但发送失败时,我按整体重试,那张已经在服务器上的图又被完整传了一次——限速下用户又白等十几秒。所以必须拆成两段、各自记录状态、各自可重试上传成功的凭据缓存在那条占位消息上,发送失败时直接复用判据是「这个流程里有没有某一步的成果值得单独保留」:上传的成果值得,那就必须能被复用。还有一个我认为很重要的决定:第二段发送完全复用文字消息那套机制(三态、回执驱动、服务端序号排序、幂等键)——单独实现的话,文字消息已经解决的那些问题要在图片消息上重新解决一遍,而我一定会漏掉某几个。
    Q:接收方直接显示原图不行吗?为什么还要生成缩略图? A:因为接收方在弱网下要等好几秒才能看到内容,而他真正需要的只是先知道「对方发了一张什么图」。我最初只存原图,接收方打开会话时图片位置是一片空白——在聊天这个场景里这个等待特别难受,因为你需要立刻知道对方发了什么,而不是等图完全清晰。所以我在客户端压缩的同时额外生成一张缩略图接收方先用缩略图渲染,原图只在用户点开查看时才加载代价是每张图多存一份、上传时多传一份(不过缩略图很小,这个代价基本可以忽略)。判据是「先看到一个粗略版本有没有价值」:聊天场景下价值很大。另外有一个配套的细节我觉得值得说:上传时要一并上报图片的宽高接收方用它预留占位——不预留的话图片加载完成的那一刻整个消息列表会跳一下,用户正在看的位置被顶走。这一条和我在图文社区做瀑布流时踩的坑是同一个道理:前端在图片下载完之前不知道它多高,而这个信息在上传时是已知的,不存下来就白丢了。还有两个和图片消息相关但容易漏的点上传与发送流程不能绑定在页面生命周期上——我踩过这个坑,用户切出会话后正在上传的图白传了,而他点了发送就认为它会发出去,切走只是不想等;以及会话列表里的摘要要显示为「[图片]」而不是空白,否则用户不知道最后一条消息是什么。

模块五:服务端连接管理与在线离线分流

  1. 服务端连接管理与在线离线分流(一个用户对应多个连接 + 连接注销要认准是哪一个 + 落库先于推送保证不丢 + 死连接的清理与写入阻塞的隔离)★★★
    简历这样写 服务端连接管理(Spring Boot + WebSocket + 内存结构 + MySQL):服务端原以「一个用户一个连接」建模,同一用户第二台设备登录时会把前一个连接挤掉,改为一个用户对应一个连接集合并支持多设备同时在线;连接注销时校验是否为当前这一个连接(原实现按用户标识直接移除,导致「旧连接超时关闭」把新连接一起注销掉,表现为刚登录就收不到消息);消息处理顺序明确为先落库再推送(反过来会出现「对方收到了但库里没有」,断线补拉时那条消息就消失了);推送时按接收方是否在线分流——在线直接推、离线只落库并更新未读,由对方上线后补拉;对已死但未关闭的连接做超时清理,并把向单个连接写入失败或阻塞与其他连接隔离(一个卡住的连接不影响同一批的其他接收方)。问题均通过多客户端脚本与人为掐断连接复现。
    展开完整拆解
    为什么要这么设计

    服务端这一侧我最初写得非常简单:一个映射表,键是用户标识、值是他的连接;来消息了就查一下对方在不在,在就推过去单客户端测完全正常。我用脚本开了多个客户端、又模拟了几种断线情况之后,四个问题出来了。

    一是同一个用户开第二台设备,第一台就收不到消息了。因为我的映射是「一个用户一个连接」——第二个连接进来时直接覆盖了第一个而多设备同时在线是聊天产品的基本能力(手机和电脑同时登着)。

    二是刚登录就收不到消息,这个问题非常隐蔽。场景是:用户网络切换,新连接建立成功了,而旧连接过了一会儿才被系统判定超时并触发关闭事件——我的注销逻辑是「按用户标识把他从映射里移除」,于是旧连接的关闭把刚建立的新连接一起注销掉了表现是用户明明显示已连接,却收不到任何消息,重连一次又好了。

    三是我先推送再落库,导致消息丢失。我当时想「先推过去让对方尽快看到」。但如果推送成功、落库因为某个原因失败了,那这条消息就只存在于接收方的界面上——他刷新一下、或者断线补拉的时候,那条消息就消失了而发送方那边显示的是发送成功。

    四是一个卡住的连接影响了同一批的其他接收方。我在一个循环里依次往每个连接写数据。其中一个连接是「已死但还没被判定关闭」的状态,向它写入会阻塞——循环卡在那里,后面的接收方全都没收到。

    所以四个改动:改为一个用户对应一个连接集合注销时校验是否为当前这一个连接顺序改为先落库再推送向单个连接的写入失败或阻塞与其他连接隔离,另外补了死连接的超时清理

    这个模块最想说的一句话是:「用户」和「连接」是两个不同的东西,我一开始把它们当成一对一,后面所有的问题都源自这个建模错误。一个用户可以有多个连接(多设备)、一个连接在任何时刻可能已经死了但服务端还不知道、而同一个用户的新旧连接会在一段时间里同时存在把这三件事想清楚之后,多设备、误注销、死连接清理这几个问题的解法就都是自然的了。

    整体链路
    连接的建立与登记 │ ├─ 握手校验身份凭据 → 通过后为这个连接分配一个连接标识 │ 连接标识很关键:后面注销与去重都要靠它 │ ├─ 登记结构:用户标识 → 连接集合(不是单个连接) │ 「一个用户一个连接」的后果 │ 第二台设备登录时直接覆盖第一个 → 第一台收不到消息 │ 而多设备同时在线是聊天产品的基本能力 │ └─ 连接上要记:连接标识 · 用户 · 建立时间 · 最后心跳时间 · 设备信息 连接的注销(这里有一个很隐蔽的坑) │ ├─ 注销时必须校验「要移除的是不是这一个连接」 │ 按用户标识直接移除的后果(我踩过) │ 用户网络切换,新连接建好了 │ 旧连接过一会儿才被系统判定超时并触发关闭事件 │ → 旧连接的关闭把刚建立的新连接一起注销掉了 │ 表现:用户显示已连接却收不到任何消息,重连一次又好了 │ ├─ 正确做法:从集合里移除「连接标识等于当前这一个」的那一项 │ └─ 集合空了才认为该用户完全离线 消息处理顺序:先落库,再推送 │ ├─ 收到消息 → 校验 → 落库并分配会话内序号 → 回执给发送方 → 推送给接收方 │ ├─ 先推送再落库的后果(我踩过) │ 推送成功、落库失败 → 消息只存在于接收方的界面上 │ 他刷新一下或断线补拉时,那条消息就消失了 │ 而发送方那边显示的是发送成功 │ └─ 顺序确定之后,「推送失败」就变成了一件不严重的事 因为消息已经在库里了,对方上线补拉一定能拿到 在线离线分流 │ ├─ 落库之后查接收方的连接集合 │ 集合非空 → 向他的每一个连接推送 │ 集合为空 → 只落库并更新未读,等他上线补拉 │ ├─ 发送方自己的其他设备也要推一份(多端同步) │ 不推的后果:手机上发的消息,电脑上看不到 │ └─ 推送失败不需要重试或补偿 —— 补拉机制已经覆盖了 这是「先落库」带来的简化 写入隔离与死连接清理 │ ├─ 向每个连接写入要相互隔离,一个失败或阻塞不影响其他 │ 在循环里依次同步写入的后果(我踩过) │ 某个连接是「已死但还没被判定关闭」的状态,写入会阻塞 │ 循环卡在那里,后面的接收方全都没收到 │ ├─ 写入要有超时,超时就放弃这一个连接(不影响消息本身) │ ├─ 心跳超时的连接要定期清理出集合 │ 不清理的后果:内存里堆积大量已死连接,每次推送都要试一遍 │ └─ 清理与推送对同一个集合的并发访问要安全 用支持并发的集合结构,而不是自己加锁保护普通集合 其他 ├─ 单连接的消息发送频率要限制(防止脚本刷) ├─ 单个连接的空闲时间过长可以主动断开(释放资源) └─ 当前在线连接数可以暴露成一个指标(便于判断清理是否生效)
    分步拆解
    1. 先把「用户」和「连接」分开建模。一个用户对应一个连接集合而不是单个连接——「一个用户一个连接」的后果是第二台设备登录时覆盖了第一个。
    2. 每个连接要有自己的连接标识。注销、去重、清理全都要靠它。
    3. 注销时必须校验「要移除的是不是这一个连接」。按用户标识直接移除的后果是旧连接超时关闭时把刚建立的新连接一起注销掉,用户显示已连接却收不到消息。
    4. 只有连接集合为空才认为该用户完全离线。否则会把「还有一台设备在线」误判成离线。
    5. 消息处理顺序必须是先落库再推送。反过来的后果是推送成功、落库失败时,消息只存在于接收方界面上,他刷新或补拉时就消失了——而发送方显示的是成功。
    6. 确定这个顺序之后,「推送失败」就不再是严重问题。因为消息已经在库里,对方上线补拉一定能拿到——这是先落库带来的简化。
    7. 落库之后按接收方是否在线分流。在线则向他的每一个连接推送,离线则只更新未读、等他上线补拉。
    8. 发送方自己的其他设备也要推一份。不推的后果是手机上发的消息,电脑上看不到。
    9. 向每个连接的写入要相互隔离。在循环里依次同步写入的后果是某个已死但未被判定关闭的连接会让写入阻塞,循环卡住,后面的接收方全都没收到。
    10. 写入要有超时,超时就放弃这一个连接。放弃单个连接不影响消息本身——因为消息已经落库了。
    11. 心跳超时的连接要定期清理出集合。不清理的后果是内存里堆积大量已死连接,每次推送都要对它们试一遍。
    12. 清理与推送会并发访问同一个连接集合,要用支持并发的结构。自己加锁保护普通集合容易漏(尤其是遍历时)。
    13. 握手时要校验身份凭据。不校验的后果是任何人都能连上来收发消息——本地测试时不会有人来连,所以特别容易漏。
    14. 单连接的消息发送频率要限制。防止脚本刷——而连接是长期存在的,一个恶意连接可以持续发。
    15. 空闲时间过长的连接可以主动断开。释放资源,而客户端有重连机制、断开是安全的。
    16. 把当前在线连接数暴露成指标。它是判断「清理是否生效」最直接的依据——如果这个数字只涨不降,说明死连接没被清掉。
    关键决策与取舍

    先落库再推送,而不是先推送再落库。先推送的直觉理由是「让对方尽快看到」。但它把「消息已被系统接受」这件事的证据顺序搞错了推送成功而落库失败时,这条消息只存在于接收方的界面上——他刷新一下就没了,而发送方显示的是发送成功代价是消息到达接收方会晚一次数据库写入的时间(几毫秒),完全可以接受判据是「哪一步才算这条消息真的存在」:落库才算,那就必须先落库这个顺序确定之后还带来一个简化:推送失败不需要任何重试或补偿机制,因为补拉已经覆盖了它。

    一个用户对应连接集合,而不是单个连接。单连接模型简单得多(映射表直接覆盖就行)。但它直接否掉了多设备同时在线这个基本能力而且它掩盖了一个更隐蔽的问题——同一个用户的新旧连接会在一段时间里同时存在(网络切换时旧连接还没被判定超时)。代价是注销、推送、清理都要处理集合而不是单个对象判据是「这个关系在现实中是一对一吗」:不是,那就不能按一对一建模——我后面所有的问题都源自这个建模错误。

    注销时校验连接标识,这是一个成本极低但必须有的判断。不校验只是一行代码的差别。但它导致的现象非常难查:用户显示已连接却收不到消息、重连一次又好了——因为它只在「网络切换、新旧连接短暂共存」这个特定时序下出现判据是「移除操作的目标是否唯一确定」:按用户标识移除时目标不唯一(他可能有多个连接),那就必须补上一个能唯一确定的条件。

    向每个连接的写入相互隔离,接受「实现比一个循环复杂」。循环同步写入是最直接的写法。但一个已死但未被判定关闭的连接会让写入阻塞,把整个循环卡住——而它影响的是同一批的其他接收方,他们和这个坏连接毫无关系判据是「一个参与方出问题会不会影响其他参与方」:会,那就必须隔离而隔离之后加上写入超时,单个坏连接的影响就被限制在它自己身上了。

    踩过的坑一:同一用户第二台设备登录,第一台就收不到消息。因为映射是一对一、直接覆盖。教训是:建模阶段就要问「这个关系在现实中是一对一吗」——我当时想的是「一个用户就一个连接」,而这个假设从一开始就是错的。

    踩过的坑二:刚登录就收不到消息,重连又好了,这是我在这个模块里查得最久的问题。现象太诡异,我一开始怀疑心跳、怀疑握手。后来打日志才看到时序:新连接建立成功、几秒后旧连接的关闭事件到达、而我的注销按用户标识把新连接一起移除了教训是:清理类操作必须能唯一确定自己要清理的那一个对象——而「按标识删除」这种写法在「同一标识可能对应多个对象」时就是错的。

    踩过的坑三:一个卡住的连接让同一批的其他接收方都没收到消息。我是用脚本开多个客户端、掐断其中一个之后复现的。教训是:对一组对象依次执行可能失败或阻塞的操作时,必须假设其中任何一个都可能卡住——而不是假设它们都会正常返回。

    没做的部分:没做多进程或多机部署下的连接寻址(用户连在哪台机器上、消息怎么转发过去)。因为我只有一台服务器、单个进程,这个问题不存在——硬做一个用不上的方案不如说清「单进程下用内存结构即可,多进程时需要一个能查询『用户连在哪』的共享结构,以及跨进程的消息转发」我认为能说出这个边界和它要解决的问题,比假装自己处理过多机部署要好。

    数字是怎么测的

    多设备在线是断言型用例:同一用户建立两个连接,向他发消息,断言两个连接都收到;关闭其中一个,断言另一个仍然收得到、且该用户仍被视为在线。

    误注销是踩坑的直接回归,而且要靠打桩制造时序:先建立连接 A,再建立连接 B,然后触发 A 的关闭事件断言 B 仍在连接集合中、仍能收到消息这条用例价值很高,因为这个 bug 只在特定时序下出现、手测极难复现。

    先落库再推送:人为让落库失败,断言接收方没有收到这条消息、发送方收到的是失败而不是成功这条验证的是「顺序对了之后不会出现『对方看到了但库里没有』」。

    在线离线分流:接收方在线时断言直接推送到达;接收方离线时断言消息落库、未读数加一、且他上线后能补拉到。

    多端同步:用同一用户的两个连接,从其中一个发消息,断言另一个也收到了这条消息。

    写入隔离用脚本构造:建立 N 个连接,把其中一个掐断(不发关闭帧,让它处于「已死但未判定关闭」的状态),然后向这一批推送,断言其余 N-1 个全部收到报法是「N 个接收方中含 1 个死连接时,其余全部正常收到」——这条是坑的直接回归。

    死连接清理:建立 N 个连接后直接掐断,断言超时后连接集合中的数量回落到零并报「当前在线连接数」这个指标的变化曲线——它是判断清理是否生效最直接的依据。

    握手校验:不带凭据或带错误凭据发起握手,断言连接被拒绝。

    不要报什么:不要报「支持多少万连接」——我只用脚本开到上百个,而单进程上限受机器配置影响,说了会被追问细节。该报的是「同一用户多连接均可收到消息」「旧连接关闭不影响新连接」「落库失败时接收方不会收到消息」「N 个接收方含 1 个死连接时其余全部正常」「掐断后连接数超时回落」这几件可核对的事。

    面试追问
    Q:服务端怎么知道该把消息推给谁?你是怎么存连接的? A:一个映射:用户标识对应他的连接集合。但我最初存的是「一个用户一个连接」,后面所有的问题都源自这个建模错误。第一个暴露的问题是多设备:同一个用户开第二台设备时,第二个连接直接覆盖了第一个,第一台就收不到消息了——而多设备同时在线(手机和电脑同时登着)是聊天产品的基本能力第二个问题更隐蔽,也是我在这个模块里查得最久的用户显示已连接,却收不到任何消息,重连一次又好了。我一开始怀疑心跳、怀疑握手,后来打日志才看到时序:用户网络切换时新连接建立成功了,而旧连接过了几秒才被系统判定超时并触发关闭事件;我的注销逻辑是「按用户标识把他从映射里移除」,于是旧连接的关闭把刚建立的新连接一起注销掉了教训是:清理类操作必须能唯一确定自己要清理的那一个对象——「按标识删除」这种写法在「同一标识可能对应多个对象」时就是错的。所以现在每个连接都有自己的连接标识,注销时从集合里移除「连接标识等于当前这一个」的那一项,集合空了才认为该用户完全离线我总结的更一般的教训是:「用户」和「连接」是两个不同的东西——一个用户可以有多个连接、一个连接在任何时刻可能已经死了但服务端还不知道、而同一用户的新旧连接会在一段时间里同时存在。把这三件事想清楚之后,多设备、误注销、死连接清理这几个问题的解法就都是自然的了。
    Q:收到消息之后,你是先推给对方还是先存库? A:先落库,再推送。我最初是反的,理由是「先推过去让对方尽快看到」,结果踩了一个会丢消息的坑。问题在于:如果推送成功、而落库因为某个原因失败了,那这条消息就只存在于接收方的界面上——他刷新一下、或者断线补拉的时候,那条消息就消失了;而发送方那边显示的是发送成功判据是「哪一步才算这条消息真的存在」:落库才算,那就必须先落库代价只是消息到达接收方会晚一次数据库写入的时间(几毫秒),完全可以接受。而这个顺序确定之后还带来一个很大的简化:推送失败不需要任何重试或补偿机制——因为消息已经在库里了,对方上线时的补拉一定能拿到它如果顺序是反的,我就得为「推送失败」单独设计一套补偿,那是完全多余的复杂度。确定顺序之后的完整流程是:收到消息 → 校验 → 落库并分配会话内序号 → 回执给发送方 → 查接收方的连接集合 → 在线就向他的每一个连接推送、离线就只更新未读等他补拉。这里还有一个容易漏的点:发送方自己的其他设备也要推一份不推的后果是手机上发的消息、电脑上看不到另外向多个连接推送时必须相互隔离——我踩过这个坑:在循环里依次同步写入,其中一个连接处于「已死但还没被判定关闭」的状态,向它写入会阻塞,循环就卡在那里,后面的接收方全都没收到教训是:对一组对象依次执行可能失败或阻塞的操作时,必须假设其中任何一个都可能卡住。

模块六:历史消息定位与跳转

  1. 历史消息定位与跳转(按序号取目标消息的前后上下文 + 跳转后可双向续翻 + 定位到具体消息并高亮 + 新消息分割线只在首次进入时确定)★★★
    简历这样写 历史消息定位(Spring Boot + MySQL + uni-app):从搜索结果或引用消息跳转到历史消息,初版实现为从最新消息开始不断向上翻页直到命中目标,跨越较多消息时需要多次请求且耗时不可控;改为按目标消息的序号一次取出它前后各若干条上下文,一次请求即可定位;跳转后的列表支持向上与向下双向续翻(原实现只支持向上,用户回不到最新消息,只能退出重进);定位后滚动到目标消息并短暂高亮,避免用户不知道该看哪一条;「以下为新消息」分割线的位置改为进入会话时根据已读序号一次性确定并在本次停留期间固定(原实现随未读数变化实时重算,导致分割线在阅读过程中不断上移);跳转态与正常态在是否自动滚到底、是否清未读上做了区分。相关问题通过灌入几万条历史消息复现。
    展开完整拆解
    为什么要这么设计

    这个模块的起因是我做完搜索之后想加一个「点击搜索结果跳到那条消息」的功能。我以为它只是「滚动到某一条」,实际上它要求的加载方式和正常的聊天列表完全不同。四个问题。

    一是跳转要翻很多页才能找到目标。我的历史消息只支持「从最新往上翻」。要跳到几个月前的一条消息,就得一页一页往上翻直到命中——我灌了几万条历史消息测试,跳转一条较早的消息要发十几次请求,耗时完全不可控而用户点搜索结果时的预期是立刻到达。

    二是跳转之后回不到最新消息。我的列表只支持向上加载。用户跳到几个月前那条消息之后,想回到最新的对话,只能一路往下滑——但往下没有加载逻辑,滑到底就是空的他只能退出会话再重新进。

    三是跳过去之后不知道该看哪一条。我只做了「滚动到那个位置」。但屏幕上有十几条消息,用户不知道自己搜的是哪一条——尤其是搜索命中的关键词在消息中间时。

    四是「以下为新消息」分割线在阅读过程中不断上移。我的实现是「根据当前未读数,从最后一条往上数」来定位分割线。而进入会话后未读数会被逐步清掉,于是分割线跟着往上跑——用户还没读完就找不到自己读到哪了。

    所以四个改动:按目标消息序号一次取出前后上下文列表支持双向续翻定位后滚动到目标并短暂高亮分割线在进入会话时一次性确定并固定

    这个模块最想说的一句话是:「跳到某条消息」和「翻看最新消息」是两种不同的加载模式,我一开始试图用同一套逻辑覆盖,所以怎么改都别扭。正常聊天是「从最新往上单向翻」,而跳转是「以某个点为中心向两侧展开」——后者需要服务端支持「取某条消息前后各若干条」这个查询,而这个查询在前一种模式下根本不需要想清楚这是两种模式之后,接口和前端状态都变得清楚了。

    整体链路
    两种加载模式(先分清,再设计接口) │ ├─ 模式一 正常聊天:从最新往上单向翻 │ 进入会话 → 取最新 N 条 → 向上翻页用「小于某序号」的游标 │ └─ 模式二 定位跳转:以某条消息为中心向两侧展开 取目标消息序号的前 N 条与后 N 条 试图用模式一覆盖模式二的后果(我的第一版) 只能从最新往上一页页翻直到命中 灌了几万条历史消息后,跳转较早的消息要发十几次请求 耗时完全不可控,而用户的预期是立刻到达 定位接口 │ ├─ 入参:会话标识 + 目标消息序号 + 上下各取多少条 ├─ 返回:目标消息 + 前后上下文 + 两端是否还有更多 │ 两端的「还有更多」标记很重要 —— 决定前端能不能继续翻 │ └─ 目标消息可能已被删除 → 返回明确状态,不要返回空 返回空的后果:前端不知道是「没有权限」还是「消息不存在」 跳转后的列表状态(关键是双向) │ ├─ 列表要同时维护上下两个游标 │ 向上翻:小于当前最上面那条的序号 │ 向下翻:大于当前最下面那条的序号 │ 只支持向上的后果(我踩过) │ 用户跳到几个月前,想回到最新对话只能一路往下滑 │ 但往下没有加载逻辑,滑到底就是空的 → 只能退出重进 │ ├─ 向下翻到没有更多时,才是真正的「最新」 │ 此时才恢复「有新消息自动追加到底部」的行为 │ └─ 提供一个「回到最新」的悬浮按钮 比让用户一路往下翻要直接得多 定位后的视觉反馈 ├─ 滚动到目标消息(居中而不是置顶,让上下文都可见) ├─ 目标消息短暂高亮,然后淡出 │ 只滚动不高亮的后果:屏幕上十几条消息,用户不知道看哪条 └─ 如果是搜索跳转,还可以在目标消息内高亮命中的关键词 新消息分割线 │ ├─ 进入会话时,根据「已读到的序号」一次性确定分割线位置 │ ├─ 本次停留期间固定不动 │ 随未读数实时重算的后果(我踩过) │ 进入会话后未读被逐步清掉 → 分割线跟着往上跑 │ 用户还没读完就找不到自己读到哪了 │ ├─ 退出再进入时重新计算(此时未读已清,通常就没有分割线了) │ └─ 停留期间到达的新消息直接追加在底部,不影响分割线 跳转态与正常态要区分 ├─ 正常进入:自动滚到底部 · 清未读 ├─ 跳转进入:滚到目标消息 · 不自动滚到底 · 不清未读 │ 跳转时清未读的后果:用户只是去看一条老消息,未读却被清了 └─ 跳转态下收到新消息:不自动滚动,只在底部显示「有新消息」提示
    分步拆解
    1. 先分清两种加载模式。正常聊天是「从最新往上单向翻」,定位跳转是「以某个点为中心向两侧展开」——用一套逻辑覆盖两者,怎么改都别扭。
    2. 服务端要提供「取某条消息前后各若干条」的接口。这个查询在正常聊天模式下根本不需要,所以我最初没有它,只能一页页往上翻。
    3. 翻页找目标的做法在数据量大时不可用。我灌了几万条历史消息,跳转一条较早的消息要发十几次请求、耗时完全不可控——而用户的预期是立刻到达。
    4. 定位接口要返回「两端是否还有更多」。它决定前端能不能继续向上或向下翻,不返回的话前端只能盲试。
    5. 目标消息已被删除时要返回明确状态。返回空的后果是前端不知道是「没有权限」还是「消息不存在」。
    6. 跳转后的列表必须同时维护上下两个游标。只支持向上的后果是用户跳到几个月前之后回不到最新消息,只能退出重进。
    7. 向下翻到没有更多时,才恢复「新消息自动追加到底部」的行为。在此之前自动追加会让用户莫名跳走。
    8. 要提供「回到最新」的悬浮按钮。比让用户一路往下翻直接得多——这个成本极低但很实用。
    9. 滚动到目标消息时要居中而不是置顶。居中让上下文都可见,而用户看一条历史消息通常就是想看它的上下文。
    10. 目标消息要短暂高亮然后淡出。只滚动不高亮的后果是屏幕上十几条消息,用户不知道该看哪一条。
    11. 搜索跳转时还可以在目标消息内高亮命中的关键词。让他一眼看到为什么这条被搜到了。
    12. 新消息分割线要在进入会话时一次性确定,之后固定。随未读数实时重算的后果是进入会话后未读被逐步清掉、分割线跟着往上跑,用户还没读完就找不到自己读到哪了。
    13. 停留期间到达的新消息直接追加在底部,不影响分割线。分割线标记的是「本次进入前的已读位置」,它不该被本次的新消息影响。
    14. 退出再进入时重新计算分割线。此时未读通常已清,所以一般就没有分割线了——这是正确的行为。
    15. 跳转进入与正常进入要区分行为。正常进入自动滚到底并清未读;跳转进入滚到目标、不自动滚到底、不清未读——跳转时清未读的后果是用户只想看一条老消息,未读却被清了。
    16. 跳转态下收到新消息不要自动滚动。只在底部显示「有新消息」提示,让用户自己决定什么时候回到底部。
    关键决策与取舍

    为定位跳转单独设计一个「取前后上下文」的接口,而不是复用正常的翻页接口。复用的好处是接口少、前端逻辑统一。但它把「定位」变成了「不断翻页直到命中」——请求次数取决于目标消息离最新有多远,完全不可控代价是多一个接口、前端多一套状态(双向游标)判据是「这两种需求的访问模式是否相同」:一个是单向连续、一个是随机定位后双向展开,本质不同,那就该有各自的接口我在这上面绕了一阵,就是因为一开始想「用一套逻辑覆盖」。

    跳转后不清未读,与正常进入区分开。统一都清最简单。但用户从搜索结果跳过去,他的意图只是「看一条老消息」——把未读清掉意味着他真正没读的那些消息被当成已读了代价是要在会话页里区分两种进入方式,状态多了一个分支判据是「这个动作代表用户读了什么」:跳转只代表他读了那一条附近的内容,不代表他读完了全部未读。这一条和我在会话未读那块的结论一致——已读的语义应该对应他真的看到了什么。

    新消息分割线一次性确定并固定,而不是实时重算。实时重算听起来更「准确」。但它的参照物(未读数)在用户阅读过程中一直在变,于是分割线不断上移——用户还没读完就找不到自己读到哪了固定的代价是它可能和「当前真实未读」不一致(本次进入后又来了新消息)但分割线的语义本来就是「我上次读到这里」,它是一个进入会话那一刻的快照判据是「这个标记描述的是哪个时刻的状态」:进入那一刻,那它就不该随后续变化而动。

    滚动到目标消息时居中而不是置顶,并且加短暂高亮。置顶实现更简单(滚动到那个偏移量就行)。但用户看一条历史消息通常是想看它的上下文,置顶会让上文全部在屏幕外而不加高亮的话,屏幕上十几条消息他不知道该看哪一条这两个都是很小的改动,但它们决定了这个功能是「能用」还是「好用」。

    踩过的坑一:跳转靠一页页往上翻,几万条历史下要发十几次请求。我是灌了几万条消息之后才发现的——之前只有几百条消息时,翻两三次就命中了,我完全没觉得有问题教训是:「循环请求直到满足条件」这种写法的次数由数据分布决定,而不是由你控制——小数据量会让它看起来完全正常。

    踩过的坑二:跳转之后回不到最新消息,用户只能退出重进。因为我的列表只有向上加载。教训是:一旦支持了「从中间某个位置开始展示」,列表就必须是双向的——而我最初的单向设计隐含了「入口永远是最新消息」这个假设,跳转功能一加进来这个假设就不成立了。

    踩过的坑三:新消息分割线在阅读过程中不断上移。因为我用实时未读数去反推它的位置。教训是:一个「标记某个历史时刻」的东西,不能用一个实时变化的量去计算它——参照物在变,标记就会跟着跑。

    没做的部分:没做消息内容的全文搜索(只做了从引用消息跳转)。它需要为消息内容建全文索引并处理分词,而聊天消息的量级增长很快、索引维护成本比帖子内容高得多而且消息内容比公开帖子更敏感,搜索能力放开之前要想清楚权限边界取舍依据是「定位能力先做扎实,搜索是它的一个入口而不是前提」——而我做的定位接口正好是搜索功能落地时需要的那一块,所以这个顺序我认为是对的。

    数字是怎么测的

    请求次数是这个模块最有说服力的数字:「在灌入几万条历史消息的会话里,跳转到一条较早的消息,请求次数从『十几次(一页页往上翻)』降到 1 次」并说明「十几次」这个数字取决于目标离最新有多远——也就是它本来就是不可控的,这个说明比数字本身更能说明问题。

    定位耗时要报清历史消息总量:「历史消息 N 条时,定位接口耗时 X」,并说明它与目标消息的位置无关——「与位置无关」才是改造后的核心性质。

    双向续翻是断言型用例:跳转到一条较早的消息后,断言可以继续向上翻、也可以继续向下翻一路向下翻到没有更多时,断言恢复了「新消息自动追加到底部」的行为。

    分割线固定是踩坑的直接回归:带着若干未读进入会话,断言分割线位置在整个停留期间不变期间收到新消息,断言新消息追加在底部而分割线不动;退出再进入,断言分割线按新的已读位置重新计算。

    跳转不清未读:带着若干未读,从搜索结果跳转进入会话,断言未读数没有被清除;正常进入时断言未读被清除。两条一起测才说明区分生效。

    目标消息已删除:删掉目标消息后再跳转,断言返回明确状态、界面给出「该消息已删除」而不是空白列表。

    视觉反馈:断言目标消息滚动后处于屏幕中部(上下文可见)、短暂高亮后淡出。

    跳转态下的新消息:在跳转态收到新消息,断言列表不自动滚动、底部出现「有新消息」提示。

    不要报什么:不要报「跳转速度提升 N 倍」——倍数取决于目标消息离最新有多远,本来就不是一个固定值。该报的是「历史消息总量」「请求次数从十几次降到 1 次且原次数不可控」「定位耗时与目标位置无关」「跳转后可双向续翻」「分割线在停留期间固定」这几件带条件的、可核对的事。

    面试追问
    Q:跳转到一条历史消息,不就是滚动到那个位置吗? A:难点不在滚动,在于那条消息以及它周围的内容还没有被加载出来——而我的历史消息只支持「从最新往上翻」。所以我第一版的实现是一页页往上翻直到命中目标我灌了几万条历史消息测试,跳转一条较早的消息要发十几次请求,耗时完全不可控而在此之前只有几百条消息时,翻两三次就命中了,我完全没觉得有问题教训是:「循环请求直到满足条件」这种写法的次数由数据分布决定,而不是由你控制——小数据量会让它看起来完全正常。而更本质的问题是我试图用一套逻辑覆盖两种不同的加载模式正常聊天是「从最新往上单向翻」,而定位跳转是「以某个点为中心向两侧展开」——后者需要服务端支持「取某条消息前后各若干条」这个查询,而这个查询在前一种模式下根本不需要,所以我最初没有它判据是「这两种需求的访问模式是否相同」:一个是单向连续、一个是随机定位后双向展开,本质不同,那就该有各自的接口想清楚这是两种模式之后,还带出了第二个必须改的地方:跳转后的列表必须是双向的。我最初只有向上加载,用户跳到几个月前那条消息之后,想回到最新对话只能一路往下滑,但往下没有加载逻辑、滑到底就是空的,他只能退出会话再重新进教训是:一旦支持了「从中间某个位置开始展示」,列表就必须是双向的——我最初的单向设计隐含了「入口永远是最新消息」这个假设,跳转功能一加进来这个假设就不成立了。另外我还加了一个「回到最新」的悬浮按钮,比让用户一路往下翻直接得多。
    Q:「以下为新消息」这条分割线,你是怎么定位的? A:进入会话时根据「已读到的序号」一次性确定,然后在本次停留期间固定不动。我最初是实时算的,结果分割线在阅读过程中不断上移。我第一版的实现是「根据当前未读数,从最后一条往上数」——听起来很自然。但进入会话后未读会被逐步清掉,于是分割线跟着往上跑,用户还没读完就找不到自己读到哪了。教训是:一个「标记某个历史时刻」的东西,不能用一个实时变化的量去计算它——参照物在变,标记就会跟着跑。改成用「已读到的序号」一次性确定之后,它就是一个进入那一刻的快照停留期间到达的新消息直接追加在底部、不影响分割线退出再进入时重新计算(此时未读通常已清,所以一般就没有分割线了,这是正确的行为)判据是「这个标记描述的是哪个时刻的状态」:进入那一刻,那它就不该随后续变化而动。顺带说这块和「已读」的联动上还有一个我特意区分的地方:跳转进入会话时不清未读。正常进入是「我来读消息了」,自动滚到底并清未读;而从搜索结果跳转过去,用户的意图只是「看一条老消息」——把未读清掉意味着他真正没读的那些消息被当成已读了判据是「这个动作代表用户读了什么」:跳转只代表他读了那一条附近的内容。这一条和我在会话未读那块的结论是一致的——已读的语义应该对应他真的看到了什么,而不是他打开了某个页面。另外跳转态下收到新消息也不自动滚动,只在底部显示「有新消息」提示。

没有匹配的内容,换个关键词试试。

项目拆解 · 即时聊天(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据