Java性能调优(十八)实战(2)

643 阅读25分钟

三、使用缓存优化系统性能

前端缓存技术

如果你是一位 Java 开发工程师,你可能会想,我们有必要去了解前端的技术吗?

不想当将军的士兵不是好士兵,作为一个技术人员,不想做架构师的开发不是好开发。作为架构工程师的话,我们就很有必要去了解前端的知识点了,这样有助于我们设计和优化系统。前端做缓存,可以缓解服务端的压力,减少带宽的占用,同时也可以提升前端的查询性能。

1. 本地缓存

平时使用拦截器(例如 Fiddler)或浏览器 Debug 时,我们经常会发现一些接口返回 304 状态码 + Not Modified 字符串,如下图中的极客时间 Web 首页。 如果我们对前端缓存技术不了解,就很容易对此感到困惑。浏览器常用的一种缓存就是这种基于 304 响应状态实现的本地缓存了,通常这种缓存被称为协商缓存。

协商缓存,顾名思义就是与服务端协商之后,通过协商结果来判断是否使用本地缓存。

一般协商缓存可以基于请求头部中的 If-Modified-Since 字段与返回头部中的 Last-Modified 字段实现,也可以基于请求头部中的 If-None-Match 字段与返回头部中的 ETag 字段来实现。

两种方式的实现原理是一样的,前者是基于时间实现的,后者是基于一个唯一标识实现的,相对来说后者可以更加准确地判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题。下面我们再来了解下整个缓存的实现流程:

  • 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 ETag 唯一标识,这个唯一标识的值是根据当前请求的资源生成的;
  • 当浏览器再次请求访问服务器中的该资源时,会在 Request 头部加上 If-None-Match 字段,该字段的值就是 Response 头部加上 ETag 唯一标识;
  • 服务器再次收到请求后,会根据请求中的 If-None-Match 值与当前请求的资源生成的唯一标识进行比较,如果值相等,则返回 304 Not Modified,如果不相等,则在 Response 头部加上新的 ETag 唯一标识,并返回资源;
  • 如果浏览器收到 304 的请求响应状态码,则会从本地缓存中加载资源,否则更新资源。

本地缓存中除了这种协商缓存,还有一种就是强缓存的实现。

强缓存指的是只要判断缓存没有过期,则直接使用浏览器的本地缓存。如下图中,返回的是 200 状态码,但在 size 项中标识的是 memory cache。 强缓存是利用 Expires 或者 Cache-Control 这两个 HTTP Response Header 实现的,它们都用来表示资源在客户端缓存的有效期。

Expires 是一个绝对时间,而 Cache-Control 是一个相对时间,即一个过期时间大小,与协商缓存一样,基于 Expires 实现的强缓存也会因为时间问题导致缓存管理出现问题。我建议使用 Cache-Control 来实现强缓存。具体的实现流程如下:

  • 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 Cache-Control,Cache-Control 中设置了过期时间大小;
  • 浏览器再次请求访问服务器中的该资源时,会先通过请求资源的时间与 Cache-Control 中设置的过期时间大小,来计算出该资源是否过期,如果没有,则使用该缓存,否则请求服务器;
  • 服务器再次收到请求后,会再次更新 Response 头部的 Cache-Control。

2. 网关缓存

除了以上本地缓存,我们还可以在网关中设置缓存,也就是我们熟悉的 CDN。

CDN 缓存是通过不同地点的缓存节点缓存资源副本,当用户访问相应的资源时,会调用最近的 CDN 节点返回请求资源,这种方式常用于视频资源的缓存。

服务层缓存技术

前端缓存一般用于缓存一些不常修改的常量数据或一些资源文件,大部分接口请求的数据都缓存在了服务端,方便统一管理缓存数据。

服务端缓存的初衷是为了提升系统性能。例如,数据库由于并发查询压力过大,可以使用缓存减轻数据库压力;在后台管理中的一些报表计算类数据,每次请求都需要大量计算,消耗系统 CPU 资源,我们可以使用缓存来保存计算结果。

