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

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

项目背景设定 社区的私信与聊天客户端,uni-app 一套代码打 App 与小程序。做的是端上那一半:长连接的建立与保活、消息的本地落盘与上屏、断连恢复、聊天页性能。服务端那一半(网关分片、写扩散、离线存储)在 社区 · 后端 · 消息与长连接 那一页,两页可以对照着看,面试里被追问「那服务端怎么配合」时能接上。
为什么选这三个模块 IM 客户端是整个题库里对「不可靠」容忍度最低的端。别的客户端请求失败了重试一次就行,IM 是服务端主动推,所以「连接到底还活着吗」变成端上必须自己维护的状态;而且消息少一条、多一条、顺序错了用户立刻就能看见,没有任何蒙混过关的空间。三个模块正好对应三层:连接层(怎么知道断了、怎么恢复、连不上怎么兜底)、数据层(不丢、不重、发现漏了能补回来)、渲染层(几千条历史加高频更新怎么不卡)。

模块一:长连接的建立与保活

  1. 长连接的建立与保活(心跳判活 + 假连接检测 + 退避重连 + 降级兜底)★★★
    简历这样写 IM 客户端的长连接生命周期管理(uni-app 全局单例 WebSocket + 应用层心跳判活 + 假连接检测 + 指数退避加抖动重连 + 前后台与网络切换重建 + 轮询降级兜底):把长连接收敛为应用级单例(小程序端有并发 socket 上限,页面各自建连会直接失败),以应用层心跳而非 socket 状态作为判活依据,连续丢失心跳响应即判定假连接并主动重建;重连采用指数退避加随机抖动,并按断开原因区分「该重连」与「不该重连」(被踢下线、凭证失效不重连);回前台与网络类型变化时立即探活或直接重建,长连接持续不可用时降级为轮询并在界面明确提示。断连发现时长由不可预期收敛到心跳间隔与失败阈值决定的确定区间,「杀进程重开才收到消息」的反馈改造后未再出现
    展开完整拆解
    为什么要这么设计

    IM 客户端和普通业务客户端有一个根本区别:普通页面是「我问你答」,连接断了下次请求自然会重连,断连这件事根本不需要感知;IM 是服务端主动往下推,连接一断,用户就静默地收不到任何消息,而界面上什么异常都看不出来。所以「连接到底还活着吗」从一个不存在的问题变成了端上必须自己维护的核心状态。

    第一版就是 uni.connectSocket 连上、监听 onMessage 就完事,上线后问题一个接一个。

    一是假连接,这是最隐蔽也最致命的。移动网络下运营商的 NAT 会话超时、中间设备静默丢弃连接,TCP 层面收不到 FIN,客户端的 socket 状态还是 open,但消息再也收不到了。用户的反馈是「消息要杀进程重开才收到」。这说明只监听 onClose 来触发重连是不够的——很多断开根本不会触发 onClose

    二是前后台切换。App 切到后台,系统会挂起 JS 线程,心跳定时器停摆;回到前台时 socket 状态显示还是 open,实际早就断了。小程序端切后台一段时间会被平台主动断开。「回前台后假设连接可用」是错的。

    三是网络切换。从 Wi-Fi 切到蜂窝网络,底层 socket 已经失效,但断开事件不一定及时到达,甚至根本不到。

    四是重连风暴。断开后立刻重连,服务端发版或抖动时所有客户端在同一时刻一起重连,把网关压满,反而让恢复更慢。

    五是小程序的并发 socket 上限。微信小程序对同时存在的 WebSocket 连接数有限制,早期每个聊天页各自建一条连接,打开几个会话之后新连接直接失败

    所以四个设计:连接做成应用级单例用应用层心跳判活而不信 socket 状态退避加抖动重连并区分断开原因持续不可用时降级轮询兜底

    整体链路
    连接建立(「连上」和「可用」是两件事) │ ├─ 先用短连接换一次性 ticket │ 不要把长期凭证放在 ws 的 url 或 header 里反复传 │ ticket 短时效、一次性,泄露了危害有限 │ ├─ uni.connectSocket,url 带 ticket │ └─ 收到服务端的鉴权成功帧,才把状态置为「可用」 onOpen 只代表通道建立,鉴权没过服务端不会推任何消息 这两个状态混在一起,就会出现「以为连上了却收不到消息」 保活(判活只能靠应用层心跳,不能信 socket 状态) │ ├─ 前台每 N 秒发一次 ping,服务端回 pong │ ├─ 连续 M 次没等到 pong → 判定假连接 │ 主动 close 掉这条死连接,再走重连流程 │ → 这一步是解决「杀进程才收到消息」的关键 │ ├─ 收到任何业务消息也算连接活着,可顺延下一次 ping │ 消息很密的时候没必要再发心跳,省电省流量 │ └─ 心跳间隔跟随前后台状态 前台密、后台稀疏或直接停(反正也收不到) 重连(必须退避,必须抖动,必须区分原因) │ ├─ 指数退避:1s → 2s → 4s → 8s,封顶不再增长 │ ├─ 叠加随机抖动 │ 否则服务端恢复的那一瞬间所有端同时冲进来,二次打挂 │ ├─ 重连成功后立刻按各会话 lastSeq 拉增量(见模块二) │ 连接恢复不等于数据恢复,断连期间的消息要补回来 │ └─ 按断开原因决定要不要重连 网络异常 / 假连接 → 重连 被其他端踢下线 → 不重连,转登录态处理 凭证失效 / 账号被封 → 不重连,否则无限循环打服务端 前后台与网络切换(uni 的平台差异集中在这里) │ ├─ onShow:立刻发一次 ping 探活,不等下一个心跳周期 │ 回前台第一件事是「确认」连接可用,而不是「假设」 │ ├─ onHide:App 端降频心跳,小程序端准备被平台断开 │ ├─ onNetworkStatusChange:网络类型变化直接重建连接 │ 不等心跳超时,因为切网后旧连接基本必死 │ └─ 冷启动、长时间后台返回 → 一律当全新连接处理 不要试图复用一条来历不明的旧连接 兜底(长连接不可用时业务不能跟着瘫) ├─ 连续重连失败超过阈值 → 降级为轮询拉消息,间隔拉长 ├─ 降级期间界面明确提示「连接不稳定」,不假装一切正常 ├─ 长连接恢复后自动切回并停掉轮询 └─ 发送走 HTTP 兜底,保证「至少发得出去」 连接状态只有一个来源 ├─ 状态收敛到全局 store:连接中 / 可用 / 重连中 / 已降级 ├─ 页面只订阅,不各自持有连接、不各自判断 └─ 长连接是应用级资源,不是页面级资源
    分步拆解
    1. 先把连接收敛成应用级单例,这是前提。小程序端有并发 socket 上限,每个聊天页各建一条,打开几个会话后新连接直接失败。连接是应用级资源不是页面级资源,页面只订阅事件。
    2. 握手用一次性 ticket,不用长期凭证。ws 的 url 会出现在日志和抓包里,长期 token 反复传递的暴露面太大。ticket 短时效、一次性,泄露的危害有限。
    3. 把「连上」和「可用」拆成两个状态。onOpen 只代表通道建立,要收到服务端的鉴权成功帧才算可用。两个状态混在一起就会出现「以为连上了却一直收不到消息」。
    4. 心跳是判活的唯一依据,绝不信 socket 状态。移动网络下 NAT 超时、中间设备静默丢连接,TCP 层面没有 FIN,socket 状态还是 open 但消息收不到了。这就是假连接。
    5. 连续 M 次收不到 pong 就主动 close 再重连。不要等平台给事件,平台不会给。这一步直接解决「杀进程重开才收到消息」。
    6. 收到业务消息可以顺延下一次心跳。消息密集时再发心跳是纯浪费。把「有数据往来」也算作连接活着的证据。
    7. 心跳间隔跟随前后台状态调整。前台密、后台稀疏或直接停——后台本来也收不到,维持高频心跳只是耗电。
    8. 重连必须指数退避并叠加随机抖动。退避防止死循环打服务端,抖动防止服务端恢复瞬间所有端同时冲进来把它二次打挂
    9. 按断开原因区分该不该重连。网络异常和假连接要重连;被踢下线、凭证失效、账号被封绝不能重连,否则是一个无限循环,既打服务端又耗用户电量。
    10. onShow 立刻探活,不等下一个心跳周期。回前台第一件事是确认连接可用,而不是假设它可用。用户回到前台的第一秒就是他最想看到新消息的时刻。
    11. 网络类型变化直接重建,不等心跳超时。切网后旧连接基本必死,等心跳超时是白白多等几秒。
    12. 重连成功后立刻拉增量。连接恢复不等于数据恢复,断连期间对方发的消息还在服务端,必须主动补(见模块二)。
    13. 降级轮询要有,而且要让用户知道。长连接连不上时降级为轮询,间隔拉长;界面明确提示「连接不稳定」——静默降级会让用户以为没人给他发消息。
    14. 连接状态收敛到全局 store 单一来源。连接中 / 可用 / 重连中 / 已降级四态,页面只订阅。状态分散在各页面自己判断,最后一定会互相矛盾。
    关键决策与取舍

    心跳间隔是一个纯粹的权衡,没有正确答案。间隔短:断连发现快,但耗电耗流量,弱信号下频繁唤醒射频模块的电量代价很明显;间隔长:省电,但断连后用户可能几十秒收不到消息还不知道。我们的做法是前台用较密的间隔、后台大幅降频、并且让业务消息顺延心跳,等于在「有人正在聊」的时候判活最灵敏,在挂着不用的时候最省电。面试里被问到这个,关键不是报一个秒数,而是说清你在权衡什么。

    为什么用 WebSocket 而不是 SSE 或纯轮询。SSE 在小程序端不可用,直接排除;纯轮询做不到实时且空轮询浪费严重。WebSocket 是 uni-app 多端里支持一致性最好的选择。但轮询没有完全丢掉——它作为长连接不可用时的降级路径保留着,这两者不是二选一,是主路径和兜底路径的关系

    踩过的坑:只监听 onClose 触发重连,假连接下永远不重连。用户反馈「消息要杀进程重开才收到」,查了很久才定位到连接在运营商侧被静默丢弃,TCP 没有 FIN,客户端的 socket 状态一直是 open,我们的重连逻辑挂在 onClose 上,所以永远不触发。修法是加应用层心跳,连续丢失响应就主动 close 再重连教训是:不能信任底层状态上报,要用应用层探针主动验证——这条在长连接、数据库连接池、RPC 长链接上都成立,凡是「连接复用」的场景都有假连接问题。

    踩过的坑二:每个聊天页各建一条连接,小程序端超上限后新连接静默失败。用户打开几个会话之后,后面的会话收不到任何消息,而且没有任何报错(连接创建失败没有被处理)。修法是连接收敛为应用级单例,页面只订阅事件教训是:平台的资源配额必须提前查清楚,把应用级资源当页面级资源用,迟早撞上限。

    踩过的坑三:断线立刻重连,服务端发版时被客户端二次打挂。网关滚动重启,所有在线客户端在同一瞬间检测到断开并立即重连,重连请求量远超正常连接量,刚起来的实例又被打挂,形成拉锯。修法是指数退避加随机抖动教训是:客户端的重试策略是服务端容量的一部分,端上不做退避,服务端的优雅重启就无从谈起。

    踩过的坑四:被踢下线后无限重连。另一台设备登录把这台踢了,客户端把踢下线当成普通断开,立刻重连、鉴权、又被踢、再重连,几秒一轮直到用户杀进程。修法是服务端在踢下线时下发明确原因,客户端按原因决定不重连教训是:断开原因必须结构化下发,「断开」这一个事件不足以决定下一步动作。

    没做的部分:没做消息的端到端加密。私信场景有这个需求,但涉及密钥协商、多端密钥同步、历史消息在换设备后的可读性,链路很长,当时优先级排在后面。

    数字是怎么测的

    断连发现时长:这个数不是「实测平均值」,而是由心跳间隔乘以失败阈值决定的设计上界,要这样说才准确。验证方式是人为制造假连接:用工具把 TCP 连接静默丢弃(只丢包不发 RST/FIN,模拟运营商 NAT 超时),然后计时到客户端判定断开并完成重连。关键是必须造「静默丢弃」而不是「直接断网」——直接断网会触发平台的网络变化事件,走的是另一条快速路径,测不到假连接这条路。

    重连恢复时长:多轮人为断网,记录从恢复网络到「连接可用且增量拉取完成」的时长,取中位数。要报的是「可用且数据补齐」而不是「socket 连上」——后者对用户没有意义。

    「杀进程才收到消息」这类反馈:这是个定性结果,诚实的表述是「改造后未再出现该类反馈」,而不是编一个「假连接率下降 N%」——假连接在改造前根本没有被度量,没有基线就没有比率。

    后台耗电:同一机型对照,App 挂后台一小时的电量差,改造前后各测几轮。必须写清机型和系统版本,因为不同厂商的后台策略差异极大,一个数字脱离机型没有意义。

    不要报「消息到达率 100%」或「长连接稳定性 99.99%」。端上根本统计不到「服务端发了但我没收到」的那部分——你不知道的消息你也统计不到,这个指标在客户端天然算不准正确表述是「心跳判活的发现上界是多少、重连的恢复时长是多少、降级路径覆盖了哪些场景」,都是可核查的。

    面试追问
    Q:客户端怎么知道长连接断了? A:只能靠应用层心跳,不能信 socket 状态。因为存在假连接——移动网络下运营商 NAT 会话超时、中间设备静默丢弃连接,TCP 层面收不到 FIN,客户端的 socket 状态还是 open,但消息再也收不到。我们踩过这个坑:重连逻辑挂在 onClose 上,而这种断开永远不触发 onClose,用户的反馈是「消息要杀进程重开才收到」。修法是前台每 N 秒发 ping,连续 M 次收不到 pong 就判定假连接,主动 close 再走重连。两个优化:收到任何业务消息也算连接活着(可顺延下一次心跳,消息密集时省电);心跳间隔跟随前后台状态(后台大幅降频,反正也收不到)。教训是不能信任底层状态上报,要用应用层探针主动验证——这条在数据库连接池、RPC 长链接上同样成立,凡是连接复用的场景都有假连接问题。
    Q:App 切到后台再回来,怎么处理连接? A:核心原则是回前台要「确认」连接可用,而不是「假设」它可用。因为切后台时系统会挂起 JS 线程,心跳定时器停摆,回来时 socket 状态显示 open 但可能早就断了;小程序端切后台一段时间还会被平台主动断开。具体做法:onShow 立刻发一次 ping 探活,不等下一个心跳周期(用户回前台的第一秒正是他最想看新消息的时刻);onHide 时 App 端降频心跳,小程序端准备被断开;冷启动或长时间后台返回一律当全新连接处理,不复用来历不明的旧连接。另外网络类型变化(Wi-Fi 切蜂窝)直接重建连接,不等心跳超时——切网后旧连接基本必死,等超时是白等几秒。还有一步不能忘:连接恢复后要立刻按 lastSeq 拉增量,连接恢复不等于数据恢复。
    Q:重连要注意什么? A:三件事。一是必须指数退避加随机抖动(1s → 2s → 4s → 8s 封顶)。我们踩过坑:网关滚动重启时所有在线客户端同一瞬间检测到断开并立即重连,重连量远超正常连接量,刚起来的实例又被打挂,形成拉锯。退避防死循环,抖动防「服务端恢复瞬间所有端同时冲进来」教训是客户端的重试策略是服务端容量的一部分,端上不退避,服务端的优雅重启就无从谈起。二是必须按断开原因区分该不该重连:网络异常和假连接要重连,被踢下线、凭证失效、账号被封绝不能重连。这个也踩过——另一台设备登录把这台踢了,客户端当普通断开处理,立刻重连、鉴权、又被踢、再重连,几秒一轮直到用户杀进程。所以断开原因必须结构化下发,光一个「断开」事件不足以决定下一步。三是重连成功后立刻拉增量补齐断连期间的消息。
    Q:长连接一直连不上,业务怎么办? A:降级为轮询,并且明确告诉用户。连续重连失败超过阈值后切换到轮询拉消息(间隔拉长以控制服务端压力),发送走 HTTP 兜底保证「至少发得出去」,长连接恢复后自动切回并停掉轮询。关键是界面要明确提示「连接不稳定」——静默降级最坏,用户会以为「没人给我发消息」,而实际是他的消息延迟了几十秒。这里体现的判断是:长连接和轮询不是二选一,是主路径和兜底路径的关系。为什么不用 SSE 做兜底?SSE 在小程序端不可用,多端一致性上直接排除了。另外连接状态要收敛到全局 store 的单一来源(连接中 / 可用 / 重连中 / 已降级四态),页面只订阅——状态分散在各页面自己判断,最后一定互相矛盾,界面上就会出现一个页面显示在线另一个显示离线。

