客户端和服务端的交互有推和拉两种方式:如果是客户端拉的话,通常就是Polling;如果是服务端推的话,一般就是Comet,目前比较流行的Comet实现方式是Long Polling。
注:如果不清楚相关名词含义,可以参考:Browser 與 Server 持續同步的作法介紹。
先来看看Polling,它其实就是我们平常所说的轮询,大致如下所示:
因为服务端不会主动告诉客户端它是否有新数据,所以Polling的实时性较差。虽然可以通过加快轮询频率的方式来缓解这个问题,但相应付出的代价也不小:一来会使负载居高不下,二来也会让带宽捉襟见肘。
再来说说Long Polling,如果使用传统的LAMP技术去实现的话,大致如下所示:
客户端不会频繁的轮询服务端,而是对服务端发起一个长连接,服务端通过轮询数据库来确定是否有新数据,一旦发现新数据便给客户端发出响应,这次交互便结束了。客户端处理好新数据后再重新发起一个长连接,如此周而复始。
在上面这个Long Polling方案里,我们解决了Polling中客户端轮询造成的负载和带宽的问题,但是依然存在服务端轮询,数据库的压力可想而知,此时我们虽然可以通过针对数据库使用主从复制,分片等技术来缓解问题,但那毕竟只是治标不治本。
我们的目标是实现一个简单的服务端推方案,但简单绝对不意味着简陋,轮询数据库是不可以接受的,下面我们来看看如何解决这个问题。在这里我们放弃了传统的LAMP技术,转而使用Nginx与Lua来实现。
此方案的主要思路是这样的:使用Nginx作为服务端,通过Lua协程来创建长连接,一旦数据库里有新数据,它便主动通知Nginx,并把相应的标识(比如一个自增的整数ID)保存在Nginx共享内存中,接下来,Nginx不会再去轮询数据库,而是改为轮询本地的共享内存,通过比对标识来判断是否有新消息,如果有便给客户端发出响应。
注:服务端维持大量长连接时内核参数的调整请参考:http长连接200万尝试及调优。
首先,我们简单写一点代码实现轮询(篇幅所限省略了查询数据库的操作):
lua_shared_dict config 1m; server { location /push { content_by_lua ' local id = 0; local ttl = 100; local now = ngx.time(); local config = ngx.shared.config; if not config:get("id") then config:set("id", "0"); end while id >= tonumber(config:get("id")) do local random = math.random(ttl - 10, ttl + 10); if ngx.time() - now > random then ngx.say("NO"); ngx.exit(ngx.HTTP_OK); end ngx.sleep(1); end ngx.say("YES"); ngx.exit(ngx.HTTP_OK); '; } ... }
注:为了处理服务端不知道客户端何时断开连接的情况,代码中引入超时机制。
其次,我们需要做一些基础工作,以便操作Nginx的共享内存:
lua_shared_dict config 1m; server { location /config { content_by_lua ' local config = ngx.shared.config; if ngx.var.request_method == "GET" then local field = ngx.var.arg_field; if not field then ngx.exit(ngx.HTTP_BAD_REQUEST); end local content = config:get(field); if not content then ngx.exit(ngx.HTTP_BAD_REQUEST); end ngx.say(content); ngx.exit(ngx.HTTP_OK); end if ngx.var.request_method == "POST" then ngx.req.read_body(); local args = ngx.req.get_post_args(); for field, value in pairs(args) do if type(value) ~= "table" then config:set(field, value); end end ngx.say("OK"); ngx.exit(ngx.HTTP_OK); end '; } ... }
如果要写Nginx共享内存的话,可以这样操作:
shell> curl -d "id=123" http://<HOST>/config
如果要读Nginx共享内存的话,可以这样操作:
shell> curl http://<HOST>/config?field=id
注:实际应用时,应该加上权限判断逻辑,比如只有限定的IP地址才能使用此功能。
当数据库有新数据的时候,可以通过触发器来写Nginx共享内存,当然,在应用层通过观察者模式来写Nginx共享内存通常会是一个更优雅的选择。
如此一来,数据库就彻底翻身做主人了,虽然系统仍然存在轮询,但已经从轮询别人变成了轮询自己,效率不可相提并论,相应的,我们可以加快轮询的频率而不会造成太大的压力,从而在根本上提升用户体验。
突然想起另一个有趣的服务端推的做法,不妨在一起唠唠:如果DB使用Redis的话,那么可以利用其提供的BLPOP方法来实现服务端推,这样的话,连sleep都不用了,不过有一点需要注意的是,一旦使用了BLPOP方法,那么Nginx和Redis之间的连接便会一直保持下去,从Redis的角度看,Nginx是客户端,而客户端的可用端口数量是有限的,这就意味着一台Nginx至多只能建立六万多个连接(net.ipv4.ip_local_port_range),有点儿少。
…
当然,本文的描述只是沧海一粟,还有很多技术可供选择,比如Pub/Sub,WebSocket等等,篇幅所限,这里就不多说了,有兴趣的读者请自己查阅。
强沙发
擦,居然出了错别字, @沙发
殊途同归,最近的项目中,用的就是这个办法。
第一个 location /push 之前是不是忘记写content_by_lua了?
谢谢,已经修复
六万还少么,单机瞬间同时并发连接数是多少属于正常?200w那个是实验,否则全国只有1000台web服务器就够了。
六万是长连接数,不是瞬时并发连接,连接上一直保持,处理能力比瞬时少很多
客户端怎么实现呢?
1: function Request()
2: {
3: Ajax.Request(url,OnSuccessed,OnFailed);
4: }
5: function OnSuccessed(response)
6: {
7: //重新发送一次请求
8: Request();
9: //处理返回数据
10: }
这样吗,貌似不靠谱啊……
我有几个问题想请教一下:1、文中介绍了一种新的服务器推方案,那在运用中针对的数据库更新不是很频繁的情况吗?如果数据库更新频繁,则触发器不断被触发(如1秒钟触发1次),那给数据库所带来的压力如何?跟原先的直接轮询数据库比怎么样?2、文中提到数据库的触发器可以直接写内存,请问具体使用的是哪个数据库软件?我现在用的mysql似乎不行。3、我现在所做的项目是基于LAMP平台开发的web远程监控系统,在实时监控方面,采用了ajax技术,即客户端定期发起请求,服务器端收到请求后去数据库查询相应的数据并返回,实现无页面刷新的数据更新。现在我想对此进行改进,可采用comet(文中提到的long polling算是comet的其中一种吧~)或websocket方式实现服务器主动推送数据到客户端,但用这两种方式都存在同一个问题,就是服务器端如何知道数据库中的数据更新了,一种方式是效仿文中所写的内容,但是感觉现有的lamp平台不支持;另一种方式是服务器直接轮询数据库;还有一种方式是写mysql的用户自定义函数,当数据库中有新的数据时会调用该函数,由服务器推送到客户端。我目前所要处理的数据更新都比较频繁,上面几种方式采用哪种方式比较好?或者还有没有其他更好的方式?非常感谢!!!
1:即便数据库频繁更新,触发器带来的压力也远远小于轮询。
2:单纯使用触发器当然是不可能直接操作Nginx共享内存的,但是你可以结合gearman-mysql-udf之类的第三方扩展来实现。
3:如果服务端架构已经确定是LAMP,通常轮询会是更稳妥的选择,因为一旦使用Comet,在LAMP架构的局限下,你只能采用轮询数据库的方案,如果用户量较大,一来DB服务器压力骤增;二来Web服务器不得不Hold住大量PHP链接,内存等资源成问题。
socket.io + node.js + redis(pubsub)
socket.io + node.js + polling redis
条条大路通罗马,也许直接用 ngx 的 nginx-push-stream-module 模块更快更好呢