redis使用心得+高并发httpclient的理解

最近有一些关于redis和httpclient的心得,这里及时整理一下算是加深对这一块的理解吧。

redis作为单线程内存存储方案用处非常多,其中一个很重要的原因就是它的5种很方便的数据结构决定了它不仅仅是类似memcache一个缓存,它可以在业务中用很多巧妙的用处。redis的心得如下:

1、老实说和维护mysql一样,个人真的很难维护好redis,就我个人经验不管在windows还是linux下搭redis都很容易出问题,所以我建议还是直接购买云服务商提供的现成redis。阿里云的单机热备和集群方案做的很稳定。

2、另外找一支好用的GUI管理工具比敲命令行方便很多,干起活来事半功倍。

3、redis中各个应用都应该有自己的前缀,这一点不说多说了。如果你要在全局存string类型的数据但是又用不到expire功能的话,劝你还是把数据存到一个hash里,否则以后你用KEYS*或者SCAN来查看数据的时候会很困难。

4、不要使用KEYS*或者对一个数据很多的hash使用hgetall操作,这会遇到严重性能问题,用SCAN和HSCAN。(注意hmget的坑)

5、对每个全局的key,尽量在名字中带上数据类型,比如一个list可以命名成prefix:test_list这样,一个hash可以命名成prefix:test_hash这样,可以看key知道类型是什么,开发不容易出错。

6、redis的list完全作为一个无状态的轻量级消息队列是可行的,比如:http://stackoverflow.com/questions/7506118/rabbitmq-activemq-or-redis-for-over-250-000-msg-s,lpush外加blocking rpop(注意加上timeout,有极小的可能遇到tcp连接错误所以在while循环中注意捕捉异常)。很遗憾redis集群不支持对list的blocking pop操作,所以如果你的qps大于了redis单机的10w qps的话redis就处理不过来了。注意阅读redis官方的文档。

7、消息队列中取出一个消息处理时,我曾经把正在处理的消息放在preserving list中,等消息处理完后再把preserving list中的消息去掉。这实际上会遇到性能问题,当qps很大的时候preserving list中数据量为qps数量级的,list中删除一个元素是O(N),所以必然会遇到性能问题。解决方法很简单,把消息放在preserving hash中,把消息本身作为key而当前时间戳做value,这样可以定时用HSCAN去检查preserving hash中的所有元素,如果发现value时间戳太旧了就说明处理此消息的程序挂了。

8、多个节点公用redis进行合作处理的时候经常会用到时间戳,比如判断某个节点A是否在线需要节点A定时去redis报道留下自己的时间戳(不在全局设置key并且加上expire是因为全局的key没法指定取),这就意味着节点A的系统时间必须要是准确的。所以开发系统的时候最好在程序启动时去互联网检测自己系统时间是否准确。(实践证明这样并不好,更好的方案是一个hset记录各个节点名字,然后各个节点定期去设置一个expire的全局key)

9、取多个数据请用pipeline,tcp来回的消耗是需要考虑的。

10、如果多个节点合作的时候需要锁来保证不会发生数据竞争,那么使用setnx是个很好的办法,不过如果某个取得了setnx锁的程序在释放锁(del锁的key)前挂了,那么就死锁了。于是我们在取得setnx之后给key加上exprie就可以补救了呐,可以那程序恰好在setnx之后加expire之前挂了呢?ok,那我们所有的节点每次先去检测锁key的ttl,如果是永久key就给加上expire就好了。

11、redis作为程序的动态参数设置、状态实时统计或者log输出都是可以的,在动态参数设置的时候程序最好自己写一个tick函数没超过几秒种读一次新的参数(否则读参数的qps太高),读参数的时候如果没读到就set一个默认的参数,不要另外在一个专门的初始化函数中在redis中设置初始化参数,当结点多了以后会很麻烦。

12、意思相近的key请把前缀保持相近,这样在管理的时候这些key就是靠近的比较好找。

