高并发下的读服务

系统背景
    几乎所有的互联网系统从开始都是一体化设计的,基本上所有的功能代码都是耦合在一起的。后续随着用户的不断增多业务也越来越多样化,系统需要的维护人员也会越来越多,相应的系统的复杂度、稳定性、可维护性也就越来越难控制,这时系统的拆分以及服务化就成了必然的选择。
    系统被拆分后实现方式也就多样化起来,各个系统可以根据自己的业务需求、技术特性、方便程度、甚至个人喜好来选择使用不同的语言;服务化后各种功能被拆分的越来越细,原来可能一次请求能够完成的事,现在就需要多次请求并将结果进行融合。
     服务化的好处是系统的职责变得清晰,可以突破单一资源限制等,比如突破数据库连接资源的限制(包括关系型数据库、非关系型数据库);不太友好的地方如服务分化、治理复杂等,比如页面要展示一个商品就需要调用库存、商品、价格、促销等各种服务。
    像库存、商品、价格等这些服务的体量(访问量+数据量)是很大的因此将他们拆成单一的服务(一个系统只提供一种服务)是很有必要的。对于体量不够大的服务,放在一个系统更便于使用和维护;另外一些业务性比较强的服务,在依赖其它服务同时,又被其它服务依赖,很难划分它属于那个系统;还有一种服务很可能生的快死的也快(比如为一次促销单独定制的业务);基于以上各种原因就产生了这样一个以读为主的系统。


系统特点
  提供的服务多
  依赖的数据源多样化;数据库、http接口、jsf(公司内部rpc框架)接口等
  系统以读为主
  整体服务加起来体量大(访问量+数据量)
  需要快速响应
  服务之间相互影响性要小


基本原则
根据以上系统特点,我们实现该系统时遵循以下几个大的原则
  用http协议对外通信
  使用短连接
  数据异构
  巧用缓存
  流量控制
  异步、并行
  数据托底
  防刷
  降级
  多域名
  多机房部署


用http协议对外通信

  前面提到服务化后各个系统使用的语言可以不相同,对于使用同一种语言实现的不同系统,可以指定语言相关的协议进行通信,比如jsf(公司内部rpc框架);不同语言的系统之间就需要找一个通用的协议来通信: soap简单对象访问协议是一种非语言相关的通信协议, 以http协议为载体进行传输,虽然有各种辅助框架,但它还是太重了;相比较http从便捷和使用范围上有绝对的优势,所以本系统以http协议对外提供服务。

使用短连接
   http协议本身是工作在tcp协议上的,这里说的长连接短连接本质上只的是tcp的长短连接。所谓的长连接顾名思义就是用完之后不立即断开连接,何时断开取决于上层业务的设置和底层协议是否发生异常;短连接就比较干脆,干完活马上就将连接关闭,过完河就拆桥。
   在http中开启长连接需要在协议头中加上Connection:keep-alive,当然最终是否使用长连接通信是需要双方进行协商的,客户端和服务端只要有一方不同意,则开启失败。长连接因为可以复用链路,所以如果请求频繁,可以减少链接的建立和关闭时间,从而节省资源。
  http1.0默认使用短连接,http1.1中开启短连接需要在协议头上加上Connection:close,如何单个客户请求频繁,tcp连接的建立和关闭多少会浪费点资源。
   既然长连接这么”好”,短连接那么”不好”为什么还要使用短连接呢?我们知道这个”连接”实际上是tcp连接。Tcp连接是有一个四元组表示的,如[源ip:源port—目标ip:目标port]。从这个四元组可以看,到理论上可以有无数个连接, 但是操作系统能够承受的连接可是有限的,假设我们设置了长连接,那么不管这个时间有多短,在高并发下server端都会产生大量的tcp连接,操作系统维护每个连接不但要消耗内存也会消耗cpu,在高并发下维护过多的活跃连接风险可想而知。而且在长连接的情况下如果有人搞恶意攻击,创建完连接后什么都不做,势必会对server产生不小的压力。所以在互联网这种高并发系统中,使用短连接是一个明智的选择。对于服务端因使用短连接而产生大量的TIME_WAIT状态的连接,可以更改系统的一些内核参数来控制,比如net.ipv4.tcp_max_tw_buckets、net.ipv4.tcp_tw_recycle、net.ipv4.tcp_tw_reuse等参数。(注:非专业人士调优内核参数要慎重)

    具体TIME_WAIT等tcp的各种状态这里不再详述,给出一个简单状态转换图供参考

  

