我们的网站保存了完整的聊天记录,这些记录已经积累了很长时间。最近,我遇到了一个问题:用户抱怨聊天按钮加载时间过长,甚至有时根本不显示!而且,不同用户之间的表现似乎也不一致。
在 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)
重度用户的追踪成员记录分布
受影响的频道类型
两条不同的路径导致了相同的结果:
-
群组私信(
chatable_type = DirectMessage,threading_enabled = false)
每次在群组私信中回复时,系统都会创建一个线程,并自动将所有私信参与者添加进去。这在create_message.rb中有明确体现(见下方代码)。 -
分类/公共频道,且
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 条以下,聊天功能恢复正常。我们计划将此操作安排为定期任务,直到上游行为得到根本性修复。
建议的修复方向
- 在合理的范围内限制或清理自动追踪成员记录(例如,通过定期任务删除闲置超过 N 天的线程中
notification_level = 2的记录)。 - 考虑群组私信中“自动将所有参与者添加到每个线程”的行为是否可以使用比为每个参与者每个线程创建完整
user_chat_thread_memberships记录更轻量级的记账机制。

