《互联网产品的客户端架构·内容、社交与实时互动》5.2 评论的顺序、草稿与失败重发

评论提交比点赞多出正文、时间顺序、草稿保存与失败重发。本节把一条评论拆成发送中、失败、已确认三态:用客户端 clientId 建占位,按时间正序合并,失败时保留原文与草稿,重试复用同一客户端幂等键,并完整复盘一次超时重试导致的重复评论与丢字事故。

只讨论一条评论的生命周期

上一节点赞只需要一个布尔值和一个计数,这一节要处理的是一条带正文、作者、时间和状态的评论。范围限定在用户按下发送之后到服务端确认之前:本地占位怎么建、三种状态怎么表达、评论按什么顺序出现、草稿什么时候保留、失败重试如何去重。

不讨论评论分页与「查看更多」的游标,也不讨论审核、折叠、举报与敏感词,这些属于服务端与内容治理。更不讨论哪些操作不该乐观,那是 5.3 的判定表。这里假定服务端最终会返回一条带稳定 id 与创建时间的评论,客户端只负责在它到达之前把界面维持住。

评论提交不是一次 POST

如果评论接口只有 POST /comments,客户端在按钮上转一个圈,成功就插入、失败就弹 toast,正常网络下能用。但真实环境里至少有四种情况会破坏这个模型:

  • 请求超时,但服务端其实已经写入。用户点重试,出现两条一样的评论。
  • 用户发送后立刻切走页面,回来时不知道这条评论到底发没发出去。
  • 评论列表按时间正序展示,本地新评论直接 push 到末尾,服务端返回的时间却落在中间。
  • 发送失败后输入框已经清空,用户写了两百字,只剩一个错误提示。

这四个问题分别对应四个设计点:幂等键、三态占位、有序合并、草稿保留。它们不能靠「请求成功后再插入」一个动作解决,因为用户需要在这条评论还没有服务端身份时就看到它。

占位必须有客户端 id

本地占位最大的风险不是样式,而是身份。如果占位只靠正文匹配,用户连续发两条「好的」,或者编辑后重发,客户端就分不清哪条响应对应哪个占位。服务端也无法判断两次请求是不是同一次提交。

正确做法是在发请求之前生成一个客户端 id,通常叫 clientId 或 idempotencyKey,并把它随请求一起发送:

POST /comments
{ "clientId": "c-7f3a...", "threadId": "t-42", "text": "..." }

这个 id 有三个用途:

  1. 本地主键。 占位、失败态、重试都通过它定位,不依赖正文或数组下标。
  2. 服务端幂等键。 服务端把它存进唯一索引;同一个 clientId 重复到达时返回第一次创建的评论,而不是再插一条。
  3. 响应关联。 即使响应里没有回显 clientId,客户端也知道自己刚发的是哪一个;服务端最好回显,便于日志对账。
  4. 生成时机。 clientId 必须在请求发出前生成,不能等响应回来再补。它也不该复用:一条新评论就是一个新 id,只有「同一条评论的重试」才复用旧 id。

三态:发送中、失败、已确认

一条本地评论至少有三态,每一态的界面与允许动作都不同:

状态界面允许动作数据位置
sending气泡加进度指示,正文可见可取消(可选)本地占位表
failed气泡加失败标记与原因重试、删除、转为草稿本地占位表
已确认(无状态字段)普通评论,无状态装饰回复、举报等常规操作服务端评论列表

三态的关键是已确认的评论不再带状态字段。如果服务端评论也保留一个 status: "sent",时间线合并时就要处理「两种条目、同一形状」的歧义;把已确认评论和本地占位分成两个类型,合并函数反而更简单。失败态也不能只弹一个 toast:toast 消失后,用户不知道哪条评论失败了,也无法只重试那一条。失败必须附着在占位气泡上,正文仍然可见,重试按钮就在旁边。

草稿:输入态与发送态要分开

草稿是输入框里的内容,占位是已经提交的评论,两者生命周期不同:

  • 用户在输入框打字,草稿随输入更新;切换线程再回来,草稿还在。
  • 按下发送,草稿被消费,生成本地占位。
  • 发送失败,占位保留原文;如果输入框为空,把原文放回草稿,避免用户离开页面后丢字。
  • 重试成功,占位被服务端评论替换;只有当草稿仍等于这条评论原文时才清空,用户已经改写的内容不能被覆盖。

最后一条容易被忽略。失败后草稿被恢复,用户可能已经在原文基础上修改;此时旧请求的成功回执到达,如果无条件清空草稿,用户刚写的新内容就没了。正确规则是「只清理自己放回去的那份」:草稿内容与失败评论正文一致才清,否则视为用户的新输入。

