[英]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.