聊聊高并发

来源:互联网 发布:客服系统组成部分知乎 编辑:程序博客网 时间:2024/06/04 00:51

聊聊高并发之隔离术

    博客分类: 
  • 架构

隔离是指将系统或资源分割开,系统隔离是为了在系统发生故障时能限定传播范围和影响范围,即发生故障后不会出现滚雪球效应,从而保证只有出问题的服务不可用,其他服务还是可用的;而资源隔离有脏数据隔离、通过隔离后减少资源竞争提升性能等。我遇到的比较多的隔离手段有线程隔离、进程隔离、集群隔离、机房隔离、读写隔离、动静隔离、爬虫隔离等。而出现系统问题时可以考虑负载均衡路由、自动/手动切换分组或者降级等手段来提升可用性。

 

线程隔离

线程隔离主要有线程池隔离,在实际使用时我们会把请求分类,然后交给不同的线程池处理,当一种业务的请求处理发生问题时,不会将故障扩散到其他线程池,从而保证其他服务可用。



我们会根据服务等级划分两个线程池,以下是池的抽象:

<bean id="zeroLevelAsyncContext" class="com.jd.noah.base.web.DynamicAsyncContext" destroy-method="stop">
<property name="asyncTimeoutInSeconds" value="${zero.level.request.async.timeout.seconds}"/>
<property name="poolSize" value="${zero.level.request.async.pool.size}"/>
<property name="keepAliveTimeInSeconds" value="${zero.level.request.async.keepalive.seconds}"/>
<property name="queueCapacity" value="${zero.level.request.async.queue.capacity}"/>
</bean>
<bean id="oneLevelAsyncContext" class="com.jd.noah.base.web.DynamicAsyncContext" destroy-method="stop">
<property name="asyncTimeoutInSeconds" value="${one.level.request.async.timeout.seconds}"/>
<property name="poolSize" value="${one.level.request.async.pool.size}"/>
<property name="keepAliveTimeInSeconds" value="${one.level.request.async.keepalive.seconds}"/>
<property name="queueCapacity" value="${one.level.request.async.queue.capacity}"/>
</bean>

 

进程隔离

在公司发展初期,一般是先进行从0到1,不会一上来就进行系统的拆分,这样就会开发出一些比较大而全的系统,系统中的一个模块/功能出现问题,整个系统就不可用了。首先想到的解决方案是通过部署多个实例,然后通过负载均衡进行路由转发,但是这种情况无法避免某个模块因BUG而出现如OOM导致整个系统不可用的风险。因此此种方案只是一个过渡,较好的解决方案是通过将系统拆分为多个子系统来实现物理隔离。通过进程隔离使得某一个子系统出现问题不会影响到其他子系统。


  

集群隔离

随着系统的发展,单实例服务无法满足需求了,此时需要服务化技术,通过部署多个服务,形成服务集群来提升系统容量,如下图所示



随着调用方的增多,当秒杀服务被刷会影响到其他服务的稳定性,此时应该考虑为秒杀提供单独的服务集群,即为服务分组,从而当某一个分组出现问题不会影响到其他分组,从而实现了故障隔离,如下图所示

 
 比如注册生产者时提供分组名:

<jsf:provider id="myService" interface="com.jd.MyService" alias="${分组名}" ref="myServiceImpl"/>

消费时使用相关的分组名即可:

<jsf:consumer id="myService" interface="com.jd.MyService" alias="${分组名}"/>

 

机房隔离

随着对系统可用性的要求,会进行多机房部署,每个机房的服务都有自己的服务分组,本机房的服务应该只调用本机房服务,不进行跨机房调用;其中一个机房服务发生问题时可以通过DNS/负载均衡将请求全部切到另一个机房;或者考虑服务能自动重试其他机房的服务从而提升系统可用性。
 

 

一种办法是根据IP(不同机房IP段不一样)自动分组,还一种较灵活的办法是通过在分组名中加上机房名解决:

<jsf:provider id="myService" interface="com.jd.MyService" alias="${分组名}-${机房}" ref="myServiceImpl"/>
<jsf:consumer id="myService" interface="com.jd.MyService" alias="${分组名}-${机房}"/>

 

读写隔离

如下图所示,通过主从模式将读和写集群分离,读服务只从从Redis集群获取数据,当主Redis集群出现问题时,从Redis集群还是可用的,从而不影响用户访问;而当从Redis集群出现问题时可以进行其他集群的重试。



 

--先读取从
status, resp = slave_get(key)
if status == STATUS_OK then
   return status, value
end
--如果从获取失败了,从主获取
status, resp = master_get(key)

 

动静隔离

当用户访问如结算页时,如果JS/CSS等静态资源也在结算页系统中时,很可能因为访问量太大导致带宽被打满导致出现不可用。


 

因此应该将动态内容和静态资源分离,一般应该将静态资源放在CDN上,如下图所示


 

爬虫隔离

