应用服务器就是处理网站业务的服务器,网站的业务代码部署在这里,是网站开发最复杂、变化最多的地方,优化手段主要是缓存、集群、异步。
分布式缓存
网站性能优化第一定律:优先考虑使用缓存优化性能。
整个网站应用中,缓存几乎无所不在,既存在于浏览器,也存在于应用服务器和数据库服务器;既可以对数据缓存,也可以对文件缓存,还可以对页面片段缓存。合理使用缓存,对网站性能优化意义重大。
缓存的基本原理
缓存指将数据存储在相对较高访问速度的存储介质中,以供系统处理。一方面缓存访问速度快,可以减少数据访问的时间,另一方面如果缓存的数据是经过计算处理得到的,那么被缓存的数据无需重复计算即可直接使用,因此缓存还起到减少计算时间的作用。
缓存的本质是一个内存 Hash 表,网站应用中,数据缓存以一对 Key、Value 的形式存储在内存 Hash 表中。Hash 表数据读写的时间复杂度为 O(1)。
缓存主要用来存放那些读写比很高、很少变化的数据。应用程序读取数据时,先到缓存中读取,如果读取不到或数据已失效,再访问数据库,并将数据写入缓存。
合理使用缓存
频繁修改的数据
如果缓存中保存的是频繁修改的数据,就会出现数据写入缓存后,应用还来不及读取缓存,数据就已失效的情形,徒增系统负担。一般说来,数据的读写比在 2 : 1 以上,即写入一次缓存,在数据更新前至少读取两次,缓存才有意义。实践中,这个读写比通常非常高,比如新浪微博的热门微博,缓存以后可能会被读取数百万次。
没有热点的访问
缓存使用内存作为存储,内存资源宝贵而有限,不可能将所有数据都缓存起来,只能将最新访问的数据缓存起来,而将历史数据清理出缓存。如果应用系统访问数据没有热点,不遵循二八定律,即大部分数据访问并没有集中在小部分数据上,那么缓存就没有意义,因为大部分数据还没有被再次访问就已经被挤出缓存了。
数据不一致与脏读
一般会对缓存的数据设置失效时间,一旦超过失效时间,就要从数据库中重新加载。因此应用要容忍一定时间的数据不一致,如卖家已经编辑了商品属性,但是需要过一段时间才能被买家看到。这种延迟通常是可以接受的,但是具体应用仍需慎重对待。还有一种策略是数据更新时立即更新缓存,不过这也会带来更多系统开销和事务一致性的问题。
缓存可用性
缓存是为提高数据读取性能的,缓存数据丢失或者缓存不可用不会影响到应用程序的处理——它可以从数据库直接获取数据。但是随着业务的发展,缓存会承担大部分数据访问的压力,数据库已经习惯了有缓存的日子,所以当缓存服务崩溃时,数据库会因为完全不能承受如此大的压力而宕机,进而导致整个网站不可用。这种情况被称作缓存雪崩,发生这种故障,甚至不能简单地重启缓存服务器和数据库服务器来恢复网站访问。
实践中,有的网站通过缓存热备等手段提高缓存可用性:当某台缓存服务器宕机时,将缓存访问切换到热备服务器上。但是这种设计显然有违缓存的初衷,缓存根本就不应该被当做一个可靠的数据源来使用。
通过分布式缓存服务器集群,将缓存数据分布到集群多台服务器上可在一定程度上改善缓存的可用性。当一台缓存服务器宕机的时候,只有部分缓存数据丢失,重新从数据库加载这部分数据不会对数据库产生很大影响。
缓存预热
缓存中存放的是热点数据,热点数据又是缓存系统利用 LRU(最近最久未用算法)对不断访问的数据筛选淘汰出来的,这个过程需要花费较长的时间。新启动的缓存系统如果没有任何数据,在重建缓存数据的过程中,系统的性能和数据库负载都不太好,那么最好在缓存系统启动时就把热点数据加载好,这个缓存预加载手段叫作缓存预热(warm up)。对于一些元数据如城市地名列表、类目信息,可以在启动时加载数据库中全部数据到缓存进行预热。
缓存穿透
如果因为不恰当的业务、或者恶意攻击持续高并发地请求某个不存在的数据,由于缓存没有保存该数据,所有的请求都会落到数据库上,会对数据库造成很大压力,甚至崩溃。一个简单的对策是将不存在的数据也缓存起来(其 value 值为 null)。
异步
使用消息队列将调用异步化,可改善网站的扩展性,同时可以改善网站系统的性能。
在不使用消息队列的情况下,用户的请求数据直接写入数据库,在高并发地情况下,会对数据库造成巨大的压力,同时也使得响应延迟加剧。在使用消息队列后,用户请求的数据发送给消息队列后立即返回,再由消息队列的消费者进程(通常该进程独立部署在专门的服务器集群上)从消息队列中获取数据,异步写入数据库。由于消息队列服务器处理速度远快于数据库(消息队列服务器也比数据库具有更好的伸缩性),因此用户的响应延迟可得到有效改善。
消息队列具有很好地削峰作用—即通过异步处理,将短时间高并发产生的事务消息存储在消息队列中,从而削平高峰期的并发事务。需要注意的是,由于数据写入消息队列后立即返回给用户,数据在后续的业务校验、写入数据库等操作可能失败,因此在使用消息队列进行业务异步处理后,需要适当修改业务流程进行配合,如订单提交后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单,甚至商品出库后,再通过电子邮件通知用户订单成功,以免交易纠纷。
在这里关于数据库相关的优化做个总结:
- DB的读优化:缓存;
- DB的写优化:消息队列。
集群
在网站高并发访问的场景下,使用负载均衡技术为一个应用构建一个由多台服务器组成的服务器集群,将并发访问请求分发到多台服务器上处理,避免单一服务器因负载压力过大而响应缓慢,使用户请求具有更好的响应延迟特性。
代码优化
1. 多线程
从资源利用的角度看,使用多线程的原因主要有两个:IO 阻塞与多 CPU。
假设服务器上执行的都是相同类型任务,针对该类任务启动的线程数有个简化估算公式:
启动线程数=[任务执行时间/(任务执行时间-IO等待时间)xCPU内核数]
最佳启动线程数和 CPU 内核数量呈正比,和 IO 阻塞时间成反比。如果任务都是 CPU 计算型任务,那么线程数最多不超过 CPU 内核数。
多线程编程需要关注线程安全问题,所有资源——对象、内存、文件、数据库,乃至另一个线程都可能被多线程访问。
解决线程安全的主要手段:
将对象设计为无状态对象:无状态对象是指对象本身不存储状态信息(对象无成员变量,或者成员变量也是无状态对象),这样多线程并发访问时候就不会出现状态不一致。Servlet 对象就设计为无状态对象,可以被多线程并发调用处理用户请求。
使用局部对象:在方法内部创建对象,这些对象会被每个进入该方法的线程创建,除非程序有意识地将这些对象传递给其他线程,否则不会出现对象被多线程并发访问的清形。
并发访问资源时使用锁:多线程访问资源的时候,通过锁的方式使多线程并发操作转化为顺序操作,从而避免资源被并发修改。但是锁导致线程同步顺序执行,可能会对系统性能产生严重影响。
2. 资源复用
系统运行时,要尽量减少那些开销很大的系统资源的创建和销毁,比如数据库连接、网络通信连接、线程、复杂对象等。从编程角度,资源复用主要有两种模式:
单例:由于目前 Web 开发中主要使用贫血模式,从 Service 到 Dao 都是些无状态对象,无需重复创建,使用单例模式就自然而然了。Java 开发常用的对象容器 Spring 默认构造的对象都是单例(要注意的是 Spring 的单例是 Spring 容器管理的单例,而不是用单例模式构造的单例)。
对象池:通过复用对象实例,减少对象创建和资源消耗。对于数据库连接对象,每次创建连接,数据库服务端都需要创建专门的资源以应对,因此频繁创建关闭数据库连接,对数据库服务器而言是灾难性的,同时频繁创建关闭连接也需要花费较长的时间。因此在实践中,应用程序的数据库连接基本都使用连接池(Connection Pool)的方式。数据库连接对象创建好以后,将连接对象放入对象池容器中,应用程序要连接的时候,就从对象池中获取一个空闲的连接使用,使用完再将该对象归还到对象池中即可,不需要创建新的连接。
对于每个 Web 请求,应用服务器都需要创建一个独立的线程去处理。这方面,应用服务器也采用线程池(Thread Pool)的方式。线程池本质上也是对象池,池管理方式基本相同。
3. 数据结构
Hash 算法必须满足:
- 冲突少
- 相似字符串的 HashCode 不能太接近
这种情况下,一个可行的方案是对字符串取信息指纹,再对信息指纹求 HashCode。由于字符串微小的变化就可以引起信息指纹的巨大不同,因此可以获得较好的随机散列。
4. 垃圾回收
垃圾回收可能会对系统性能产生巨大影响。理解垃圾回收有助于程序优化和参数调优,以及编写内存安全的代码。
以 JVM 为例,其内存主要可划分为堆(heap ) 和堆栈(stack)。堆栈用于存储线程上下文信息,如方法参数、局部变量等。堆则是存储对象的内存空间,对象的创建和释放、垃圾回收就在这里进行。通过对对象生命周期的观察,发现大部分对象的生命周期都极其短暂,这部分对象产生的垃圾应该被更快地收集,以释放内存,这就是 JVM 分代垃圾回收。
在 JVM 分代垃圾回收机制中,将应用程序可用的堆空间分为年轻代(Young Generation)和年老代(Old Generation),又将年轻代分为 Eden 区(Eden Space)、From 区和 To 区,新建对象总是在 Eden 区中被创建,当 Eden 区空间已满,就触发一次 Young GC,将还被使用的对象复制到 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。