13、程序的数据存redis里面本质上只有一个string类型可选,所以基本都需要序列化和反序列化(这部分程序自己处理消耗很小),建议就用json不要用各语言自己的序列化函数(虽然更紧凑但是可读性更差)。

14、不要让大量的key在同一时间内expire,redis的expire虽然是惰性删除,但是还是会扫全局的key来删除过期的key的。

15、如果把数据存在hash中是没办法设置expire的,数据量大了以后我们怎么去掉不要的旧数据呢?有个折衷方案就是用UUID作为key,因为UUID中含有时间戳信息,我们用HSCAN的O(N)的复杂度扫完hash再把key中时间戳太旧的hdel掉就ok了。这个方法的坏处就是UUID是32位占用内存比较大,另外需要我们自己写脚本定期去删除hash中的元素。

关于httpclient这里说的不是说同步单请求的httpclient,而是大并发下的httpclient问题,爆栈上有个关于python的thread专门讨论这个问题:https://stackoverflow.com/questions/2632520/what-is-the-fastest-way-to-send-100-000-http-requests-in-python,实际上httpclient在大并发下还有新的挑战:1、就是如果我大并发下针对不同url的http请求返回的response需要不同的处理函数去处理呢? 2、如果我完成某个目的需要针对某个url顺序几次发起http请求呢(这次http的request请求需要上一次http请求的response)?

首先是语言选择,我个人最熟悉php,也调查了一段时间python在这个问题上的解决方案,我也可以对nodejs和java在这个问题上的方案做一点推测。于是干脆就上面的爆栈上面讨论python大并发httpclient问题的多线程解决方案和异步IO调用方案这2种思路来讨论这个问题吧。

1、多线程解决方案,实际上大并发意味着你肯定不能开太多线程,所以你需要维护一个线程池。上面爆栈那个python大并发问题里排名第一的方案也是多线程,但是php说我没有多线程,nodejs说我单线程但是我是异步调用可以处理大并发,java说我开个线程才8M内存。论并发似乎nodejs天生的异步调用是个很好的解决方案,但是多线程方案比起异步事件调用有个好处就是,编码起来比较简单。如果是简单的逻辑,比如我给一个http的request绑定对response返回后用callback函数来处理,这样用异步的确没问题。但是如果当我们需要对某个url顺序的发送几次http请求(这次http的request请求需要上一次http请求的response),那么用异步来做就会很绕,实际上异步事件来处理稍微复杂的逻辑是不合适的。但是python有个问题就是它的多线程由于GIL的存在导致实际上只能用一个核,对应的补救方案就是用multiprocessing库来开多进程+多线程来克服这个缺点,而java就不存在这个问题。那么单线程的php岂不是输的一塌糊涂?别急,其实nodejs、python的多线程都只能用到一个cpu核心,php只能单线程(虽然有多线程版本但是一般不用)也是一样的,所以php在cpu时间上面并没有输。总结一下,多线程解决方案中nodejs异步调用论外,python有个假的多线程,php不支持多线程,而java支持真的多线程,所以多线程解决方案中java无疑是最优的选择。

2、异步IO调用方案,异步调用方案中爆栈的讨论结果可以看出python主要有tornado的异步httpclient(用的libcurl的multi_curl)和grequests(很轻的包装了一下gevent)2个方案,老实说库的质量不怎么样。php的异步方案有ReactPHP(你可以理解成php的nodejs,而Reactphp底层事件库为libevent或者libev)和guzzle(里面的异步并发用的libcurl的multi_curl接口,你也可以用Reactphp接口),guzzle库的质量很不错。实际上py的gevent底层也是libevent或者libev,而nodejs底层也是libevent(nodejs在win下支持iocp很棒),既然都是基于epoll/kqueue实现的全异步非阻塞io,我相信各脚本语言的异步性能不会差太多。关键还是在于异步方式在复杂的逻辑写起来很绕很绕。为了更好的说明这个问题,我就用我比较熟悉的php异步写法来举例子,看guzzle官方的异步io例子:http://docs.guzzlephp.org/en/latest/quickstart.html#concurrent-requests,guzzle的异步httpclient底层(默认libcurl)如果使用ReactPHP(libevent事件库)的handler我相信性能和nodejs一样,但是大家看guzzle的例子中异步的写法觉得还能勉强接受对么?那么如果不同url的http请求需要不同的callback去处理呢?我们可以写出这样的伪代码:

use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Request;
$requests=[];
$callbacks=[];
$res=[];
//a site
$headers=[/*balabla*/];//设置cookie、user agent、proxy等信息
$body=[/*balabla*/];//设置post form之类的信息
//注意Request在$requests中的index应该和对应的匿名函数在$callbacks中的index一样
$requests[]=new Request('POST', 'http://a.com', $headers, $body);
$callbacks[]=function ($response)use(&$res){$res['a.com']=$response->getBody();}

//b site
//c site
//...

$client = new Client();

$pool = new Pool($client, $requests, [
'concurrency' =>5,
'fulfilled' => function ($response, $index)use(&$callbacks){
$callbacks[$index]($response);
},
'rejected' => function ($reason, $index) {
// this is delivered each failed request
},
]);

// Initiate the transfers and create a promise
$promise = $pool->promise();

// Force the pool of requests to complete.
$promise->wait();

虽然绕了点,但似乎还能凑合,那么如果我在处理a.com这个site的时候需要post两次,第二次需要第一次结果呢?于是我们再来写个类似这样的代码:

use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Request;
$requests=[];
$callbacks=[];
$res=[];
//a site
$headers=[/*balabla*/];//设置cookie、user agent、proxy等信息
$body=[/*balabla*/];//设置post form之类的信息
$requests[]=new Request('POST', 'http://a.com', $headers, $body);
$callbacks[]=function ($response)use(&$res, &$requests, &$callbacks)
{
    $body=$response->getBody();
    //处理$body,balabala
    //新的请求
    $headers=[/*balabla*/];//设置cookie、user agent、proxy等信息
    $body=[/*balabla*/];//设置post form之类的信息
    $requests[]=new Request('POST', 'http://a.com', $headers, $body);
    $callbacks[]=function ($response)use(&$res, &$requests)
    {
        $body=$response->getBody();
        $res['a.com']=$body;
    }
}

//b site
//c site
//...

$client = new Client();

$pool = new Pool($client, $requests, [
'concurrency' => 5,
'fulfilled' => function ($response, $index)use(&$callbacks){
$callbacks[$index]($response);
},
'rejected' => function ($reason, $index) {
// this is delivered each failed request
},
]);

// Initiate the transfers and create a promise
$promise = $pool->promise();

// Force the pool of requests to complete.
$promise->wait();

经过一番努力后,似乎解决了顺序2次http调用的问题,那么3次、4次呢?会绝望的,这说明异步调用在逻辑复杂的情况下是不适合的。

总结一下,在httpclient大并发请求下,异步IO调用单线程方案可以在低CPU消耗下得到大并发,异步单线程的http服务器方案中nodejs、ReactPHP都可以承受很大并发的(所以说换成httpclient本质上是一样的),但是如果逻辑复杂了就会给编码造成很大困难。多线程方案中为了支持高并发需要很大的线程池,而脚本语言都没办法真正利用多核,而且线程直接的切换开销会很大很大,但是编码非常简单容易维护,如果想利用多核可以采用脚本语言的某些不那么完美的多进程/多线程方案(比如py的multiprocessing,php的pthreads,nodejs的Cluster等等)。

以上就是我对httpclient的大并发异步调用的理解。

redis使用心得+高并发httpclient的理解》有4个想法

发表评论

电子邮件地址不会被公开。 必填项已用*标注