Chat Pitchfork 超时:回复静默创建线程,自动追踪随时间膨胀

我们的网站保存了完整的聊天记录,这些记录已经积累了很长时间。最近,我遇到了一个问题:用户抱怨聊天按钮加载时间过长,甚至有时根本不显示!而且,不同用户之间的表现似乎也不一致。

在 Claude Code 的帮助下,我认为我已经找到了问题所在,这个问题可能会影响所有站点。以下是关于该问题的报告汇总。

简而言之:聊天中的回复会生成追踪事件,这些事件会随着时间推移不断膨胀(即使已关闭线程功能)。

以下是经过我轻微编辑的 AI 生成报告:


问题描述

活跃用户报告称,聊天功能会一直加载转圈,永远无法完成。在管理员日志中,我们看到了 Pitchfork 工作进程超时并抛出回溯堆栈,例如:

Pitchfork worker is about to timeout, dumping backtrace for main thread
...
lib/mini_sql/postgres/connection.rb:... MiniSql::Postgres::Connection#query
plugins/chat/app/queries/chat/thread_unreads_query.rb:132  Chat::ThreadUnreadsQuery.call
plugins/chat/app/queries/chat/tracking_state_report_query.rb:71 Chat::TrackingStateReportQuery.call
plugins/chat/app/services/chat/list_user_channels...

Web 工作进程被终止,因为单个 SQL 查询(ThreadUnreadsQuery)未在工作进程超时时间内返回。同一条回溯堆栈在一天内重复出现了数百次。

在客户端,该错误表现为 /chat/api/me/channels 返回 500 错误:

/chat/api/me/channels  Failed to load resource: the server responded with a status of 500

这就是浏览器端所呈现的同一个 Pitchfork 超时问题:触发 ThreadUnreadsQuery 的端点永远无法完成,工作进程被回收,UI 无法获取渲染聊天所需的频道列表。


根本原因(通俗解释)

每当用户在特定聊天消息上点击“回复”时,Discourse 都会创建(或重用)一个线程来保存该回复——即使该频道已禁用线程功能。在群组私信中,系统还会自动将所有私信参与者添加到新线程的成员列表中。随着时间的推移,每个活跃用户都会在 user_chat_thread_memberships 表中积累数千条成员记录,而他们从未有意选择追踪任何内容。

当这些用户中的任何一人打开聊天时,核心追踪查询会遍历他们追踪的所有线程,并对每个线程运行三个相关子查询。拥有数千条成员记录的用户会导致查询超出 Postgres + Pitchfork 的等待时间,导致工作进程在聊天加载完成前被终止。

目前没有任何清理机制:成员记录从未被修剪,当前插件代码中也从未为自动创建的记录设置 notification_level = muted/normal


数据

调查时我们生产数据库的数据如下:

  • 闲置 180 天以上的过时追踪级别成员记录(notification_level = 2):29,309
  • 闲置 15 天以上的同类记录:41,139
  • 拥有超过 1,500 条线程成员记录的用户:11
  • 单个用户最高记录:3,738 条成员记录 —— 超过了 ThreadUnreadsQuery 中的 MAX_THREADS = 3000 上限
  • 在这 11 位重度用户中,100% 的成员记录均为 notification_level = 2(自动追踪),没有任何一条是用户主动选择的“关注”(级别 3)

重度用户的追踪成员记录分布


受影响的频道类型

两条不同的路径导致了相同的结果:

  1. 群组私信(chatable_type = DirectMessage, threading_enabled = false
    每次在群组私信中回复时,系统都会创建一个线程,并自动将所有私信参与者添加进去。这在 create_message.rb 中有明确体现(见下方代码)。

  2. 分类/公共频道,且 threading_enabled = false
    我们发现某个分类频道中存在数千条线程,该频道的 threading_enabled 标志已设置为 false 并持续超过一年——但今天仍然在创建新线程(查询运行后几分钟内就出现了新的线程 ID)。create_message.rb 中的 fetch_thread 路径在回复时静默构建线程,而并未检查频道的线程设置。

因此,“关闭线程功能”并不奏效:该设置仅隐藏了专用线程界面,但当用户使用“回复消息”功能时,后台仍会创建线程。


相关代码

静默创建线程的回复路径plugins/chat/app/services/chat/create_message.rb:131

def fetch_thread(params:, reply:, channel:, options:)
  return Chat::Thread.find_by(id: params.thread_id) if params.thread_id.present?

  reply.thread ||
    reply.build_thread(
      ...
      force: options.force_thread,
      ...
    )
end

代码中未检查 channel.threading_enabled?。在非私信的分类频道中,无论设置如何都会构建线程。

私信中自动将所有参与者加入线程 — 同一文件,约第 203 行

if channel.direct_message_channel? && !channel.threading_enabled
  # 将所有私信参与者添加到线程,以便实现未读追踪和标记已读功能
  channel.chatable.users.each { |user| thread.add(user) }
  thread.membership_for(guardian.user).update!(last_read_message: message_instance)

每次在群组私信中回复,成员记录数量就会增加该私信的参与者人数,而这些记录从未被清理。

性能瓶颈查询plugins/chat/app/queries/chat/thread_unreads_query.rb:14-54

class ThreadUnreadsQuery
  MAX_THREADS = 3000
  ...
  # 每行 user_chat_thread_memberships 对应三个相关子查询:
  #   SELECT (SELECT COUNT(*) ... unread) AS unread_count,
  #          (SELECT COUNT(*) ... mentions) AS mention_count,
  #          (SELECT COUNT(*) ... watched)  AS watched_threads_unread_count,
  #          chat_threads.channel_id, memberships.thread_id
  #   FROM user_chat_thread_memberships AS memberships
  #   ...
  #   WHERE memberships.user_id = :user_id
  #   ...
  #   LIMIT :limit

对于 N 条成员记录,该查询会执行 3 × N 次相关子查询,每次子查询需连接 5 个以上的表。一旦用户的成员记录超过几千条,查询就会可靠地超出 Pitchfork 超时限制。MAX_THREADS = 3000 的设置本身已承认了扩展性问题,但用户确实可能并已经超过了该限制。

user_chat_thread_memberships 表上已存在 (user_id, thread_id) UNIQUE(thread_id, user_id) 索引,因此这不是缺失索引的问题。


临时解决方案

作为即时缓解措施,我们删除了底层线程在过去 15 天内无任何活动的追踪级别成员记录:

DELETE FROM user_chat_thread_memberships uctm
USING chat_threads t
WHERE t.id = uctm.thread_id
  AND uctm.notification_level = 2
  AND NOT EXISTS (
    SELECT 1 FROM chat_messages m
    WHERE m.thread_id = t.id
      AND m.created_at > NOW() - INTERVAL '15 days'
  )

清理后,那位重度用户的成员记录从 3,738 条降至 77 条,聊天瞬间加载完成。

此举是安全的:这些记录均不对应用户的明确选择(全部为自动追踪,级别 2),如果用户再次与已清理的线程互动,下次回复或阅读时会自动创建新的成员记录。

重度用户的成员记录从 1,500–3,700 条降至 150 条以下,聊天功能恢复正常。我们计划将此操作安排为定期任务,直到上游行为得到根本性修复。


建议的修复方向

  1. 在合理的范围内限制或清理自动追踪成员记录(例如,通过定期任务删除闲置超过 N 天的线程中 notification_level = 2 的记录)。
  2. 考虑群组私信中“自动将所有参与者添加到每个线程”的行为是否可以使用比为每个参与者每个线程创建完整 user_chat_thread_memberships 记录更轻量级的记账机制。
1 个赞