数据异构
    一个大的原则,依赖的服务都是不可靠的,是随时都可能出问题的。对于依赖服务的数据,能异构的就要拿过来,有了数据就可以做任何你想做的事,有了数据,依赖服务再怎么变着花的挂,对你的影响也是有限的。
   异构时可以将数据打散,将数据原子化,这样在向外提供服务时,可以任意组装拼合。


巧用缓存
   应对高并发系统,缓存是必不可少的利器,巧妙的使用缓存会使系统的性能有质的飞跃,下面就介绍一下本系统使用缓存的几种方式。

使用redis缓存
  首先看一下使用redis缓存的简单数据流向图:
 

  很典型的使用缓存的一种方式,这里先重点介绍一下在缓存命中与不命中时都做了哪些事。
  当用户发起请求后,首先在nginx这一层直接从redis获取数据, 这个过程中nginx使用lua-resty-redis操作redis,该模块支持网络socket和unix domain socket。如果命中缓存,则直接返回客户端,如果没有则回源请求数据,这里要记住另一个原则,不可”随意回源”(为了保护后端应用)。为了解决高并发下缓存失效后引发的雪崩效应,我们使用lua-resty-lock(异步非阻塞锁)来解决这个问题。
  很多人一谈到锁就心有忌惮,认为一旦用上锁必然会影响性能,这种想法是不妥的。我们这里使用的lua-resty-lock是一个基于nginx共享内存(ngx.shared.DICT)的非阻塞锁(基于nginx的时间事件实现),说它是非阻塞的是因为他不会阻塞nginx的worker进程,当某个key(请求)获取到该锁后,后续试图对该key再一次获取锁时都会”阻塞”在这里,但不会阻塞其它的key。当第一个获取锁的key将获取到的数据更新到缓存后,后续的key就不会再回源后端应用了,从而可以起到保护后端应用的作用

  下面贴一段从官网弄过来的简化代码,详细使用请移步https://github.com/openresty/lua-resty-lock

local resty_lock = require "resty.lock";
local redis = requer “redis”;
local val, _ = redis:get(key);--从缓存获取数据
--命中缓存
if val then 
	ngx.print(val);
	return;
end 

--未命中缓存
local lock = resty_lock:new(“共享缓存”);
local elapsed, err = lock:lock(key); --试图获取锁,如果锁以被占用则当前请求被阻塞
if not elapsed then
    if err == “timeout” then
        --锁超时,被自动释放,根据自己的业务情况选择后续动作
        do something
        return;
     end 
     
      do something
      return;
end

--第一次被获取锁的请求可能已经执行完毕,并且更新了缓存
--后续该key表示的其它请求直接再次从缓存取数据
local val, _ = redis:get(key);
if val then
   lock:unlock();--释放锁
   …
   ngx.print(val);
   return;
end

--第一次获取锁,回源
local val = backend(key);
lock:unlock();--释放锁
ngx.print(val);

return;


使用nginx共享缓存
   上面提到的redis缓存,即使redis部署在本地仍然会有进程间通信、内核态和用户态的数据拷贝,使用nginx的共享缓存可以将这些动作都省略掉。
   nginx共享缓存是worker共享的,也就是说它是一个全局的缓存,使用nginx的lua_shared_dict
配置指令定义。语法如下:

  
#指定一个100m的共享缓存
lua_shared_dict cache 100m;


  数据流向图如下:
 

缓存分片
   当缓存数据的总量大到一定程度后,单个redis实例就会成为瓶颈,这时候就要考虑分片了,具体如何分片可以根据自己的系统特性来定,如果不是对性能有苛刻的要求,可以直接使用一些redis代理(如temproxy),代理对redis性能会有一定的损耗。使用代理的另一个好处是它支持多种分片算法,而且对用户是透明的。我们这里没有选择代理,而是自己实现了一个简单的分片算法。
  该分片算法在java端,基于jedis扩展出一套取摸算法,用它向redis写数据。nginx这端使用lua+c实现同样的算法从redis读数据。
另一种是对nginx的共享缓存(dict)做分片,dict本身使用自旋锁加红黑树实现,它这个锁是一个阻塞锁,所以当缓存在dict中的数据量和访问并发量大到一定程度后,对其分片也是必须的了。


缓存数据切割
    早前阅读redis代码发现redis在每个事件循环中,一次最多向某个连接吐64K的数据,也就是说当缓存的数据大于64K时,至少需要两个事件循环才能将数据吐完。当然,在网络发生拥堵或者对端处理数据慢时,即使缓存数据小于64K,也不能保证在一个事件循环内吐完数据。基于这种情况,我们可以考虑当数据大于某个阀值时,将数据切割成多个小块,然后将其放到不同的redis上。
   简单描述下实现方式:
   1. 存储时先判断数据大小(数据大小用n表示,阀值用a表示),如果n大于a则代表需要将数据切割存储,切割的块数用b表示,b是redis的实例个数,用n整除b得出的数c是要切割的数据块(前b-1块)的大小,最后一块数据的大小是n-c*2。存储前生成一个版本号,将这个版本号放到被切割块的第一个字节,然后按照顺序异步将其存入各个redis中,最后再为代表该数据的key打上标记,标记该key的数据是被切割的。
   2. 取数据时先检查该key是否被标记,如果被标记则使用ngx.thread.spawn(),按照顺序异步并行向各个redis发送get命令,然后对比获取到的所有数据块的第一个字节,如果比对一直,则拼装输出。
    注: 这种算法用在nginx的共享缓存不会有性能的提升,因为共享缓存的操作都是阻塞操作,只有支持非阻塞操作的网络通信才会对性能有提升。


缓存更新
   根据业务的不同,缓存更新的方式也各有不同,一种容易带来隐患的方式是被动更新,这种更新方式,在缓存失效后需要通过回源的方式来更新缓存,这时需要运用多种手段来控制回源量(比如前面说到的非阻塞锁)。
   另一种我们称之为主动更新,主动更新一般有消息、worker(定时器)等方式,使用消息方式可以确保数据实时性比较高,worker方式实时性要稍微差一点。实际项目中使用哪种方式更新缓存,可以从可维护性、安全性、业务性、实时性等方面找一个平衡点,以便选择适合自己的更新方式。


数据一致性
   为了保证服务快速响应,我们的redis都是部署在本地的,这样可以减少网络传输消耗,也可以避免缓存和应用之间网络故障造成的风险。另外单机部署会造成互相数据不一致,为了解决这个问题,我们使用了redis的主从功能,并且redis以树形结构进行部署,这时每个集群一个主redis,同一个集群中的服务都向主redis写数据, 由主redis将数据逐个同步下去,每个服务器只读自己本机的redis。
 
   redis的部署结构像这样:

   
   在描述缓存更新时,提到了worker更新,基于上述的redis部署方式,我们用worker更新缓存时会存在一定的问题。如果所有的机器都部署了worker,那么这些worker会在某个时刻会同时执行,这显然是不可行的。如果我们每个集群都部署一个worker,那么势必造成单点问题。基于以上问题我们实现了一种分布式worker,这种worker基于redis以集群为单位,在一个时间段内(比如3分钟)只会有一个worker被启动,这样既可以避免worker单点,又可以保持代码的统一。
  


流量控制
  这一原则主要为了避免系统过载,可以采用多种方式达到此目的。流量控制可以在前端做(nginx),也可以在后端做。
  我们知道servelt2.x在处理请求时,用的是多线程同步模型,每一个请求都会创建一个线程,然后同步的执行该请求,这个模型受限于线程资源的限制,很难产生大的吞吐量,而且某个业务阻塞就会引起连锁反应。基于servlet2.x的容器我们一般采用池化技术和同步并行操作,使用池化技术可以将资源进行配额分配,比如数据库连接池;同步并行需要业务特性的支持,比如一个请求依赖多个后端服务,如果这些后端服务在业务上没有一个先后顺序的依赖,那么我们完全可以将这些服务放到一个线程池中去并行执行,说它同步是因为我们需要等到所有的服务都返回结果后才能继续向下执行。
   目前servlet3支持异步请求,是一种多线程异步模型, 它的每个请求仍然要使用一个线程,只不过可以进行异步操作了。这种模型的一个优点是可以按业务来分配请求资源了,比如你的系统要向外提供10种服务,你可以为每种服务分配一个固定的线程池,这样服务之间就可以相互隔离。缺点是由于是异步,所以就需要各种回调,开发和维护成本高;同事有一个项目用到了servelt3,测试结果显示这种方式不但不会获得更短的响应时间,反而响应时间会有稍微下降,但是吞吐量确实有提升。所以最终是否使用这种方式,取决于你的系统更倾向于完成哪些特性。
除了在后端进行流量控制,还可以在nginx层做控制。目前在nginx层有多种模块可以支持流量控制,如ngx_http_limit_conn_module 、ngx_http_limit_req_module、lua-resty-limit-traffic(需安装lua模块)等,限于篇幅,使用方式就不在详述,感兴趣可到官网查看。


数据托底
  生产环境中有些服务可能非常重要,需要保证绝对可用,这时如果业务允许,我们就可以为其做个数据托底。
  托底方式非常多,这里简单介绍几种。一种是在应用后端进行数据托底,这种方式比较灵活,可以将数据存储在内存、磁盘等各种设备上,当发生异常时可以返回托底数据,缺点是和后端应用高度耦合,一旦应用容器挂掉托底也就不起作用了。
  另一种方式将托底功能跟应用剥离出来,可以使用lua的方式在nginx做一层拦截,用每次回源返回的正确数据来更新托底数据(这个过程可以做各种校验),当服务或应用出问题时可以直接从nginx层返回数据。
  还有一种是使用nginx的error_page指令,简单配置如下:

server {
    listen       80;
    server_name  v.jd.com;		
    
    proxy_intercept_errors on
    recursive_error_pages on;
    error_page 404  500 502 = @error;
    
    location @error {
           root /path/to/html;
           types {}
           default_type "text/html";
           charset utf-8;
           rewrite / /bottom.html break; 
           error_page 404 = http://www.jd.com/;
    }

}



降级
    降级的意义其实和流量控制的意义差不多,都是为了确保系统负载稳定。当线上流量超过我们预期时,为了降低系统负载就可以实施降级了。
   降级的方式可以是自动降级,比如我们对一个依赖服务可以设置一个超时,当超过这个时间时就可以自动的返回一个默认值(前提是业务允许)。
   手动降级,提前为某些服务设置降级开关,出现问题是可以将开关打开,比如前面我们说到了有些服务是有托底数据的,当系统过载后我们可以将其降级到直接走托底数据。

防刷
   对于一些有规律入参的请求,我们可以严格检验入参,从而规避非法入参穿透缓存的行为(比如一些爬虫程序无限制的猜测商品价格),这种方式可以在前端(nginx)做,也可以在后端(tomcat)做,推荐在nginx层做。
   在nginx层做入参校验的例子:

# 假设入参为skuid
location ~ ^/demo/get$ {
	if ($arg_skuid !~ “^\d{5,10}$”) { #限制skuid必须是5到10位的数字
	return 200 “error”;
}

# 如果入参比较复杂,可以在lua中校验
location ~ ^/demo/get$ {
	content_by_lua ‘
            local skuid = ngx.var.arg_skuid;
            check skuid
            if pass then
	        do someting
	     end
	     …
        ’
}

  使用计数器识别恶意用户,比如在一段时间内为每个用户或ip等记录访问次数,如果在规定的时间内超过规定的次数,则做一些应对策略。
  对恶意用户设置黑名单,每次访问都检查是否在黑名单中,存在就直接拒绝。
  使用cookie,如果用户访问时没有带指定的cookie,或者和规定的cookie规则不符,则做一些应对策略。
  通过访问日志实时计算用户的行为,发现恶意行为后对其做相应的对策。


多域名
   除正常域名外,为系统提供其它访问域名。使用cdn域名缩短用户请求链路;使用不带cookie的域名,降低用户请求流量;

系统部署
  多集群多机房部署,提高系统可用性。

总结

以上大致介绍了开发以读为主系统的一些基本原则,用好这些原则,单台机器每天抗几十亿流量不是问题。另外上面提到的好多原则,限于篇幅并没有详细展开描述,后续有时间再详细展开。

猜你喜欢

转载自deyimsf.iteye.com/blog/2310071
今日推荐