新消息到达时,已读为什么不能直接清零?已读位置与单调更新

简介: 本文剖析消息已读状态管理的核心陷阱:清零请求易误删新消息,旧回执乱序到达可能导致已读回退。提出以`seenThrough`标识连续已读前缀,并用`Math.max`单调更新,结合计数资格控制,确保未读统计准确可靠。(239字)

页面显示了消息 1、2、3,用户刚读完,消息 4 又到了。如果已读请求只说“把未读数设成 0”,服务端就可能连尚未展示的消息 4 一起清掉。

另一个方向也会出错:设备 B 已经读到消息 4,设备 A 的旧回执“读到 2”稍后才到。若直接覆盖已读位置,旧消息可能重新变成未读。

这两个问题分别涉及确认范围和更新方向。本文用一个独立的 JavaScript 状态模型,解释为何“读到哪里”比“清零”提供了更多信息,并复现新消息与延迟回执交错的结果。

米米商聊的手机端资料确认免打扰不会阻止消息接收,这为区分接收、提醒与已读提供了背景。本文不推断产品的未读样式、计数规则或已读协议;下文编号、接口和计数资格均为独立教学选择。

1. 清零请求缺少一个关键边界

假设用户实际读完的连续前缀到消息 3。请求发出后、处理前,服务端追加了消息 4。服务端“现在的最新消息”与用户“已经看过的最后一条”已经不是同一个对象。

更明确的操作是报告 seenThrough = 3,只确认该位置及之前的连续前缀。消息 4 的序号更大,仍可属于未读范围。

请求含义 能表达的范围 新消息4是否会被一起确认
把未读数设为0 没有说明读到哪个位置 若按处理时最新状态清零,可能会
已读到位置3 连续前缀至3 本例不会
读到了某几条离散消息 单条标识集合 需要另一个模型,不能直接等同于前缀

这里有一个重要前提:已读位置代表连续前缀。只打开搜索结果中的消息 100,不一定看过消息 1 至 99,不能因此把前缀推进到 100。本例不处理这种离散已读需求。

已读范围与新消息到达

图:页面读到 3 后,消息 4 先于回执处理到达;推进至 3 仍将 4 留在未读范围。此图为独立技术示意,不是产品界面。

2. 旧回执可以重复到达,位置应只前进

在本例的同一会话和同一阅读规则下,已读位置从 2 推进到 4 后,再收到“读到 2”,不应回退。更新规则是 next = Math.max(previous, seenThrough)。MDN Math.max 说明它返回参数中的较大值。

这个规则同时处理相同位置的重复回执:再报告一次 4,位置仍然是 4。它不是将未读数量做加减,因此不会因为重复请求把计数减成负数。

单调更新只回答“旧位置是否覆盖新位置”。它并不证明用户确实看过某条消息,也不自动解决会话权限、丢失数据和数据库并发写入。

序号也必须有明确来源。本例按一个会话内的发布次序从 1 递增,位置不采用设备钟表时间;不同会话的 4 没有可互换的含义。真实系统若使用其他标识,需要另外定义顺序与比较方式。

3. 未读数量是规则结果,不等于两个编号相减

即使服务端已有消息 7、用户读到 3,也不能在所有业务中都用 7 - 3 得到未读数量。用户自己发送的消息、系统事件和被排除的记录,可能不计入这个提示。

本例采用以下计数规则:序号大于已读位置、发送者不是当前读者,而且 countable 为真。countable 只是教学资格标记,并不代表任何产品内部字段。

为展示编号与资格分离,示例提供 excludeFromCount(),仅改变本例计数资格,不删除消息,也不实现撤回。这样序号不重排,已读位置不因某条记录退出计数而移动。

function createReadModel(memberIds) {
   
  if (!Array.isArray(memberIds) || memberIds.length === 0 ||
      memberIds.some(id => !Number.isSafeInteger(id) || id <= 0) ||
      new Set(memberIds).size !== memberIds.length) {
   
    throw new TypeError("expected unique positive integer members");
  }
  const positions = new Map(memberIds.map(id => [id, 0]));
  const messages = [];

  function requireMember(id) {
   
    if (!positions.has(id)) throw new RangeError("unknown member");
  }
  function requireSequence(sequence, minimum = 0) {
   
    if (!Number.isSafeInteger(sequence) || sequence < minimum ||
        sequence > messages.length) {
   
      throw new RangeError("sequence outside published range");
    }
  }

  return {
   
    publish(senderId, countable = true) {
   
      requireMember(senderId);
      if (typeof countable !== "boolean") {
   
        throw new TypeError("countable must be boolean");
      }
      const sequence = messages.length + 1;
      messages.push({
    sequence, senderId, countable });
      return sequence;
    },
    markRead(memberId, seenThrough) {
   
      requireMember(memberId);
      requireSequence(seenThrough);
      const next = Math.max(positions.get(memberId), seenThrough);
      positions.set(memberId, next);
      return next;
    },
    unreadCount(memberId) {
   
      requireMember(memberId);
      const through = positions.get(memberId);
      return messages.filter(message =>
        message.sequence > through && message.senderId !== memberId &&
        message.countable,
      ).length;
    },
    readPosition(memberId) {
   
      requireMember(memberId);
      return positions.get(memberId);
    },
    latestSequence() {
   
      return messages.length;
    },
    excludeFromCount(sequence) {
   
      requireSequence(sequence, 1);
      messages[sequence - 1].countable = false;
    },
  };
}