在实际业务中我们曾经统计过一些页面型应用的爬虫比例,爬虫和正常流量的比例能达到5:1,甚至更高。而一些系统是因为爬虫访问量太大而导致服务不可用;一种解决办法是通过限流解决;还一种解决办法是在负载均衡层面将爬虫路由到单独集群,从而保证正常流量可用,爬虫流量尽量可用。

 

比如最简单的使用Nginx可以这样配置:

 

set $flag 0; 
if ($http_user_agent ~* "spider") {
   set $flag "1";
}
if($flag = "0") {
   //代理到正常集群
}
if ($flag = "1") {
   //代理到爬虫集群
}

 

实际场景我们使用了Openresty,不仅仅对爬虫user-agent过滤,还会过滤一些恶意IP(统计IP访问量,配置阀值),将他们分流到固定分组。还有一种办法是种植Cookie,访问特殊服务前先种植Cookie,访问服务时验证该Cookie,如果没有或者不对可以考虑出验证码或者分流到固定分组。

 

热点隔离

秒杀、抢购属于非常合适的热点例子;对于这种热点是能提前知道的,所以可以将秒杀和抢购做成独立系统或服务进行隔离,从而保证秒杀/抢购流程出现问题不影响主流程。

 

还存在一些热点可能是因为价格或突发事件引起的;对于读热点我使用多级缓存搞定;而写热点我们一般通过缓存+队列模式削峰,可以参考《前端交易型系统设计原则》。

 

资源隔离

最常见的资源如磁盘、CPU、网络;对于宝贵的资源都会存在竞争问题。

 

在《构建需求响应式亿级商品详情页》中我们使用JIMDB数据同步时要dump数据,SSD盘容量用了50%以上,dump到同一块磁盘时遇到了容量不足的问题,我们通过单独挂一块SAS盘来专门同步数据。还有如使用Docker容器时,有的容器写磁盘非常频繁,因此要考虑为不同的容器挂载不同的磁盘。

 

默认CPU的调度策略在一些追求极致性能的场景下可能并不太适合,我们希望通过绑定CPU到特定进程来提升性能。如我们一台机器会启动很多个Redis实例,通过将CPU通过taskset绑定到Redis实例上可以提升一些性能;还有Nginx提供了worker_processes和worker_cpu_affinity来绑定CPU。还有如系统网络应用比较繁忙的话,可以考虑绑定网卡IRQ到指定的CPU来提升系统处理中断的能力,从而提升性能。

 

还有如大数据计算集群、数据库集群应该和应用集群隔离到不同的机架,并尽量隔离网络;因为大数据计算或数据库同步时时会有比较大的网络带宽,可能拥塞网络导致应用响应慢。

 

还有一些其他类似的隔离术,如环境隔离(测试环境、预发布环境/灰度环境、正式环境)、压测隔离(真实数据、压测数据隔离)、ABTest(为不同的用户提供不同版本的服务)、缓存隔离(有些系统混用缓存,而有些系统会扔大字节值到如Redis,造成Redis慢查询)、查询隔离(简单、批量、复杂条件查询分别路由到不同的集群)等。通过隔离后可以将风险降低到最低、性能提升至最优。

 


聊聊高并发系统之队列术

    博客分类: 
  • 架构

队列在数据结构中是一种线性表,从一端插入数据,然后从另一端删除数据。本文目的不是讲解各种队列算法,而是在应用层面讲述使用队列能解决哪些场景问题。

 

在我开发过的系统中,不是所有的业务都必须实时处理、不是所有的请求都必须实时反馈结果给用户、不是所有的请求/处理都必须100%处理成功、不知道谁依赖“我”的处理结果、不关心其他系统如何处理后续业务、不需要强一致性,只需保证最终一致性即可、想要保证数据处理的有序性;此时你应该考虑使用队列来解决这些问题。在实际开发时我们经常使用队列进行异步处理、系统解耦、数据同步、流量削峰、缓冲、限流等。

 

应用场景

异步处理:使用队列的一个主要原因是进行异步处理,比如用户注册成功后需要发送注册成功邮件/新用户积分/优惠券等等、缓存过期时先返回老的数据,然后异步更新缓存、异步写日志等;通过异步处理,可以提升主流程响应速度,而非主流程/非重要业务可以异步集中处理,这样还可以将任务聚合然后批量处理;因此可以使用消息队列/任务队列来进行异步处理。

 

系统解耦:比如用户成功支付完成订单后,需要通知生产配货系统、发票系统、库存系统、推荐系统、搜索系统、风控系统等进行业务处理;而未来需要添加/支持哪些业务是不清楚的,而且这些业务处理不需要实时处理、不需要强一致,只需要最终一致性即可,因此可以通过消息队列/任务队列进行系统解耦。

 

数据同步:比如想把Mysql变更的数据同步到Redis、或者将Mysql数据同步到Mongodb、或者机房间数据同步、或者主从数据同步等,此时可以考虑使用如databus、canal、otter。使用数据总线队列进行数据同步的好处是可以保证数据修改的有序性。

 

