Sidekiq 重新分叉后站点设置过时

优先级: 中,无数据丢失,但会静默地从错误的账户发送面向用户的私信(PM),且在启动后更改的任何站点设置在后台作业内部均可能错误。

平台: 自托管,标准 discourse_docker 双容器配置(data + web_only)。核心版本 2026.7.0-latest30d8364f0ab)。非浏览器特定问题 —— 属于服务器端问题。


描述

实际结果: 在 Sidekiq 守护进程重新派生(例如在 Demon::Sidekiq.rss_memory_check 中因 RSS 内存重启后),新的 Sidekiq 进程会从 unicorn 主进程的启动时快照 中读取站点设置,而非从数据库中读取。自主进程启动以来更改的任何设置,在后台作业内部均会静默出错,直到满足以下任一条件:(a) 在该 Sidekiq 进程存活期间再次更改该设置,或 (b) 重启容器。

我站点的可见症状:自动化系统私信(post_hiddenflags_agreed_and_post_deleted、自动解除暂停)是从 site_contact_username 值发送的。数据库中已保存了修正后的值五天,且工作人员操作日志显示该时间段内无写入操作。与此同时,通过 Web 请求发送的相同类型的消息使用了正确的用户,因为 Web 工作进程已处理了 MessageBus 刷新,而 Sidekiq 未处理。

预期结果: 新派生的守护进程应读取当前的站点设置。无论 Sidekiq 自主进程启动以来重启了多少次,Sidekiq 中的 SiteSetting 值都应与数据库保持一致。


复现步骤

  1. 启动自托管实例。记录当前的 site_contact_username(称为 userA)。
  2. 在管理后台 → 设置中,将 site_contact_username 更改为 userB。所有运行中的进程均正确获取此值(MessageBus /site_settingsSiteSetting.refresh!)。
  3. 终止容器内的 Sidekiq 进程,以便 unicorn 主进程重新派生它(kill <sidekiq_pid>;或者只需等待 Demon::Sidekiq.rss_memory_check 在其 RSS 超过 1000 MB 阈值时重启它 —— 在繁忙的站点上,这会自动发生)。
  4. 触发任何通过 Jobs::SendSystemMessage 投递的系统消息 —— 例如,让帖子因社区举报而被隐藏,这会从 Post#hide! 中排队 :send_system_message
  5. 打开生成的私信。

观察结果: 私信由 userA 撰写 —— 即 主进程 启动时当前的值,而非 userB
预期结果:userB 撰写。


分析

Discourse.after_fork 调用 SiteSetting.after_forklib/site_setting_extension.rb:736):

def after_fork
  @process_id = nil
  ensure_listen_for_changes
end

它从未调用 refresh!,因此子进程通过写时复制继承父进程的 current 设置哈希,并在其整个生命周期内保持该哈希,除非有 后续 更改被广播。

ensure_listen_for_changeslib/site_setting_extension.rb:713)在子进程中实际上也是一个空操作,因为 @subscribed 被继承为 true

def ensure_listen_for_changes
  return if @listen_for_changes == false

  unless @subscribed
    MessageBus.subscribe(SITE_SETTINGS_CHANNEL) { |message| ... }
    @subscribed = true
  end
end

它之所以能正常工作,仅仅是因为 MessageBus.after_fork 先运行(lib/discourse.rb:1064)并针对继承的回调注册表恢复了总线线程。

守护进程是从主进程进行的普通 forklib/demon/base.rb:181),且 Sidekiq 在生产环境中常规地重新派生:当 RSS 超过 DEFAULT_MAX_ALLOWED_SIDEKIQ_RSS_MEGABYTES = 1000 时,Demon::Sidekiq.rss_memory_check 会重启它。每次派生都会复活一个两周前曾为真的值。

这是一个长期存在的问题,而非回归错误:自 8fc2549(2014 年)以来,after_fork 一直具有此形态,且在当前的 main 分支上未变。

为何容易被忽视: 在首次启动时,主进程和 Sidekiq 共享相同的(正确的)快照,且在 Sidekiq 存活期间,它确实会接收更改广播。只有在主进程启动 之后 更改的设置,且在 之后 派生的 Sidekiq 进程中,才会出现偏差 —— 即它会静默开始出错,且不会在任何地方产生错误。


对联系人用户之外的影响

site_contact_username 只是可见的情况,因为它将用户名标记在用户阅读的私信上。相同的陈旧性适用于作业内部咨询的任何站点设置 —— 速率限制、电子邮件/通知设置、功能开关、插件设置 —— 且无错误,无日志行。操作员会合理地得出结论“设置无法保存”,并去寻找覆盖数据库的其他原因。

建议的修复方案

SiteSetting.after_fork 中,丢弃继承的状态并重新读取:

def after_fork
  @process_id = nil
  @subscribed = false
  ensure_listen_for_changes
  refresh!
end

@subscribed = false 也使子进程自己的订阅变得明确,而不是依赖 MessageBus.after_fork 来恢复继承的那个。

操作员的变通方法

./launcher restart web_only(完全重启容器,以便主进程在启动时重新读取设置)。在管理后台重新保存设置仅修复 当前存活 的 Sidekiq 进程 —— 下一次重新派生会静默地将其恢复原状。

1 个赞

感谢您的报告!我已在以下链接中提交了一个修复方案:

2 个赞