簡體   English   中英

Redis + ActionController::活動線程沒有死

[英]Redis + ActionController::Live threads not dying

背景:我們在現有的 Rails 應用程序中構建了一個聊天功能。 我們正在使用新的ActionController::Live模塊並運行 Puma(生產中使用 Nginx),並通過 Redis 訂閱消息。我們正在使用EventSource客戶端異步建立連接。

問題摘要:當連接終止時,線程永遠不會死亡。

例如,如果用戶導航離開、關閉瀏覽器,甚至 go 到應用程序中的另一個頁面,就會產生一個新線程(如預期的那樣),但舊線程繼續存在。

我目前看到的問題是,當任何這些情況發生時,服務器無法知道瀏覽器端的連接是否終止,直到有人嘗試寫入這個損壞的 stream,一旦瀏覽器就永遠不會發生已經離開原來的頁面。

這個問題似乎記錄在 github 上,並且在 StackOverflow此處(完全相同的問題)此處(關於獲取活動線程數)提出了類似的問題。

基於這些帖子,我能夠想出的唯一解決方案是實現一種線程/連接撲克。 嘗試寫入斷開的連接會生成一個IOError ,我可以捕獲它並正確關閉連接,從而使線程終止。 這是該解決方案的 controller 代碼:

def events
  response.headers["Content-Type"] = "text/event-stream"

  stream_error = false; # used by flusher thread to determine when to stop

  redis = Redis.new

  # Subscribe to our events
  redis.subscribe("message.create", "message.user_list_update") do |on| 
    on.message do |event, data| # when message is received, write to stream
      response.stream.write("messageType: '#{event}', data: #{data}\n\n")
    end

    # This is the monitor / connection poker thread
    # Periodically poke the connection by attempting to write to the stream
    flusher_thread = Thread.new do
      while !stream_error
        $redis.publish "message.create", "flusher_test"
        sleep 2.seconds
      end
    end
  end 

  rescue IOError
    logger.info "Stream closed"
    stream_error = true;
  ensure
    logger.info "Events action is quitting redis and closing stream!"
    redis.quit
    response.stream.close
end

(注意: events方法似乎在subscribe方法調用時被阻止。其他一切(流媒體)都正常工作,所以我認為這是正常的。)

(其他注意事項:flusher 線程概念作為單個長時間運行的后台進程更有意義,有點像垃圾線程收集器。我上面的實現的問題是為每個連接生成一個新線程,這是沒有意義的。任何人嘗試實現這個概念應該更像一個單一的進程,而不是像我概述的那樣。當我成功地將它重新實現為一個單一的后台進程時,我會更新這篇文章。)

這個解決方案的缺點是我們只是延遲或減輕了問題,並沒有完全解決它。 除了其他請求(例如 ajax)之外,我們每個用戶仍然有 2 個線程,從擴展的角度來看這似乎很糟糕; 對於具有許多可能並發連接的大型系統來說,這似乎是完全無法實現和不切實際的。

我覺得我錯過了一些重要的東西; 我發現很難相信 Rails 有一個功能,如果沒有像我一樣實現自定義連接檢查器,那么它顯然是壞掉的。

問題:我們如何在不實施諸如“連接撲克”或垃圾線程收集器之類的陳詞濫調的情況下允許連接/線程終止?

一如既往地讓我知道我是否遺漏了任何內容。

更新只是為了添加一些額外的信息:github 的 Huetsch 發表了這條評論指出 SSE 基於 TCP,它通常在連接關閉時發送 FIN 數據包,讓另一端(在本例中為服務器)知道關閉連接是安全的。 Huetsch 指出,要么是瀏覽器沒有發送該數據包(可能是EventSource庫中的一個錯誤?),要么是 Rails 沒有捕獲它或對它做任何事情(如果是這樣的話,那肯定是 Rails 中的一個錯誤)。 搜索繼續...

另一個使用 Wireshark 的更新,我確實可以看到正在發送的 FIN 數據包。 誠然,我對協議級別的東西不是很了解或沒有經驗,但是據我所知,當我使用瀏覽器中的 EventSource 建立 SSE 連接時,我肯定檢測到從瀏覽器發送的 FIN 數據包,如果我沒有發送任何數據包刪除該連接(意味着沒有 SSE)。 雖然我對 TCP 的了解並不十分清楚,但這似乎表明連接確實被客戶端正確終止了; 也許這表明 Puma 或 Rails 中存在錯誤。