服务端的缓存也分为进程缓存和分布式缓存,在 Java 中进程缓存就是 JVM 实现的缓存,常见的有我们经常使用的容器类,ArrayList、ConcurrentHashMap 等,分布式缓存则是基于 Redis 实现的缓存。

1. 进程缓存

对于进程缓存,虽然数据的存取会更加高效,但 JVM 的堆内存数量是有限的,且在分布式环境下很难同步各个服务间的缓存更新,所以我们一般缓存一些数据量不大、更新频率较低的数据。常见的实现方式如下:

// 静态常量
public final staticS String url = "https://time.geekbang.org";
//list 容器
public static List<String> cacheList = new Vector<String>();
 //map 容器   
private static final Map<String, Object> cacheMap= new ConcurrentHashMap<String, Object>();

除了 Java 自带的容器可以实现进程缓存,我们还可以基于 Google 实现的一套内存缓存组件 Guava Cache 来实现。

Guava Cache 适用于高并发的多线程缓存,它和 ConcurrentHashMap 一样,都是基于分段锁实现的并发缓存。

Guava Cache 同时也实现了数据淘汰机制,当我们设置了缓存的最大值后,当存储的数据超过了最大值时,它就会使用 LRU 算法淘汰数据。我们可以通过以下代码了解下 Guava Cache 的实现:

public class GuavaCacheDemo {
    public static void main(String[] args) {
        Cache<String,String> cache = CacheBuilder.newBuilder()
                .maximumSize(2)
                .build();
        cache.put("key1","value1");
        cache.put("key2","value2");
        cache.put("key3","value3");
        System.out.println(" 第一个值:" + cache.getIfPresent("key1"));
        System.out.println(" 第二个值:" + cache.getIfPresent("key2"));
        System.out.println(" 第三个值:" + cache.getIfPresent("key3"));
    }
}

运行结果:

第一个值:null
第二个值:value2
第三个值:value3

那如果我们的数据量比较大,且数据更新频繁,又是在分布式部署的情况下,想要使用 JVM 堆内存作为缓存,这时我们又该如何去实现呢?

Ehcache 是一个不错的选择,Ehcache 经常在 Hibernate 中出现,主要用来缓存查询数据结果。Ehcache 是 Apache 开源的一套缓存管理类库,是基于 JVM 堆内存实现的缓存,同时具备多种缓存失效策略,支持磁盘持久化以及分布式缓存机制。

2. 分布式缓存

由于高并发对数据一致性的要求比较严格,我一般不建议使用 Ehcache 缓存有一致性要求的数据。对于分布式缓存,我们建议使用 Redis 来实现,Redis 相当于一个内存数据库,由于是纯内存操作,又是基于单线程串行实现,查询性能极高,读速度超过了 10W 次 / 秒。

Redis 除了高性能的特点之外,还支持不同类型的数据结构,常见的有 string、list、set、hash 等,还支持数据淘汰策略、数据持久化以及事务等。

两种缓存讲完了,接下来我们看看其中可能出现的问题。

数据库与缓存数据一致性问题

在查询缓存数据时,我们会先读取缓存,如果缓存中没有该数据,则会去数据库中查询,之后再放入到缓存中。

当我们的数据被缓存之后,一旦数据被修改(修改时也是删除缓存中的数据)或删除,我们就需要同时操作缓存和数据库。这时,就会存在一个数据不一致的问题。

例如,在并发情况下,当 A 操作使得数据发生删除变更,那么该操作会先删除缓存中的数据,之后再去删除数据库中的数据,此时若是还没有删除成功,另外一个请求查询操作 B 进来了,发现缓存中已经没有了数据,则会去数据库中查询,此时发现有数据,B 操作获取之后又将数据存放在了缓存中,随后数据库的数据又被删除了。此时就出现了数据不一致的情况。

那如果先删除数据库,再删除缓存呢?

我们可以试一试。在并发情况下,当 A 操作使得数据发生删除变更,那么该操作会先删除了数据库的操作,接下来删除缓存,失败了,那么缓存中的数据没有被删除,而数据库的数据已经被删除了,同样会存在数据不一致的问题。

所以,我们还是需要先做缓存删除操作,再去完成数据库操作。那我们又该如何避免高并发下,数据更新删除操作所带来的数据不一致的问题呢?

通常的解决方案是,如果我们需要使用一个线程安全队列来缓存更新或删除的数据,当 A 操作变更数据时,会先删除一个缓存数据,此时通过线程安全的方式将缓存数据放入到队列中,并通过一个线程进行数据库的数据删除操作。

当有另一个查询请求 B 进来时,如果发现缓存中没有该值,则会先去队列中查看该数据是否正在被更新或删除,如果队列中有该数据,则阻塞等待,直到 A 操作数据库成功之后,唤醒该阻塞线程,再去数据库中查询该数据。

但其实这种实现也存在很多缺陷,例如,可能存在读请求被长时间阻塞,高并发时低吞吐量等问题。所以我们在考虑缓存时,如果数据更新比较频繁且对数据有一定的一致性要求,我通常不建议使用缓存。

缓存穿透、缓存击穿、缓存雪崩

对于分布式缓存实现大数据的存储,除了数据不一致的问题以外,还有缓存穿透、缓存击穿、缓存雪崩等问题,我们平时实现缓存代码时,应该充分、全面地考虑这些问题。

缓存穿透是指大量查询没有命中缓存,直接去到数据库中查询,如果查询量比较大,会导致数据库的查询流量大,对数据库造成压力。

通常有两种解决方案,一种是将第一次查询的空值缓存起来,同时设置一个比较短的过期时间。但这种解决方案存在一个安全漏洞,就是当黑客利用大量没有缓存的 key 攻击系统时,缓存的内存会被占满溢出。

另一种则是使用布隆过滤算法(BloomFilter),该算法可以用于检查一个元素是否存在,返回结果有两种:可能存在或一定不存在。这种情况很适合用来解决故意攻击系统的缓存穿透问题,在最初缓存数据时也将 key 值缓存在布隆过滤器的 BitArray 中,当有 key 值查询时,对于一定不存在的 key 值,我们可以直接返回空值,对于可能存在的 key 值,我们会去缓存中查询,如果没有值,再去数据库中查询。

BloomFilter 的实现原理与 Redis 中的 BitMap 类似,首先初始化一个 m 长度的数组,并且每个 bit 初始化值都是 0,当插入一个元素时,会使用 n 个 hash 函数来计算出 n 个不同的值,分别代表所在数组的位置,然后再将这些位置的值设置为 1。

假设我们插入两个 key 值分别为 20,28 的元素,通过两次哈希函数取模后的值分别为 4,9 以及 14,19,因此 4,9 以及 14,19 都被设置为 1。 那为什么说 BloomFilter 返回的结果是可能存在和一定不存在呢?

假设我们查找一个元素 25,通过 n 次哈希函数取模后的值为 1,9,14。此时在 BitArray 中肯定是不存在的。而当我们查找一个元素 21 的时候,n 次哈希函数取模后的值为 9,14,此时会返回可能存在的结果,但实际上是不存在的。

BloomFilter 不允许删除任何元素的,为什么?假设以上 20,25,28 三个元素都存在于 BitArray 中,取模的位置值分别为 4,9、1,9,14 以及 14,19,如果我们要删除元素 25,此时需要将 1,9,14 的位置都置回 0,这样就影响 20,28 元素了。

因此,BloomFilter 是不允许删除任何元素的,这样会导致已经删除的元素依然返回可能存在的结果,也会影响 BloomFilter 判断的准确率,解决的方法则是重建一个 BitArray。

那什么缓存击穿呢?在高并发情况下,同时查询一个 key 时,key 值由于某种原因突然失效(设置过期时间或缓存服务宕机),就会导致同一时间,这些请求都去查询数据库了。这种情况经常出现在查询热点数据的场景中。通常我们会在查询数据库时,使用排斥锁来实现有序地请求数据库,减少数据库的并发压力。

缓存雪崩则与缓存击穿差不多,区别就是失效缓存的规模。雪崩一般是指发生大规模的缓存失效情况,例如,缓存的过期时间同一时间过期了,缓存服务宕机了。对于大量缓存的过期时间同一时间过期的问题,我们可以采用分散过期时间来解决;而针对缓存服务宕机的情况,我们可以采用分布式集群来实现缓存服务。



从前端到后端,对于一些不常变化的数据,我们都可以将其缓存起来,这样既可以提高查询效率,又可以降低请求后端的压力。对于前端来说,一些静态资源文件都是会被缓存在浏览器端,除了静态资源文件,我们还可以缓存一些常量数据,例如商品信息。

服务端的缓存,包括了 JVM 的堆内存作为缓存以及 Redis 实现的分布式缓存。如果是一些不常修改的数据,数据量小,且对缓存数据没有严格的一致性要求,我们就可以使用堆内存缓存数据,这样既实现简单,查询也非常高效。如果数据量比较大,且是经常被修改的数据,或对缓存数据有严格的一致性要求,我们就可以使用分布式缓存来存储。

在使用后端缓存时,我们应该注意数据库和缓存数据的修改导致的数据不一致问题,如果对缓存与数据库数据有非常严格的一致性要求,我就不建议使用缓存了。

同时,我们应该针对大量请求缓存的接口做好预防工作,防止查询缓存的接口出现缓存穿透、缓存击穿和缓存雪崩等问题。

四、记一次双十一抢购性能瓶颈调优

每年的双十一都是很多研发部门最头痛的节日,由于这个节日比较特殊,公司一般都会准备大量的抢购活动,相应的瞬时高并发请求对系统来说是个不小的考验。

还记得我们公司商城第一次做双十一抢购活动,优惠力度特别大,购买量也很大,提交订单的接口 TPS 一度达到了 10W。在首波抢购时,后台服务监控就已经显示服务器的各项指标都超过了 70%,CPU 更是一直处于 400%(4 核 CPU),数据库磁盘 I/O 一直处于 100% 状态。由于瞬时写入日志量非常大,导致我们的后台服务监控在短时间内,无法实时获取到最新的请求监控数据,此时后台开始出现一系列的异常报警。

更严重的系统问题是出现在第二波的抢购活动中,由于第一波抢购时我们发现后台服务的压力比较大,于是就横向扩容了服务,但却没能缓解服务的压力,反而在第二波抢购中,我们的系统很快就出现了宕机。

这次活动暴露出来的问题很多。首先,由于没有限流,超过预期的请求量导致了系统卡顿;其次,基于 Redis 实现的分布式锁分发抢购名额的功能抛出了大量异常;再次,就是我们误判了横向扩容服务可以起到的作用,其实第一波抢购的性能瓶颈是在数据库,横向扩容服务反而又增加了数据库的压力,起到了反作用;最后,就是在服务挂掉的情况下,丢失了异步处理的业务请求。

接下来我会以上面的这个案例为背景,重点讲解抢购业务中的性能瓶颈该如何调优。

抢购业务流程

在进行具体的性能问题讨论之前,我们不妨先来了解下一个常规的抢购业务流程,这样方便我们更好地理解一个抢购系统的性能瓶颈以及调优过程。

  • 用户登录后会进入到商品详情页面,此时商品购买处于倒计时状态,购买按钮处于置灰状态。
  • 当购买倒计时间结束后,用户点击购买商品,此时用户需要排队等待获取购买资格,如果没有获取到购买资格,抢购活动结束,反之,则进入提交页面。
  • 用户完善订单信息,点击提交订单,此时校验库存,并创建订单,进入锁定库存状态,之后,用户支付订单款。
  • 当用户支付成功后,第三方支付平台将产生支付回调,系统通过回调更新订单状态,并扣除数据库的实际库存,通知用户购买成功。

抢购系统中的性能瓶颈

熟悉了一个常规的抢购业务流程之后,我们再来看看抢购中都有哪些业务会出现性能瓶颈。

1. 商品详情页面

如果你有过抢购商品的经验,相信你遇到过这样一种情况,在抢购马上到来的时候,商品详情页面几乎是无法打开的。

这是因为大部分用户在抢购开始之前,会一直疯狂刷新抢购商品页面,尤其是倒计时一分钟内,查看商品详情页面的请求量会猛增。此时如果商品详情页面没有做好,就很容易成为整个抢购系统中的第一个性能瓶颈。

类似这种问题,我们通常的做法是提前将整个抢购商品页面生成为一个静态页面,并 push 到 CDN 节点,并且在浏览器端缓存该页面的静态资源文件,通过 CDN 和浏览器本地缓存这两种缓存静态页面的方式来实现商品详情页面的优化。

2. 抢购倒计时

在商品详情页面中,存在一个抢购倒计时,这个倒计时是服务端时间的,初始化时间需要从服务端获取,并且在用户点击购买时,还需要服务端判断抢购时间是否已经到了。

如果商品详情每次刷新都去后端请求最新的时间,这无疑将会把整个后端服务拖垮。我们可以改成初始化时间从客户端获取,每隔一段时间主动去服务端刷新同步一次倒计时,这个时间段是随机时间,避免集中请求服务端。这种方式可以避免用户主动刷新服务端的同步时间接口。

3. 获取购买资格

可能你会好奇,在抢购中我们已经通过库存数量限制用户了,那为什么会出现一个获取购买资格的环节呢?

我们知道,进入订单详情页面后,需要填写相关的订单信息,例如收货地址、联系方式等,在这样一个过程中,很多用户可能还会犹豫,甚至放弃购买。如果把这个环节设定为一定能购买成功,那我们就只能让同等库存的用户进来,一旦用户放弃购买,这些商品可能无法再次被其他用户抢购,会大大降低商品的抢购销量。

增加购买资格的环节,选择让超过库存的用户量进来提交订单页面,这样就可以保证有足够提交订单的用户量,确保抢购活动中商品的销量最大化。

获取购买资格这步的并发量会非常大,还是基于分布式的,通常我们可以通过 Redis 分布式锁来控制购买资格的发放。

4. 提交订单

由于抢购入口的请求量会非常大,可能会占用大量带宽,为了不影响提交订单的请求,我建议将提交订单的子域名与抢购子域名区分开,分别绑定不同网络的服务器。

用户点击提交订单,需要先校验库存,库存足够时,用户先扣除缓存中的库存,再生成订单。如果校验库存和扣除库存都是基于数据库实现的,那么每次都去操作数据库,瞬时的并发量就会非常大,对数据库来说会存在一定的压力,从而会产生性能瓶颈。与获取购买资格一样,我们同样可以通过分布式锁来优化扣除消耗库存的设计。

由于我们已经缓存了库存,所以在提交订单时,库存的查询和冻结并不会给数据库带来性能瓶颈。但在这之后,还有一个订单的幂等校验,为了提高系统性能,我们同样可以使用分布式锁来优化。

而保存订单信息一般都是基于数据库表来实现的,在单表单库的情况下,碰到大量请求,特别是在瞬时高并发的情况下,磁盘 I/O、数据库请求连接数以及带宽等资源都可能会出现性能瓶颈。此时我们可以考虑对订单表进行分库分表,通常我们可以基于 userid 字段来进行 hash 取模,实现分库分表,从而提高系统的并发能力。

5. 支付回调业务操作

在用户支付订单完成之后,一般会有第三方支付平台回调我们的接口,更新订单状态。

除此之外,还可能存在扣减数据库库存的需求。如果我们的库存是基于缓存来实现查询和扣减,那提交订单时的扣除库存就只是扣除缓存中的库存,为了减少数据库的并发量,我们会在用户付款之后,在支付回调的时候去选择扣除数据库中的库存。

此外,还有订单购买成功的短信通知服务,一些商城还提供了累计积分的服务。

在支付回调之后,我们可以通过异步提交的方式,实现订单更新之外的其它业务处理,例如库存扣减、积分累计以及短信通知等。通常我们可以基于 MQ 实现业务的异步提交。

性能瓶颈调优

了解了各个业务流程中可能存在的性能瓶颈,我们再来讨论下商城基于常规优化设计之后,还可能出现的一些性能问题,我们又该如何做进一步调优。

1. 限流实现优化

限流是我们常用的兜底策略,无论是倒计时请求接口,还是抢购入口,系统都应该对它们设置最大并发访问数量,防止超出预期的请求集中进入系统,导致系统异常。

