高并发处理
并发概念
-
并发
在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行;在互联网时代,所讲的并发,高并发通常是指并发访问,也就是在某个时间点,有多少个访问同时到来。
通常一个系统的日PV在千万以上,有可能是一个高并发的系统。
-
QPS:每秒钟请求或者查询的数量,在互联网领域,指每秒响应请求数(指HTTP请求);并发连接数是系统同时处理的请求数量
-
吞吐量:单位时间内处理的请求数量(通常由QPS与并发数决定)
-
响应时间:从请求发出到收到响应花费的时间。例如系统处理一个HTTP请求需要100ms。
-
PV:综合浏览量(page view),即页面浏览量或者点击量,一个访客在24小时内访问的页面数量;同一个人浏览网站同一页面,只记作一次PV
-
UV:独立访客(unique visitor),即一定时间范围内相同访客多次访问网站,只计算为一个独立访客
-
带宽:计算带宽大小需关注两个指标,峰值流量和页面的平均大小
-
日网站带宽=PV/统计时间(换算到s)*平均页面大小(单位KB)*8;峰值一般是平均值的倍数,根据实际情况来定 峰值每秒请求数(QPS)=(总PV数*80%)/(6小时秒数*20%);
80%的访问量集中在20%的时间
- 压力测试:测试能承受的最大并发,测试最大承受的QPS值
常用性能测试工具:apache benchmark,wrk,http_load,web_bench,siege,apache jmeter;
高并发解决方案
流量优化
防盗链
盗链:在自己的页面上展示一些并不在自己服务器上的内容,获得他人服务器上的资源地址,绕过别人的资源展示页面,直接在自己的页面上向最终用户提供此内容,常见的是小站盗用大站的图片,音乐,视频,软件等资源,通过盗链的方法可以减轻自己服务器的负担,因为真实的空间和流量均是来自别人的服务器。
防盗链:防止别人通过一些技术手段绕过本站的资源展示页面,盗用本站的资源,让绕开本站资源展示页面的资源链接失效,可以大大减轻服务器及带宽的压力。
工作原理:通过请求头中的referer或者签名,网站可以检测目标网页访问的来源网页,如果是资源文件,则可以跟踪到显示它的网页地址,一旦检测到来源不是本站即进行阻止或者返回制定的页面,通过计算签名的方式,判断请求是否合法,如果合法则显示,否则返回错误信息。
-
实现方法:
- referer
nginx模块ngx_http_referer_module用于阻挡来源非法的域名请求,nginx指令valid_referers none | blocked | server_names | string...,none表示referer来源头部为空的情况,blocked表示referer来源头部不为空,但是里面的值被代理或者防火墙删除了,这些值都不以http://或者https://开头,server_names表示referer来源头部包含当前的server_names,全局变量$invalid_referer。不能彻底防范,只能提高门槛。也可以针对目录进行防盗链。
//在nginx的conf中配置 location ~.*\.(gif|jpg|png|flv|swf|rar|zip)$ { valid_referers none blocked zi.com *.zi.com; if($invalid_referer) { #return 403; rewrite ^/ http://www.zi.com/403.jpg; } }
传统防盗链遇到的问题:伪造referer:可以使用加密签名解决
-
加密签名
使用第三方模块HttpAccessKeyModule实现Nginx防盗链。accesskey on|off 模块开关,accesskey_hashmethod md5|sha-1 签名加密方式,accesskey_arg GET参数名称,accesskey_signature 加密规则,在nginx的conf中设置
location ~.*\.(gif|jpg|png|flv|swf|rar|zip)$ { accesskey on; accesskey_hashmethod md5; accesskey_arg sign; accesskey_signature "jason$remote_addr"; } <?php $sign = md5('jason'.$SERVER['REMOTE_ADDR']); echo '<img src=".logo.png?sign='.$sign.'">';
前端优化:
减少HTTP请求次数
性能黄金法则:只有10%-20%的最终用户响应时间花在接收请求的HTML文档上,剩下的80%-90%时间花在HTML文档所引用的所有组件(img,script,css,flash等)进行的HTTP请求上。
如何改善:改善响应时间的最简单途径就是减少组件的数量,并由此减少HTTP请求的数量
HTTP连接产生的开销:域名解析--TCP连接--发送请求--等待--下载资源--解析时间
疑问:DNS缓存,查找DNS缓存也需要时间,多个缓存就要查找多次有可能缓存会被清除;Keep-Alive,HTTP1.1协议规定请求只能串行发送,前面的一个请求完成才能开始下个请求
减少HTTP请求的方式:图片地图:允许在一个图片上关联多个URL,目标URL的选择取决于用户单击了图片上的哪个位置,以位置信息定位超链接,把HTTP请求减少为一个,可以保证设计的完整性和功能的齐全性,使用map和area标签;
<img usemap="#map" src="/map.gif?t=111">
<map name="map">
<area shape="rect" coords="0,0,30,30" href=... title="">
...
</map>
CSS Sprites:CSS精灵,通过使用合并图片,通过指定css的background-image和background-position来显示元素。图片地图与css精灵的响应时间基本上相同,但比使用各自独立图片的方式要快50%以上。
合并脚本和样式表:使用外部的js和css文件引用的方式,因为这要比直接写在页面中性能要更好一点;独立的一个js比用多个js文件组成的页面载入要快38%;把多个脚本合并为一个脚本,把多个样式表合并为一个样式表
图片使用base64编码减少页面请求数:采用base64的编码方式将图片直接嵌入到网页中,而不是从外部载入
添加异步请求
-
几种调用方式
同步阻塞调用
即串行调用,响应时间为所有服务的响应时间总和;
半异步(异步Future)
线程池,异步Future,使用场景:并发请求多服务,总耗时为最长响应时间;提升总响应时间,但是阻塞主请求线程,高并发时依然会造成线程数过多,CPU上下文切换;
全异步(Callback)
Callback方式调用,使用场景:不考虑回调时间且只能对结果做简单处理,如果依赖服务是两个或两个以上服务,则不能合并两个服务的处理结果;不阻塞主请求线程,但使用场景有限。
异步回调链式编排
异步回调链式编排(JDK8 CompletableFuture),使用场景:其实不是异步调用方式,只是对依赖多服务的Callback调用结果处理做结果编排,来弥补Callback的不足,从而实现全异步链式调用。
浏览器缓存和数据压缩优化
-
HTTP缓存机制:如果请求成功会有三种情况:
- 200 from cache:直接从本地缓存中获取相应,最快速,最省流量,因为根本没有向服务器进行请求;
- 304 not modified:协商缓存,浏览器在本地没有命中的情况下请求头中发送一定的校验数据到服务端,如果服务端数据没有改变浏览器从本地缓存响应,返回304,快速,发送的数据很少,只返回一些基本的响应头信息,数据量很小,不发送实际响应体;
- 200 OK:以上两种缓存全部失败,服务器返回完整响应,没有用到缓存,相对最慢。
-
缓存策略的选择:
- 适合缓存的内容:不变的图像,如logo,图标等,js,css静态文件,可下载的内容,媒体文件;
- 建议使用协商缓存:html文件,经常替换的图片,经常修改的js,css文件,js和css文件的加载可以加入文件的签名来拒绝缓存,如a.css?签名或a.签名.js;
- 不建议缓存的内容:用户隐私等敏感数据,经常改变的api数据接口
-
nginx配置缓存策略: 本地缓存配置:
- add_header指令:添加状态码为2xx和3xx的响应头信息,
- add_header name value [always];,可以设置Pragma/Expires/Cache-Control,可以继承
- expires指令:通知浏览器过期时长,expires time;,为负值时表示Cache-Control: no-cache;,当为正或者0时,就表示Cache-Control: max-age=指定的时间;;当为max时,Cache-Control设置到10年; 协商缓存相关配置:Etag指令:指定签名;etag on|off;,默认是on
-
前端代码和资源的压缩:
让资源文件更小,加快文件在网络中的传输,让网页更快的展现,降低带宽和流量开销;
-
压缩方式:
-
js,css,图片,html代码的压缩,Gzip压缩。
-
js代码压缩:一般是去掉多余的空格和回车,替换长变量名,简化一些代码写法等,代码压缩工具很多UglifyJS(压缩,语法检查,美化代码,代码缩减,转化)、YUI Compressor(来自yahoo,只有压缩功能)、Closure Compiler(来自google,功能和UglifyJS类似,压缩的方式不一样),有在线工具tool.css-js.com,应用程序,编辑器插件。
-
css代码压缩:原理和js压缩原理类似,同样是去除空白符,注释并且优化一些css语义规则等,压缩工具CSS Compressor(可以选择模式)。
-
html代码压缩:不建议使用代码压缩,有时会破坏代码结构,可以使用Gzip压缩,当然也可以使用htmlcompressor工具,不过转换后一定要检查代码结构。
-
img压缩:一般图片在web系统的比重都比较大,压缩工具:tinypng,JpegMini,ImageOptim
-
Gzip压缩:配置nginx服务,gzip on|off,gzip_buffers 32 4K|16 8K #缓冲(在内存中缓存几块?每块多大),gzip_comp_level [1-9] #推荐6 压缩级别(级别越高,压的越小,越浪费CPU计算资源)
gzip_disable #正则匹配UA 什么样的uri不进行gzip, gzip_min_length 200 #开始压缩的最小长度, gzip_http_version 1.0|1.1 #开始压缩的http协议版本, gzip_proxied #设置请求者代理服务器,该如何缓存内容, gzip_types text/plain applocation/xml #对哪些类型的文件用压缩, gzip_vary on|off #是否传输gzip压缩标志。
-
CDN加速
CDN:Content Delivery Network,内容分发网络,尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节,使内容传输的更快更稳定;在网络各处放置节点服务器所构成的在现有的互联网基础之上的一层智能虚拟网络;CDN系统能够实时的根据网络流量和各节点的连接,负载情况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上。
本地cache加速,提高了企业站点(尤其含有大量img和静态页面站点)的访问速度;跨运营商的网络加速,保证不同网络的用户都得到良好的访问质量;远程访问用户根据DNS负载均衡技术智能自动选择cache服务器;自动生成服务器的远程Mirror cache服务器,远程用户访问时从cache服务器上读数据,减少远程访问的带宽,分担网络流量,减轻原站点web服务器负载等功能;广泛分布的CDN节点加上节点之间的只能智能冗余机制,可以有效的预防黑客入侵。
-
CDN的工作原理:
- 传统访问:用户在浏览器输入域名发起请求--解析域名获取服务器IP地址--根据IP地址找到对应的服务器--服务器响应并返回数据;
- 使用CDN访问:用户发起请求--智能DNS的解析(根据IP判断地理位置,接入网类型,选择路由最短和负载最轻的服务器)--取得缓存服务器IP--把内容返回给用户(如果缓存中有)--向源站发起请求--将结果返回给用户--将结果存入缓存服务器
-
CDN适用场景:
站点或者应用中大量静态资源的加速分发,如css,js,img和html;大文件下载;直播网站等
-
CDN的实现:
BAT等都有提供CDN服务,可用LVS做4层负载均衡;可用nginx,Varnish,Squid,Apache TrafficServer做7层负载均衡和cache;使用squid反向代理,或者nginx等的反向代理
- 二层负载均衡(mac)
一般是用
虚拟mac地址
方式,外部对虚拟MAC地址请求,负载均衡接收后分配后端实际的MAC地址响应。 - 三层负载均衡(ip)
一般采用
虚拟IP地址
方式,外部对虚拟的ip地址请求,负载均衡接收后分配后端实际的IP地址响应。 - 四层负载均衡(tcp)
用
虚拟ip+port
接收请求,再转发到对应的真实机器。 - 七层负载均衡(http)
用
虚拟的url或主机名
接收请求,再转向相应的处理服务器。
- 二层负载均衡(mac)
一般是用
建立独立图片服务器
-
独立的必要性:
分担web服务器的I/O负载-将耗费资源的图片服务分离出来,提高服务器的性能和稳定性;能够专门的图片服务器进行优化-为图片服务设置有针对性的缓存方案,减少带宽成本,提高访问速度;提高网站的可扩展性-通过增加图片服务器,提高图片吞吐能力
-
采用独立域名:
原因:同一域名下浏览器的并发连接数有限制,突破浏览器连接数的限制;由于cookie的原因,对缓存不利,大部分web cache都只缓存不带cookie的请求,导致每次的图片请求都不能命中cache
- 独立后的问题:如何进行图片上传和图片同步:NFS共享方式;利用FTP同步
服务端优化:
页面静态化
将现有PHP等动态语言的逻辑代码生成为静态HTML文件,用户访问动态脚本重定向到静态HTML文件的过程。
- 对实时性要求不高的页面比较适合。
- 原因:动态脚本通常会做逻辑计算和数据查询,访问量越大,服务器压力越大;访问量大时可能会造成CPU负载过高,数据库服务器压力过大;静态化可以降低逻辑处理压力,降低数据库服务器查询压力
将现有的PHP等动态语言的逻辑代码生成为静态HTML文件,用户访问动态脚本重定向到静态的HTML文件的过程。
扩容
-
原因
每个线程都有自己的工作内存,占用内存的大小取决于工作内存里变量的多少与大小,单个线程占用内存通常不会很大,但是随着并发的不断增加占用的内存会越来越多,这个时候我们就需要考虑扩容了;
-
扩容方式
-
垂直扩展(纵向扩展) : 提高系统部件能力(例如增加内存);
-
水平扩展(横向扩展) : 增加更多的系统成员来实现(例如增加服务器);
通常垂直扩展是非常有限的例如一台机器的内存不可能无限的往上叠加;
而水平扩展理论上是无限的我们可以根据需要不断的增加服务器的数量;
-
多线程并发
线程
-
线程生命周期
Java线程具有五中基本状态
新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread(); 就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行; 运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就 绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中; 阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种: 1.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态; 2.同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态; 3.其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
-
多线程创建使用
- 继承Thread类,重写该类的run()方法
Java线程类也是一个object类,它的实例都继承自java.lang.Thread或其子类。 可以用如下方式用java中创建一个线程:
Tread thread = new Thread();
执行该线程可以调用该线程的start()方法:
thread.start();
在上面的例子中,我们并没有为线程编写运行代码,因此调用该方法后线程就终止了。
-
实现Runnable接口,并重写该接口的run()方法
该run()方法同样是线程执行体,创建Runnable实现类的实例,并以此实例作为Thread类的target来创建Thread对象,该Thread对象才是真正的线程对象。
//创建线程 class MyRunnable implements Runnable { private int i = 0; @Override public void run() { for (i = 0; i < 100; i++) { System.out.println(Thread.currentThread().getName() + " " + i); } } }
启动线程
Runnable myRunnable = new MyRunnable(); // 创建一个Runnable实现类的对象 Thread thread1 = new Thread(myRunnable); // 将myRunnable作为Thread target创建新的线程 thread1.start(); // 调用start()方法使得线程进入就绪状态
说明:
当执行到Thread类中的run()方法时,会首先判断target是否存在,存在则执行target中的run()方法,也就是实现了Runnable接口并重写了run()方法的类中的run()方法。
- 使用Callable和Future接口创建线程
具体是创建Callable接口的实现类,并实现clall()方法。并使用FutureTask类来包装Callable实现类的对象,且以此FutureTask对象作为Thread对象的target来创建线程。
创建Callable示例:
class MyCallable implements Callable<Integer> { private int i = 0; // 与run()方法不同的是,call()方法具有返回值 @Override public Integer call() { int sum = 0; for (; i < 100; i++) { System.out.println(Thread.currentThread().getName() + " " + i); sum += i; } return sum; } }
线程调用:
public class ThreadTest { public static void main(String[] args) { // 创建MyCallable对象 Callable<Integer> myCallable = new MyCallable(); //使用FutureTask来包装MyCallable对象 FutureTask<Integer> ft = new FutureTask<Integer>(myCallable); for (int i = 0; i < 100; i++) { System.out.println(Thread.currentThread().getName() + " " + i); if (i == 30) { //FutureTask对象作为Thread对象的target创建新的线程 Thread thread = new Thread(ft); //线程进入到就绪状态 thread.start(); } } try { //取得新创建的新线程中的call()方法返回的结果 int sum = ft.get(); System.out.println("sum = " + sum); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }
我们发现FutureTask类实际上是同时实现了Runnable和Future接口,由此才使得其具有Future和Runnable双重特性。通过Runnable特性,可以作为Thread对象的target,而Future特性,使得其可以取得新创建线程中的call()方法的返回值。
ft.get()方法获取子线程call()方法的返回值时,当子线程此方法还未执行完毕,ft.get()方法会一直阻塞,直到call()方法执行完毕才能取到返回值。
线程池
-
作用
- 重用存在的线程,减少对象创建、消亡的开销,性能佳。
- 可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。
- 提供定时执行、定期执行、单线程、并发数控制等功能。
-
线程池方法说明
-
shutdown:关闭线程池,等待任务都执行完成
-
shutdownNow:关闭线程池,不等待任务执行完成
-
submit:提交任务,能够返回执行结果 execute+Future
- execute:提交任务,交给线程池执行
-
-
线程池使用说明
常规使用
在 Java 中,新建一个线程池对象非常简单,Java 本身提供了工具类
java.util.concurrent.Executors
,该方式提供四种线程池:-
newSingleThreadExecutor
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
- 构造实现
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); } //可指定线程工厂 public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory)); }
-
newFixedThreadPool
创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- 构造实现
//参数解释看下节所述 public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); } //可指定线程工厂 public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory); }
-
newScheduledThreadPool
创建一个可定期或者延时执行任务的定长线程池,支持定时及周期性任务执行。
- 构造实现
//corePoolSize 指定线程池大小 //ScheduledThreadPoolExecutor继承ThreadPoolExecutor public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } //corePoolSize 指定线程池大小 //threadFactory 指定线程工厂 //ScheduledThreadPoolExecutor继承ThreadPoolExecutor public static ScheduledExecutorService newScheduledThreadPool( int corePoolSize, ThreadFactory threadFactory) { return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory); }
-
newCachedThreadPoo
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- 构造实现
//参数解释看下文所述 public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); } //带指定线程工厂 public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), threadFactory); }
自定义线程池
在阿里巴巴代码规范中,建议我们自己指定线程池的相关参数,为的是让开发人员能够自行理解线程池创建中的每个参数,根据实际情况,创建出合理的线程池。接下来,我们来剖析下
java.util.concurrent.ThreadPoolExecutor
的构造方法参数。public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { if (corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.acc = System.getSecurityManager() == null ? null : AccessController.getContext(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; }
-
corePoolSize、 maximumPoolSize
线程池会自动根据corePoolSize和maximumPoolSize去调整当前线程池的大小。当你通过submit或者execute方法提交任务的时候,如果当前线程池的线程数小于corePoolSize,那么线程池就会创建一个新的线程处理任务, 即使其他的core线程是空闲的。如果当前线程数大于corePoolSize并且小于maximumPoolSize,那么只有在队列"满"的时候才会创建新的线程。因此这里会有很多的坑,比如你的core和max线程数设置的不一样,希望请求积压在队列的时候能够实时的扩容,但如果制定了一个无界队列,那么就不会扩容了,因为队列不存在满的概念。
-
keepAliveTime、unit
如果当前线程池中的线程数超过了corePoolSize,那么如果在keepAliveTime时间内都没有新的任务需要处理,那么超过corePoolSize的这部分线程就会被销毁。默认情况下是不会回收core线程的,可以通过设置allowCoreThreadTimeOut改变这一行为。unit为单位,单位枚举见TimeUnit。
-
workQueue
即实际用于存储任务的队列,这个可以说是最核心的一个参数了,直接决定了线程池的行为,比如说传入一个有界队列,那么队列满的时候,线程池就会根据core和max参数的设置情况决定是否需要扩容,如果传入了一个SynchronousQueue,这个队列只有在另一个线程在同步remove的时候才可以put成功,对应到线程池中,简单来说就是如果有线程池任务处理完了,调用poll或者take方法获取新的任务的时候,新提交的任务才会put成功,否则如果当前的线程都在忙着处理任务,那么就会put失败,也就会走扩容的逻辑,如果传入了一个DelayedWorkQueue,顾名思义,任务就会根据过期时间来决定什么时候弹出,即为ScheduledThreadPoolExecutor的机制。
-
threadFactory
创建线程都是通过ThreadFactory来实现的,如果没指定的话,默认会使用Executors.defaultThreadFactory(),一般来说,我们会在这里对线程设置名称、异常处理器等。
-
handler
即当任务提交失败的时候,会调用这个处理器,ThreadPoolExecutor内置了多个实现,比如抛异常、直接抛弃等。这里也需要根据业务场景进行设置,比如说当队列积压的时候,针对性的对线程池扩容或者发送告警等策略。
-
缓存
常用缓存方案示例:
-
缓存作用:
- 降低对数据库查询性能损耗
- 提高系统反应速度,降低响应时间
-
缓存分类和应用场景
- 本地缓存:编程实现(成员变量、局部变量、静态变量)、Guava Cache 最大的优点是应用进程的cache是在同一个进程中内部请求缓存非常的快速,没有过多的网络开销。缺点就是各个应用要单独维护自己的缓存,无法共享。在单应用中使用较为好。
- 分布式缓存: Memcache, Redis
最大的优点是自身是一个独立的应用,与本地应用是隔离的,多个应用可以共享。
缓存问题
-
缓存穿透
缓存穿透是说收到了一个请求,但是该请求缓存里没有,只能去数据库里查询,然后放进缓存。
这里面有两个风险,一个是同时有好多请求访问同一个数据,然后业务系统把这些请求全发到了数据库;第二个是有人恶意构造一个逻辑上不存在的数据,然后大量发送这个请求,这样每次请求都会被发送到数据库,可能导致数据挂掉。
怎么应对这种情况呢?对于恶意访问,一个思路是事先做校验,对恶意数据直接过滤掉,不要发到数据库层;第二个思路是缓存空结果,就是对查询不存在的数据仍然记录一条该数据不存在在缓存里,这样能有效的减少查询数据库的次数。
那么非恶意访问呢?这个要结合缓存击穿来讲。
-
缓存击穿
上面提到的某个数据没有,然后好多请求都被发到数据库其实可以归为缓存击穿的范畴:对于热点数据,当数据过期失效的一瞬间,所有请求都被下放到数据库去请求更新缓存,数据库被压垮。
怎么防范这种问题呢?一个思路是全局锁,就是所有访问某个数据的请求都共享一个锁,获得锁的那个才有资格去访问数据库,其他线程必须等待。但是现在的业务都是分布式的,本地锁没法控制其他服务器也等待,所以要用到全局锁,比如用redis的setnx实现全局锁。
另一个思路是对即将过期的数据主动刷新,做法可以有很多,比如起一个线程轮询数据,比如把所有数据划分为不同的缓存区间,定期分区间刷新数据等等。这第二个思路又和我们接下来要讲的缓存雪崩有关系。
-
缓存雪崩
缓存雪崩是当缓存服务器重启或者大量缓存集中在某一个时间段失效,然后瞬间所有的请求都被打到了数据库,数据库就崩了。
解决思路要么是分治,划分更小的缓存区间,按区间过期;要么是给每个key的过期时间加个随机值,避免同时过期,达到错峰刷新缓存的目的。
队列处理
- 特性
- 对于一致性要求不高
- 只做消息分发,不关心业务
- 性能优异,提神通信效率
- FIFO
- 容灾性好,节点动态调整,消息持久化
- 好处
- 业务解耦
- 最终一致性
- 广播
- 错峰与流控
RPC场景是需要 强一致性、对延迟敏感、注重结果立马返回、重量级。
应用拆分
-
拆分方式
-
微服务:分散能力
- 分布式:分散压力
- SOA:粗粒度服务
-
-
关注点
- CAP
- C:数据一致性(consistency)
- 所有节点拥有数据的最新版本
- A:可用性(availability)
- 数据具备高可用性
- P:分区容错性(partition-tolerance)
- 容忍网络出现分区,分区之间网络不可达。
- 拆分注意
- 业务是否适合拆分,拆分后耦合度不能高
- 是否需要拆分,拆分是为了高并发高可用
- 拆分服务边界,不同拆分方式边界不一样
-
微服务示例
- SpringCloud的基础功能
- 服务治理: Spring Cloud Eureka
- 客户端负载均衡: Spring Cloud Ribbon
- 服务容错保护: Spring Cloud Hystrix
- 声明式服务调用: Spring Cloud Feigni
- API网关服务:Spring Cloud Zuul
-
分布式配置中心: Spring Cloud Config
- SpringCloud的高级功能
- 消息总线: Spring Cloud Bus
- 消息驱动的微服务: Spring Cloud Stream
- 分布式服务跟踪: Spring Cloud Sleuth
限流
概念说明
应用请求波动较大,波峰期的服务响应速度以及宽带问题都容易导致服务响应异常,故而可以采用高峰期服务限流方式,通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务(定向到错误页或告知资源没有了)、排队或等待(比如秒杀、评论、下单)、降级(返回兜底数据或默认数据,如商品详情页库存默认有货)。
限流算法
-
令牌桶限流
令牌桶是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌,填满了就丢弃令牌,请求是否被处理要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求。令牌桶允许一定程度突发流量,只要有令牌就可以处理,支持一次拿多个令牌。令牌桶中装的是令牌。
-
漏桶限流
漏桶一个固定容量的漏桶,按照固定常量速率流出请求,流入请求速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝。漏桶可以看做是一个具有固定容量、固定流出速率的队列,漏桶限制的是请求的流出速率。漏桶中装的是请求。
-
计数器限流
有时我们还会使用计数器来进行限流,主要用来限制一定时间内的总并发数,比如数据库连接池、线程池、秒杀的并发数;计数器限流只要一定时间内的总请求数超过设定的阀值则进行限流,是一种简单粗暴的总数量限流,而不是平均速率限流。
- 致命问题:
- 临界问题——当遇到恶意请求,在0:59时,瞬间请求100次,并且在1:00请求100次,那么这个用户在1秒内请求了200次,用户可以在重置节点突发请求,而瞬间超过我们设置的速率限制,用户可能通过算法漏洞击垮我们的应用。
- 致命问题:
-
在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器counter,比如当一个请求 在0:35秒的时候到达,那么0:30~0:39对应的counter就会加1。
那么滑动窗口怎么解决刚才的临界问题的呢?在上图中,0:59到达的100个请求会落在灰色的格子中,而1:00到达的请求会落在橘×××的格子中。当时间到达1:00时,我们的窗口会往右移动一格,那么此时时间窗口内的总请求数量一共是200个,超过了限定的100个,所以此时能够检测出来触发了限流。
服务降级与服务熔断
-
服务降级
当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。
降级后的处理可以设置一些默认的页面或返回,如:
- 服务接口拒绝服务:页面能访问,但是添加删除提示服务器繁忙。页面内容也可在Varnish或CDN内获取。
- 页面拒绝服务:页面提示由于服务繁忙此服务暂停。跳转到varnish或nginx的一个静态页面。
- 延迟持久化:页面访问照常,但是涉及记录变更,会提示稍晚能看到结果,将数据记录到异步队列或log,服务恢复后执行。
- 随机拒绝服务:服务接口随机拒绝服务,让用户重试,目前较少有人采用。因为用户体验不佳。
-
服务熔断
服务熔断一般是指软件系统中,由于某些原因使得服务出现了过载现象,为防止造成整个系统故障,从而采用的一种保护措施,所以很多地方把熔断亦称为过载保护。
-
共性
- 目的很一致,都是从可用性可靠性着想,为防止系统的整体缓慢甚至崩溃,采用的技术手段;
- 最终表现类似,对于两者来说,最终让用户体验到的是某些功能暂时不可达或不可用;
- 粒度一般都是服务级别,当然,业界也有不少更细粒度的做法,比如做到数据持久层(允许查询,不允许增删改);
- 自治性要求很高,熔断模式一般都是服务基于策略的自动触发,降级虽说可人工干预,但在微服务架构下,完全靠人显然不可能,开关预置、配置中心都是必要手段;
-
区别
- 触发原因不太一样,服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑;
-
管理目标的层次不太一样,熔断其实是一个框架级的处理,每个微服务都需要(无层级之分),而降级一般需要对业务有层级之分(比如降级一般是从最外围服务开始)
- 实现方式不太一样
任务调度系统
分布式调度在互联网企业中占据着十分重要的作用,尤其是电子商务领域,由于存在数据量大、高并发的特点,对数据处理的要求较高,既要保证高效性,也要保证准确性和安全性,相对比较耗时的业务逻辑往往会从中剥离开来进行异步处理。
-
简单比价以下几种常用任务调用系统:
数据库优化
数据库扩容
-
读操作扩展:
假如网站是读操作比较多,比如博客这类。通过通过mysql进行垂直扩展是个不错的选择,并且结合memcathe、redis、CDN等构建一个健壮的缓存系统。如果系统超负荷运行,将更多的数据放在缓存中来缓解系统的读压力。采用水平扩容没有太大的意义,因为性能的瓶颈不在写操作,所以不需要实时去完成,用更多的服务器来分担压力性价比太低。所以针对单个系统去强化它的读性能就可以了
-
写操作扩展:
假如写操作比较多,比如大型网站的交易系统,可考虑可水平扩展的数据存储方式,比如Cassandra、Hbase等。和大多数的关系型数据库不同,这种数据存储会随着增长增加更多的节点。也可以考虑垂直扩容提升单个数据库的性能,但会发现资金与硬盘的IO能力是有限的,所以需要增加更多数据库来分担写的压力。
分库分表、分区
通过某种特定的条件,将存放在同一个数据库中的数据分散存放到多个数据库上,实现分布存储,通过路由规则路由访问特定的数据库,这样一来每次访问面对的就不是单台服务器了,而是N台服务器,这样就可以降低单台机器的负载压力。提示:sqlserver 2005版本之后,可以友好的支持“表分区”。
垂直(纵向)拆分:是指按功能模块拆分,比如分为订单库、商品库、用户库...这种方式多个数据库之间的表结构不同。
水平(横向)拆分:将同一个表的数据进行分块保存到不同的数据库中,这些数据库中的表结构完全相同。
(横向拆分)
-
实现原理
使用垂直拆分,主要要看应用类型是否合适这种拆分方式,如系统可以分为,订单系统,商品管理系统,用户管理系统业务系统比较明的,垂直拆分能很好的起到分散数据库压力的作用。业务模块不明晰,耦合(表关联)度比较高的系统不适合使用这种拆分方式。但是垂直拆分方式并不能彻底解决所有压力问题,例如 有一个5000w的订单表,操作起来订单库的压力仍然很大,如我们需要在这个表中增加(insert)一条新的数据,insert完毕后,数据库会针对这张表重新建立索引,5000w行数据建立索引的系统开销还是不容忽视的,反过来,假如我们将这个表分成100个table呢,从table_001一直到table_100,5000w行数据平均下来,每个子表里边就只有50万行数据,这时候我们向一张只有50w行数据的table中insert数据后建立索引的时间就会呈数量级的下降,极大了提高了DB的运行时效率,提高了DB的并发量,这种拆分就是横向拆分。
-
实现方法
垂直拆分,拆分方式实现起来比较简单,根据表名访问不同的数据库就可以了。横向拆分的规则很多,这里总结前人的几点,
-
顺序拆分:如可以按订单的日前按年份才分,2003年的放在db1中,2004年的db2,以此类推。当然也可以按主键标准拆分。
优点:可部分迁移
缺点:数据分布不均,可能2003年的订单有100W,2008年的有500W。
- hash取模分: 对user_id进行hash(或者如果user_id是数值型的话直接使用user_id的值也可),然后用一个特定的数字,比如应用中需要将一个数据库切分成4个数据库的话,我们就用4这个数字对user_id的hash值进行取模运算,也就是user_id%4,这样的话每次运算就有四种可能:结果为1的时候对应DB1;结果为2的时候对应DB2;结果为3的时候对应DB3;结果为0的时候对应DB4,这样一来就非常均匀的将数据分配到4个DB中。 优点:数据分布均匀 缺点:数据迁移的时候麻烦;不能按照机器性能分摊数据 。
- 在认证库中保存数据库配置 就是建立一个DB,这个DB单独保存user_id到DB的映射关系,每次访问数据库的时候都要先查询一次这个数据库,以得到具体的DB信息,然后才能进行我们需要的查询操作。 优点:灵活性强,一对一关系 缺点:每次查询之前都要多一次查询,会造成一定的性能损失。
-
读写分离
实现原理:读写分离简单的说是把对数据库读和写的操作分开对应不同的数据库服务器,这样能有效地减轻数据库压力,也能减轻io压力。主数据库提供写操作,从数据库提供读操作,其实在很多系统中,主要是读的操作。当主数据库进行写操作时,数据要同步到从的数据库,这样才能有效保证数据库完整性。
负载均衡
实现数据库的负载均衡技术,首先要有一个可以控制连接数据库的控制端。在这里,它截断了数据库和程序的直接连接,由所有的程序来访问这个中间层,然后再由中间层来访问数据库。这样,我们就可以具体控制访问某个数据库了,然后还可以根据数据库的当前负载采取有效的均衡策略,来调整每次连接到哪个数据库。
服务器优化
负载均衡
- 负载均衡的种类:
-
一种是通过硬件来进行解决,常见的硬件有NetScaler、F5、Radware和Array等商用的负载均衡器,但是它们是比较昂贵的
- 一种是通过软件来进行解决的,常见的软件有LVS、Nginx、apache等,它们是基于Linux系统并且开源的负载均衡策略.
-
常用NGINX负载均衡
-
七层负载均衡的实现:基于URL等应用层信息的负载均衡,nginx的proxy是它一个很强大的功能,实现了7层负载均衡,功能强大,性能卓越,运行稳定,配置简单灵活,能够自动剔除工作不正常的后端服务器,上传文件使用异步模式,支持多种分配策略,可以分配权重,分配方式灵活。
- nginx负载均衡:内置策略:IP Hash,加权轮询;扩展策略:fair策略,通用hash,一致性hash
加权轮询:首先将请求都分给高权重的机器,直到该机器的权值降到了比其他机器低,才开始将请求分给下一个高权重的机器,当所有后端机器都down掉时,nginx会立即将所有机器的标志位清成初始状态,以避免造成所有的机器都处在timeout的状态;
IP Hash:流程和轮询很类似,只是其中的算法和具体的策略有些变化,算法是一种变相的轮询算法;
fair:根据后端服务器的响应时间判断负载情况,从中选出负载最轻的机器进行分流;
通用hash,一致性hash:通用hash比较简单,可以以nginx内置的变量为key进行hash,一致性hash采用了nginx内置的一致性hash环,支持memcache
- nginx配置示例:
http { upstream cluster{ #ip_hash; server srv1 weight=1; server srv2; server srv3; } server { listen 80; location / { proxy_pass http://cluster; } } }
- 四层负载均衡的实现:通过报文中的目标地址和端口,再加上负载均衡设备设置的服务器选择方式,决定最终选择的内部服务器。LVS实现服务器集群负载均衡有三种方式,NAT,DR和TUN。
-