Наш сайт хранит полную историю чата, которая накапливается уже довольно давно. Недавно я столкнулся с проблемой, когда пользователи жаловались на то, как долго загружается кнопка чата или вообще не отображается! Кроме того, поведение казалось непоследовательным у разных пользователей.
Я думаю, что с помощью Claude Code мне удалось найти причину проблемы, и она может затрагивать все сайты. Ниже представлен отчёт, составленный на основе анализа этой проблемы.
Если коротко: ответы в чате создают события отслеживания, которые со временем начинают раздувать базу данных (даже когда потоковая тема отключена).
Ниже представлен сгенерированный ИИ отчёт с небольшими правками с моей стороны.
Проблема
Активные пользователи сообщали, что чат зависает в состоянии загрузки (спиннер крутится вечно) и никогда не открывается. В логах администратора мы видели таймауты воркера 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...
Веб-воркер был убит, потому что один SQL-запрос (ThreadUnreadsQuery) не завершался в течение времени ожидания воркера. Аналогичная трассировка стека повторялась сотни раз в день.
На стороне клиента ошибка проявляется как ответ 500 на запрос /chat/api/me/channels:
/chat/api/me/channels Failed to load resource: the server responded with a status of 500
Это браузерная сторона того же таймаута Pitchfork — конечная точка, запускающая ThreadUnreadsQuery, никогда не завершается, воркер перезапускается, и интерфейс не получает список каналов, необходимый для отображения чата.
Выявленная причина, простыми словами
Каждый раз, когда пользователь нажимает «Ответить» на конкретное сообщение в чате, Discourse создаёт (или переиспользует) потоковую тему (thread), чтобы хранить этот ответ — даже если в канале потоковые темы отключены. В групповых личных сообщениях (DM) ко всем участникам нового потока автоматически добавляются все участники этого DM. Со временем каждый активный пользователь накапливает тысячи записей о членстве в таблице user_chat_thread_memberships, даже никогда намеренно не выбирая отслеживание чего-либо.
Когда любой из этих пользователей открывает чат, основной запрос отслеживания перебирает все отслеживаемые им потоковые темы и выполняет три коррелированных подзапроса для каждой темы. Пользователь с несколькими тысячами записей о членстве выводит запрос за пределы того, что Postgres + Pitchfork готовы ждать, и воркер убивается до того, как чат успеет загрузиться.
Механизма очистки не существует: записи о членстве никогда не удаляются, и ни в одном из текущих плагинов кода не устанавливается notification_level = muted/normal для автоматически созданных записей.
Данные
Из нашей производственной базы данных на момент расследования:
- Устаревшие записи о членстве с уровнем отслеживания (
notification_level = 2) для потоков без активности более 180 дней: 29 309 - То же правило для 15 дней: 41 139
- Пользователей с >1 500 записями о членстве в потоках: 11
- Максимальное количество записей у одного пользователя: 3 738 — выше лимита
MAX_THREADS = 3000вThreadUnreadsQuery - Среди этих 11 активных пользователей практически 100% записей имели уровень
notification_level = 2(автоматическое отслеживание) — ни одна не была результатом ручного выбора «слежения» (уровень 3)
Записи о членстве в отслеживании для топ-пользователей
Какие каналы были затронуты
Два различных пути привели к одному и тому же результату:
-
Групповые DM (
chatable_type = DirectMessage,threading_enabled = false)
При каждом ответе в групповом DM создаётся поток, и все участники DM автоматически добавляются к нему. Это явно прописано вcreate_message.rb(см. код ниже). -
Категории/публичные каналы с
threading_enabled = false
Мы обнаружили тысячи потоков в канале категории, у которого флагthreading_enabledв настоящее время установлен вfalseи был таковым более года — тем не менее, потоки всё ещё создаются сегодня (новые ID потоков появляются через несколько минут после запуска запроса). Путьfetch_threadвcreate_message.rbтихо создаёт поток при любом ответе, не проверяя настройку потоковых тем канала.
Таким образом, «отключение потоковых тем» неэффективно: настройка скрывает специальный интерфейс потоков, но не предотвращает создание потоков под капотом, когда пользователи используют функцию «Ответить на сообщение».
Соответствующий код
Путь ответа, который тихо создаёт потоки — 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?. В каналах категорий, не являющихся DM, поток создаётся в любом случае.
Автоматическое добавление всех участников в DM — тот же файл, строка ~203
if channel.direct_message_channel? && !channel.threading_enabled
# Add all DM participants to threads so they have memberships
# for unread tracking and mark-as-read functionality
channel.chatable.users.each { |user| thread.add(user) }
thread.membership_for(guardian.user).update!(last_read_message: message_instance)
Каждый ответ в групповом DM увеличивает количество записей о членстве на размер группы DM, и эти записи никогда не удаляются.
Запрос-«бутылочное горлышко» — plugins/chat/app/queries/chat/thread_unreads_query.rb:14-54
class ThreadUnreadsQuery
MAX_THREADS = 3000
...
# three correlated subqueries per row of 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, и чат снова загружается нормально. Мы планируем запланировать эту операцию как периодическую задачу до тех пор, пока не будет изменено основное поведение на уровне ядра.
Предлагаемые направления для исправления
- Ограничить или очищать записи об автоматическом отслеживании на разумном горизонте (например, удалять записи с
notification_level = 2для потоков без активности более N дней через запланированную задачу). - Рассмотреть возможность использования более дешёвого механизма учёта вместо полной записи
user_chat_thread_membershipsдля каждого участника в каждом потоке в групповых DM, чтобы реализовать поведение «автоматически добавлять всех участников DM к каждому потоку».