另一個更新@JamesBoutcher / boutcheratwest(github) 讓我看到了 redis 網站上關於這個問題的討論,特別是關於.(p)subscribe方法永遠不會關閉的事實。 該站點上的張貼者指出了我們在這里發現的同一件事,即當客戶端連接關閉時,Rails 環境永遠不會收到通知,因此無法執行.(p)unsubscribe方法。 他詢問.(p)subscribe方法的超時,我認為它也可以工作,但我不確定哪種方法(我在上面描述的連接撲克,或他的超時建議)會是更好的解決方案. 理想情況下,對於連接撲克解決方案,我想找到一種方法來確定另一端的連接是否已關閉,而無需寫入 stream。就像現在一樣,如您所見,我必須實現客戶端 -單獨處理我的“戳”消息的輔助代碼,我認為這很突兀和愚蠢。

我剛剛做的一個解決方案(從@teeg借了很多)似乎工作正常(沒有失敗測試它,所以)

配置/初始化/ redis.rb

$redis = Redis.new(:host => "xxxx.com", :port => 6379)

heartbeat_thread = Thread.new do
  while true
    $redis.publish("heartbeat","thump")
    sleep 30.seconds
  end
end

at_exit do
  # not sure this is needed, but just in case
  heartbeat_thread.kill
  $redis.quit
end

然后在我的控制器中:

def events
    response.headers["Content-Type"] = "text/event-stream"
    redis = Redis.new(:host => "xxxxxxx.com", :port => 6379)
    logger.info "New stream starting, connecting to redis"
    redis.subscribe(['parse.new','heartbeat']) do |on|
      on.message do |event, data|
        if event == 'parse.new'
          response.stream.write("event: parse\ndata: #{data}\n\n")
        elsif event == 'heartbeat'
          response.stream.write("event: heartbeat\ndata: heartbeat\n\n")
        end
      end
    end
  rescue IOError
    logger.info "Stream closed"
  ensure
    logger.info "Stopping stream thread"
    redis.quit
    response.stream.close
  end

我目前正在制作一個圍繞ActionController的應用程序:Live,EventSource和Puma,對於那些在關閉流等問題時遇到問題,而不是在Rails 4.2中拯救IOError ,你需要拯救ClientDisconnected 例:

def stream
  #Begin is not required
  twitter_client = Twitter::Streaming::Client.new(config_params) do |obj|
    # Do something
  end
rescue ClientDisconnected
  # Do something when disconnected
ensure
  # Do something else to ensure the stream is closed
end

我從這個論壇帖子(一直到底)發現了這個方便的提示: http//railscasts.com/episodes/401-actioncontroller-live?view = comment

在@James Boutcher的基礎上,我在群集Puma中使用了以下2個worker,因此我在config / initializers / redis.rb中只為心跳創建了1個線程:

配置/ puma.rb

on_worker_boot do |index|
  puts "worker nb #{index.to_s} booting"
  create_heartbeat if index.to_i==0
end

def create_heartbeat
  puts "creating heartbeat"
  $redis||=Redis.new
  heartbeat = Thread.new do
    ActiveRecord::Base.connection_pool.release_connection
    begin
      while true
        hash={event: "heartbeat",data: "heartbeat"}
        $redis.publish("heartbeat",hash.to_json)
        sleep 20.seconds
      end
    ensure
      #no db connection anyway
    end
  end
end

這是一個可能更簡單的解決方案,不使用心跳。 經過大量的研究和實驗,這里是我與sinatra + sinatra sse gem使用的代碼(應該很容易適應Rails 4):

class EventServer < Sinatra::Base
 include Sinatra::SSE
 set :connections, []
 .
 .
 .
 get '/channel/:channel' do
 .
 .
 .
  sse_stream do |out|
    settings.connections << out
    out.callback {
      puts 'Client disconnected from sse';
      settings.connections.delete(out);
    }
  redis.subscribe(channel) do |on|
      on.subscribe do |channel, subscriptions|
        puts "Subscribed to redis ##{channel}\n"
      end
      on.message do |channel, message|
        puts "Message from redis ##{channel}: #{message}\n"
        message = JSON.parse(message)
        .
        .
        .
        if settings.connections.include?(out)
          out.push(message)
        else
          puts 'closing orphaned redis connection'
          redis.unsubscribe
        end
      end
    end
  end
