1 分布式缓存
回顾网站架构演化历程,当网站遇到性能瓶颈时,第一个想到的解决方案就是使用缓存。在整个网站应用中,缓存几乎无处不在,既存在于浏览器,也存在于应用服务器和数据库服务器;即可以对数据缓存,也可以对文件缓存,还可以对页面片段缓存,对网站性能优化意义重大。
网站性能优化第一定律:优先考虑使用缓存优化性能。
缓存原理
(1)什么是缓存?(将数据存储在相对较高访问速度的介质中,以供系统处理)
(2)缓存的优点:访问速度快,如果需要计算可以减少计算时间
(3)缓存的本质是一张以键值对存储的内存hash表
(4)主要用来存储:读写比例高,很少变化的数据
(5)网站的访问遵循28定律
合理使用缓存应该注意以下问题
(1)频繁更新的数据(数据还没有读就已经失效,一般要求读写比在2:1以上才有意义)
(2)没有热点数据(无疑浪费资源)
(3)数据不一致与脏读(缓存被加载的过程中可能会产生数据不一致,有效时间过程数据在内存中就会变成脏数据)
(4)缓存的可用性(如果太过依赖缓存,容易产生雪崩。使用缓存热备并不能提高缓存的可用性,使用集群可以提高可用性)
(5)缓存预热(LRU计算时间过长,有的数据需要提前加载)
(6)缓存穿透(解决部分数据无法命中,而加重数据库压力的问题,一般设置空值)
分布式缓存架构
(1)JBoss Cache:数据相同
(2)Memcached:数据不同互不通信
(3)Memcached5大优点:协议简单、通用性强(支持各种语言)、Libevent网络通信、内存管理高效、互不通信
1.1 缓存原理
缓存指将数据存储在相对较高访问速度的存储介质中,以供系统处理。一方面缓存访问速度快,可以减少数据访问的时间,另一方面如果缓存的数据是经过计算处理得到的,那么被缓存的数据无需重复计算即可直接使用,因此缓存还起到减少计算时间的作用。
缓存的本质是一个内存Hash表,网站应用中,数据缓存以一对Key、Value的形式存储在内存Hash表中。Hash表数据读写的时间复杂度为O(1)。下图为一对KV在Hash表中的存储。
缓存主要用来存放那些读写比很高、很少变化的数据,如商品的类目信息,热门词的搜索信息,热门商品信息等。应用程序读取数据时,先到缓存中读取,如果读取不到或数据已失效,再访问数据库,并将数据库写入缓存。
网站数据访问通常遵循二八定律,即80%的访问落在20%的数据上,因此利用Hash和内存的高速访问特性,
将这20%的数据缓存起来,可很好的改善系统性能,提高数据读取的速度,降低存储访问压力。
1.2 合理使用缓存
使用缓存对提高系统性能有很多好处,但是不合理使用缓存非但不能提高系统的性能,还会成为系统的累赘,甚至风险。实践中,缓存滥用的情景屡见不鲜——过分依赖低可用的缓存系统、不恰当地使用缓存的数据访问特性等。
频繁更新的数据
如果缓存中保存的数据是 频繁修改的数据,就会出现数据写入缓存后,应用还来不及读取缓存,数据就已失效的情形,徒增系统负担。一般来说,数据的读写比在2:1以上,即写入一次缓存,在数据更新前至少读取两次,缓存才有意义。实践中,这个读写比通常非常高,比如新浪微博的热门博客,缓存以后可能会被读取数百万次。
没有热点的访问
缓存使用内存作为存储,内存资源宝贵而有限,不可能将所有数据都缓存起来,只能将最新的访问数据缓存起来,而将历史数据清理出缓存。如果系统访问数据没有热点,不遵循二八定律,即大部分数据访问没有集中在小部分数据上,那么缓存就没有意义,因为大部分数据还没有被再次访问就已经被挤出缓存了。
数据不一致与脏读
一般对缓存的数据设置失效时间,一旦超过失效时间,就要从数据库中重新加载。因此数据要容忍一时的数据不一致,如卖家已经编辑来商品属性,但是需要过一段时间才能被卖家看到。在互联网应用中,这种延迟通常是可以接受的,但是具体 应用仍需慎重对待。还有一种策略是数据更新时立即更新缓存,不过这也会带来更多系统开销和事务一致性问题。
缓存的可用性
缓存是为提高数据读取性能的,缓存数据丢失或者缓存不可用不会影响到应用程序的处理——它可以从数据库直接获取数据。但是随着业务的发展,缓存会承担大部分数据访问的压力,数据库已经习惯了有缓存的日子,所以当缓存服务崩溃时,数据库会因为完全不能承受如此大的压力而当宕机,进而导致整个网站不可用。这种情况被称为缓存雪崩,发生这种故障,甚至不能简单地重启缓存服务器和数据库服务器来恢复网站访问。
实践中,有的网站通过缓存热备等手段提高缓存可用性:将某台缓存服务器宕机时,将缓存访问切换到热备服务器上。但是这种设计显然有违缓存的初衷,缓存根本就不应该被当作一个可靠的数据源来使用。
通过分布式缓存服务器集群,将缓存数据分布到集群多台服务器上可在一定程度上改善缓存的可能性。当一台缓存服务器宕机 时,只有部分缓存数据丢失,重新从数据库加载这部分数据不会对数据库产生很大影响。
产品在设计之初就需要一个明确的定位:什么是产品要实现的功能,什么不是产品提供的特性。在产品漫长的生命周期中,会有形形色色的困难和诱惑来改变产品的发展方向,左右摇摆、什么都想做的产品,最后有可能成为一个失去生命力的四不像。
缓存预热
缓存中存放的是热点数据,热点数据又是缓存系统利用LRU(最近最久未用算法)对不断访问的数据筛选出来的, 这个过程需要花费较长的时间。新启动的缓存系统如果没有任何数据,在重建缓存数据的过程中,系统的性能和数据库负载都不太好,那么最好在缓存系统启动时就把热点数据加载好,这个缓存预加载手段叫作预热。对于一些元数据如城市地名列表、类目信息,可以在启动时加载数据库中全部数据到缓存进行预热。
缓存穿透
如果因为不恰当的业务、或者恶意攻击持续高并发地请求某个不存在的数据,由于缓存没有保存该数据,所有的请求都会落在数据库上,会对数据库造成很大压力,甚至崩溃。一个简单的对策是将不存在的数据也缓存起来(其value值为null)。
1.3 分布式缓存架构
分布式缓存指缓存部署在多个服务器组成的集群中,以集群方式提供缓存服务,其架构方式有两种,一种是以JBoss Cache为代表的需要更新同步的分布式缓存,一种是以Memcached为代表的互不通信的分布式缓存。
JBoss Cache
Boss Cache的分布式缓存在集群中所有服务器中保存相同的缓存数据,当某台服务器有缓存数据更新的时候,会通知集群中其他机器更新缓存数据或清除缓存数据。JBoss Cache通常将应用程序和缓存部署在同一台服务器上,应用程序可从本地缓存读取数据,但是这种方式带来的问题是缓存数据的数量受限于单一服务器的内存空间,而且当集群规模较大的时候,缓存更新信息需要同步到集群所有机器,其代价惊人。因而这种方案更多见于企业应用系统中,而很少在大型网站使用。
大型网站需要缓存的数据量一般都很庞大,可能需要数TB的内存做缓存,这时候就需要另一种分布式缓存。
Memcached
Memcached采用一种集中式的缓存集群管理,也被称作互不通信的分布式架构方式。
缓存与应用分离部署,缓存系统部署在一种专门的服务器上,应用程序通过一致性hash等路由算法选择缓存服务器远程访问缓存数据,缓存服务器之间不通信,缓存服务器的规模可以很容易的实现扩容,具有良好的可伸缩性。
2 异步操作
数据直接写入数据库会造成巨大的压力
异步可以减缓响应时间,还能提升网站性能
对于大型网站来说,异步可以实现削峰
使用消息队列将调用异步化,可改善网站的扩展性,事实上。使用消息队列还可以改善网站系统的性能。
在不使用消息队列的情况下,用户的请求数据直接写入数据库,在高并发的情况下,会对数据库造成巨大的压力,同时也使响应延迟加剧。在使用消息队列后,用户请求的数据发送给消息队列之后立即返回,再由消息队列的消费者进程(通常情况下该进程独立部署在专门的服务器集群上)从消息队列中获取数据,异步写入数据库。由于消息队列服务器处理速度远快于数据库(消息队列服务器比数据库具有更好的伸缩性),因此用户的响应延迟可以得到有效改善。
消息队列具有很好的削峰作用——即通过异步处理,将短时间高并发产生的事务消息存储在消息队列中,从而削平高峰期的并发事务。在电子商务网站促销活动中,合理使用消息队列,可以抵御促销活动刚开始大量涌入的订单对系统的造成的冲击。
需要注意的是,由于数据写入消息队列之后立即返回给用户数据在后续的业务校验、写数据库操作可能失败,因此在使用消息队列进行业务的异步处理后,需要适当修改业务流程进行配合,如提交订单后,订单数据写入消息队列,不能立即返回用户订单数据提交成功,需要在消息队列的订单消费者进程真正处理完该订单,甚至商品出库后,在通过电子邮件或SMS消息通知用户订单成功,以免交易纠纷。
3 使用集群:使单台服务处于最佳性能区间
在网站高并发的场景下,使用负载均衡技术为一个应用构建一个由多台服务器组成的一个集群,将并发访问请求分发到多台服务器上处理,避免单一服务器因负载压力过大而响应缓慢,使用户请求具有更好的响应延迟特性。
三台Web服务器共同处理来自用户浏览器的访问请求,这样每台Web服务器需要处理的http请求只有并发请求总数的三分之一,根据性能曲线测试,使服务器的并发请求数目控制在最佳的运行区间,获取最佳的访问请求延迟。
4 代码优化
网站的业务逻辑实现代码主要部署在应用服务器上,需要处理复杂的并发事务。合理优化业务代码,可以很好地改善网站的性能。不同编程语言的代码优化手段有很多,下面主要讲几个重要的方面。
多线程
(1)线程优于进程的原因:更轻量级、资源消耗更少、切换更容易
(2)使用多线程的原因:IO阻塞和多CPU
(3)保证线程安全的手段:无状态对象、局部对象、锁
数据结构
(1)Hash表数据结构:依赖于HashCode,越散列,冲突越少,读写性能越高。
(2)优化手段:Time33和信息指纹
资源复用:单例和线程池
垃圾回收:理解JVM垃圾回收机制
4.1 多线程
多用户并发访问是网站的基本需求,大型网站的并发用户数会达到数万,单台服务器的并发用户数也会达到数百。CGI编程时代,每个用户请求都会创建一个独立的系统进程去处理。由于线程比进程更轻量,更少占用系统资源,切换代价更小,所以目前主要的Web应用服务器都采用多线程的方式响应并发用户请求,因此网站开发天然就是多线程编程。
从资源利用的角度看,使用多线程的原因主要有两个:IO阻塞和多CPU。当前线程进行IO处理的时候,会被阻塞释放CPU以等待IO操作完成,由于IO操作(不管是磁盘IO还是网络IO)通常都需要较长的时间,这时CPU可以调度其它的线程进行处理。前面我们提到,理想的系统Load是既没有进程(线程)等待也没有CPU空闲,利用多线程IO阻塞与执行交替进行,可以最大限度地利用CPU资源。使用多线程的另一个原因是服务器有多个CPU,在这个连手机都有四核CPU的时代,除了最低配置的虚拟机,一般数据中兴的服务器至少16核CPU,要想最大限度地使用这些CPU,必须启动多线程。
网站的应用程序一般被Web服务器容器管理,用户请求的多线程也通常被Web服务器Web服务器容器管理,但不管是Web容器管理的线程,还是应用程序自己创建的线程,一台服务器上启动多少线程合适呢?假设服务器上执行的都是相同类型的任务,针对该任务启动的线程数有个简化的估算公式可供参考:
启动线程数=[任务执行时间/(任务执行时间-IO等待时间)] x CPU内核数
1
最佳启动线程数和CPU内核数量成正比,和IO等待时间成正比。如果任务都是CPU计算型任务,那么线程数最多不超过CPU内核数,因为启动再多线程,CPU也来不及调度;相反如果是任务需要等待磁盘操作,网络响应,那么多启动线程有助于提高任务并发度,提高系统吞吐能力,改善系统性能。
多线程编程一个需要注意的问题是线程安全问题,即多线程并发对某个资源进行修改,导致数据混乱。这也是缺乏经验的网站工程师最容易犯错的地方,而线程安全Bug又难以测试和重现,网站故障中,许多所谓偶然发生的“灵异事件“都和多线程并发问题有关。对网站而言,不管有没有进行多线程编程,工程师写的每一行代码都会被多线程执行,因为用户请求是并发提交的,也就是说,所有的资源——对象、内存、文件、数据库,乃至另一个线程都可能被多线程并发访问。
编程上,解决线程安全的主要手段有如下几点:
(1)将对象设计为无状态对象
所谓无状态对象是指对象本身不存储状态信息(对象无成员变,或者成员变量也是无状态对象),这样多线程并发访问的时候就不会出现状态不一致,Java Web开发中常用的Servlet对象就设计为无状态对象,可以被应用程序多线程并发调用处理用户请求。而Web开发中常用的贫血模型对象都是些无状态对象。不过从面向对象设计的角度看,无状态对象是一种不良设计。
(2)使用局部对象
即在方法内部创建对象,这些对象会被每个进入该方法的线程创建,除非程序有意识地将这些对象传递给其它程序,否则不会出现对象被多线程访问地情形。
(3)并发访问资源时使用锁
即多线程访问资源的时候,通常锁的方式使多线程并发操作转化为顺序操作,从而避免资源被并发修改。随着操作系统和编程语言的进步,出现各种轻量级锁,使得运行期线程获取锁和释放锁的代价都变得更小,但是锁导致线程同步顺序执行,可能会对系统性能产生严重影响。
4.2 数据结构
早期关于程序的一个定义是,程序就是数据结构+算法,数据结构对于编程的重要性不言而喻。在不同场景中合理使用恰当的数据结构,灵活组合各种数据结构读写和计算性能可极大优化程序的性能。
前面缓存部分已经描述过Hash表的基本原理,Hash表的读写性能在很大程度上依赖HashCode的随机性,即HashCode越随机散列,Hash表的冲突就越少,读写性能也就越高,目前比较好的字符串Hash散列算法有Time33算法,即对字符串逐字符迭代乘以33,求得Hash值,算法原型为:
hash(i) = hash(i-1) * 33 + str[i]
1
Time33虽然可以比较好的解决冲突,但是有可能相似字符串的HashCode也比较接近,如字符串”AA“的HashCode是2210,字符串”AB“的HashCode是2211。这在某些应用场景是不能接受的,这种情况下,一个可行的方案是对字符串取信息指纹,再对信息指纹求HashCode,由于字符串微小的变化就可以引起信息指纹的巨大不同,因此可以获得较好的随机散列。
4.3 资源复用
系统运行时,要尽量减少那些开销很大的系统资源的创建和销毁,比如数据库连接、网络通信连接、线程、复杂对象等。从编程角度,资源复用主要的两种模式:单例(Singleton)和对象池(Object Pool)。
单例虽然是GoF经典设计模式中较多被诟病的一个模式,但由于目前Web开发中主要使用贫血模式,从Service到Dao都是些无状态对象,无需重复创建,使用单例模式也就自然而然了。事实上,Java开发常用的对象容器Spring默认构造的对象都是单例(需要注意的是Spring的单例是Spring容器管理的单例而不是用单例模式构造的单例)。
对象池模式通过复用对象实例,减少对象创建和资源消耗。对于数据库连接对象,每次创建连接,数据库服务端都需要创建专门的资源以应对,因此频繁创建关闭数据库连接,对数据库服务器而言是灾难性的,同时频繁创建关闭连接也需要花费较长时间。因此在实践中,应用程序的数据库连接基本都使用连接池(Connection Pool)的方式。数据库连接对象创建好以后,将连接对象放入对象池容器中,应用程序要连接的时候,就从连接池中取一个空闲的连接使用,使用完毕再将对象归还到对象池中即可,不需要在创建新的连接。
对于每个Web请求(HTTP Request),Web应用程序都需要创建一个独立的线程去处理,这方面,应用服务器也采用线程池的方式。这些所谓的连接池、线程池,本质上都是对象池,即连接、线程都是对象,池管理方式也基本相同。
4.4 垃圾回收
如果Web应用运行在JVM等具有垃圾回收功能的环境中,那么垃圾回收可能对系统的性能特性产生极大影响。理解垃圾回收机制有助于程序优化和参数调优,以及编写内存安全的代码。
以JVM为例,其内存主要可划分为堆(heap)和堆栈(stack)。堆栈用于存储线程上下文信息,如方法参数、局部变量等。堆则是存储对象的内存空间,对象的创建和释放、垃圾回收就在这里进行。通过对对象生命周期的观察,发现大部分对象的生命周期都极其短暂,这部分对象产生的垃圾应该被更快的收集,以释放内存,这就是JVM分代垃圾回收。
在JVM分代垃圾回收机制中,将应用程序可用的堆栈空间分为年轻代(Young Generation)和年老代(Old Generation),又将年轻带分为Eden区(Eden Space)、From区和To区,新建对象总是在Eden区被创建,当Eden区空间已满,就触发一次Young GC(Garbage Collection,垃圾回收),将还被使用的对象复制到From区,这样整个Eden区都是未被使用的空间,可以继续创建对象,当Eden区再次用完,再触发一次Young GC,将Eden区和From区还在被使用的对象复制到To区,下一次Young GC则是将Eden区和To区还未被使用的对象复制到From区。因此经过多次的young GC,某些对象会在From区和To区多次复制,如果超过某个阀值,对象还未被释放,则将该对象复制到Old Generation。如果Old generation空间也已用完,那么就会触发Full GC,即所谓的全变量回收,全变量回收会对系统性能产生较大的影响,因此应根据系统的业务特点和对象的生命周期合理设置Young generation和Old generation的大小,尽量减少Full GC。事实上,某些Web应用在整个运行期间可以做到从不进行Full GC。