流量削峰:系统瓶颈一般在数据库上,比如扣减库存、下单等;此时可以考虑使用队列将变更请求暂时放入队列,通过缓存+队列暂存的方式将数据库流量削峰;还有如秒杀系统,下单服务会是该系统的瓶颈,此时会使用队列进行排队和限流,从而保护下单服务。通过队列暂存或者队列限流来削峰。

 

比如减库存,可以考虑这样设计:


 

直接在Redis中扣减,然后记录下扣减日志(FIFO队列),通过Worker去同步到DB。

 

实际队列的应用场景还是非常多的,本文列举了笔者遇到过比较多的场景。

 

缓冲区队列

典型的如Log4j的日志缓冲区,当我们使用log4j记录日志时,可以配置字节缓冲区,字节缓存区满时会立即同步到磁盘(flush操作)。Log4j使用BufferedWriter实现的;此模式不是异步写,在缓冲区满的时候还是会阻塞主线程。如果需要异步模式可以使用AsyncAppender,然后通过bufferSize控制日志事件缓冲区大小。

 

通过缓冲区队列可以实现:批量处理、异步处理。

 

任务队列

使用任务队列将一些不需要与主线程同步执行的任务扔到任务队列异步处理即可;笔者用的最多的是线程池任务队列(默认LinkedBlockingQueue)和Disruptor任务队列(RingBuffer)。如刷数据时,将任务扔到队列异步处理即可,处理成功后再异步通知用户;还有如删除SKU操作,用户请求时直接将任务分解并扔到队列,异步处理,处理成功后异步通知用户即可;还有如查询聚合,将多个可并行处理的任务扔到队列然后等待最慢的一个返回。如果使用的是内存任务队列请记住可能存在系统重启等问题造成的数据丢失。

 

通过任务队列可以实现:异步处理、任务分解/聚合处理。

 

注:JDK7提供了ExecutorService的新的实现ForkJoinPool,其提供了Work-stealing机制,可以更好地提升并发效率。

 

在使用Executors.newFixedThreadPool时,其没有设置队列大小(默认Integer.MAX_VALUE),如果有大量任务被缓存到LinkedBlockingQueue中等待线程执行,会出现GC慢等问题,造成系统响应慢甚至OOM。因此在使用线程池时候,要指定队列大小并设置合理的RejectedExecutionHandler;要记录请求来源的参数方便定位引发问题的源头。

 

消息队列

笔者所在公司使用的是自研的JMQ;开源的有ActiveMQ、Kafka、Redis。使用消息队列存储各业务数据,其他系统根据需要订阅即可。常见的模式是:点对点(一个消息只有一个消费者)、发布订阅(一个消息可以有多个消费者);而常用的是发布订阅模式。

 

比如用户注册成功、修改商品数据、订单状态变更等都应该将变更发送到消息队列,从而其他系统根据需要订阅该消息,然后按照自己的需求进行业务逻辑开发。

 

在添加新功能时,消息消费者只需要订阅该消息,然后开发相应的业务逻辑,消息生产者根本不关心你怎么使用消息和你做什么业务处理。


 

同步调用,添加什么新功能都需要到用户系统提需求。其中一个服务出现问题了,整个服务就不可用了。


 

消息队列,用户系统只需要发布用户注册成功的消息即可,相关系统订阅该消息,然后执行相关的业务逻辑。相关服务出问题不影响到注册主流程。

 

通过消息队列可以实现:异步处理、系统解耦。

 

请求队列

请求队列是指如在Web环境下对用户请求排队,从而进行一些特殊控制:流量控制、请求分级、请求隔离;如将请求按照功能划分到不同的队列,从而使得不同的队列出现问题后相互不影响;还可以对请求分级,一些重要请求可以优先处理(发展到一定程度应将功能物理分离);还有服务器处理能力有限,在接近服务器瓶颈时需要考虑限流,最简单的限流时丢弃处理不了的请求,此时可以使用队列进行流量控制。

 

数据总线队列

一般消息队列中的消息都是业务维度的,比如业务键或者业务状态等,比如哪个SKU变更了,而有些订阅者需要再查一遍来获取最新的修改数据(比如缓存同步);通过现有的消息队列方式的缺点是很难只进行修改部分的推送和保证数据有序性。而此种场景比较适合使用数据总线队列实现。如数据库数据修改后需要同步数据到缓存,或者需要将一个机房数据同步到另一个机房,只是数据维度的同步,此时应该使用数据总线队列如canal、otter、databus;使用数据总线队列的好处是可以保证数据的有序性。

 

混合队列

在《构建需求响应式亿级商品详情页》曾介绍过该方式的队列,使用混合队列来解决实际问题。


 

此处MQ是使用京东自研的JMQ,消息是可靠持久化存储的;应用会按照不同的维度发布消息到JMQ;下游应用接收到该消息后会放入到Redis,使用Redis List来存储这些任务;应用将Redis消息消费处理后,会按照不同的维度聚合商品消息然后再次发送出去。

 

使用Redis队列的主要原因是想提升消息堆积能力和并发处理能力。另外在使用Redis构建消息队列时需要考虑网络抖动造成的消息丢失问题,因为Redis是没有回滚事务的,或者说是确认机制。我们使用如下方式防止消息丢失:

try {
    id =
queueRedis.opsForList().rightPopAndLeftPush(queueName, processingQueueName);
}
catch (Exception e) {
   
//发生了网络异常,需要把processing中的id再放回到waiting queue
   
String msg = queueName + " to " + processingQueueName + " rpoplpush error";
   
LOG.error(msg, e);
    //
报警代码
}

 

而对于失败我们会进行重试三次,重试失败后放入失败队列,而失败队列是具有防重功能的(从本地队列和失败队列排重),使用的是Redis Lua脚本实现:

static EventQueueScript ADD_TO_FAIL_QUEUE_REDIS_SCRIPT = new EventQueueScript(
       
"redis.call('lrem', KEYS[1], 1, ARGV[1]) redis.call('lrem', KEYS[2], 1, ARGV[1]) return redis.call('lpush', KEYS[2], ARGV[1])"
);

 

Redis作者Antirez开发的内存分布式消息队列Disque是未来更好的内存消息队列选择。

 

其他

优先级队列:在实际开发时肯定有些任务是紧急的,此时应该优先处理紧急的任务;所以请考虑对队列进行分级。

副本队列:在进行一些系统重构或者上新的功能时,如果没有足够的信心保证业务逻辑正确,可以考虑存储一份队列的副本(比如1小时、1天的),从而当业务出现问题时可以对这些消息进行回放。

镜像队列:每个队列不会无限制订阅数量,一定会有一个极限的;当到达极限时请考虑使用镜像队列方式解决该问题。

队列并发数:不同队列实现,队列服务端并发连接数是不一样的;一定不是增大队列并发连接数消费能力也随着增加;也不会因为增加了消费服务器消费并发能力也随着增加,需要根据实际情况来设置合理的并发连接数。

推还是拉:消息体内容不是越全越好,需要根据具体业务设计消息体;如有些系统依赖商品变更消息(只有一个SKU)、有些系统依赖商品状态消息(SKU、状态)、有些系统依赖商品属性变更消息(SKU、变更的属性)等,如果让所有系统都消费商品变更消息,那么这些系统都会调用商品查询服务拉一下最新的商品信息然后进行处理。因此要根据实际情况来决定是使用推送方式(将系统需要的所有信息推过去)还是拉取方式(只推送ID,然后再查一遍)。

消息合并:如果消息写入量非常大,应该考虑将消息合并写,可以"写应用本地磁盘队列"-->“同步本地磁盘队列到消息中间件;同步时可以根据需求制定同步策略,如1秒同步1次。



聊聊高并发系统之HTTP缓存

    博客分类: 
  • 架构
cachenginx

简介

最近遇到很多人来咨询我关于浏览器缓存的一些问题,而这些问题都是类似的,因此总结本文来解答以后遇到类似问题的朋友。

 

因本文主要以浏览器缓存场景介绍,所以非浏览器场景下的一些用法本文不会介绍,而且本文以chrome为测试浏览器。

 

浏览器缓存是指当我们使用浏览器访问一些网站页面或者http服务时,根据服务端返回的缓存设置响应头将响应内容缓存到浏览器,下次可以直接使用缓存内容或者仅需要去服务端验证内容是否过期即可。这样的好处可以减少浏览器和服务端之间来回传输的数据量,节省带宽提升性能。

 

首先看个例子;当我们第一次访问http://item.jd.com/1856588.html时将得到如下响应头:



                              

然后接着按F5刷新页面,将得到如下响应头


  

第二次返回的相应状态码为304,表示服务端文档没有修过过,浏览器缓存的内容还是最新的。

 

接下来我们看下如何在Java应用层控制浏览器缓存。

 

示例

Last-Modified

如下是我们的spring mvc缓存测试代码:

@RequestMapping("/cache")
public ResponseEntity<String> cache(
     HttpServletRequest request,
     
//为了方便测试,此处传入文档最后修改时间
     @RequestParam("millis") long lastModifiedMillis,
     
//浏览器验证文档内容是否修改时传入的Last-Modified
     @RequestHeader (value = "If-Modified-Since", required = false) Date ifModifiedSince) {

   
//当前系统时间
   long now = System.currentTimeMillis();
   
//文档可以在浏览器端/proxy上缓存多久
   long maxAge = 20;

   
//判断内容是否修改了,此处使用等值判断
   if(ifModifiedSince != null && ifModifiedSince.getTime() == lastModifiedMillis) {
       
return new ResponseEntity<String>(HttpStatus.NOT_MODIFIED);
   }

   DateFormat gmtDateFormat =
new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);

   String body =
"<a href=''>点击访问当前链接</a>";
   MultiValueMap<String, String> headers =