end

redis連接阻止on.message並且只接受(p)subscribe /(p)unsubscribe命令。 取消訂閱后,redis連接不再被阻止,並且可以由初始sse請求實例化的Web服務器對象釋放。 當您在redis上收到消息時,它會自動清除,並且集合數組中不再存在與瀏覽器的連接。

在這里你是超時的解決方案,將退出阻止Redis。(p)訂閱呼叫並殺死未使用的連接。

class Stream::FixedController < StreamController
  def events
    # Rails reserve a db connection from connection pool for
    # each request, lets put it back into connection pool.
    ActiveRecord::Base.clear_active_connections!

    # Last time of any (except heartbeat) activity on stream
    # it mean last time of any message was send from server to client
    # or time of setting new connection
    @last_active = Time.zone.now

    # Redis (p)subscribe is blocking request so we need do some trick
    # to prevent it freeze request forever.
    redis.psubscribe("messages:*", 'heartbeat') do |on|
      on.pmessage do |pattern, event, data|
        # capture heartbeat from Redis pub/sub
        if event == 'heartbeat'
          # calculate idle time (in secounds) for this stream connection
          idle_time = (Time.zone.now - @last_active).to_i

          # Now we need to relase connection with Redis.(p)subscribe
          # chanel to allow go of any Exception (like connection closed)
          if idle_time > 4.minutes
            # unsubscribe from Redis because of idle time was to long
            # that's all - fix in (almost)one line :)
            redis.punsubscribe
          end
        else
          # save time of this (last) activity
          @last_active = Time.zone.now
        end
        # write to stream - even heartbeat - it's sometimes chance to
        # capture dissconection error before idle_time
        response.stream.write("event: #{event}\ndata: #{data}\n\n")
      end
    end
    # blicking end (no chance to get below this line without unsubscribe)
  rescue IOError
    Logs::Stream.info "Stream closed"
  rescue ClientDisconnected
    Logs::Stream.info "ClientDisconnected"
  rescue ActionController::Live::ClientDisconnected
    Logs::Stream.info "Live::ClientDisconnected"
  ensure
    Logs::Stream.info "Stream ensure close"
    redis.quit
    response.stream.close
  end
end

你必須使用紅色。(p)取消訂閱以結束此阻止通話。 沒有例外可以打破這個。

我的簡單應用程序包含有關此修復程序的信息: https//github.com/piotr-kedziak/redis-subscribe-stream-puma-fix

如果您可以容忍錯過消息的小概率,您可以使用subscribe_with_timeout

sse = SSE.new(response.stream)
sse.write("hi", event: "hello")
redis = Redis.new(reconnect_attempts: 0)
loop do
  begin
    redis.subscribe_with_timeout(5 * 60, 'mycoolchannel') do |on|
      on.message do |channel, message|
        sse.write(message, event: 'message_posted')
      end
    end
  rescue Redis::TimeoutError
    sse.write("ping", event: "ping")
  end
end

此代碼訂閱 Redis 頻道,等待 5 分鍾,然后關閉與 Redis 的連接並再次訂閱。

不是向所有客戶端發送心跳,而是為每個連接設置監視器可能更容易。 [感謝@NeilJewers]

class Stream::FixedController < StreamController
  def events
    # Rails reserve a db connection from connection pool for
    # each request, lets put it back into connection pool.
    ActiveRecord::Base.clear_active_connections!

    redis = Redis.new

    watchdog = Doberman::WatchDog.new(:timeout => 20.seconds)
    watchdog.start

    # Redis (p)subscribe is blocking request so we need do some trick
    # to prevent it freeze request forever.
    redis.psubscribe("messages:*") do |on|
      on.pmessage do |pattern, event, data|
        begin
          # write to stream - even heartbeat - it's sometimes chance to
          response.stream.write("event: #{event}\ndata: #{data}\n\n")
          watchdog.ping

        rescue Doberman::WatchDog::Timeout => e
          raise ClientDisconnected if response.stream.closed?
          watchdog.ping
        end
      end
    end

  rescue IOError
  rescue ClientDisconnected

  ensure
    response.stream.close
    redis.quit
    watchdog.stop
  end
end

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM