提示:如有轉載請註明作者 小遊戲 及出處
原文:A Million-user Comet Application with Mochiweb, Part 3
參考資料:Comet--基於 HTTP 長連接、無須在瀏覽器端安裝插件的“服務器推”技術爲“Comet”
MochiWeb--建立輕量級HTTP服務器的Erlang庫
在這個系列的第一部分 和第二部分 展示了怎樣用mochiweb構建一個comet應用,怎樣把消息路由到連接的客戶端。 我們完成了把應用內存壓縮到每個連接8KB的程度。我們也做了老式的c10k測試, 注意到10,000個連接用戶時到底發生了什麼。 我們也做了幾個圖。很有樂趣,但是現在是時候把我們標題所宣稱的做好了,把它調優到一百萬個連接。
有以下內容:
- 添加一個發佈訂閱式的基於Mnesia的訂閱數據庫
- 爲一百萬用戶生成一個真實的朋友數據集
- 調整mnesia載入朋友數據
- 從一個機子打開一百萬連接
- 有一百萬連接用戶的基準測試
- 用Libevent + C進行連接處理
- 最後的思考
這個測試的挑戰之一是實際上一個測試用機實際上只能打開1M個連接。寫一個能接收1M個連接的服務器比創建1M個連接用來測試更容易些,所以這篇文章的相當一部分是關於在一臺機器上打開1M個連接的技術 。
趕快進行我們的發佈訂閱
在第二部分 我們用路由器給特定用戶發送消息。對於聊天/及時通訊系統這是很好的,但是我們有更加有吸引力的事情要做。在我們進行大規模伸縮測試前,讓我們再添加一個訂閱數據庫。我們讓應用存儲你的朋友是誰,這樣, 當你的朋友有些什麼事情消息時都會推送給你.
我的意圖是把這個用於Last.fm,我能夠得到實時的我朋友正在聽的歌曲的反饋。他也同樣的適合由社會化網絡產生的其他信息 Flickr圖片上傳,Facebook的newsfeed, Twitter的消息,總總。 FriendFeed甚至有一個beta版的實時API,所以這種事確定很熱門. (即使我還沒有聽說過除了Facebook用Erlang做這種事)。
實現訂閱管理器
我們正實現一個通用訂閱管理器,但是我們將把一個人自動簽名給其朋友列表中的人 - 這樣你可以認爲這就是一個朋友數據庫。
訂閱管理器API:
- add_subscriptions([{Subscriber, Subscribee},...])
- remove_subscriptions([{Subscriber, Subscribee},...])
- get_subscribers(User)
subsmanager.erl
-
-module ( subsmanager) .
-
-behaviour ( gen_server) .
-
-include( "/usr/local/lib/erlang/lib/stdlib-1.15.4/include/qlc.hrl" ) .
-
-export ( [ init/1 , handle_call/3 , handle_cast/2 , handle_info/2 , terminate/2 , code_change/3 ] ) .
-
-export ( [ add_subscriptions/1 ,
-
remove_subscriptions/1 ,
-
get_subscribers/1 ,
-
first_run/0 ,
-
stop/0 ,
-
start_link /0 ] ) .
-
-record( subscription, { subscriber, subscribee} ) .
-
-record( state, { } ) . % state is all in mnesia
-
-define( SERVER , global:whereis_name ( ?MODULE ) ) .
-
-
start_link ( ) ->
-
gen_server :start_link ( { global, ?MODULE } , ?MODULE , [ ] , [ ] ) .
-
-
stop( ) ->
-
gen_server :call ( ?SERVER , { stop} ) .
-
-
add_subscriptions( SubsList ) ->
-
gen_server :call ( ?SERVER , { add_subscriptions, SubsList } , infinity) .
-
-
remove_subscriptions( SubsList ) ->
-
gen_server :call ( ?SERVER , { remove_subscriptions, SubsList } , infinity) .
-
-
get_subscribers( User ) ->
-
gen_server :call ( ?SERVER , { get_subscribers, User } ) .
-
-
%%
-
-
init( [ ] ) ->
-
ok = mnesia:start ( ) ,
-
io:format ( "Waiting on mnesia tables..\n " ,[ ] ) ,
-
mnesia:wait_for_tables ( [ subscription] , 30000 ) ,
-
Info = mnesia:table_info ( subscription, all) ,
-
io:format ( "OK. Subscription table info: \n ~w\n \n " ,[ Info ] ) ,
-
{ ok, #state{ } } .
-
-
handle_call( { stop} , _From , State ) ->
-
{ stop, stop, State } ;
-
-
handle_call( { add_subscriptions, SubsList } , _From , State ) ->
-
% Transactionally is slower:
-
% F = fun() ->
-
% [ ok = mnesia:write(S) || S <- SubsList ]
-
% end,
-
% mnesia:transaction(F),
-
[ mnesia:dirty_write ( S ) || S <- SubsList ] ,
-
{ reply, ok, State } ;
-
-
handle_call( { remove_subscriptions, SubsList } , _From , State ) ->
-
F = fun( ) ->
-
[ ok = mnesia:delete_object ( S ) || S <- SubsList ]
-
end ,
-
mnesia:transaction ( F ) ,
-
{ reply, ok, State } ;
-
-
handle_call( { get_subscribers, User } , From , State ) ->
-
F = fun( ) ->
-
Subs = mnesia:dirty_match_object ( #subscription{ subscriber=‘_’ , subscribee=User } ) ,
-
Users = [ Dude || #subscription{ subscriber=Dude , subscribee=_} <- Subs ] ,
-
gen_server:reply ( From , Users )
-
end ,
-
spawn( F ) ,
-
{ noreply, State } .
-
-
handle_cast( _Msg , State ) -> { noreply, State } .
-
handle_info( _Msg , State ) -> { noreply, State } .
-
-
terminate( _Reason , _State ) ->
-
mnesia :stop ( ) ,
-
ok.
-
-
code_change( _OldVersion , State , _Extra ) ->
-
io :format ( "Reloading code for ?MODULE\n " ,[ ] ) ,
-
{ ok, State } .
-
-
%%
-
-
first_run( ) ->
-
mnesia :create_schema ( [ node( ) ] ) ,
-
ok = mnesia:start ( ) ,
-
Ret = mnesia:create_table ( subscription,
-
[
-
{ disc_copies, [ node( ) ] } ,
-
{ attributes, record_info( fields, subscription) } ,
-
{ index, [ subscribee] } , %index subscribee too
-
{ type, bag}
-
] ) ,
-
Ret .
幾點值得注意的:
- 我包含了qlc.hrl,mnesia用list comprehension做查詢時需要,用了絕對路徑。那不是最好的方法。
get_subscribers
生成另外一個進程且把這個工作委派給他,用gen_server:reply
。這意味這gen_server loop 不能組塞在那個調用上,假如我們拋出大量查找在其上,那麼mnesia會慢下來。rr(”subsmanager.erl”).
下面的例子允許你在erl shell中用record定義。把你的record定義寫入records.hrl文件並把它包含到你的模塊中,這是一種很好的形式,我嵌入它是爲了比較簡潔。
現在測試他。first_run()
創建 mnesia schema,
因此首先運行它是很重要的。另一個隱含的問題是在mnesia中數據庫只能被創建他的那個節點訪問,因此給erl shell 一個名稱,關聯起來。
$ mkdir /var/mnesia
$ erl -boot start_sasl -mnesia dir '"/var/mnesia_data"' -sname subsman
(subsman@localhost)1> c(subsmanager).
{ok,subsmanager}
(subsman@localhost)2> subsmanager:first_run().
...
{atomic,ok}
(subsman@localhost)3> subsmanager:start_link().
Waiting on mnesia tables..
OK. Subscription table info:
[{access_mode,read_write},{active_replicas,[subsman@localhost]},{arity,3},{attributes,[subscriber,subscribee]},{checkpoints,[]},{commit_work,[{index,bag,[{3,{ram,57378}}]}]},{cookie,{{1224,800064,900003},subsman@localhost}},{cstruct,{cstruct,subscription,bag,[],[subsman@localhost],[],0,read_write,[3],[],false,subscription,[subscriber,subscribee],[],[],{{1224,863164,904753},subsman@localhost},{{2,0},[]}}},{disc_copies,[subsman@localhost]},{disc_only_copies,[]},{frag_properties,[]},{index,[3]},{load_by_force,false},{load_node,subsman@localhost},{load_order,0},{load_reason,{dumper,create_table}},{local_content,false},{master_nodes,[]},{memory,288},{ram_copies,[]},{record_name,subscription},{record_validation,{subscription,3,bag}},{type,bag},{size,0},{snmp,[]},{storage_type,disc_copies},{subscribers,[]},{user_properties,[]},{version,{{2,0},[]}},{where_to_commit,[{subsman@localhost,disc_copies}]},{where_to_read,subsman@localhost},{where_to_write,[subsman@localhost]},{wild_pattern,{subscription,’_',’_'}},{{index,3},57378}]
{ok,<0.105.0>}
(subsman@localhost)4> rr("subsmanager.erl").
[state,subscription]
(subsman@localhost)5> subsmanager:add_subscriptions([ #subscription{subscriber=alice, subscribee=rj} ]).
ok
(subsman@localhost)6> subsmanager:add_subscriptions([ #subscription{subscriber=bob, subscribee=rj} ]).
ok
(subsman@localhost)7> subsmanager:get_subscribers(rj).
[bob,alice]
(subsman@localhost)8> subsmanager:remove_subscriptions([ #subscription{subscriber=bob, subscribee=rj} ]).
ok
(subsman@localhost)8> subsmanager:get_subscribers(rj).
[alice]
(subsman@localhost)10> subsmanager:get_subscribers(charlie).
[]
爲測試我們將用整數id值標誌用戶-但這個測試我用原子(rj, alice, bob)且假設alice和bob都在rj的朋友列表中。非常好mnesia (和ets/dets)不關心你用的什麼值-任何Erlang term都可以。這意味着給多種支持的資源升級是很簡單的。你可以開始用{user, 123}或
{photo, 789}描述人們可能訂閱的不同的事情
, 不用改變subsmanager模塊的任何東西。
Modifying the router to use subscriptions
取代給特定的用戶傳遞消息,也就是router:send(123, "Hello user 123"),我們將用主題標誌消息
- 也就是,生成消息的人們(放歌的,上傳圖片的,等等) - 擁有路由器投遞消息給訂閱他主題的每個用戶。換句話說,將像這樣工作: router:send(123, "Hello everyone subscribed to user 123")
Updated router.erl:
-
-module ( router) .
-
-behaviour ( gen_server) .
-
-
-export ( [ start_link /0 ] ) .
-
-export ( [ init/1 , handle_call/3 , handle_cast/2 , handle_info/2 ,
-
terminate/2 , code_change/3 ] ) .
-
-
-export ( [ send/2 , login/2 , logout/1 ] ) .
-
-
-define( SERVER , global:whereis_name ( ?MODULE ) ) .
-
-
% will hold bidirectional mapping between id <–> pid
-
-record( state, { pid2id, id2pid} ) .
-
-
start_link ( ) ->
-
gen_server :start_link ( { global, ?MODULE } , ?MODULE , [ ] , [ ] ) .
-
-
% sends Msg to anyone subscribed to Id
-
send( Id , Msg ) ->
-
gen_server :call ( ?SERVER , { send, Id , Msg } ) .
-
-
login( Id , Pid ) when is_pid( Pid ) ->
-
gen_server :call ( ?SERVER , { login, Id , Pid } ) .
-
-
logout( Pid ) when is_pid( Pid ) ->
-
gen_server :call ( ?SERVER , { logout, Pid } ) .
-
-
%%
-
-
init( [ ] ) ->
-
% set this so we can catch death of logged in pids:
-
process_flag( trap_exit, true) ,
-
% use ets for routing tables
-
{ ok, #state{
-
pid2id = ets:new ( ?MODULE , [ bag] ) ,
-
id2pid = ets:new ( ?MODULE , [ bag] )
-
}
-
} .
-
-
handle_call( { login, Id , Pid } , _From , State ) when is_pid( Pid ) ->
-
ets :insert ( State #state.pid2id, { Pid , Id } ) ,
-
ets:insert ( State #state.id2pid, { Id , Pid } ) ,
-
link( Pid ) , % tell us if they exit, so we can log them out
-
%io:format("~w logged in as ~w\n",[Pid, Id]),
-
{ reply, ok, State } ;
-
-
handle_call( { logout, Pid } , _From , State ) when is_pid( Pid ) ->
-
unlink ( Pid ) ,
-
PidRows = ets:lookup ( State #state.pid2id, Pid ) ,
-
case PidRows of
-
[ ] ->
-
ok ;
-
_ ->
-
IdRows = [ { I ,P } || { P ,I } <- PidRows ] , % invert tuples
-
ets:delete ( State #state.pid2id, Pid ) , % delete all pid->id entries
-
[ ets:delete_object ( State #state.id2pid, Obj ) || Obj <- IdRows ] % and all id->pid
-
end ,
-
%io:format("pid ~w logged out\n",[Pid]),
-
{ reply, ok, State } ;
-
-
handle_call( { send, Id , Msg } , From , State ) ->
-
F = fun( ) ->
-
% get users who are subscribed to Id:
-
Users = subsmanager:get_subscribers ( Id ) ,
-
io:format ( "Subscribers of ~w = ~w\n " ,[ Id , Users ] ) ,
-
% get pids of anyone logged in from Users list:
-
Pids0 = lists:map (
-
fun( U ) ->
-
[ P || { _I , P } <- ets:lookup ( State #state.id2pid, U ) ]
-
end ,
-
[ Id | Users ] % we are always subscribed to ourselves
-
) ,
-
Pids = lists:flatten ( Pids0 ) ,
-
io:format ( "Pids: ~w\n " , [ Pids ] ) ,
-
% send Msg to them all
-
M = { router_msg, Msg } ,
-
[ Pid ! M || Pid <- Pids ] ,
-
% respond with how many users saw the message
-
gen_server:reply ( From , { ok, length( Pids ) } )
-
end ,
-
spawn( F ) ,
-
{ noreply, State } .
-
-
% handle death and cleanup of logged in processes
-
handle_info( Info , State ) ->
-
case Info of
-
{ ‘EXIT’ , Pid , _Why } ->
-
handle_call ( { logout, Pid } , blah, State ) ;
-
Wtf ->
-
io :format ( "Caught unhandled message: ~w\n " , [ Wtf ] )
-
end ,
-
{ noreply, State } .
-
-
handle_cast( _Msg , State ) ->
-
{ noreply, State } .
-
terminate( _Reason , _State ) ->
-
ok .
-
code_change( _OldVsn , State , _Extra ) ->
-
{ ok, State } .
這是一個不需要mochiweb的快速測試 - 我用原子代替用戶ID, 爲清晰忽略了一些輸出
(subsman@localhost)1> c(subsmanager), c(router), rr("subsmanager.erl").
(subsman@localhost)2> subsmanager:start_link().
(subsman@localhost)3> router:start_link().
(subsman@localhost)4> Subs = [#subscription{subscriber=alice, subscribee=rj}, #subscription{subscriber=bob, subscribee=rj}].
[#subscription{subscriber = alice,subscribee = rj},
#subscription{subscriber = bob,subscribee = rj}]
(subsman@localhost)5> subsmanager:add_subscriptions(Subs).
ok
(subsman@localhost)6> router:send(rj, “RJ did something”).
Subscribers of rj = [bob,alice]
Pids: []
{ok,0}
(subsman@localhost)7> router:login(alice, self()).
ok
(subsman@localhost)8> router:send(rj, “RJ did something”).
Subscribers of rj = [bob,alice]
Pids: [<0.46.0>]
{ok,1}
(subsman@localhost)9> receive {router_msg, M} -> io:format(”~s\n”,[M]) end.
RJ did something
ok
這演示了當主題是她訂閱的某人 (rj),alice怎樣接收一條消息, 即使這條消息不是直接發送給alice的。輸出顯示路由器儘可能的標誌目標爲[alice,bob]
但是消息值傳給一個人alice, 因爲bob還沒有登陸。
生成一個典型的社會化網絡朋友數據集
我們可以隨機的生成大量的朋友關係,但是那樣特別不真實。 社會化網絡有助於發揮分佈規則的力量。社會化網絡通常很少有超公衆化的用戶(一些 Twitter 用戶 有超過100,000的追隨者) 而是很多的人只有少量的幾個朋友。Last.fm朋友數據就是個典型 - 他符合Barabási–Albert 圖模型 , 因此它就是我用的類型。
爲了生成數據集,我用了很出色的igraph庫 的模塊:
fakefriends.py:
-
import igraph
-
g = igraph.Graph .Barabasi ( 1000000 , 15 , directed=False )
-
print "Edges: " + str ( g.ecount ( ) ) + " Verticies: " + str ( g.vcount ( ) )
-
g.write_edgelist ( "fakefriends.txt" )
這產生了用空格分隔的每行2個用戶id。這就有了我們要調入subsmanager的朋友關係數據,用戶id從1到一百萬。
向mnesia大量調入朋友數據
這個小模塊讀fakefriends.txt文件並創建一個訂閱記錄列表.
readfriends.erl - 讀fakefriends.txt創建訂閱記錄
-
-module ( readfriends) .
-
-export ( [ load/1 ] ) .
-
-record( subscription, { subscriber, subscribee} ) .
-
-
load( Filename ) ->
-
for_each_line_in_file ( Filename ,
-
fun( Line , Acc ) ->
-
[ As , Bs ] = string:tokens ( string:strip ( Line , right, $\n) , " " ) ,
-
{ A , _} = string:to_integer ( As ) ,
-
{ B , _} = string:to_integer ( Bs ) ,
-
[ #subscription{ subscriber=A , subscribee=B } | Acc ]
-
end , [ read] , [ ] ) .
-
-
% via: http://www.trapexit.org/Reading_Lines_from_a_File
-
for_each_line_in_file( Name , Proc , Mode , Accum0 ) ->
-
{ ok, Device } = file:open ( Name , Mode ) ,
-
for_each_line( Device , Proc , Accum0 ) .
-
-
for_each_line( Device , Proc , Accum ) ->
-
case io:get_line ( Device , "" ) of
-
eof -> file :close ( Device ) , Accum ;
-
Line -> NewAccum = Proc ( Line , Accum ) ,
-
for_each_line( Device , Proc , NewAccum )
-
end .
現在在subsmanager shell中, 你可以從文本中讀數據並添加訂閱:
$ erl -name [email protected] +K true +A 128 -setcookie
secretcookie -mnesia dump_log_write_threshold 50000 -mnesia
dc_dump_limit 40
erl> c(readfriends), c(subsmanager).
erl> subsmanager:first_run().
erl> subsmanager:start_link().
erl> subsmanager:add_subscriptions( readfriends:load("fakefriends.txt") ).
注意這額外的mnesia參數 - 這是避免** WARNING ** Mnesia is overloaded 你可能在別的地方看到的警告信息。提到我以前發表的: On bulk loading data into Mnesia 有另外的調入大量數據的方法。最好的解決方案看起來是設置這些選項(在評論中指出的, 謝謝Jacob!) 。Mnesia 參考手冊 在Configuration參數中包含了很多其他的設置,值得一看.
調到一百萬
在一臺主機上創建一百萬個tcp連接是可以的。
我有個感覺就是做這個是用個小集羣來模擬大量的客戶端連接,可能運行一個像Tsung的真實工具。 甚至調整增加內核內存,增加文件描述符限制,設置本地端口範圍到最大值,我們將一直堅持打破臨時端口的限制。當建立一個tcp連接時,客戶端被分配(或者你可以指定)一個端口,範圍在 /proc/sys/net/ipv4/ip_local_port_range裏
.
假如你手工指定也沒什麼問題, 用臨時端口我們會超出界限。 在第一部分,我們設置這個範圍在“1024
65535″之間 - 這就意味這有65535-1024 = 64511個端口可用。他們中的一些將會被別的進程使用,但是我們從沒有超過64511個客戶連接,因爲我們會超出端口界限。
局部端口區間被賦給ip的一段, 因此假如我們是我們輸出連接在一個指定的局部端口區間的話我們就能夠打開大於64511 個外出連接。
因此讓我們弄出17個新的IP地址, 每個讓他建立62000個連接 - 給我們總共1,054,000 個連接.
$ for i in `seq 1 17`; do echo sudo ifconfig eth0:$i 10.0.0.$i up ; done
假如你現在運行ifconfig
你應該看到你的虛擬往裏接口: eth0:1, eth0:2 … eth0:17, 每個有不同的IP地址。很顯然,你應該選擇一個你所需要的地址空間。
現在剩下的就是更改第一部分地道的floodtest工具
,爲其指定他應該連接的本地IP…不行的是erlang http 客戶端
不讓你指定源IP。 ibrowse,另一個可選的http客戶端庫也不行。媽的。
<瘋狂的想法>
我想到另外的一個選擇:建立17對IP
- 一個在服務器一個在客戶端-- 每對都有自己隔離的 /30 子網。我想假如我隨後讓客戶端連接任何一個給定的服務器IP,他將迫使本地IP在子網上成爲這對的另一部分,因爲只有本地IP能夠達到服務器IP。理論上 ,這將意味這在客戶端聲明本地源IP將不是必須的 (雖然服務器IP區間需要被指定).我不知道這是否能工作 - 這時聽起來可以。最後因它太不正規了所以我決定不試了。
</瘋狂的想法>
我也研究了OTP’s http_transport
代碼並且想爲其加入對指定本地IP的支持。儘管它不是你真正需要的一個特性,但它需要更多的工作。
gen_tcp
讓你指定源IP ,因此我最終爲這個測試用gen_tcp寫一個比較粗糙的客戶端: