2017 年 3 月 25 日,又拍云在广州举行了 Open Talk 唯品会专场“如何打造高性能高可用的电商平台”。


唯品会资深开发工程师莫伟强带来了《低延迟服务开发之路》的精彩演讲,分享低延迟服务开发的挑战和解决策略。


Clipboard Image.png

一、低延迟服务的挑战

1. 服务的延迟

客户端延迟是指从请求发起到收到响应的时间差,而服务端延迟是指从接收到请求和发出响应的时间差。当请求的平均延迟从 50 ms 降到 40 ms 时,同样的服务器资源可以多服务 25% 的用户,也意味着在同样的用户规模下可以节省 20% 服务器。将这个比例放到唯品会的规模将是非常可观的运维资源。


目前唯品会服务的延迟情况是:聚合服务层接口平均的延迟 23 毫秒;绝大部分的请求会在 50 毫秒内返回;如果某个响应超过 1 秒钟仍然未能完成响应将被判定会请求超时。


低延迟服务的设计要从业务需求出发,本文主要和大家探讨低延迟服务的领域会遭遇的挑战—— JVM 的垃圾回收机制。

2. 世界停顿了 2 秒:GC 并不是一个黑盒子

一台服务器突然出现大量的超时,然后迅速恢复了正常。检查这台服务器的应用日志发现,长达 2 秒时间完全没有日志的输出。


继续追查原因时发现了一条线索:对应时间的 GC 日志显示,有一次的新生代垃圾回收耗时达到了 2 秒。而普遍的情况之下,这个时间不会超过 200 毫秒。


翻阅 GC 的源码后发现,JVM 的 GC 日志是以同步的方式写入的。如果在写入日志时遭遇到 IO 繁忙,那么 GC 的过程将被阻塞。找到原因之后的应对方法很简单,将 GC 日志改为写入到内存盘就可以。


上面的例子告诉大家不能忽视 GC 的存在,更不能继续将它看成黑盒子,要去了解它。

二:两种垃圾回收机制:ParNew GC 和 CMS GC

追求低停顿的互联网业务系统,通常都会使用 ParNew + CMS 组合来做解决方案。ParNew 用于新生代,是一个几乎全程 STW 的过程,使用多线程进行垃圾回收以降低停顿时间。CMS 用于老年代,它只有两个比较短阶段会 STW,其余时间可以与业务线程并发进行。

1. ParNew GC

Clipboard Image.png


上图公式中,Tyoung 表示新生代垃圾回收的时间,这个时间由两部分组成:标记和复制。

  • 标记阶段


Tstack_scan 是指扫描所有方法栈中的根的时间,可以理解为当前仍然处于作用域内的局部变量。这里的数量比较小,而且随时间的变化量也比较小。


Tcard_scan 与 Told_scan 可以理解为:新生代的对象除了可能被局部变量保持引用,还可能被老年代的某个对象引用了。对于找出这些对象,JVM 的方案是将内存区域划分为许多个 card,每个 card 的大小是512 字节。每次对堆内的对象写入时,对象所在的 card 就被标记为 dirty。然后扫描老年代的过程就可以简化为先遍历找出标记为 dirty 的 card,然后只标记这些 cards 里的对象。

  • 复制阶段


新生代的 GC 基于新生对象多数短寿情况,基本都采用复制算法。JVM 的新生代划分为 1 个 Eden 区和 2 个 survivor 区。Eden 区是用于分配新对象内存空间的,其中 1 个 Survivor 区存放了从经过上次 YGC 后仍然存活但未晋升到老年代的对象,另外一个 Survivor 区是空的。每次 YGC 执行的时候首先会重置一下标记为 dirty 的 cards。之后就是以上个阶段标记的对象为根遍历存活的对象并复制到空的 Survivor 区,再将另外两个区的空间清空。


Tyoung 的时间一般比较短,经过 JVM 参数的调优可以控制在 50 ms 以内。而在唯品会的服务中可以优化到 25 ms 左右。

2. CMS GC

CMS GC(Concurrent Mark Sweep)。它的优势是能够提供比较短的应用停顿时间。


Clipboard Image.png

上图所示:整个 OGC 的过程花了 5 秒左右,造成 STW 的只有两个阶段。Initial Mark 时间比较短,通常在数十毫秒,Final Remark 时间比较长,会达到百毫秒这个水平。


注意:以下有几点说明。


  • CMS G C的停顿比 ParNew 更长。

  • 有两个 Concurrent 阶段共持续长达 5 秒,在这 5 秒内的时间,CPU 资源不能全部用于业务线程,相当一部分消耗在 GC 线程了,意味着GC 线程和服务的业务线程会造成业务能力的下降。

  • CMS 为了减少停顿的时间,老年代的内存是只清除而不整理,也就是CMS GC 会造成内存碎片。举个例子,某一次 CMS GC 清理了一个小对象大小 512 bytes,刚好这个空间附近的对象长期存活,大于 512 bytes 大小的对象无法分配到这个区域,小于这个大小的对象放到这个空间又要造成缝隙。长此以往,老年代中可能充满了许多难以重用的小空间。

三、降低 GC 负担


GC 擅长应付短寿命的小对象,但它的工作还是挺重的。

1. 减轻 GC 负担的三种思路


  • 及早离开作用域——设为 Null


前部分讨论 ParNew 的时候谈到 Tcopy 只需要复制存活的对象,也就是不论创建了多少对象。只要在 GC 触发时已经不再存活,就基本上不会对GC 造成明显的影响。早期的一些优化资料可能会提到通过将一些失效的变量设置为 Null 以尽量缩短对象的生命周期。然而如非这个对象非常大并且存在于一比较长的方法中,否则这个语句带来的效果非常的微小,且会影响代码的洁净度。


  • 减少大对象创建——中间对象


大对象比起小对象需要分配更多的空间,使得 GC 的触发周期缩短。唯品会经常会有从一个远程服务调用获取所需对象的情况,例如业务需要获取一个档期里的所有商品信息。唯品会的做法是将远程服务返回的二进制流复制到一个 char[] 中,将这个 char[] 转换为字符串,最后将这个字符串通过 JSON 解释工具转换为 Java 对象。然而这中间生成几百 k 大小的 char[] 和字符串并没有被使用。发现这个问题后唯品会做了一个调整,直接从返回的二进制流转换为 Java 对象。实验的结果该接口的 QPS 提升到 10%,GC 也可以轻松一点。


  • 长寿对象的摇篮——缓存数据


唯品会在设计低延迟服务时,通常会针对数据读取设计缓存服务,它可以降低读取延迟,提升新生代复制。但缓存是一柄双刃剑,它也会带来非常大的开销,比如增大老年代空间和增加 Old GC 频率。

2. 缓存的五种解决方案

  • 热点数据


对于非常大的数据量唯品会启用热点数据来进行缓存,当缓存里的数据不存在时,则需要从数据源里把这个数据加载起来,还需要把一些失效的对象或者是已经冷却的对象提到的策略里面。为了保证足够高的缓存命中率,也会高淘汰量提高利用率。


  • 堆外内存


DirectBuffer 设计的初衷是:在 JVM 中,bytes 的数组可能因为 GC 原因被移动,难以与操作系统的 IO 例程交互。因此在内存中划出了一片不受 GC 影响的“自治区”,这片区域的内存分配和回收由用户自己管理。


聪明的程序员利用了这个特性,将需要缓存的对象放到这个自治区中。这样老年代的空间需求小了,也不会有频繁的修改,对象的淘汰也不会累积为 OGC 。


唯品会有一个业务试用了堆外缓存,CPU 飙升,疯狂的 YGC,不到半天就回滚。原因是利用堆外内存是 bytes 数组,需要每次都通过反序列化为新的实例,而唯品会缓存的对象,除了自身字段多以外,还引用很多的下级对象。反序列化这样的对象是一个相当耗 CPU 的操作,同时也可能创建大量的新对象。


  • 定时置换


热点数据是一个回源的问题,一个对象找一个键,需要回到原服务器里去拿数据,需要处理并发,而且考虑回源的时候,会造成下游的崩溃。


对此唯品会在背景里逐步构建一个缓存结构,然后替换掉当前的结构。这种方式是由背景切换去写入的,数据读取时不会有任何的写入操作,读取的效率会非常高。但这种方式会带来一个短期的 GC 高峰,因为产生新的缓存集合时,会有一个很大的缓存集合对象,里面有非常多的小集合对象,这会涉及非常大量的数据调配,它可能会延长新时代 GC 的情况。


  • 增量更新


为了避免数据回源造成的延迟和复杂度,唯品会部分缓存数据采用定期全量更新的方式。这个方案很简单实用,但是仍然存在两个比较大的问题。首先是定期同步会造成数据不一致;另一方面在每次同步的时候 YGC 的时候会明显上涨,随着缓存量越来越多,OGC 也会变得更加频繁。


为了解决这两个问题,唯品会对系统进行了改造。根据业务情形缓存的对象多数情况下只有少部分需要更新,甚至多数情况下不需要更新。而需要更新的部分数据通过消息队列来进行实时增量的更新。


Clipboard Image.png


以下是增量更新模型详细说明:


每一次的数据更新都会为这个版本的数据标记自增的序号,同时这次更新生产的增量数据也会带上这个序号。同步这个增量的消息时,版本号也需要同步更新。假定某一时间生产方的版本号为 n,并且已经将变更的信息发送到队列中,同一时刻某一个消费者的版本号为 n-4。


首先消费者从队列中发现一条比自己目前后一版本消息( #n-3 ),消息版本比自己的版本大一,这是合法的情况,可以正常消费将自己更新为 #n-3 版本。


再次消费时发现收到的消息是( #n-1 ),比自己当前的版本大小 2 级。这意味着消息错乱或者丢失了。解决办法就是全量同步当前最新的版本( #n )。


当再次重新读取队列的时候可能还会读到( n-1 )和( n ),因为版本号并不比自己大,所以这是旧消息,直接丢弃就可以了。


然后生产者再产生( n+1 )版本的数据,并推送序号同为( n+1 )的增量消息,消费者就可以正常更新到( n+1 )了。


从这个简要的模型可以看到,要实现增量更新并不是有一个消息队列然后更新就可以了。我们还需要花 80% 的精力在保证增量更新的可靠性。


(1)全量快照同步的功能还是需要保留的,冷启动,数据错乱,甚至是业务降级时都需要使用。


(2)每一份数据与这份数据产生的增量消息都需要有序号,消费时需要校验


(3)如果生产方本身不提供增量数据,还需要另外实现一个服务去获得增量数据。


(4)在业务应用之外,我们还需要增加一个消费者。这个消息者与业务服务器的一同产生新版本的数据,但是并不负载实际的流量,而是将每次产品的数据与生产方校对。


  • 对象重用


有过打印堆对象经验的同学知道,内存中数量最大的对象是字符串,可以轻松抛开第二名一个数量级。因为即使是拼接结果相同的字符串,每次都还会创建一个独立的对象。


这种情形如果采用 String.intern 会有很大的坑。因为 String.intern 是一个定长的哈希表实现。哈希表并不会随数据量扩容,只会在哈希冲突时对应的链表下追加一个新的节点。当添加的字符串很多的时候,性能的下滑是相当的明显的。


既然 JDK 并未提供很好的解决方案,相信经过之前的讨论大家不难设计出一个好的字符串缓存方案。


总的来说,低延迟情况下 GC 会影响低延迟服务的可用性,性能优化是一个长期的过程,对于缓存需要多花心思设计,因为它是影响 GC 停顿非常重要的因素。