new HttpHeaders();

   
//文档修改时间
   headers.add("Last-Modified", gmtDateFormat.format(new Date(lastModifiedMillis)));
    //当前系统时间
   headers.add("Date", gmtDateFormat.format(new Date(now)));
   
//过期时间 http 1.0支持
   headers.add("Expires", gmtDateFormat.format(new Date(now + maxAge)));
   
//文档生存时间 http 1.1支持
   headers.add("Cache-Control", "max-age=" + maxAge);
   
return new ResponseEntity<String>(body, headers, HttpStatus.OK);
}

为了方便测试,测试时将文档的修改时间通过millis参数传入,实际应用时可以使用如商品的最后修改时间等替代。

 

首次访问

首次访问http://localhost:9080/cache?millis=1471349916709,将得到如下响应头:


 

响应状态码200表示请求内容成功,另外有如下几个缓存控制参数:

Last-Modified:表示文档的最后修改时间,当去服务器验证时会拿这个时间去;

Expires:http/1.0规范定义,表示文档在浏览器中的过期时间,当缓存的内容超过这个时间则需要重新去服务器获取最新的内容;

Cache-Control:http/1.1规范定义,表示浏览器缓存控制,max-age=20表示文档可以在浏览器中缓存20秒。

 

根据规范定义Cache-Control优先级高于Expires;实际使用时可以两个都用,或仅使用Cache-Control就可以了(比如京东的活动页sale.jd.com)。一般情况下Expires=当前系统时间(Date) + 缓存时间(Cache-Control: max-age)。大家可以在如上测试代码进行两者单独测试,缓存都是可行的。

 

F5刷新

接着按F5刷新当前页面,将看到浏览器发送如下请求头:

 

此处发送时有一个If-Modified-Since请求头,其值是上次请求响应中的Last-Modified,即浏览器会拿这个时间去服务端验证内容是否发生了变更。接着收到如下响应信息:

 

响应状态码为304,表示服务端告诉浏览器说“浏览器你缓存的内容没有变化,直接使用缓存内容展示吧”。

 

注:在测试时要过一段时间更改下参数millis来表示内容修改了,要不然会一直看到304响应。

 

Ctrl+F5强制刷新

如果你想强制从服务端获取最新的内容,可以按Ctrl+F5:

 

浏览器在请求时不会带上If-Modified-Since,并带上Cache-Control:no-cache和Pragma:no-cache,这是为了告诉服务端说我请给我一份最新的内容。

 

from cache

当我们按F5刷新、Ctrl+F5强制刷新、地址栏输入地址刷新时都会去服务端验证内容是否发生了变更。那什么情况才不去服务端验证呢?即有些朋友还会发现有一些“from cache”的情况,这是什么情况下发生的呢?

 

从A页面跳转到A页面或者从A页面跳转到B页面时:


 

大家可以通过如上方式模拟,即从A页面跳转到A页面也是情况1。此时如果内容还在缓存时间之内,直接从浏览器获取的内容,而不去服务端验证。

 

访问页面http://item.jd.com/11056556.html,然后点击面包屑中的HTTP权威指南时会跳转到当前页面,此时看到如下结果,页面及页面异步加载的一些js、css、图片都from cache了。


 
还有如通过浏览器历史记录进行前进后退时也会走from cache。本文是基于chrome 52.0.2743.116 m版本测试,不同浏览器行为可能存在差异。

 

Age

一般用于代理层(如CDN),大家在访问京东一些页面时会发现有一个Age响应头,然后强制刷新(Ctrl+F5)后会发现其不断的变化;其表示此内容在代理层从缓存到现在经过了多长时间了,即在代理层缓存了多长时间了。


Vary

一般用于代理层(如CDN),用于代理层和浏览器协商什么情况下使用哪个版本的缓存内容(比如压缩版和非压缩版),即什么情况下后续请求才能使用代理层缓存的该版本内容,比如如下响应是告知浏览器Content-Encoding:gzip,即缓存代理层缓存了gzip版本的内容;那么后续的请求在请求时Accept-Encoding头部中包含gzip时才能使用改代理层缓存。


 

Via

一般用于代理层(如CDN),表示访问到最终内容经过了哪些代理层,用的什么协议,代理层是否缓存命中等等;通过它可以进行一些故障诊断。

  

ETag

@RequestMapping("/cache/etag")
public ResponseEntity<String> cache(
     HttpServletRequest request,
     HttpServletResponse response,
     
//浏览器验证文档内容的实体 If-None-Match
     @RequestHeader (value = "If-None-Match", required = false) String ifNoneMatch) {

   
//当前系统时间
   long now = System.currentTimeMillis();
   
//文档可以在浏览器端/proxy上缓存多久
   long maxAge = 10;

   String body =
"<a href=''>点击访问当前链接</a>";

   
//弱实体
   String etag = "W/\"" + md5(body) + "\"";

   
if(StringUtils.equals(ifNoneMatch, etag)) {
       
return new ResponseEntity<String>(HttpStatus.NOT_MODIFIED);
   }

   DateFormat gmtDateFormat =
new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
   MultiValueMap<String, String> headers =
new HttpHeaders();

   
//ETag http 1.1支持
   headers.add("ETag", etag);
   //
当前系统时间

   headers.add(
"Date", gmtDateFormat.format(new Date(now)));
   
//文档生存时间 http 1.1支持
   headers.add("Cache-Control", "max-age=" + maxAge);
   
return new ResponseEntity<String>(body, headers, HttpStatus.OK);
}