草稿还必须按线程隔离。一个全局草稿字符串会导致用户在 A 线程写了一半,去 B 线程看一眼,回来发现内容串了。存储的 key 至少是 threadId,如果产品支持多账号,还要带账号 id。

按时间正序插入

评论通常按时间正序展示:最早的在上,最新的在下。这个方向和信息流相反,容易在实现时抄错。合并规则要同时处理两类条目:

  • 已确认评论按服务端的 createdAt 升序,同一毫秒按服务端 id 升序。
  • 本地占位按客户端时钟的 createdAt 升序,同一毫秒按 clientId 升序。
  • 两类条目合并后再整体排序;同一毫秒内先显示已确认评论,再显示本地占位,保证结果确定。

占位使用客户端时间,是因为服务端时间还没拿到。客户端时钟可能偏快或偏慢,所以确认之后要用服务端时间重新排序。这会让评论在成功的一瞬间轻微移动:如果用户正盯着底部,移动通常可接受;如果用户正在读中间一段,应该用锚点 id 保住阅读位置,而不是简单滚动到底。

还有一种选择是「占位永远追加到末尾,确认后也不重排」。它避免了跳动,但会破坏时间顺序:用户在旧帖子里发评论,本地占位跑到最新一条后面,刷新后又跳回中间。两害相权,按时间排序更符合评论区的语义,跳动交给锚点解决。

失败重发:同一个 clientId 再去重

重试的请求体必须与第一次完全一致:同样的 clientId、同样的正文、同样的线程。只改重试次数或时间戳可以,但不能生成新 id,否则服务端会当成两条评论。

服务端按 clientId 去重之后,会出现一种「失败但实际成功」的响应路径:第一次请求已经写入,响应丢失;客户端重试,服务端发现 clientId 已存在,返回第一次创建的评论。客户端收到这条评论时,本地占位还在,于是用服务端 id 替换占位。整个过程只有一条评论。

客户端侧还要做第二层去重。确认响应可能重复投递,或者用户在弱网下连续点重试,导致两个响应带着同一个服务端 id 回来。markSent 在插入前检查两件事:服务端 id 是否已经在列表里,clientId 是否已经对应某条已确认评论。任一命中就删除占位并返回 duplicate: true,不新增条目。

两层去重缺一不可:只有服务端去重,客户端可能把同一个响应插两次;只有客户端去重,换设备或清缓存后仍会产生重复评论。

一个可运行的评论线程

下面的 CommentThread 属于 L2:时钟与 id 生成器都可注入,测试里用假时钟控制顺序,用递增 id 控制 clientId。它同时维护已确认评论、本地占位与按线程隔离的草稿。

// file: src/interaction/comments.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/interaction/comments.test.ts
export type Clock = () => number;
export type PendingStatus = "sending" | "failed";

export interface CommentRecord {
  id: string;
  threadId: string;
  authorId: string;
  text: string;
  createdAt: number;
  clientId?: string;
}

export interface PendingComment {
  clientId: string;
  threadId: string;
  authorId: string;
  text: string;
  createdAt: number;
  status: PendingStatus;
  attempts: number;
  error: string | null;
}

export interface CommentRequest {
  clientId: string;
  threadId: string;
  text: string;
  attempt: number;
}

export interface SentResult {
  inserted: boolean;
  duplicate: boolean;
  entry: CommentRecord | null;
}

export interface TimelineEntry {
  kind: "confirmed" | "pending";
  id: string;
  clientId: string | null;
  text: string;
  createdAt: number;
  status: "sent" | PendingStatus;
}

interface OrderKey {
  createdAt: number;
  rank: number;
  key: string;
}

function orderKey(entry: TimelineEntry): OrderKey {
  return {
    createdAt: entry.createdAt,
    // 同一毫秒内先展示服务端已确认评论,再展示本地占位,最后按 id 稳定排序。
    rank: entry.kind === "confirmed" ? 0 : 1,
    key: entry.kind === "confirmed" ? entry.id : (entry.clientId ?? entry.id),
  };
}

function compareOrder(a: OrderKey, b: OrderKey): number {
  if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
  if (a.rank !== b.rank) return a.rank - b.rank;
  return a.key.localeCompare(b.key);
}

export class CommentThread {
  #confirmed = new Map<string, CommentRecord[]>();
  #pending = new Map<string, PendingComment>();
  #drafts = new Map<string, string>();
  #now: Clock;
  #idFactory: () => string;
  #authorId: string;

  constructor(options: { now?: Clock; idFactory?: () => string; authorId?: string } = {}) {
    this.#now = options.now ?? (() => Date.now());
    this.#idFactory = options.idFactory ?? (() => crypto.randomUUID());
    this.#authorId = options.authorId ?? "me";
  }

  seed(threadId: string, comments: CommentRecord[]): void {
    for (const comment of comments) {
      if (comment.threadId !== threadId) throw new Error("评论不属于该线程");
      if (!Number.isFinite(comment.createdAt)) throw new RangeError("createdAt 必须是时间戳");
    }
    this.#confirmed.set(threadId, [...comments].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)));
  }

  confirmed(threadId: string): CommentRecord[] {
    return [...(this.#confirmed.get(threadId) ?? [])];
  }

  pending(clientId: string): PendingComment | null {
    const comment = this.#pending.get(clientId);
    return comment ? { ...comment } : null;
  }

  // 草稿按线程隔离,不随提交其他线程或切换页面而清空。
  setDraft(threadId: string, text: string): void {
    if (text === "") {
      this.#drafts.delete(threadId);
      return;
    }
    this.#drafts.set(threadId, text);
  }

  draft(threadId: string): string {
    return this.#drafts.get(threadId) ?? "";
  }

  // 提交时生成 clientId 并消费草稿;重试必须复用同一个 clientId。
  submit(threadId: string, text: string, createdAt?: number): CommentRequest {
    const body = text.trim();
    if (body === "") throw new Error("评论内容不能为空");
    const clientId = this.#idFactory();
    if (this.#pending.has(clientId)) throw new Error(`clientId 重复:${clientId}`);
    const at = createdAt ?? this.#now();
    const comment: PendingComment = {
      clientId,
      threadId,
      authorId: this.#authorId,
      text: body,
      createdAt: at,
      status: "sending",
      attempts: 1,
      error: null,
    };
    this.#pending.set(clientId, comment);
    if (this.draft(threadId).trim() === body) this.#drafts.delete(threadId);
    return { clientId, threadId, text: body, attempt: 1 };
  }

  retry(clientId: string): CommentRequest | null {
    const comment = this.#pending.get(clientId);
    if (!comment || comment.status !== "failed") return null;
    comment.status = "sending";
    comment.attempts += 1;
    comment.error = null;
    if (this.draft(comment.threadId).trim() === comment.text) {
      this.#drafts.delete(comment.threadId);
    }
    return {
      clientId,
      threadId: comment.threadId,
      text: comment.text,
      attempt: comment.attempts,
    };
  }

  markFailed(clientId: string, error = "network"): PendingComment | null {
    const comment = this.#pending.get(clientId);
    if (!comment) return null;
    comment.status = "failed";
    comment.error = error;
    // 把原文放回草稿框,用户离开页面再回来也不会丢字;已有编辑则不动。
    if (this.draft(comment.threadId).trim() === "") {
      this.#drafts.set(comment.threadId, comment.text);
    }
    return { ...comment };
  }

  // 服务端确认可能晚于失败回执,也可能重复投递;clientId 与服务端 id 都要去重。
  markSent(clientId: string, server: CommentRecord): SentResult {
    const pending = this.#pending.get(clientId);
    const threadId = server.threadId;
    if (pending && pending.threadId !== threadId) {
      throw new Error("确认响应的线程与本地占位不一致");
    }
    const list = this.#confirmed.get(threadId) ?? [];
    const existing =
      list.find((comment) => comment.id === server.id || comment.clientId === clientId) ?? null;
    if (existing) {
      if (pending) this.#pending.delete(clientId);
      return { inserted: false, duplicate: true, entry: { ...existing } };
    }
    if (pending) {
      this.#pending.delete(clientId);
      if (this.draft(threadId).trim() === pending.text) this.#drafts.delete(threadId);
    }
    const record: CommentRecord = { ...server, clientId: server.clientId ?? clientId };
    const merged = [...list, record].sort(
      (a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id),
    );
    this.#confirmed.set(threadId, merged);
    return { inserted: true, duplicate: false, entry: { ...record } };
  }

  // 已确认评论与本地占位合并成一条按时间正序的时间线,发送中的评论不会被藏起来。
  timeline(threadId: string): TimelineEntry[] {
    const confirmed: TimelineEntry[] = (this.#confirmed.get(threadId) ?? []).map((comment) => ({
      kind: "confirmed" as const,
      id: comment.id,
      clientId: comment.clientId ?? null,
      text: comment.text,
      createdAt: comment.createdAt,
      status: "sent" as const,
    }));
    const pending: TimelineEntry[] = [...this.#pending.values()]
      .filter((comment) => comment.threadId === threadId)
      .map((comment) => ({
        kind: "pending" as const,
        id: comment.clientId,
        clientId: comment.clientId,
        text: comment.text,
        createdAt: comment.createdAt,
        status: comment.status,
      }));
    return [...confirmed, ...pending].sort((a, b) => compareOrder(orderKey(a), orderKey(b)));
  }
}

三个关键决策

占位不是假数据

PendingComment 与 CommentRecord 是两个类型,前者有 status、attempts、error,后者没有。把它们合成一个「带可选 status 的评论」会让每个消费方都要判断「这条到底是不是真的」,渲染层很容易把占位当成已发布内容。类型分开之后,时间线用 kind 区分,UI 也必须显式处理两种分支。

草稿与失败气泡各管一段

草稿负责「还没提交的输入」,失败气泡负责「已经提交但没成功的那一条」。失败时把正文放回草稿,是为了用户离开页面再回来不丢字;重试仍然从失败气泡发起,复用它的 clientId,不是从草稿重新提交。两者分工清楚,才不会出现「草稿里一份、气泡里一份,点发送又发一条」的重复。

时间线合并而不是两个列表

把已确认评论和本地占位分成两个列表上下拼接,看起来简单,但顺序会错:占位永远在最下面,哪怕它的时间应该落在中间。timeline 每次合并并排序,返回单一数组;排序键包含时间、类型优先级和 id,保证同一组输入永远得到同一顺序。这个性质可以直接写成断言,比「看起来对」可靠。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/interaction/comments.test.ts
✔ 提交后立即出现发送中占位,输入草稿被消费 (1.508875ms)
✔ 失败后恢复草稿,重试复用同一个 clientId 且不新增占位 (0.174083ms)
✔ 成功后占位被服务端评论替换,并按服务端时间重新排序 (0.829875ms)
✔ 同一 clientId 的重复确认只保留一条服务端评论 (0.112416ms)
✔ 多条本地占位与服务端评论按时间正序合并 (0.118708ms)
✔ 空评论不能提交,未知 clientId 的重试与失败回调都返回 null (0.24775ms)
✔ 草稿按线程隔离,提交只消费当前线程 (0.079042ms)
✔ 失败恢复的草稿被编辑后,成功回执不清空新草稿 (0.073625ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 160.798

八条用例覆盖三态、草稿恢复、重试复用、成功重排、重复确认、时间线合并、空内容与线程隔离。它们没有覆盖「服务端幂等索引是否真的生效」,那需要接口测试;这里证明的是客户端在响应丢失、重复投递和乱序时间下不会自己制造重复或丢字。

失败复盘:两条一样的评论

「回声」第一版的评论接口没有 clientId,客户端超时时间是 8 秒。上线后收到两类投诉:同一句话出现两次;以及发送失败后写好的长评论不见了。

重复评论的路径很典型。服务端写入成功但响应超过 8 秒才返回,客户端判定失败,用户点了重试。第二次请求没有幂等键,服务端又插了一条。更糟的是,客户端用「正文相同」匹配占位,两条一模一样的评论会匹配到同一个占位,界面上看起来只多了一条,刷新后才发现两条。

丢字的路径更简单:失败时客户端清空了输入框,只弹了一个 toast。用户切走再回来,草稿是空的,占位也没有,正文彻底丢失。

修复分成三层:

  1. 请求发出前生成 clientId,服务端加唯一索引,重试复用同一个 id。
  2. 失败不再清空输入,而是把原文放回该线程的草稿;草稿按线程隔离。
  3. 本地占位用 clientId 定位,确认响应按服务端 id 与 clientId 双重去重。

上线后重复评论消失,但多了一个新问题:用户失败后修改了草稿再发送,旧请求的成功回执把新草稿清空了。于是补上最后一条规则:成功回执只清理与失败正文完全一致的草稿。这个细节说明,草稿与发送态即使分开存储,清理时机仍然要交叉判断。

小结

一条评论从输入到确认,要经过草稿、占位、发送中、失败或成功几个阶段。稳定身份用发送前生成的 clientId,三态用独立类型表达,时间线每次合并排序,失败时保留原文并恢复草稿,重试复用同一个幂等键。服务端与客户端各做一层去重,才能覆盖响应丢失与重复投递。

点赞和评论都假设「失败可以回滚、重试可以幂等」。但并不是所有写操作都满足这个假设:支付、下单、上传完成各有不同的回滚成本。下一节给出一张判定表,回答哪些操作可以乐观、哪些只能等服务端确认。

阅读导航:上一节:5.1 点赞与收藏:本地先行、服务端为准 · 下一节:5.3 回滚的边界:哪些操作不能乐观 。

常见错误与边界

  • 用正文或数组下标匹配占位,连续两条相同评论时对应错响应。
  • 重试时生成新的 clientId,服务端插入第二条评论。
  • 失败只弹 toast 并清空输入,用户离开页面后正文丢失。
  • 草稿使用全局单例,切换线程后内容串到另一个帖子。
  • 成功回执无条件清空草稿,覆盖用户刚修改的新内容。
  • 把本地占位直接 push 到末尾,服务端时间返回后顺序错乱。
  • 只按服务端 id 去重,响应丢失后的重试仍会多插一条。
  • 已确认评论也保留 status 字段,渲染层把占位当成已发布内容。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查