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

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

项目背景设定 社区与短视频业务共用的长连接网关,Java + Netty + Redis + 消息队列。承载私信互动通知(点赞评论关注)、在线状态。用户分布在多个国家,移动端网络频繁切换,一个账号可能同时在手机和网页登录。
为什么选这三个模块 长连接是后端面试的硬骨头,而且很难糊过去:面试官会直接问「用户连在哪台机器你怎么知道」「消息丢了怎么办」「两个端怎么同步已读」。这三个问题分别对应连接管理、可靠投递、多端一致,答不上来就说明没真做过。

模块一:连接管理与心跳

  1. 连接管理与心跳(Netty 长连接 + 自适应心跳 + 退避重连)★★★
    简历这样写 长连接网关的连接管理(Netty + WebSocket + 自适应心跳 + Redis 连接注册表):设计握手鉴权、注册路由、断开清理的完整生命周期,用自适应心跳(前台密后台疏)配合读空闲检测识别假死连接,客户端按指数退避加随机抖动重连以避免恢复瞬间的重连风暴。单机稳定承载 5 万长连接(压测,堆内存约 4GB),网络切换后重连并恢复收发在 3 秒上下,服务端发版时连接分批重连、未出现集中冲击。
    展开完整拆解
    为什么要这么设计

    第一版是最朴素的实现:客户端连上来,服务端把 userIdChannel 存进一个 ConcurrentHashMap,发消息时查这个 map。本地测试完美,上线后问题一个接一个。

    一是连接假死。手机进电梯、切飞行模式、网络从 WiFi 切到蜂窝,TCP 连接在服务端看起来还是 ESTABLISHED,但数据实际发不出去。服务端以为用户在线,消息发给一个死连接,用户什么都收不到,而服务端不认为消息丢了。这类连接还会一直堆积,占着内存和文件描述符。

    二是重连风暴。服务端发一次版本,所有连接同时断开,然后所有客户端在同一秒全部重连,瞬时的连接建立请求把新起来的实例又打挂,形成雪崩。

    三是心跳一刀切。为了快速发现断连把心跳设成 10 秒一次,结果移动端耗电和流量投诉激增——App 在后台每 10 秒唤醒一次网络,电量掉得很快。

    所以三个设计点:心跳要双向且自适应(服务端也要能主动探测,前后台不同频率)、重连要退避加随机抖动(把重连时间打散)、连接的注册与清理必须严格配对(否则路由表会残留脏数据,而路由表是下一个模块的地基)。

    整体链路
    连接建立 │ ├─ 客户端携带 token 发起 WebSocket 握手 │ ├─ 握手阶段就鉴权,不要等连上再验 │ token 无效直接拒绝握手,无效连接不占资源 │ ├─ 分配 connectionId,绑定 userId 与设备标识 │ ├─ 注册路由:Redis 写入「用户 → 网关实例」映射,带 TTL │ 一个用户可有多条(手机 / 网页 / 平板) │ ├─ 拉取离线消息(见模块二) │ └─ 更新在线状态 连接存续 │ ├─ 客户端心跳:前台约 30 秒,后台拉长到分钟级 │ 服务端收到心跳就续期 Redis 路由 TTL │ ├─ 服务端读空闲检测(IdleStateHandler) │ 连续几个周期没收到任何数据则判定可疑 │ 先主动发探测帧,仍无响应才关闭连接 │ └─ 路由 TTL 是兜底:进程被强杀来不及清理时 TTL 到期映射自动消失,不会永久残留 连接断开 │ ├─ 正常关闭 / 异常断开 / 服务端主动踢 │ ├─ 清理:移除本地 Channel,删除 Redis 路由 │ 只能删属于自己实例的那条映射 │ ├─ 更新在线状态,要延迟判定避免闪断造成状态抖动 │ └─ 清理必须幂等,否则用户在新实例重连后 旧实例的延迟清理会把新映射误删 客户端重连 │ ├─ 指数退避:1s → 2s → 4s → 8s 上限约 30s ├─ 叠加随机抖动,把万台设备的重连时间打散 ├─ 监听网络状态变化事件,变化时立即重连不等退避 └─ 重连成功后带上「最后收到的消息序号」做增量补齐
    分步拆解
    1. 鉴权必须放在握手阶段。如果先建连接再验 token,攻击者可以大量建立无效连接耗尽服务端资源。握手时 token 不合法直接拒绝,连接根本不进入业务处理链。
    2. 心跳要自适应,这是移动端的硬要求。前台约 30 秒、后台拉长到分钟级、息屏可以更长。判断依据是「发现断连的及时性」和「耗电流量」的权衡——用户在前台等消息,慢几秒就有感知;在后台时晚几分钟发现断连没有影响。一刀切的短心跳一定会被投诉耗电。
    3. 服务端要有独立的读空闲检测,不能只依赖客户端心跳。客户端可能压根不发心跳(版本 bug、被系统冻结)。Netty 的 IdleStateHandler 检测到长时间没有读事件时先主动发一次探测帧再决定关闭,避免误杀刚好网络卡了一下的连接。
    4. 路由映射必须带 TTL,这是最重要的兜底。进程被 kill -9 或者机器宕机时,清理代码压根来不及执行,Redis 里会留下指向已死实例的映射。TTL 让脏映射自动过期,心跳续期保证活连接的映射不过期。没有 TTL 的路由表迟早会烂掉。
    5. 清理只能删自己实例的映射,这是个真实的坑。用户从实例 A 断开、立刻在实例 B 重连,此时 A 的清理逻辑才慢半拍执行,如果它无脑删除「该用户的路由」,就会把 B 刚写的映射删掉,用户明明连着却收不到消息。做法是映射的值里带实例标识,删除时用 Lua 脚本比对「是我的才删」。
    6. 重连必须加随机抖动,纯指数退避不够。所有客户端在同一时刻断开,即使都用 1s→2s→4s 退避,它们的重连时刻仍然是同步的,只是把风暴推后了。在退避时间上乘一个随机系数才能真正打散。这是分布式重试的通用做法。
    7. 要监听系统的网络状态变化事件。从蜂窝切到 WiFi 时,旧连接必然失效,但退避计时器可能还在等 16 秒。网络变化时应立即重连,这能把恢复时间从十几秒压到 1 到 2 秒,用户体感差别很大。
    8. 在线状态要延迟判定。连接断开就立刻标记离线,会导致地铁里的用户在线状态疯狂闪烁,也会让「对方正在输入」这类功能抖动。做法是断开后延迟一段时间再置离线,期间若重连成功就取消。
    9. 发版要做优雅下线。先从负载均衡摘掉实例,然后分批主动断开连接并在关闭帧里带上「建议重连延迟」,让客户端错峰重连,而不是所有连接同时断。
    关键决策与取舍

    为什么用 WebSocket 而不是自定义 TCP 协议。自定义协议在流量和灵活性上更优,但WebSocket 能穿透绝大多数代理和防火墙,而国际业务里用户的网络环境极其复杂,很多地区的运营商会拦截非标准端口的长连接。可用性优先于效率,所以选 WebSocket。代价是协议开销略大,通过消息体用二进制序列化来补偿一部分。

    单机连接数上不去时,先看内存还是先看 CPU。长连接场景瓶颈几乎总是内存而不是 CPU——每个连接要维持读写缓冲区、会话上下文。所以调优方向是把每连接的内存占用压下来:缓冲区大小按实际消息体调小、会话上下文只存必要字段、用堆外内存承接缓冲。盲目加机器不如先算清单连接内存

    踩过的坑:连接数上到几万后 GC 停顿明显。大量连接对象和缓冲区在堆上,每次 Full GC 停顿几百毫秒,期间所有连接的心跳都收不到,触发大面积误判断连,然后大面积重连,雪上加霜。修法两个:缓冲区改用堆外内存减少堆压力;把断连判定的容忍度调宽(允许错过更多个心跳周期),避免一次 GC 停顿就误杀连接。这个坑很能体现「参数不是孤立的」——GC 表现直接影响心跳阈值该设多少。

    踩过的坑二:路由 TTL 设得比心跳间隔短。后台心跳拉长到 3 分钟后,路由 TTL 还是 2 分钟,导致后台用户的映射频繁过期,消息投递时查不到路由,被当成离线用户。修法是 TTL 必须显著大于最大心跳间隔(通常两到三倍)。心跳间隔一改,TTL 必须跟着改,这两个参数是耦合的。

    没做的部分:没做多机房的连接就近接入。用户全球分布但网关只在少数机房,远地区用户的连接延迟明显更高。理想方案是就近接入加机房间消息转发,涉及跨机房路由和数据同步,成本较大,当时没做。

    数字是怎么测的

    单机连接数:用压测工具建立大量 WebSocket 连接并保持心跳,逐步加压看到什么量级出现异常(心跳超时、内存告警、GC 频繁)。必须同时报出堆内存配置、消息收发频率、机器规格——「单机 5 万连接」如果不说这三个就是没有意义的数字,纯空闲连接和每秒都在收发消息的连接,能撑的量差好几倍。

    重连恢复时间:客户端埋点,量从「检测到网络恢复」到「重连成功且能正常收发」的耗时。要分场景报:主动切换网络(快,1 到 2 秒)、超时断连后退避重连(慢,取决于当前退避到第几档)。只报快的那个是选择性呈现。

    发版冲击:观察发版期间的连接建立速率曲线。「未出现集中冲击」的依据是曲线平缓而不是尖峰,这个用监控截图说明比数字更直观。要能说出分批的批次大小和间隔。

    不要报「消息到达率 99.99%」。长连接场景下「到达」的定义本身很模糊(发出去算到达?客户端 ack 算?渲染出来算?),分母也说不清。可以报的是端到端延迟分位数和离线消息补齐的成功情况,这两个口径清晰。

    面试追问
    Q:怎么判断一个连接是不是还活着?TCP 不是有 keepalive 吗? A:TCP keepalive 不够用,两个原因。一是探测周期太长,系统默认通常是两小时才开始探测,而且改内核参数影响全局,不好按业务调。二是它只能证明「TCP 链路通」,不能证明「应用还在正常工作」——应用可能死锁了、GC 卡住了、业务线程池满了,TCP 层照样回应探测。所以必须做应用层心跳:客户端定期发心跳帧,服务端用读空闲检测识别长时间没有数据的连接,先主动发探测帧再决定关闭。应用层心跳的另一个好处是可以捎带信息,比如客户端把「最后收到的消息序号」放在心跳里,服务端顺便就能发现漏消息。
    Q:服务端要重启,几万个连接怎么处理? A:优雅下线,核心是错峰。步骤:先从负载均衡摘掉这个实例(不再有新连接进来);然后分批主动关闭已有连接,比如每秒关几百个而不是一次全关;关闭时在关闭帧里带上建议的重连延迟,客户端按这个值加上自己的随机抖动再重连。这样连接会平滑迁移到其他实例。如果一次性全关,所有客户端会同时重连,把剩下的实例打挂——这就是重连风暴。另外要注意关闭前把正在发送的消息处理完,或者确保它们会进离线消息,不然这批消息就丢了。
    Q:用户在电梯里网络断了但服务端还没发现,这段时间的消息去哪了? A:这正是为什么不能只靠长连接投递。服务端以为连接还活着,消息写进 Channel,操作系统缓冲区收下了但发不出去,从服务端视角这次投递是「成功」的,实际上丢了。解决办法是消息投递必须有客户端 ack:服务端发出后不立即认为送达,等客户端回 ack 才标记已投递;超时未 ack 的消息转入离线消息队列,用户重连时补齐。这是模块二的核心内容。关键认知是:写入 socket 成功不等于对端收到,可靠投递必须靠应用层确认。
    Q:一个用户手机和网页都登录了,路由怎么存? A:路由不能是「用户 → 一个实例」的一对一映射,必须是「用户 → 多条连接记录」,每条记录包含设备标识、网关实例地址、连接 ID、最后活跃时间。Redis 里可以用 Hash 存(field 是设备标识,value 是实例地址加连接 ID),投递时取出所有记录逐个投递。要处理几个细节:同一设备重复登录要踢掉旧连接(否则会有幽灵连接);删除时按设备标识精确删而不是删整个 Hash;不同端的已读状态要同步(模块三的内容)。另外要限制单用户的连接数上限,防止异常客户端疯狂建连接占资源。

模块二:集群路由与可靠投递

  1. 集群路由与可靠投递(路由表 + ack 确认 + 离线消息补齐)★★★
    简历这样写 集群下的消息路由与可靠投递(Redis 路由表 + 消息队列 + 客户端 ack + 离线消息表):发消息方与收消息方连在不同网关实例,通过Redis 路由表定位目标实例、消息队列做实例间转发;投递以客户端 ack 为送达凭证,超时未确认的转入离线消息,用户重连时按最后收到的序号增量补齐,并用消息 ID 做接收端去重。压测 3000 条/秒投递下端到端延迟 P95 约 180ms,断网重连后离线消息补齐完整,重复投递由接收端去重消化。
    展开完整拆解
    为什么要这么设计

    单机的时候发消息很简单:从本地 map 里找到对方的 Channel 写进去。一上集群就全塌了。

    一是找不到人。A 连在实例 1,B 连在实例 2。A 发给 B,实例 1 的本地 map 里没有 B,于是认为 B 不在线。集群化之后必须有一个全局的「用户在哪台机器」的路由表。

    二是「发出去」不等于「收到了」。前一个模块讲过,写入 socket 成功只说明操作系统缓冲区收下了,用户可能在电梯里什么都没收到。而服务端把这次投递记为成功,这条消息就永久丢了——用户永远不知道有人给他发过消息。私信丢消息是不可接受的。

    三是离线用户的消息没有着落。用户根本没连接的时候,消息发给谁?必须存起来,等他上线补给他。

    所以三个机制:全局路由表(解决找人)、客户端 ack 作为送达凭证(解决丢消息)、离线消息加序号增量补齐(解决不在线)。

    还有一个必须提前想清楚的取舍:可靠投递必然带来重复投递。ack 丢了服务端会重发,用户就收到两条。所以接收端必须做去重——这是「至少一次投递 + 接收端幂等」的经典组合,追求「恰好一次」在分布式下代价极高且没必要。

    整体链路
    A 发消息给 B(两人连在不同实例) │ ├─ 实例 1 收到 A 的发送请求 │ ├─ 生成消息 ID(全局唯一)+ 会话内递增序号 seq │ seq 由「会话」维度自增,是补齐和排序的依据 │ ├─ 先落库(消息表),再投递 │ 顺序不能反:先投递后落库,若落库失败消息就成了幽灵 │ ├─ 回 A 一个「已发送」确认(带消息 ID 与 seq) │ ├─ 查路由表:B 有哪些在线连接 │ │ │ ├─ 有在线连接 → 按实例分组,投消息队列 │ │ 队列按目标实例分区,实例只消费自己的分区 │ │ │ └─ 无在线连接 → 直接写离线消息 + 触发推送(APNs/FCM) │ ├─ 实例 2 消费到消息 → 查本地 Channel → 写出 │ ├─ 等 B 的 ack(有超时) │ ├─ 收到 ack → 标记该设备已投递 │ └─ 超时未 ack → 转离线消息,等下次重连补 │ └─ B 的每个端都要各自 ack(多端各自维护投递状态) B 重连后的增量补齐 │ ├─ 客户端上报「我这个会话最后收到的 seq」 │ ├─ 服务端查该会话 seq 大于它的消息,按 seq 升序返回 │ 分页返回,一次不超过 N 条,避免离线很久时一次拉爆 │ └─ 客户端按消息 ID 去重后插入本地库,再按 seq 排序展示 去重与排序(接收端责任) ├─ 去重:本地库以消息 ID 建唯一索引,重复的直接丢弃 └─ 排序:不靠到达顺序,一律按 seq 排序展示 网络乱序、重发、补齐混在一起时,只有 seq 是可靠的
    分步拆解
    1. 必须先落库再投递。如果先投递后落库,落库失败时消息已经在对方屏幕上了,但服务端没有记录,用户换个设备就看不到这条消息,而且无法补齐。落库成功才是消息「存在」的定义。
    2. 会话内序号 seq 是整套机制的基石。它必须是会话维度单调递增的,不能用全局自增 ID(全局 ID 在会话内不连续,客户端无法判断有没有漏)。有了 seq,客户端能做三件事:判断是否漏消息(seq 不连续)、增量补齐(只要大于我的 seq 的)、正确排序(不依赖到达顺序)。
    3. seq 的生成要考虑并发。同一会话两人同时发消息,两个实例同时取 seq,不能重复。用 Redis 的 INCR 按会话 key 自增即可,性能足够。要处理 Redis 丢数据的情况——重启后 seq 回退会造成严重混乱,所以 seq 也要能从数据库的最大值恢复。
    4. 路由表查出来后按实例分组批量投递。用户有三个端连在两个实例上,不要发三次跨实例转发,按实例分组,每个实例发一次,实例内部再分发给多个 Channel。
    5. 消息队列按目标实例分区。每个网关实例只消费自己的分区,避免所有实例都消费全部消息再过滤(那是巨大的浪费)。实例上下线时分区要能重新分配,这是集群管理要处理的。
    6. ack 必须按设备维度记录。用户手机收到了但网页没收到,不能算整体已投递。每个设备各自 ack,各自维护投递状态,未 ack 的设备走离线补齐。
    7. ack 超时的消息转离线,不要在线重试。在线重试解决不了问题——如果连接假死,重试多少次都发不到,只是浪费资源。直接转离线消息,等对方重连时补齐,这个路径是确定有效的。
    8. 离线消息补齐要分页且有上限。用户离线一个月,会话里积压几千条消息,一次全推会撑爆客户端也会拖慢首屏。分页拉取,并且对超长时间离线的会话只补最近 N 条 + 标记「有更早的历史消息」,用户往上滑再拉。
    9. 离线时要触发系统推送。长连接断了,唯一能触达用户的手段是 APNs 或 FCM。推送只是「通知有新消息」的手段,不能承载消息内容的可靠传输(推送本身不保证送达,而且有内容长度限制和隐私问题)。用户点开推送后走正常的补齐流程拿真实消息。
    关键决策与取舍

    选择「至少一次 + 接收端去重」而不是追求「恰好一次」。严格的恰好一次需要投递方与接收方做两阶段确认,在移动网络下几乎不可实现(确认包本身也会丢)。接受重复、在接收端用消息 ID 去重,是成本最低且可靠的方案。代价是客户端必须维护本地消息库并建唯一索引,这个成本很小。面试时能说出「我不追求恰好一次以及为什么」,比声称实现了恰好一次可信得多。

    路由表放 Redis 而不是注册中心或数据库。路由查询在每条消息的关键路径上,QPS 极高,必须是内存级读取。Redis 合适,而且天然支持 TTL 做兜底清理。代价是 Redis 成了强依赖——它挂了消息就投递不出去。缓解手段是网关本地缓存一份自己持有的连接(同实例内的消息可以不查 Redis),以及 Redis 集群化部署。

    踩过的坑:ack 丢失导致消息重复补齐,用户看到大量重复消息。客户端收到了消息但 ack 在回传路上丢了,服务端超时后把消息转离线,重连时又补一遍。当时客户端没有去重,用户会话里出现成对的重复消息。修法就是接收端按消息 ID 去重(本地库唯一索引)。这个坑说明「可靠投递」的完整方案必须包含接收端的幂等,只在服务端做重试是半个方案。

    踩过的坑二:seq 用了全局自增 ID,客户端无法判断漏消息。最初的 seq 直接用了消息表的自增主键,同一会话里的 seq 是 1、57、203 这种跳跃的值,客户端拿到 1 和 203 时无法判断中间是不是漏了。修法是改成会话维度自增,让同一会话的 seq 严格连续。「序号必须在什么维度上连续」取决于你要用它做什么判断,这个设计不能随手来。

    没做的部分:没做消息的端到端加密。私信的隐私要求下这是应该做的,但会让服务端无法做内容审核(违规内容检测),两者天然冲突。当时业务上选择了可审核,这个取舍要说清是业务决策而非技术做不到。

    数字是怎么测的

    端到端延迟:发送方发出时打时间戳,接收方渲染时对比本地时间上报差值。跨设备时钟不同步会污染这个数字,所以更可靠的做法是在服务端量两段:发送请求到落库完成、落库到目标实例写出 socket。客户端那一段单独用「同一台设备自己发给自己」的方式量(时钟一致)。压测 3000 条/秒下 P95 约 180ms,要说明是几个网关实例、多少并发会话

    离线补齐完整性:造场景验证——客户端断网,期间发 100 条消息,重连后检查是否收到全部 100 条且无重复、顺序正确。这是个可复现的确定性测试,比统计线上的「消息到达率」有说服力,因为后者的分母口径说不清。

    重复投递的量:可以从客户端去重命中次数统计。报这个数字反而增强可信度——它说明你知道重复真实存在并且被正确处理了。声称「没有重复」的人大概率没做 ack 超时重投。

    不要报「消息不丢不重」。这是绝对化断言,而且「不重」和「至少一次投递」在语义上就矛盾。正确表述是「不丢(有落库和补齐兜底),可能重复(由接收端去重消化)」。

    面试追问
    Q:A 和 B 连在不同的服务器上,A 发的消息怎么到 B? A:靠全局路由表加实例间转发。A 所在的实例 1 收到请求后,先落库拿到消息 ID 和会话 seq,然后去 Redis 查路由表——B 有哪些在线连接、分别在哪个实例上。查到 B 在实例 2,就把消息投到按实例分区的消息队列,实例 2 消费自己的分区,从本地 Channel 表找到 B 的连接写出去。为什么用队列而不是实例间直接 RPC:队列天然提供缓冲和重试,实例 2 短暂不可用时消息不会丢;直接 RPC 的话调用失败就要自己处理重试和堆积。如果 B 完全不在线,跳过转发直接写离线消息表并触发系统推送。
    Q:怎么保证消息不丢? A:三个环节各自保证。一是发送端到服务端:客户端发出后等服务端的「已发送」确认,没收到就重试(带客户端生成的幂等键,服务端去重)。二是服务端持久化:必须先落库再投递,落库成功才回确认,这样即使投递环节全挂,消息也在库里,能靠补齐送达。三是服务端到接收端:以客户端 ack 为送达凭证,超时未 ack 就转离线消息,等对方重连时按 seq 增量补齐。核心思想是「消息一旦落库就一定有办法送到」,投递失败只是延迟送达而不是丢失。代价是可能重复,由接收端按消息 ID 去重。
    Q:消息乱序了怎么办?比如后发的先到。 A:不试图保证到达顺序,而是让展示顺序不依赖到达顺序。每条消息带会话维度递增的 seq,客户端收到后存本地库,展示时一律按 seq 排序。这样无论网络乱序、重发、还是离线补齐和实时消息混在一起,展示都是对的。另外 seq 连续这个性质还能用来检测漏消息:客户端发现收到 seq 5 和 7,就知道 6 漏了,主动去拉。服务端侧也可以做一层——同一会话的转发消息路由到同一队列分区,让它们串行处理,减少乱序发生的概率。但这只是降低概率,客户端按 seq 排序才是根本保证。
    Q:用户离线三个月,会话里有五千条消息,上线怎么处理? A:不能一次全推,会撑爆客户端内存也会让首屏卡死。做法是分页加截断:只补最近的 N 条(比如 50 条),并在会话上标记「有更早的未读」,用户往上滑再分页拉。未读数要单独算而不是靠消息条数——未读数从服务端聚合接口拿,而且超过一定量就显示「99+」,避免为了算准确数字去扫全表。另外离线消息要有保留期,超过一定时间(比如三个月)的未送达消息可以清理,否则存储无限增长,而且三个月前的私信送达也没什么意义了。这个保留期要产品确认,不能技术自己定。

模块三:多端同步与已读状态

  1. 多端同步与已读状态(会话位点 + 已读水位 + 未读数一致)★★★
    简历这样写 多端消息同步与已读状态(会话位点 + 已读水位线 + 增量同步接口 + 缓存计数):把「已读」建模为会话维度的已读水位线(read seq)而非逐条标记,任一端读完后同步水位到其他端,未读数由最新 seq 与已读水位之差推导;会话列表走增量同步(只拉变更过的会话)避免全量刷新。手机端已读后网页端未读消失在 1 秒上下,未读数与实际消息数的偏差由定时校准收敛,会话列表拉取从全量改增量后响应体下降一个数量级
    展开完整拆解
    为什么要这么设计

    多端是最容易被低估的复杂度。用户在手机上把消息看完了,切到网页发现还是红点未读,或者反过来——网页看过了手机上还提示。这类问题的投诉量远超预期,因为它每天都在发生。

    第一版的实现是逐条标记已读:每条消息一个 is_read 字段,用户看到就更新。三个问题:

    一是写放大严重。用户滑过一屏 20 条消息,就是 20 次更新。会话列表一次刷新可能触发几百次写。

    二是多端同步逻辑爆炸。每条消息在每个端的已读状态都要单独存,用户三个端就是三倍数据量,而且要处理「手机已读网页未读」这种中间态——但业务上其实不需要区分哪个端读过,只需要「这个用户读到哪了」

    三是未读数算不准。未读数靠 count where is_read = 0 算,会话消息多了这个查询就慢,加缓存又要面对缓存与实际不一致。

    关键洞察是:已读不是「逐条的布尔状态」,而是「会话上的一个位点」。用户看消息是从旧到新连续看的,不会跳着看,所以只需要记「读到 seq 多少了」这一个数字。这一个改动同时解决了上面三个问题——写放大(一次会话一个数字)、多端同步(同步一个数字就行)、未读数(最新 seq 减已读 seq,纯计算不查表)。

    整体链路
    数据模型 │ ├─ 会话 conversation │ 会话 ID / 参与者 / 最新消息 seq / 最新消息摘要 / 更新时间 │ └─ 用户会话状态 user_conversation(每人每会话一行) 用户 ID / 会话 ID read_seq 已读水位:读到哪条了 last_seq 该会话当前最新 seq(冗余,便于算未读) 免打扰 / 置顶 / 草稿 / 删除标记 未读数 = last_seq - read_seq(不查消息表,纯减法) 已读上报(任一端) │ ├─ 客户端上报「会话 X 我读到 seq 120」 │ ├─ 服务端更新 read_seq,但只许前进不许后退 │ update ... set read_seq = 120 where read_seq < 120 │ 条件更新是关键:乱序上报不会把水位拉回去 │ ├─ 广播已读事件给该用户的其他在线端 │ 其他端收到后本地把该会话未读清掉 │ └─ 更新用户总未读数缓存 会话列表增量同步 │ ├─ 客户端带上「上次同步的时间戳或版本号」 │ ├─ 服务端只返回该时间点之后有变更的会话 │ 变更包括:来新消息 / 已读水位变化 / 置顶免打扰改动 / 被删除 │ ├─ 客户端合并到本地会话列表,按最新消息时间重排 │ └─ 首次登录或版本差距过大 → 退回一次全量拉取 多端一致的三个同步项 ├─ 已读水位:任一端读完,其他端未读同步清除 ├─ 会话操作:置顶 / 免打扰 / 删除会话,需要同步 └─ 草稿:可选同步,产品决定(同步了体验好但写入频繁) 校准(定时) └─ 以消息表为准重算 last_seq,与会话表比对 偏差超阈值告警,未读数缓存重建
    分步拆解
    1. 已读水位只许前进,用条件更新保证。where read_seq < 新值。多端并发上报、网络乱序导致旧的上报后到,如果无条件覆盖,已读会被拉回去,用户会看到已经读过的消息又变未读。这一条是整个模块最关键的实现细节。
    2. 未读数用减法推导,不要查表 count。last_seq - read_seq 就是未读数。前提是 seq 在会话内严格连续(模块二保证的),如果 seq 有跳跃这个减法就不准。这也是为什么 seq 必须会话维度自增。
    3. 撤回和删除会打破减法的准确性,要单独处理。消息撤回后 seq 还占着位置,减法会多算一条未读。做法是撤回时不删除 seq,而是把消息标记为撤回并单独维护一个「该会话被撤回的消息数」,未读数减去它。或者接受小误差(用户看到未读 1 点进去没有新消息,影响很小),按产品容忍度定。能主动指出这个边界问题很加分。
    4. 已读事件要广播给同用户的其他端。手机上报已读后,服务端通过长连接把「会话 X 已读到 120」推给该用户的网页端和平板端,它们本地立刻清红点。不能等其他端下次轮询才更新,那会有明显延迟。
    5. 会话列表必须做增量同步。用户有几百个会话,每次进入应用全量拉取,响应体几百 KB 且大部分数据没变。改成带版本号的增量同步,只返回变更过的会话。这是响应体降一个数量级的原因。
    6. 增量同步要能退回全量。客户端版本号太旧(比如离线很久,中间的变更已被清理)时,服务端返回「需要全量」标记,客户端重新全量拉一次。没有这个退路的话,长期离线的客户端会永远同步不上。
    7. 会话的操作类状态也要同步。置顶、免打扰、删除会话这些是用户维度的偏好,在手机上设了网页上也应该生效。它们和已读水位一起放在 user_conversation 表里,走同一套增量同步。
    8. 总未读数(应用角标)要单独缓存。它等于所有会话未读数之和,每次实时求和在会话多时会慢。缓存起来,收到新消息时递增、已读时递减,并且定时以数据库为准校准——角标数字长期不准是很明显的体验问题。
    关键决策与取舍

    水位模型的前提是「用户按顺序阅读」,这个前提大部分成立但有例外。如果产品支持「标记某条消息为未读」或者「跳到某条历史消息」,纯水位模型就不够了。我们的处理是不支持逐条标记未读(产品也确认这个功能价值不大),保住模型的简单性。如果业务必须支持,就要在水位之外额外维护一个「例外集合」——这会让复杂度显著上升,要慎重。面试时说清「我的模型依赖什么前提」,比声称模型万能强。

    草稿要不要多端同步,我们选了不同步。同步草稿体验更好(手机上打了一半的字网页能接着写),但草稿是高频变更的——用户每敲几个字就要同步一次,写入量和长连接消息量都会大幅上升,收益却很边缘。折中是只在切换会话或退出时保存一次草稿到服务端,不做实时同步。

    踩过的坑:已读上报被无条件覆盖,未读数变负数。两个端并发上报,先到的是 seq 150、后到的是 seq 120(网络乱序),无条件覆盖后 read_seq 变成 120,而客户端本地认为已读到 150,界面上出现「未读 -30」。修法就是那条条件更新 where read_seq < 新值。另外未读数在展示层也要做下限保护(小于 0 就显示 0),双重保险。

    踩过的坑二:删除会话在多端表现不一致。用户在手机删了会话,网页上还在;而且删除后如果对方又发来消息,手机上会话又出现了(这是对的),但被删除前的历史消息该不该恢复显示,两个端行为不同。修法是明确定义删除语义——我们定的是「删除只是隐藏会话并把已读水位推到最新,历史消息保留,新消息到达时会话重新出现且不显示旧消息」,然后两端严格按这个定义实现这个坑的本质是产品语义没定清就各自实现,不是技术问题。

    没做的部分:没做「对方已读」(回执)功能。它需要把接收方的已读水位反向同步给发送方,技术上不难但涉及隐私(有些用户不希望别人知道自己已读),需要开关和产品设计,当时没做。

    数字是怎么测的

    多端已读同步延迟:两台设备同时登录同一账号,在 A 上标记已读,量 B 上红点消失的时间。用同一台机器的两个客户端或者时钟同步过的两台设备量,否则时钟偏差会污染结果。约 1 秒上下(主要是长连接推送的延迟)。

    会话列表响应体大小:抓包对比全量与增量两种模式的响应字节数。要说清测试条件——会话总数多少、其中变更的有几个。「下降一个数量级」是在「几百个会话、只有几个有变更」这个典型场景下的结果,如果所有会话都变了增量就没有优势。给出条件的数字才可信。

    未读数准确性:造场景验证——发 N 条消息、读一部分、撤回一条、断网重连,检查未读数是否符合预期。这类正确性用确定性用例覆盖,不用比率。定时校准发现的偏差记录数可以作为线上指标,用绝对数报。

    不要报「已读同步准确率 100%」。异步链路不可能保证,而且这个指标怎么统计说不清。用「偏差由定时校准收敛」加上「校准发现的偏差量级」来描述

    面试追问
    Q:已读状态为什么不逐条存?逐条存不是更准确吗? A:更准确但代价不成比例。写放大:滑过一屏 20 条就是 20 次更新,会话列表刷新可能几百次写。存储放大:每条消息在每个端都要一条已读记录,三个端就是三倍。未读数计算慢:要 count where is_read = 0,会话消息一多就慢。而水位模型只用一个数字就表达了同样的信息,因为用户阅读消息是从旧到新连续的,不会跳着读——这个业务前提让「读到哪了」这一个位点足够描述全部已读状态。代价是不支持「标记某条为未读」这类跳跃操作,如果产品要这个功能就得在水位之外加例外集合,复杂度会上一个台阶。我们和产品确认过不需要,所以选了简单模型。
    Q:用户在手机上读了,网页上的红点怎么消失? A:手机上报已读后,服务端更新 read_seq,然后通过长连接把已读事件推给该用户的所有其他在线端——路由表里能查到这个用户还有哪些连接。网页端收到「会话 X 已读到 seq 120」后,本地把该会话未读数清掉并更新自己的水位。如果网页端当时不在线,它下次上线走会话列表增量同步时会拿到最新的 read_seq,同样能正确显示。关键点是已读水位存在服务端而不是各端本地,服务端是唯一真相,各端只是它的缓存。
    Q:两个端同时上报已读,一个说读到 150 一个说读到 120,怎么办? A:取最大值,用条件更新实现update ... set read_seq = ? where read_seq < ?。这样无论到达顺序如何,最终水位都是较大的那个,而且晚到的小值不会把水位拉回去。这是必须做的——我们踩过坑,无条件覆盖会导致未读数变负数(客户端本地以为读到 150,服务端被改回 120,两边一减就是负数)。另外展示层要做下限保护,未读数小于 0 一律显示 0,作为双重保险。「只许前进的状态用条件更新」是分布式状态同步的通用手法,不止用在已读上。
    Q:消息被撤回了,未读数怎么算? A:这是水位模型的一个真实边界问题。撤回后消息的 seq 还占着位置(不能删,否则 seq 不连续,整个减法就废了),所以 last_seq - read_seq 会把撤回的消息也算成未读,用户看到未读 1、点进去发现没有新消息。两种处理:一是单独维护该会话「已撤回且在未读区间内的消息数」,从未读数里减掉,准确但要额外维护;二是接受这个误差,因为撤回本身是低频操作,而且用户点进去就自动清零了,影响很小。我们选了第二种,但在会话列表的摘要上做了处理——如果最新消息被撤回,摘要显示上一条未撤回的消息,避免列表里显示「对方撤回了一条消息」这种无信息量的内容。能说出「这里有误差、误差多大、为什么可以接受」,比假装没问题好。

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

项目拆解 · 消息与长连接(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据