模块二:消息不丢不重与空洞补齐

  1. 消息不丢不重与空洞补齐(本地先落盘 + clientMsgId 幂等 + seq 空洞检测与二次拉取)★★★
    简历这样写 IM 客户端的消息一致性方案(本地先落盘再上屏 + clientMsgId 幂等重发 + 服务端 seq 单调序 + 空洞检测与区间二次拉取 + 断连后按 lastSeq 增量对齐 + readSeq 位点式已读):发送走先写本地库、再乐观上屏、最后发网络,超时重发复用同一 clientMsgId 由服务端幂等去重;接收以服务端 seq 而非客户端时间作为唯一顺序与完整性依据,检测到 seq 空洞即按缺失区间主动二次拉取补齐,重连后按各会话 lastSeq 增量对齐而非全量拉取;已读用会话级 readSeq 位点替代逐条已读标记,未读数由 maxSeq 减 readSeq 本地计算,多端取较大值单调收敛。断网期间的消息在确定性用例下可完整补齐且 seq 无残留空洞,弱网重发不再产生重复条目,切页面返回后发送中的消息不再丢失
    展开完整拆解
    为什么要这么设计

    IM 对正确性的要求比一般业务高一个档次,原因很直接:错误是用户可见的。库存少算一个用户看不出来,消息少一条、多一条、顺序颠倒,用户立刻就发现了,而且会截图发到群里。

    而客户端要同时面对三个不可靠:网络不可靠(随时断、随时慢)、进程不可靠(随时被系统杀)、多端并存(手机和 Pad 同时登录)。第一版的实现几乎每一条都踩了。

    一是发送中的消息会凭空消失。点了发送,网络卡住,用户切到别的页面再回来,那条消息不见了——因为它只存在于内存里的列表,页面重建就没了。用户以为发出去了,对方根本没收到。

    二是重发产生重复。网络超时后重发,其实第一条服务端已经收到了,结果会话里出现两条一模一样的消息

    三是漏消息,而且完全不知道自己漏了。断连期间对方发的消息,重连后没有任何机制去补——客户端不知道「我不知道什么」。用户是过了很久点进会话才发现中间少了一段。

    四是顺序错乱。早期用客户端时间排序,某个用户的手机系统时间快了一天,他发的所有消息永远排在会话最上面,谁都看不懂发生了什么。

    五是多端已读不一致。手机上读完了,Pad 上还是一堆红点。逐条消息存已读标记的方案在多端下根本收敛不了。

    所以这一层的设计围绕四个点:本地先落盘再上屏(解决消失)、clientMsgId 幂等(解决重复)、服务端 seq 作为唯一顺序与完整性依据(解决顺序和漏消息)、readSeq 位点式已读(解决多端)。

    整体链路
    发送(顺序很关键:先落盘,再上屏,最后才发网络) │ ├─ 生成 clientMsgId(端上唯一,幂等与去重都靠它) │ ├─ 写本地库:status = 发送中 │ 用本地临时序占位,排在会话末尾 │ → 必须先落盘。用户已经看到的东西不能只在内存里 │ ├─ 立刻上屏(乐观渲染,带明确的「发送中」态) │ ├─ 通过长连接发出;长连接不可用时走 HTTP 兜底 │ ├─ 收到服务端 ACK │ 带回 serverMsgId + seq + 服务端时间 │ 回填本地记录,status = 已发送,按 seq 重排到正确位置 │ └─ 超时未收到 ACK → status = 失败,界面给重发入口 重发复用同一个 clientMsgId → 服务端据此幂等,第一条已收到就不会产生第二条 接收(seq 是唯一可信的顺序与完整性依据) │ ├─ 每个会话的消息带服务端生成的单调递增 seq │ ├─ seq == 本地 lastSeq + 1 → 连续,直接落库上屏 │ ├─ seq > lastSeq + 1 → 中间有空洞 │ 按区间 [lastSeq+1, seq-1] 主动拉一次(二次拉取) │ 补齐后统一按 seq 排序渲染 │ → 这是「知道自己漏了什么」的唯一手段 │ ├─ seq <= lastSeq → 重复推送,直接丢弃 │ └─ 去重用双键 serverMsgId 已存在 → 丢弃 clientMsgId 命中本地记录 → 更新那条而不是新增 → 自己发的消息被服务端回推时,要认出「这就是我刚发的」 断连恢复(增量对齐,不做全量) │ ├─ 重连成功后带各会话 lastSeq 请求增量 ├─ 服务端返回缺失区间的消息 + 还有没有更多的标记 │ ├─ 缺口太大(离线很久)→ 只拉最近 N 条,标「有更早的消息」 │ 不要一次拉几千条,客户端会直接卡死 │ └─ 会话列表走单独一条增量接口 不要靠遍历所有会话逐个拉,会话多了就是几十个请求 时间与顺序(客户端时间是不可信输入) ├─ 排序一律用服务端 seq ├─ 展示的时间戳用服务端时间 └─ 客户端时间只用于「发送中」那一小段的临时占位排序 已读与多端(位点比标记好) │ ├─ 每个会话只存一个 readSeq,不给每条消息存已读标记 │ 上报的是一个数字,多端天然收敛 │ ├─ 未读数 = 该会话 maxSeq - readSeq │ 本地就能算出来,不必依赖服务端逐会话下发 │ └─ 多端同步 readSeq 时取较大值,保证单调不回退 → 手机上读到第 100 条,Pad 不能把它退回第 50 条
    分步拆解
    1. 发送的三步顺序不能变:先落盘、再上屏、最后发网络。先落盘是因为用户已经看到的东西必须能在进程重建后还在。第一版只写内存,用户切页面回来消息就没了。
    2. clientMsgId 在端上生成,是幂等和去重的锚。它要在消息第一次创建时就确定并落盘,重发时绝不能重新生成
    3. 乐观上屏可以,但状态必须是明确的三态。发送中 / 已发送 / 失败,不能一点发送就显示成已送达——那是把「我发出了」和「服务端收到了」混为一谈。
    4. ACK 要带回 serverMsgId、seq 和服务端时间三样。回填后按 seq 重排到正确位置。只回一个「成功」是不够的,那条消息还没有真正的身份和位置。
    5. 重发复用同一 clientMsgId,由服务端做幂等。这是不产生重复的根本手段。端上做去重只能治自己看到的重复,治不了服务端真的存了两条。
    6. 接收侧以服务端 seq 判断连续性。seq 等于 lastSeq + 1 就直接落库;大于则说明中间有空洞;小于等于则是重复推送直接丢。
    7. 发现空洞就按区间主动二次拉取。这是「知道自己漏了什么」的唯一手段——没有 seq,客户端连「我漏了消息」这件事都无法察觉
    8. 去重必须双键。serverMsgId 命中就丢弃;clientMsgId 命中就更新那条而不是新增——自己发的消息被服务端回推时要认出「这就是我刚发的那条」,否则会看到自己的消息出现两遍。
    9. 重连后按 lastSeq 拉增量,不做全量。连接恢复不等于数据恢复。增量的粒度是「每个会话的 lastSeq」,服务端返回缺失区间。
    10. 缺口太大时只拉最近 N 条并标记「有更早的消息」。离线三天回来一次拉几千条会把客户端卡死。宁可让用户手动往上翻,也不能首屏卡几十秒。
    11. 会话列表要有独立的增量接口。不要靠遍历所有会话逐个拉——几十个会话就是几十个请求,弱网下根本拉不完。
    12. 排序一律用服务端 seq,客户端时间只用于「发送中」的临时占位。客户端时间是用户可篡改的不可信输入
    13. 已读用会话级 readSeq 位点,不用逐条已读标记。上报一个数字,多端天然收敛;未读数由 maxSeq 减 readSeq 本地算出,不必依赖服务端逐会话下发。
    14. 多端同步 readSeq 取较大值,保证单调不回退。手机上读到第 100 条,Pad 上的旧位点不能把它退回第 50 条。
    关键决策与取舍

    为什么用 readSeq 位点而不是逐条已读标记。逐条标记的表达能力更强,但代价是一次已读要上报 N 条记录、多端合并时要逐条求并集、未读数要逐条统计。位点方案只需要一个数字:上报简单、多端取较大值就收敛、未读数一次减法算出来。代价是无法表达「跳读」(读了第 10 条但没读第 5 条),但 IM 场景里用户是顺序阅读的,这个能力用不上。这是一个典型的「用业务事实换实现简化」的决策,面试里值得主动讲。

    乐观上屏的边界要划清:上屏可以乐观,「已发送」这个状态不能乐观。先落盘再上屏保证了用户看到的东西不会丢,但那条消息必须显示为「发送中」而不是已送达。这和骑手端、审核台的判断是同一条线——客户端已经做出的动作可以乐观呈现,但需要服务端确认的结果不能乐观呈现。IM 里这一点尤其重要,因为「对方到底收到没有」是用户唯一真正关心的事。

    踩过的坑:重发时重新生成了 clientMsgId,服务端当成两条独立消息。弱网下用户连点几次重发,会话里出现三四条一样的消息,而且对方也收到了三四条。修法是clientMsgId 在消息创建时确定并落盘,重发只是把同一条重新发一次教训是:幂等键必须和「业务上的同一次操作」绑定,而不是和「本次网络请求」绑定——这两者的区别就是幂等做对和做错的分界线。

    踩过的坑二:用客户端时间排序,一个用户的手机时间快了一天。他发的所有消息永远排在会话最顶部,其他人完全看不懂对话在讲什么,还以为是 bug 在刷屏。修法是排序一律用服务端 seq,展示时间用服务端时间,客户端时间只用于发送中的临时占位教训是:凡是来自客户端的数据都是不可信输入,时间戳也不例外,任何跨用户比较的顺序都必须由服务端定。

    踩过的坑三:离线三天回来一次拉全量,客户端卡死几十秒然后崩溃。拉回来几千条消息,一次性落库加渲染直接把主线程占满。修法是缺口超过阈值就只拉最近 N 条并标记「有更早的消息」,其余按需往上翻。教训是:增量同步一定要设上界,「增量」不代表「量小」,离线时间越长增量越接近全量。

    没做的部分:没做消息的本地全文搜索。需要在端上建倒排索引并随消息增量维护,小程序端的存储和计算配额下收益不明显,当时改成了服务端搜索接口。

    数字是怎么测的

    不丢消息:用确定性场景验证,不要用比率。断网,让对端发 N 条,恢复网络后检查本地是否正好 N 条、seq 是否连续无空洞。这类正确性问题的正确表述是「构造了哪几个场景、每个场景的预期是什么、跑了多少轮无偏差」,而不是「消息到达率 99.99%」——端上统计不到「服务端发了但我没收到」的那部分,这个比率天然算不准。

    不重复:弱网下对同一条消息连续触发重发若干次,检查会话里只有一条,且对端也只收到一条。这个必须两端一起看,只看自己这端会漏掉「服务端真的存了两条」的情况。

    空洞补齐:人为丢弃中间的几条推送(或在断连窗口里让对端发消息),验证客户端能检测到 seq 不连续并把缺失区间补回来。这是模块里最该演示的一条,因为它证明的是「系统能发现自己的错误」。

    发送中消息不丢:发一条消息时立刻切页面/杀进程,重新进入后检查那条消息还在且状态是发送中或失败(有重发入口),而不是凭空消失。

    重连后首屏可用时长:从网络恢复到会话列表和当前会话都补齐完成的时长。要说明测试条件——离线多久、缺口多少条,因为缺口大小直接决定这个数。

    不要报「消息零丢失」「消息准确率 100%」。绝对化断言一问就穿,而且只要出一次就被推翻。正确表述是「seq 空洞检测 + 区间二次拉取 + 重连增量对齐三层,构造了哪些场景、多轮验证未出现残留空洞」——说清机制和验证方式,比给一个漂亮数字可信得多。

    面试追问
    Q:消息怎么保证不丢? A:分发送和接收两侧看,机制完全不同。发送侧靠「先落盘再上屏最后发网络」这个顺序——先落盘是因为用户已经看到的东西必须能在进程重建后还在。我们踩过坑:第一版发送中的消息只存在内存列表里,用户切页面再回来那条消息就凭空消失了,他以为发出去了但对方根本没收到。落盘后加超时判失败、给重发入口。接收侧靠服务端 seq——每条消息带单调递增 seq,客户端比对本地 lastSeq,发现不连续就按缺失区间主动二次拉取;重连后按各会话 lastSeq 拉增量。关键点是:没有 seq,客户端连「我漏了消息」这件事都无法察觉,这是整个方案里最本质的一环。另外增量必须设上界——我们踩过「离线三天回来一次拉几千条把客户端卡死」的坑,改成缺口超阈值只拉最近 N 条并标「有更早的消息」。
    Q:怎么保证消息不重复? A:核心是 clientMsgId 幂等,而且它必须在消息创建时就确定并落盘。重发只是把同一条消息重新发一次,绝不能重新生成 id。我们踩过这个坑:重发时生成了新的 clientMsgId,服务端当成两条独立消息,弱网下用户连点几次重发,会话里出现三四条一样的,对方也收到了三四条教训是幂等键必须和「业务上的同一次操作」绑定,而不是和「本次网络请求」绑定——这两者的区别就是幂等做对和做错的分界线。接收侧还要双键去重serverMsgId 已存在就丢弃;clientMsgId 命中本地记录就更新那条而不是新增——自己发的消息被服务端回推时要认出「这就是我刚发的那条」,否则用户会看到自己的消息出现两遍。另外 seq 小于等于 lastSeq 的推送直接丢,这能兜住服务端重复推送。
    Q:消息顺序用什么排?能用时间戳吗? A:一律用服务端 seq,客户端时间绝对不能用来排序。我们踩过一个很典型的坑:早期用客户端时间排序,某个用户的手机系统时间快了一天,他发的所有消息永远排在会话最顶部,其他人完全看不懂对话在讲什么,还以为是 bug 在刷屏。教训是凡是来自客户端的数据都是不可信输入,时间戳也不例外,任何跨用户比较的顺序都必须由服务端定。所以:排序用服务端 seq,展示的时间戳用服务端时间,客户端时间只用于「发送中」那一小段的临时占位排序(因为那条消息还没有 seq,ACK 回来后按真实 seq 重排到正确位置)。seq 还有第二个更重要的作用——它是完整性的依据,客户端靠 seq 连续性发现空洞,这是时间戳做不到的(时间戳不连续是正常的,你没法判断中间是否少了消息)。
    Q:多端已读怎么同步?未读数怎么算? A:用会话级的 readSeq 位点,不给每条消息存已读标记。每个会话只存一个数字表示「读到哪了」,多端同步时取较大值保证单调不回退(手机上读到第 100 条,Pad 上的旧位点不能把它退回第 50 条)。未读数 = 该会话 maxSeq - readSeq,本地一次减法就算出来,不必依赖服务端逐会话下发。为什么不用逐条已读标记:逐条方案表达能力更强,但一次已读要上报 N 条记录、多端合并要逐条求并集、未读数要逐条统计,成本高得多。位点方案的代价是无法表达「跳读」(读了第 10 条但没读第 5 条),但 IM 场景里用户是顺序阅读的,这个能力用不上——这是一个典型的用业务事实换实现简化的决策。顺带一个性能收益:未读数本地可算,所以会话列表刷新时不需要为未读数额外请求,这在会话很多时差别明显。