Map 将本例成员编号映射到各自已读位置;消息列表和位置表保留在函数内部。Number.isSafeInteger 用于拒绝本例不支持的非整数和精度范围外输入。成员编号与序号均为合成数据。

函数只校验演示名单和已发布范围,没有认证服务,也没有验证界面展示。实际请求中的用户和会话身份应由可信上下文取得;“数值在范围内”不能证明阅读事实。excludeFromCount() 是内部教学操作,不是对外授权接口。

4. 用固定操作顺序复现交错到达

先执行上述模型定义,再运行这段代码。每个模型实例只代表一个会话,成员 1 为当前读者,成员 2 为另一位发送者。

const chat = createReadModel([1, 2]);
chat.publish(2); // 1
chat.publish(2); // 2
chat.publish(2); // 3
const viewedThrough = chat.latestSequence(); // 页面已读前缀至3

chat.publish(2); // 4:在回执处理前到达,尚未读到
chat.markRead(1, viewedThrough);
console.assert(chat.readPosition(1) === 3);
console.assert(chat.unreadCount(1) === 1);

chat.markRead(1, 4); // 模拟另一设备随后已读至4
chat.markRead(1, 2); // 旧回执延迟到达
chat.markRead(1, 4); // 相同回执重试
console.assert(chat.readPosition(1) === 4);
console.assert(chat.unreadCount(1) === 0);

chat.publish(1);        // 5:读者自己发送,不计未读
chat.publish(2, false); // 6:本例不计入提示的事件
const seventh = chat.publish(2); // 7:计入未读
console.assert(chat.unreadCount(1) === 1);
chat.excludeFromCount(seventh);
console.assert(chat.unreadCount(1) === 0);
console.assert(chat.readPosition(1) === 4);
console.assert(chat.publish(2) === 8); // 序号没有重排或复用
console.assert(chat.unreadCount(1) === 1);

这些断言检查的是操作交错后的状态:回执针对已观察的前缀,旧回执不回退位置,计数资格变化不重排编号。它们不是网络或设备实测。

本模型的 publish() 在一个串行状态实例内分配序号。它没有实现多个服务实例同时分配序号、消息流乱序接收、断线补齐或离线合并。真实消息序号如何发布,仍需相应的存储和同步机制保证。

5. 一次串行 max,不等于数据库并发已经安全

若两个服务请求各自读取旧位置 0,一个计算出 4,另一个计算出 2,最后按各自结果写回,后写的 2 仍可能覆盖 4。问题发生在读、计算、写入之间,本地算出较大值并没有锁住数据库记录。

真实服务需要让比较和更新在同一个原子操作或受保护的事务中完成,并限制在对应用户与会话上。本例只展示更新规则,没有实现或验证数据库方案。

边界 本例已处理 仍需业务或系统定义
新消息先于已读回执处理到达 只推进到报告的位置 页面如何确认连续前缀已展示
较小位置的回执延迟到达 位置不回退 跨服务实例的原子更新
自发消息或排除事件 按明确资格计算数量 真实产品的计数规则
搜索跳转后只看一条旧消息 不提供离散已读模型 用单条集合还是连续前缀
离开后重新加入会话 未实现成员生命周期 初始化位置、历史范围和权限变化

已读状态还不能代替通知策略。会话被标为已读后,不代表系统通知已经撤销;停止声音提醒也不代表消息已经阅读。清空本地记录、改变阅读位置与删除消息属于不同操作,不能把它们合并成一个“清零”按钮的隐含效果。

如果业务另有“标为未读”或“稍后处理”,应单独设计显示标记或阅读状态版本,而不是让旧自动回执随意回退位置。本例不实现这些功能,也不声称产品已有对应入口。

6. 实际验证与适用范围

本次在 Node.js v24.19.0 中原样执行文章两个 JavaScript 代码块,并完成16类本地验证。覆盖新消息与回执交错、延迟与重复回执、成员独立位置、自己发送的消息、计数排除、编号不重排、非法范围及跨会话实例隔离。

固定混合样本还检查了每个前缀位置的预期未读数量,以及非法操作失败后状态没有被部分修改。验证范围是单进程内存模型和合成数据,没有验证产品、真实界面、网络、消息可见性、认证权限、数据库事务、多进程并发或性能。

这段模型适合用来检查阅读位置与计数规则是否混在了一起。接入真实系统前,还要明确:哪种展示行为可以推进前缀,哪个服务分配消息顺序,哪些记录参与未读,以及多设备更新如何落到同一份持久状态。

创作说明:本文由AI辅助阅读MDN资料、起草和制作示意图;独立代码与本地断言由AI编写并执行。产品背景仅采用已有手机端资料,代码不代表产品内部实现。

参考资料:

相关文章
|
19天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8793 25
|
18天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3407 15
|
17天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2199 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
12天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
18天前
|
云安全 人工智能 安全
|
4天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
371 1
|
6天前
|
人工智能 Linux Windows
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面
千问办公(QwenWork)是阿里云推出的AI智能办公平台,支持网页端直接使用及Windows/Mac/Linux客户端下载。提供PPT生成、财报分析、网页搭建等AI功能,个人版免费,企业版198元/席/月。详情见官网qwenwork.cn或阿里云产品页。
825 0
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面

热门文章

最新文章