5.2.1 大内存硬件上的程序部署策略

5.2.1 大内存硬件上的程序部署策略

这是笔者很久之前处理过的一个案例,但今天仍然具有代表性。一个15万PV/日左右的在线文档类型网站最近更换了硬件系统,服务器的硬件为四路志强处理器、16GB物理内存,操作系统为64位CentOS 5.4,Resin作为Web服务器。整个服务器暂时没有部署别的应用,所有硬件资源都可以提供给这访问量并不算太大的文档网站使用。软件版本选用的是64位的JDK 5,管理员启用了一个虚拟机实例,使用-Xmx和-Xms参数将Java堆大小固定在12GB。使用一段时间后发现服务器的运行效果十分不理想,网站经常不定期出现长时间失去响应。

监控服务器运行状况后发现网站失去响应是由垃圾收集停顿所导致的,在该系统软硬件条件下, HotSpot虚拟机是以服务端模式运行,默认使用的是吞吐量优先收集器,回收12GB的Java堆,一次Full GC的停顿时间就高达14秒。由于程序设计的原因,访问文档时会把文档从磁盘提取到内存中,导致内存中出现很多由文档序列化产生的大对象,这些大对象大多在分配时就直接进入了老年代,没有在Minor GC中被清理掉。这种情况下即使有12GB的堆,内存也很快会被消耗殆尽,由此导致每隔几分钟出现十几秒的停顿,令网站开发、管理员都对使用Java技术开发网站感到很失望。

分析此案例的情况,程序代码问题这里不延伸讨论,程序部署上的主要问题显然是过大的堆内存进行回收时带来的长时间的停顿。经调查,更早之前的硬件使用的是32位操作系统,给HotSpot虚拟机只分配了1.5GB的堆内存,当时用户确实感觉到使用网站比较缓慢,但还不至于发生长达十几秒的明显停顿,后来将硬件升级到64位系统、16GB内存希望能提升程序效能,却反而出现了停顿问题,尝试过将Java堆分配的内存重新缩小到1.5GB或者2GB,这样的确可以避免长时间停顿,但是在硬件上的投资就显得非常浪费。

每一款Java虚拟机中的每一款垃圾收集器都有自己的应用目标与最适合的应用场景,如果在特定场景中选择了不恰当的配置和部署方式,自然会事倍功半。目前单体应用在较大内存的硬件上主要的部署方式有两种:

1)通过一个单独的Java虚拟机实例来管理大量的Java堆内存。
2)同时使用若干个Java虚拟机,建立逻辑集群来利用硬件资源。

此案例中的管理员采用了第一种部署方式。对于用户交互性强、对停顿时间敏感、内存又较大的系统,并不是一定要使用Shenandoah、ZGC这些明确以控制延迟为目标的垃圾收集器才能解决问题 (当然不可否认,如果情况允许的话,这是最值得考虑的方案),使用Parallel Scavenge/Old收集器,并且给Java虚拟机分配较大的堆内存也是有很多运行得很成功的案例的,但前提是必须把应用的Full GC 频率控制得足够低,至少要低到不会在用户使用过程中发生,譬如十几个小时乃至一整天都不出现一次Full GC,这样可以通过在深夜执行定时任务的方式触发Full GC甚至是自动重启应用服务器来保持内存可用空间在一个稳定的水平。

控制Full GC频率的关键是老年代的相对稳定,这主要取决于应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应当太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定。

在许多网站和B/S形式的应用里,多数对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象相对较少。只要代码写得合理,实现在超大堆中正常使用没有Full GC应当并不困难,这样的话,使用超大堆内存时,应用响应速度才可能会有所保证。除此之外,如果读者计划使用单个Java虚拟机实例来管理大内存,还需要考虑下面可能面临的问题:

  • 回收大块堆内存而导致的长时间停顿,自从G1收集器的出现,增量回收得到比较好的应用^1, 这个问题有所缓解,但要到ZGC和Shenandoah收集器成熟之后才得到相对彻底地解决。
  • 大内存必须有64位Java虚拟机的支持,但由于压缩指针、处理器缓存行容量(Cache Line)等因素,64位虚拟机的性能测试结果普遍略低于相同版本的32位虚拟机。
  • 必须保证应用程序足够稳定,因为这种大型单体应用要是发生了堆内存溢出,几乎无法产生堆转储快照(要产生十几GB乃至更大的快照文件),哪怕成功生成了快照也难以进行分析;如果确实出了问题要进行诊断,可能就必须应用JMC这种能够在生产环境中进行的运维工具。
  • 相同的程序在64位虚拟机中消耗的内存一般比32位虚拟机要大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的,可以开启(默认即开启)压缩指针功能来缓解。

鉴于上述这些问题,现阶段仍然有一些系统管理员选择第二种方式来部署应用:同时使用若干个虚拟机建立逻辑集群来利用硬件资源。做法是在一台物理机器上启动多个应用服务器进程,为每个服务器进程分配不同端口,然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。这里无须太在意均衡器转发所消耗的性能,即使是使用第一个部署方案,多数应用也不止有一台服务器, 因此应用中前端的负载均衡器总是免不了的。

考虑到我们在一台物理机器上建立逻辑集群的目的仅仅是尽可能利用硬件资源,并不是要按职责、按领域做应用拆分,也不需要考虑状态保留、热转移之类的高可用性需求,不需要保证每个虚拟机进程有绝对准确的均衡负载,因此使用无Session复制的亲合式集群是一个相当合适的选择。仅仅需要保障集群具备亲合性,也就是均衡器按一定的规则算法(譬如根据Session ID分配)将一个固定的用户请求永远分配到一个固定的集群节点进行处理即可,这样程序开发阶段就几乎不必为集群环境做任何特别的考虑。

当然,第二种部署方案也不是没有缺点的,如果读者计划使用逻辑集群的方式来部署程序,可能会遇到下面这些问题:

  • 节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致I/O异常。
  • 很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这样有可能导致一些节点的连接池已经满了,而另外一些节点仍有较多空余。尽管可以使用集中式的JNDI来解决,但这个方案有一定复杂性并且可能带来额外的性能代价。
  • 如果使用32位Java虚拟机作为集群节点的话,各个节点仍然不可避免地受到32位的内存限制,在32位Windows平台中每个进程只能使用2GB的内存,考虑到堆以外的内存开销,堆最多一般只能开到1.5GB。在某些Linux或UNIX系统(如Solaris)中,可以提升到3GB乃至接近4GB的内存,但32位中仍然受最高4GB(2的32次幂)内存的限制。
  • 大量使用本地缓存(如大量使用HashMap作为K/V缓存)的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点上都有一份缓存,这时候可以考虑把本地缓存改为集中式缓存。

介绍完这两种部署方式,重新回到这个案例之中,最后的部署方案并没有选择升级JDK版本,而是调整为建立5个32位JDK的逻辑集群,每个进程按2GB内存计算(其中堆固定为1.5GB),占用了10GB内存。另外建立一个Apache服务作为前端均衡代理作为访问门户。考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问,处理器资源敏感度较低,因此改为CMS收集器进行垃圾回收。部署方式调整后,服务再没有出现长时间停顿,速度比起硬件升级前有较大提升。