其中ETag用于发送到服务端进行内容变更验证的,而Catch-Control是用于控制缓存时间的(浏览器、代理层等)。此处我们使用了弱实体W\”343sda”,弱实体(”343sda”)只要内容语义没变即可,比如内容的gzip版和非gzip版可以使用弱实体验证;而强实体指字节必须完全一致(gzip和非gzip情况是不一样的),因此建议首先选择使用弱实体。nginx在生成etag时使用的算法是Last-Modified + Content-Length计算的:

ngx_sprintf(etag->value.data,"\"%xT-%xO\"",

                                 r->headers_out.last_modified_time,

                                 r->headers_out.content_length_n)

 

 

到此简单的基于文档修改时间和过期时间的缓存控制就介绍完了,在内容型响应我们大多数根据内容的修改时间来进行缓存控制,ETag根据实际需求而定(比如)。另外还可以使用html Meta标签控制浏览器缓存,但是对代理层缓存无效,因此不建议使用。

 

总结


 
 
1、服务端响应的Last-Modified会在下次请求时以If-Modified-Since请求头带到服务端进行文档是否修改的验证,如果没有修改则返回304,浏览器可以直接使用缓存内容;

2、Cache-Control:max-age和Expires用于决定浏览器端内容缓存多久,即多久过期,过期后则删除缓存重新从服务端获取最新的;另外可以用于from cache场景;

3、http/1.1规范定义的Cache-Control优先级高于http/1.0规范定义的Expires;

4、一般情况下Expires=当前系统时间 + 缓存时间(Cache-Control:max-age);

5、http/1.1规范定义了ETag来通过文档摘要的方式控制。

 

Last-Modified与ETag同时使用时,浏览器在验证时会同时发送If-Modified-Since和If-None-Match,按照http/1.1规范,如果同时使用If-Modified-Since和If-None-Match则服务端必须两个都验证通过后才能返回304;且nginx就是这样做的。因此实际使用时应该根据实际情况选择。还有If-Match和If-Unmodified-Since本文就不介绍了。

 

接下来我们看下如何使用nginx进行缓存控制。

 

nginx缓存设置

nginx提供了expires、etag、if-modified-since指令来进行浏览器缓存控制。

 

expires

假设我们使用nginx作为静态资源服务器,此时可以使用expires进行缓存控制。

       location /img {

         alias /export/img/;

         expires 1d;

       }

当我们访问静态资源时,如http://192.168.61.129/img/1.jpg,将得到类似如下的响应头:


 

对于静态资源会自动添加ETag,可以通过添加“etag off”指令禁止生成ETag。如果是静态文件Last-Modified是文件的最后修改时间;Expires是根据当前服务端系统时间算出来的。如上nginx配置的计算逻辑(实际计算逻辑比这个多,具体参考官方文档):

if (expires == NGX_HTTP_EXPIRES_ACCESS ||r->headers_out.last_modified_time == -1) {

   max_age = expires_time;

   expires_time += now;

}

 

if-modified-since

此指令用于表示nginx如何拿服务端的Last-Modified和浏览器端的If-Modified-Since时间进行比较,默认“if_modified_since exact”表示精确匹配,也可以使用“if_modified_sincebefore”表示只要文件的上次修改时间早于或等于浏览器短的If-Modified-Since时间,就返回304。

 

nginx proxy expires

使用nginx作为反向代理时,请求会先进入nginx,然后nginx将请求转发给后端应用。如下图所示:

 

首先配置upstream:

upstream backend_tomcat {

   server 192.168.61.1:9080 max_fails=10 fail_timeout=10s weight=5;

}

 

接着配置location:

location = /cache {

   proxy_pass http://backend_tomcat/cache$is_args$args;

}

 

接下来我们可以通过如http://192.168.61.129/cache?millis=1471349916709访问nginx,nginx会将请求转发给后端java应用。也就是说nginx只是做了相关的转发(负载均衡),并没有对请求和响应做什么处理。

 

假设对后端返回的过期时间需要调整,可以添加expires指令到location:

location = /cache {

   proxy_pass http://backend_tomcat/cache$is_args$args;

    expires 5s;

}

 

然后再请求相关的URL,将得到如下响应:


 
过期时间相关的响应头被expires指令更改了,但是Last-Modified是没有变的。

 

即使我们更改了缓存过期头,但nginx本身没有对这些内容做缓存,每次请求还是要到后端验证的,假设在过期时间内,这些验证在nginx这一层验证就可以了,不需要到后端验证,这样可以减少后端的很大压力。即整体流程是:

1、浏览器发起请求,首先到nginx,nginx根据url在nginx本地查找是否有文档缓存;

2、nginx没有找到本地缓存,则去后端获取最新的文档,并放入到nginx本地缓存中;返回200状态码和最新的文档给浏览器;

3、nginx找到本地缓存了,首先验证文档是否过期(Cache-Control:max-age=5),如果过期则去后端获取最新的文档,并放入nginx本地缓存中,返回200状态码和最新的文档给浏览器;如果文档没有过期,如果If-Modified-Since与缓存文档的Last-Modified匹配,则返回300状态码给浏览器,否则返回200状态码和最新的文档给浏览器。

 

即内容不需要动态(计算、渲染等)速度更快,内容越接近于用户速度越快。像apache traffic server、squid、varnish、nginx等技术都可以来进行内容缓存。还有CDN就是用来加速用户访问的:


 
即用户首先访问到全国各地的CDN节点(使用如ATS、Squid实现),如果CDN没命中,会回源到中央nginx集群,该集群如果没有命中缓存(该集群的缓存不是必须的,要根据实际命中情况等决定),最后回源到后端应用集群。

 

像我们商品详情页的一些服务就大量使用了nginx缓存减少回源到后端的请求量,从而提升访问速度。可以参考《构建需求响应式亿级商品详情页》、《京东商品详情页服务闭环实践》和《应用多级缓存模式支撑海量读服务》。

 

nginx代理层缓存

http模块配置:

proxy_buffering               on;

proxy_buffer_size             4k;

proxy_buffers                  512 4k;

proxy_busy_buffers_size      64k;

proxy_cache_path             /export/cache/proxy_cachelevels=1:2 keys_zone=cache:512m inactive=5m max_size=8g use_temp_path=off;

#proxy timeout

proxy_connect_timeout  3s;

proxy_read_timeout     5s;

proxy_send_timeout     5s;

 

其中红色部分是proxy_cache_path指令相关配置:

levels=1:2 :表示创建两级目录结构,比如/export/cache/proxy_cache/7/3c/,将所有文件放在一级目录结构中如果文件量很大会导致访问文件慢;

keys_zone=cache:512m :设置存储所有缓存key和相关信息的共享内存区,1M大约能存储8000个key;

inactive=5m :inactive指定被缓存的内容多久不被访问将从缓存中移除,以保证内容的新鲜;默认10分钟;

max_size=8g :最大缓存阀值,“cachemanager”进程会监控最大缓存大小,当缓存达到该阀值,该进程将从缓存中移除最近最少使用的内容;

use_temp_path:如果为on,则内容首先被写入临时文件(proxy_temp_path ),然后重命名到proxy_cache_path指定的目录;如果设置为off,则内容直接被写入到proxy_cache_path指定的目录,如果需要cache建议off,该特性是1.7.10提供的。

 

location配置

location = /cache {

     proxy_cache cache;

     proxy_cache_key $scheme$proxy_host$request_uri;

     proxy_cache_valid 200 5s;

     proxy_pass http://backend_tomcat/cache$is_args$args;

    add_header cache-status $upstream_cache_status;

}

缓存相关配置:

proxy_cache :指定使用哪个共享内存区域存储缓存键和相关信息;

proxy_cache_key :设置缓存使用的key,默认为访问的完整URL,根据实际情况设置缓存key;

proxy_cache_valid :为不同的响应状态码设置缓存时间;如果是proxy_cache_valid 5s 则200、301、302响应将被缓存;

 

proxy_cache_valid

proxy_cache_valid不是唯一设置缓存时间的,还可以通过如下方式(优先级从上到下):

1、以秒为单位的“X-Accel-Expires”响应头来设置响应缓存时间;

2、如果没有“X-Accel-Expires”,可以根据“Cache-Control”、“Expires”来设置响应缓存时间;

3、否则使用proxy_cache_valid设置的缓存时间;

 

如果响应头包含Cache-Control:private/no-cache/no-store、Set-Cookie或者只有一个Vary响应头且其值为*,则响应内容将不会被缓存。可以使用proxy_ignore_headers来忽略这些响应头。

 

add_headercache-status $upstream_cache_status在响应头中添加缓存命中的状态:

HIT:缓存命中了,直接返回缓存中内容,不回源到后端;

MISS:缓存没有命中,回源到后端获取最新的内容;

EXPIRED:缓存命中但过期了,回源到后端获取最新的内容;

UPDATING:缓存已过期但正在被别的nginx进程更新;配置了proxy_cache_use_staleupdating指令时会存在该状态;

STALE:缓存已过期,但因后端服务出现了问题(比如后端服务挂了)返回过期的响应;配置了如proxy_cache_use_stale error timeout指令后会存在该状态;

REVALIDATED:启用proxy_cache_revalidate指令后,当缓存内容过期时nginx通过一次If-Modified-Since的请求头去验证缓存内容是否过期,此时会返回该状态;

BYPASS:proxy_cache_bypass指令有效时强制回源到后端获取内容,即使已经缓存了;

 