模块三:会话列表与聊天页的性能

  1. 会话列表与聊天页的性能(本地优先首屏 + 分段保留 + 高频更新收敛 + 存储治理)★★★
    简历这样写 IM 客户端的渲染与存储性能治理(本地缓存优先首屏 + 消息分段保留与稳定 key + 上翻锚点回滚 + 新消息合并渲染与单项更新 + 图片尺寸预存 + 分片存储与容量淘汰):冷启动先读本地库渲染再拉增量做局部更新,弱网下不再白屏;长会话不做完整虚拟列表而采用分段保留加稳定 key(消息高度不定,虚拟列表的高度预估在多端表现不稳,见「关键决策」);上翻加载历史用锚点消息回滚保持视觉位置不跳;高频新消息合并渲染并只更新变化的会话项,未读数由位点相减本地得出;图片消息预存缩略图尺寸避免加载后高度变化引起跳动;本地消息按会话分片存储并按活跃度与时间淘汰,写入超限可感知并触发清理。冷启动到会话列表可见由依赖网络改为依赖本地缓存,长会话连续上翻的内存占用可控且不随历史条数线性增长
    展开完整拆解
    为什么要这么设计

    IM 是客户端里最容易卡的页面类型,因为三件难事同时叠在一起:数据量大(一个活跃会话几千条历史)、更新极其频繁(来一条消息要同时更新会话列表、未读数、聊天页三处)、富媒体密集(图片语音视频混排,高度还都不一样)。任何一件单独出现都好办,叠在一起就很难。

    第一版的问题很集中。

    一是冷启动白屏。等网络拉回会话列表才渲染,弱网下白屏好几秒,用户以为 App 挂了。而其实本地数据库里明明有上次的会话列表。

    二是进入聊天页卡顿。一次把几百条历史全渲染出来,低端机上明显卡顿,而且滚动到底部的过程用户能看见列表在跳

    三是上翻加载历史后滚动位置跳走。触顶加载更早的一页,插入到列表头部后视觉位置整个跳掉,用户刚才在看的那条消息不知道去哪了,必须重新找。这个体验问题被反馈得最多。

    四是高频更新引起整列表重渲染。群里消息一密,会话列表每来一条就整体重排一次,加上未读数是逐条统计的,会话多了以后每条消息都要全量算一遍。

    五是本地存储爆掉。小程序端 storage 有硬容量上限,早期一个 key 存整个会话的消息,会话长了以后每次写入都要序列化几 MB,写入本身就卡;更糟的是写超限是静默失败的,用户重启后最近的消息全没了。

    六是图片消息导致列表跳动。图片加载完成后高度变化,整个列表重新布局,正在看的位置又跳了。

    所以这一层围绕四件事:本地优先首屏分段保留控制渲染与内存规模高频更新收敛存储分片与容量治理

    整体链路
    冷启动(本地优先,网络兜后) ├─ 先读本地库渲染会话列表,离线也能看 ├─ 同时发增量请求,回来做局部更新而不是整体替换 └─ 不做「等网络回来再渲染」,弱网下那就是几秒白屏 聊天页首屏 ├─ 只从本地取最近 N 条渲染,不是把全部历史铺开 ├─ 定位到底部用「初始就定位」而不是渲染完再滚动 │ 渲染完再滚,用户能看见列表往下窜的过程 └─ 同时按 lastSeq 补增量,补回来的按 seq 插入 历史消息上翻(位置不能跳,这是最影响体感的一条) │ ├─ 触顶加载更早的一页 │ ├─ 插入前记录锚点:当前顶部可见消息的 id 与其偏移 ├─ 插入后回滚到锚点位置 │ → 用户视觉上停在原处,只是上方多了内容 │ └─ 没有更早的消息时明确显示「没有更早的消息」 不要一直转圈,用户会以为卡住了 长列表(uni-app 的现实约束) ├─ 消息条目组件化,key 用 serverMsgId,绝不用数组下标 │ 用下标做 key,插入头部会导致整列表复用错乱 ├─ 分段保留:内存里只留当前若干段,翻太远的段卸载掉 └─ 不做完整虚拟列表,原因见「关键决策与取舍」 高频更新的收敛 ├─ 新消息合并渲染:短窗口内的多条合成一次更新 ├─ 会话列表只更新变化的那一项,不整列表重排 ├─ 未读数用 maxSeq 减 readSeq,不逐条统计 └─ 「对方正在输入」这类高频信令做节流,且不落盘 富媒体 │ ├─ 图片消息存缩略图地址 + 宽高 │ 尺寸提前存下来,占位框先按比例撑开 │ → 否则图片加载完高度变化,列表又跳一次 │ ├─ 列表里只加载缩略图,点开才加载原图 ├─ 语音按需下载并缓存,播完不立刻释放(用户常重听) └─ 视频只显示首帧,列表内不自动播放 本地存储治理(小程序端有硬上限,必须主动管) │ ├─ 消息按「会话 + 时间段」分片存 │ 不要一个 key 存整个会话,几 MB 的序列化会卡主线程 │ ├─ 只保留近期消息,更早的按需回源 ├─ 定期淘汰:按会话活跃度与时间清理 │ └─ 写入失败(超限)必须能感知 捕获失败 → 触发清理 → 重试 发送中的消息优先保证写入成功 → 静默失败最坏:用户重启后最近的消息全没了
    分步拆解
    1. 冷启动一定要本地优先。先读本地库渲染会话列表,同时发增量请求做局部更新。「等网络回来再渲染」在弱网下就是几秒白屏,而本地明明有上次的数据。
    2. 增量回来要做局部更新,不要整体替换。整体替换会让列表闪一下,而且丢掉滚动位置。
    3. 聊天页首屏只渲染最近 N 条。把几百条历史一次铺开在低端机上必然卡。历史靠上翻按需加载。
    4. 定位到底部要「初始就定位」,不要渲染完再滚。渲染完再滚动,用户能看见列表往下窜的过程,这是很明显的廉价感。
    5. 上翻加载必须做锚点回滚。插入前记录当前顶部可见消息的 id 与偏移,插入后回滚到该锚点。用户视觉上停在原处,只是上方多了内容——这一条是体感差异最大的优化。
    6. 没有更早的消息时要明确显示出来。一直转圈用户会以为卡住了,反复下拉反复触发请求。
    7. 列表 key 必须用 serverMsgId,绝不用数组下标。用下标做 key,往头部插入历史会导致整列表的组件复用错乱,表现是内容错位、图片串台。
    8. 长会话用分段保留控制内存。内存里只留当前若干段,用户翻太远就把远端的段卸载。这样内存占用不随历史条数线性增长。
    9. 新消息合并渲染。短时间窗口内的多条消息合成一次更新,避免一条一次渲染。群聊高峰时这个差别很明显。
    10. 会话列表只更新变化的那一项。不要整列表重排。排序变化(某会话跳到最前)单独处理成移动,而不是重建整个列表。
    11. 未读数用 maxSeq 减 readSeq,不逐条统计。逐条统计在会话多了以后每来一条消息就要全量算一遍,是典型的隐藏性能陷阱。
    12. 「正在输入」这类高频信令要节流,而且绝不落盘。它是纯瞬时状态,落盘既没意义又增加写入压力。
    13. 图片消息必须提前存宽高。占位框先按比例撑开,否则图片加载完成后高度变化,列表又跳一次。这一条和上翻锚点是同一类问题:任何会改变已渲染元素高度的事情都会引起跳动。
    14. 列表只加载缩略图,点开才加载原图。视频只显示首帧不自动播放,语音按需下载并缓存(播完不立刻释放,用户常重听)。
    15. 消息按「会话 + 时间段」分片存储。一个 key 存整个会话,会话长了以后每次写入都要序列化几 MB,写入本身就卡主线程
    16. 存储写入失败必须能感知并处理。捕获超限失败 → 触发清理 → 重试,发送中的消息优先保证写入成功。静默失败是最坏的结果:用户重启后最近的消息全没了。
    关键决策与取舍

    为什么不做完整的虚拟列表,这是这个模块最该讲清的决策。虚拟列表的前提是能预估每一项的高度,而 IM 消息的高度是不定的:一行文字和一段长文差十倍,图片按比例撑开,语音、视频、卡片各不相同。高度估错的直接后果就是滚动跳动和滚动条抽搐,而这恰恰是我们要解决的问题。加上 uni-app 多端的现实——小程序端的长列表回收组件在动态高度下表现不稳定,App 端和小程序端行为还不一致,一套代码要同时调好两端的成本很高。我们的选择是「分段保留 + 稳定 key」:内存里只保留当前若干段消息,翻太远的段卸载,这样内存不随历史条数线性增长,同时完全避开高度预估问题。取舍是:极长会话连续快速上翻时,卸载与重载会有可感知的加载态,但这比全程滚动跳动可接受得多。面试里这道题的价值在于说清「为什么不上更复杂的方案」,而不是证明自己会写虚拟列表。

    本地优先渲染的代价是可能先展示旧数据。用户看到的会话列表可能是几分钟前的状态,增量回来后才更新。缓解手段是增量做局部更新而不是整体替换,用户看到的是「个别条目变化」而不是「整个列表闪一下」。这个取舍是明确划算的——展示稍旧的真实数据远好过白屏,而且 IM 的会话列表本身就是一个不断变化的视图,用户对它有「随时在变」的心理预期。

    踩过的坑:未读数逐条统计,会话一多每条新消息都触发全量计算。表现是群聊活跃时整个会话列表卡顿,而且卡顿程度随会话数量增长。查下来是每来一条消息就遍历所有会话的所有未读消息重新计数。修法是改成 maxSeq 减 readSeq 的位点相减教训是:任何「随数据量增长的重复计算」都要提前设计成增量或常量代价的形式,尤其是挂在高频事件上的计算。

    踩过的坑二:一个 storage key 存整个会话,写入卡主线程。活跃会话攒到几千条后,每收一条新消息都要把整个会话序列化再写一遍,几 MB 的 JSON 序列化直接卡住主线程,表现是收消息时界面掉帧。修法是按「会话 + 时间段」分片,新消息只写最新的那一片。教训是:本地存储的读写粒度要和更新粒度对齐,用大颗粒存高频更新的数据,成本是乘法级的。

    踩过的坑三:storage 写超限静默失败,用户重启后最近的消息全没了。小程序端 storage 到达容量上限后写入失败,而我们没有检查写入结果,所以一切看起来正常——直到用户重启,那些「以为存下来了」的消息全部消失。修法是捕获写入失败、触发清理、重试,并且给发送中的消息最高优先级教训是:有配额上限的资源,写入结果必须检查,「静默失败」比「明确报错」危险得多,因为它推迟了问题暴露的时间。

    踩过的坑四:图片消息没有预存尺寸,加载完成后列表跳动。用户正在看的位置因为上方某张图片加载完成撑高而整个跳走。修法是消息里预存缩略图宽高,占位框先按比例撑开教训是:任何会改变已渲染元素高度的异步过程都会引起跳动——这和上翻加载历史是同一类问题,解决思路都是「让高度提前确定」。

    没做的部分:没做本地全文搜索。需要在端上建倒排索引并随消息增量维护,小程序端的存储与计算配额下收益不明显,改成了走服务端搜索接口。

    数字是怎么测的

    冷启动到会话列表可见:埋点记录启动到列表首次渲染完成的时长。必须区分两个场景分别报——「本地有缓存」(走本地优先路径)和「首次安装无缓存」(只能等网络)。只报有缓存的那个数字是选择性呈现,会被追问穿。

    聊天页首屏可交互时长:从点击会话到列表可滚动的时长。要说明会话的历史条数和消息类型构成(纯文本会话和图片密集的会话差别很大),脱离这些条件的数字没有意义。

    上翻加载的位置保持:这一条是确定性验证而不是数字——构造场景:在长会话中滚到中部,记住某条消息的位置,触发上翻加载,检查该消息仍在视觉上的同一位置。改造前每次都跳,改造后不跳。这类体验问题用「能不能复现」描述比用毫秒数描述更有说服力。

    长会话内存占用:连续上翻到 N 条时的内存占用,对比分段保留开启前后。关键是要说明它「不随历史条数线性增长」这个性质,而不只是给一个峰值数字。必须写清机型,低端机才是真正的约束条件。

    存储治理:可以报本地存储的占用量、清理触发的频率、以及写入失败被捕获并成功恢复的次数。最后这个数字尤其值得报——它证明这条兜底路径是真的在生效,而不是写了没跑过

    不要报「列表帧率稳定 60fps」。帧率高度依赖机型、会话长度、消息类型构成,而且「稳定」这个词一问就穿(快速滚动时必然有掉帧)。正确表述是「在哪个机型、多长的会话、什么消息构成下,滚动的掉帧情况如何」,并且主动说出最差的场景。也不要报「性能提升 N 倍」——首屏、滚动、内存是三个不同的指标,混成一个倍数只会让人怀疑。

    面试追问
    Q:一个几千条历史消息的聊天页,怎么做到不卡? A:分三层。首屏只从本地取最近 N 条渲染,不把全部历史铺开,历史靠上翻按需加载;定位到底部用「初始就定位」而不是渲染完再滚动(后者用户能看见列表往下窜)。长会话用分段保留控制内存——内存里只留当前若干段,翻太远的段卸载,这样内存占用不随历史条数线性增长。列表 key 必须用 serverMsgId 绝不用数组下标,用下标做 key 往头部插入历史会导致组件复用错乱,表现是内容错位、图片串台。富媒体上:列表只加载缩略图、点开才加载原图,视频只显示首帧不自动播放。还有一个容易漏的关键点:图片消息必须预存宽高让占位框先按比例撑开,否则图片加载完高度变化会让列表跳一次——任何会改变已渲染元素高度的异步过程都会引起跳动,这是同一类问题的统一解法。
    Q:uni-app 里虚拟列表怎么做?有什么坑? A:我的结论是这个场景不做完整虚拟列表,这是个有意识的取舍。虚拟列表的前提是能预估每一项的高度,而 IM 消息高度是不定的:一行文字和一段长文差十倍,图片按比例撑开,语音、视频、卡片各不相同。高度估错的直接后果就是滚动跳动和滚动条抽搐——而这恰恰是我们要解决的问题,等于用一个新问题换掉旧问题。加上 uni-app 的现实约束:小程序端的长列表回收组件在动态高度下表现不稳定,而且和 App 端行为不一致,一套代码同时调好两端的成本很高。我们的方案是「分段保留 + 稳定 key」:内存只保留当前若干段,翻太远的段卸载。取舍是极长会话连续快速上翻时,卸载重载会有可感知的加载态,但这比全程滚动跳动可接受得多。这道题我认为考的是「知不知道什么时候不该上复杂方案」,而不是会不会写虚拟列表。
    Q:上翻加载历史消息,怎么让滚动位置不跳? A:锚点回滚。插入前记录当前顶部可见消息的 id 和它的偏移量,把更早的一页插入到列表头部后,把滚动位置回滚到那个锚点——用户视觉上停在原处,只是上方多了内容。这一条是体感差异最大的优化,改造前每次上翻位置都跳走,用户刚才在看的那条消息不知道去哪了,得重新找,这个问题被反馈得最多。配套还要做两件事没有更早的消息时明确显示「没有更早的消息」(一直转圈用户会以为卡住,反复下拉反复触发请求);图片要预存尺寸,否则回滚之后上方的图片陆续加载完成,高度一变位置又跳了——锚点回滚和图片预存尺寸必须一起做,只做前者会被后者破坏。验证方式是确定性的:滚到中部记住某条消息位置,触发加载,检查它是否还在视觉上的同一位置。
    Q:本地存储满了怎么办? A:这个我们踩过一个很疼的坑:小程序端 storage 到达容量上限后写入失败,而我们没有检查写入结果,所以一切看起来正常——直到用户重启,那些「以为存下来了」的消息全部消失。教训是有配额上限的资源,写入结果必须检查,「静默失败」比「明确报错」危险得多,因为它把问题暴露推迟到了最坏的时刻。修法三层:捕获写入失败 → 触发清理 → 重试,并且给发送中的消息最高写入优先级(那是用户唯一无法容忍丢失的数据);按会话活跃度与时间做淘汰,只保留近期消息,更早的按需回源;按「会话 + 时间段」分片存储。分片这一点还解决了另一个坑——早期一个 key 存整个会话,攒到几千条后每收一条新消息都要把整个会话序列化再写一遍,几 MB 的 JSON 序列化直接卡主线程教训是本地存储的读写粒度要和更新粒度对齐,用大颗粒存高频更新的数据,成本是乘法级的。
    Q:一条新消息来了要同时更新会话列表、未读数、聊天页,怎么不让它卡? A:三个收敛手段。一是新消息合并渲染:短时间窗口内的多条消息合成一次更新,不要一条一次渲染,群聊高峰时差别很明显。二是会话列表只更新变化的那一项,不整列表重排;排序变化(某会话跳到最前)单独处理成一次移动,而不是重建整个列表。三是未读数用位点相减而不是逐条统计——这是我们踩过的坑:早期逐条统计,每来一条消息就遍历所有会话的所有未读消息重新计数,表现是群聊活跃时整个列表卡顿,而且卡顿程度随会话数量增长。改成 maxSeq - readSeq 之后是一次减法。教训是任何「随数据量增长的重复计算」都要提前设计成增量或常量代价的形式,尤其是挂在高频事件上的计算。另外「对方正在输入」这类高频信令要节流,而且绝不落盘——它是纯瞬时状态,落盘既没意义又增加写入压力。

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

项目拆解 · 私信与聊天(uni-app)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据