通常我们是在网关层实现高并发请求接口的限流,如果我们使用了 Nginx 做反向代理的话,就可以在 Nginx 配置限流算法。Nginx 是基于漏桶算法实现的限流,这样做的好处是能够保证请求的实时处理速度。

Nginx 中包含了两个限流模块:ngx_http_limit_conn_modulengx_http_limit_req_module,前者是用于限制单个 IP 单位时间内的请求数量,后者是用来限制单位时间内所有 IP 的请求数量。以下分别是两个限流的配置:

limit_conn_zone $binary_remote_addr zone=addr:10m;
 
server {
    location / {
        limit_conn addr 1;
    }
http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
    server {
        location / {
            limit_req zone=one burst=5 nodelay;
        }
} 

在网关层,我们还可以通过 lua 编写 OpenResty 来实现一套限流功能,也可以通过现成的 Kong 安装插件来实现。除了网关层的限流之外,我们还可以基于服务层实现接口的限流,通过 Zuul RateLimit 或 Guava RateLimiter 实现。

2. 流量削峰

瞬间有大量请求进入到系统后台服务之后,首先是要通过 Redis 分布式锁获取购买资格,这个时候我们看到了大量的“JedisConnectionException Could not get connection from pool”异常。

这个异常是一个 Redis 连接异常,由于我们当时的 Redis 集群是基于哨兵模式部署的,哨兵模式部署的 Redis 也是一种主从模式,我们在写 Redis 的时候都是基于主库来实现的,在高并发操作一个 Redis 实例就很容易出现性能瓶颈。

你可能会想到使用集群分片的方式来实现,但对于分布式锁来说,集群分片的实现只会增加性能消耗,这是因为我们需要基于 Redission 的红锁算法实现,需要对集群的每个实例进行加锁。

后来我们使用 Redission 插件替换 Jedis 插件,由于 Jedis 的读写 I/O 操作还是阻塞式的,方法调用都是基于同步实现,而 Redission 底层是基于 Netty 框架实现的,读写 I/O 是非阻塞 I/O 操作,且方法调用是基于异步实现。

但在瞬时并发非常大的情况下,依然会出现类似问题,此时,我们可以考虑在分布式锁前面新增一个等待队列,减缓抢购出现的集中式请求,相当于一个流量削峰。当请求的 key 值放入到队列中,请求线程进入阻塞状态,当线程从队列中获取到请求线程的 key 值时,就会唤醒请求线程获取购买资格。

3. 数据丢失问题

无论是服务宕机,还是异步发送给 MQ,都存在请求数据丢失的可能。例如,当第三方支付回调系统时,写入订单成功了,此时通过异步来扣减库存和累计积分,如果应用服务刚好挂掉了,MQ 还没有存储到该消息,那即使我们重启服务,这条请求数据也将无法还原。

重试机制是还原丢失消息的一种解决方案。在以上的回调案例中,我们可以在写入订单时,同时在数据库写入一条异步消息状态,之后再返回第三方支付操作成功结果。在异步业务处理请求成功之后,更新该数据库表中的异步消息状态。

假设我们重启服务,那么系统就会在重启时去数据库中查询是否有未更新的异步消息,如果有,则重新生成 MQ 业务处理消息,供各个业务方消费处理丢失的请求数据。



减少抢购中操作数据库的次数,缩短抢购流程,是抢购系统设计和优化的核心点。

抢购系统的性能瓶颈主要是在数据库,即使我们对服务进行了横向扩容,当流量瞬间进来,数据库依然无法同时响应处理这么多的请求操作。我们可以对抢购业务表进行分库分表,通过提高数据库的处理能力,来提升系统的并发处理能力。

除此之外,我们还可以分散瞬时的高并发请求,流量削峰是最常用的方式,用一个队列,让请求排队等待,然后有序且有限地进入到后端服务,最终进行数据库操作。当我们的队列满了之后,可以将溢出的请求放弃,这就是限流了。通过限流和削峰,可以有效地保证系统不宕机,确保系统的稳定性。