proxy_cache_min_uses

用于控制请求多少次后响应才被缓存;默认“proxy_cache_min_uses 1;”,如果缓存热点比较集中、存储有限,可以考虑修改该参数以减少缓存数量和写磁盘次数;

 

proxy_no_cache 

用于控制什么情况下响应将不被缓存;比如配置“proxy_no_cache $args_nocache”,如果带的参数值至少有一个不为空或者0,则响应将不被缓存;

 

proxy_cache_bypass

类似于proxy_no_cache,但是其控制什么情况不从缓存中获取内容,而是直接到后端获取内容;如果命中则$upstream_cache_status为BYPASS;

 

proxy_cache_use_stale

当对缓存内容的过期时间不敏感,或者后端服务出问题时即使缓存的内容不新鲜也总比返回错误给用户强(类似于托底),此时可以配置该参数,如“proxy_cache_use_stale error timeout http_500 http_502 http_503http_504”:即如果超时、后端连接出错、500、502、503等错误时即使缓存内容已过期也先返回给用户,此时$upstream_cache_status为STALE;还有一个updating表示缓存已过期但正在被别的nginx进程更新将先返回过期的内容,此时 $upstream_cache_status为UPDATING;

 

proxy_cache_revalidate

当缓存过期后,如果开启了proxy_cache_revalidate,则会发出一次If-Modified-Since和If-None-Match条件请求,如果后端返回304则会得到两个好处:节省带宽和减少写磁盘的次数;此时$upstream_cache_status为REVALIDATED;

 

 proxy_cache_lock

当多个客户端同时请求同一份内容时,如果开启proxy_cache_lock(默认off)则只有一个请求被发送至后端;其他请求将等待该内容返回;当第一个请求返回时,其他请求将从缓存中获取内容返回;当第一个请求超过了proxy_cache_lock_timeout超时时间(默认5s),则其他请求将同时请求到后端来获取响应,且响应不会被缓存(在1.7.8版本之前是被缓存的);启用proxy_cache_lock可以应对Dog-pile effect(当某个缓存失效时,同时又大量相同的请求没命中缓存,而同时请求到后端,从而导致后端压力太大,此时限制一个请求去拿即可)。

 

proxy_cache_lock_age是1.7.8新添加的,如果在proxy_cache_lock_age指定的时间内(默认5s),最后一个发送到后端进行新缓存构建的请求还没有完成,则下一个请求将被发送到后端来构建缓存(因为1.7.8版本之后,proxy_cache_lock_timeout超时之后返回的内容是不缓存的,需要下一次请求来构建响应缓存)。

 

 

清理缓存

有时候缓存的内容是错误的,需要手工清理,nginx plus版本提供了purger的功能,但是对于非plus版本的nginx可以考虑使用ngx_cache_purge(https://github.com/FRiCKLE/ngx_cache_purge)模块进行清理缓存,如:

location ~ /purge(/.*) {

   allow              127.0.0.1;

   deny               all;

   proxy_cache_purge  cache$1$is_args$args;

}

注意该方法应该只允许内网可以访问,如有必要可以考虑需要密码才能访问。

 

到此代理层缓存就介绍完了,通过代理层缓存可以解决很多问题,可以参考《京东商品详情页服务闭环实践》和《京东商品详情页服务闭环实践》。

 

一些经验

1、只缓存200状态码的响应,像302等要根据实际场景决定(比如当系统出错时自动302到错误页面,此时缓存302就不对了);

2、有些页面不需要强一致,可以进行几秒的缓存(比如商品详情页展示的库存,可以缓存几秒钟,短时间的不一致对于用户来说是没有影响的);

3、js/css/image等一些内容缓存时间可以设置的很久(比如1个月甚至1年),通过在页面修改版本来控制过期,不建议随机数方式;

4、假设商品详情页异步加载的一些数据使用的是Last-Modified进行的过期控制,而服务端做了逻辑修改但内容是没有修改的,即内容的最后修改时间没变,如果想过期这些异步加载的数据,可以考虑在商品详情页添加异步加载数据的版本号,通过添加版本号来加载最新的数据,或者将Last-Modified时间加1来解决;而这种情况比较适合使用ETag;

5、商品详情页异步加载的一些数据,可以考虑更长时间的缓存(比如1个月而不是几分钟),可以通过MQ将修改时间推送商品详情页,从而实现按需过期数据;

6、服务端考虑使用内存缓存(tmpfs)、SSD缓存;考虑服务端负载均衡算法,如一致性哈希提升缓存命中率;

7、缓存KEY要合理设计(比如去掉参数/排序参数保证代理层缓存命中),要有清理缓存的工具,出问题时能快速清理掉问题KEY;

8、AB测试/个性化需求时应禁用掉浏览器缓存,但考虑服务端缓存;

9、为了便于查找问题,一般会在响应头中添加源服务器信息,如访问京东商品详情页会看到ser响应头,此头存储了源服务器IP,以便出现问题时知道哪台服务器有问题。


0 0