服务假死问题解决过程实记(三)——缓存问题优化

2019-05-01  本文已影响0人  琦小虾

接上篇 《服务假死问题解决过程实记(二)—— C3P0 数据库连接池配置引发的血案》


五、04.17—04.21 缓存逻辑修正

这段时间我一直在优化服务的性能,主要是从分布式缓存和业务逻辑修正两个角度出发进行的。首先是将我们的缓存逻辑给修正了一下。

关于缓存,我们业务存在两个重要问题:

对于第一个问题,主要有两个隐患:首先集群部署,也就意味着为了提高服务的性能,环境中有多台服务,所以对于相同的数据,每个服务都要自己记录一份缓存,这样对内存是很大的浪费。其次多台服务的缓存也很容易出现不同步的问题,极易出现数据脏读的现象。
对于第二个问题,将结果存放到缓存中,本身与业务并没有关系,不管是否置入缓存,都不会对业务结果不会有影响。但如果将缓存的一部分放在业务逻辑中,就相当于缓存被强行的绑在了业务逻辑之中。所以对这个问题进行优化,就是将缓存从业务逻辑中解耦。

笔者是先解决了后一个问题,然后再解决前一个问题。

1. 切面思想的体会

我认为将缓存从业务逻辑中解耦,这种工作交给 AOP 后置增强是最合适的。所以我就开始对业务代码进行一通分析,提取出来他们的共同点,将置入缓存的逻辑从业务代码中拆了出来,放到了一个后置切面中。具体思路就是这样,过程不表。
笔者之前只是会使用 AOP 切面,但在这个过程中,笔者切实的加深了对 AOP 的理解。代码抽取过程中,同事也问我这样做有什么好处,对性能有什么优化?我想了一下,回答:这对性能没有任何优化。同事问我做 AOP 切面的意义,我开了个脑洞,用这个例子给出了一个比较通俗易懂的解释:

问:把大象放在冰箱里总共分几步?
答:分三步。第一步把冰箱门打开,第二步把大象给塞进去,第三步把冰箱门关上。

这个经典段子在笔者看来,很有用 AOP 思路分析的价值。首先,我们的目的是把大象放进冰箱里,这就是我们的业务所在。但是要放大象进去,开冰箱门关冰箱门可以省略吗?不能。那这两者和塞大象的业务有关吗?没有。
所以与业务无关,但又必须做的工作(或者优化的工作),就是切面的意义所在了。缓存的加入,优化了数据的读取,但如果去掉了缓存,业务依旧可以正常工作,只是效率低一点而已。所以把缓存从业务代码中拿出来,就实现了解耦。

2. AOP 的代理思想

参考地址:
《Spring AOP 的实现原理》
[《Spring service 本类中方法调用另一个方法事务不生效问题》](https:// blog.csdn.net/dapinxiaohuo/article/details/52092447)

另外在该过程中,笔者也终于理解了代理的意义。
首先叙述一下问题:笔者有一次在 A 类的 a 方法上加入了后置切面方法后,用 A 类的 b 方法调用了自身的 a 方法,但多次测试发现怎么也不会进后置切面方法。经过好长时间的加班折腾,笔者终于发现了一个问题:自身调用方法,是不会进入切面方法的

AOP 的基本是使用代理实现的。通常使用的是 AspectJ 或者 Spring AOP 切面。
AspectJ 使用静态编译的方式实现 AOP 功能。对于一个写好的类,对其编写 aspectj 脚本,然后对该 *.java 文件进行编译指令,如 <code>ajc -d . Hello.java TxAspect.aj</code>,即可编译生成一个类,该类会比原先的类多一些内容,通过这种方式实现切面。

原始类:

public class Hello {
    public void sayHello() {
        System.out.println("hello");
    }
 
    public static void main(String[] args) {
        Hello h = new Hello();
        h.sayHello();
    }
}

编写的 aspectj 语句:

public aspect TxAspect {
    void around():call(void Hello.sayHello()){
        System.out.println("开始事务 ...");
        proceed();
        System.out.println("事务结束 ...");
    }
}

执行 aspectj 语句 <code>ajc -d . Hello.java TxAspect.aj</code> 编译后生成的类:

public class Hello {
    public Hello() {
    }
 
    public void sayHello() {
        System.out.println("hello");
    }
 
    public static void main(String[] args) {
        Hello h = new Hello();
        sayHello_aroundBody1$advice(h, TxAspect.aspectOf(), (AroundClosure)null);
    }
}

Spring AOP 是通过动态代理的形式实现的,其中又分为通过 JDK 动态代理,以及 CGLIB 动态代理

主要原因笔者后续也终于分析理解了:由于笔者虽然使用的是 @AspectJ 注解,但实际上使用的依旧是 Spring AOP。

如果使用 Spring AOP,使用过程中可能会出现一个问题:自身调用切面注解方法,切面失效。这是因为 AOP 的实现是通过代理的形式实现的,所以自身调用方法不满足代理调用的条件,所以不会执行切面。切面的调用流程如下文链接所示,文中以事务出发,讲解了 AOP 的实现原理 (注:事务的实现原理也是切面):

[图片上传失败...(image-96f767-1556680853759)]

所以,对于笔者这种自身调用切面的情况,可以改变方法的调用方式:改变调用自身方法的方式,使用调用代理方法的形式。笔者在 Spring 的 XML 中对 aop 进行配置:

<!—- 注解风格支持 -->
<aop:aspectj-autoproxy expose-proxy="true"/>
<!—- xml 风格支持 -->
<aop:config expose-proxy="true"/>

然后在方法中通过 Spring 的 AopContext.currentProxy 获取代理对象,然后通过代理调用方法。例如有自身方法调用如下:

this.b();

变为:

((AService) AopContext.currentProxy()).b();

笔者又开了一次脑洞,用娱乐圈明星和代理人之间的关系来类比理解了一下代理模式。作为一个代理人,目的是协助明星的工作。明星主要工作,就是唱,跳,RAP 之类的,而代理人,就是类似于在演出开始之前找厂商谈出场费,演出之后找厂商结账,买热搜,或者发个律师函之类的。总之不管好事儿坏事儿,代理干的事儿都贼 TM 操心,又和明星的演出工作没有直接的关系。
数据库事务也是一样的道理。增删改查,是 SQL 语句关心的核心业务,SQL 语句只要按照语句执行就顺利完成了任务。由于事务的原子性,一个事务内的所有执行完毕后,事务一起提交结果。如果执行过程中出现了意外呢?那么事务就把状态回滚到最开始的状态。事务依旧做着处理后续工作,还有帮人擦屁股的工作,而且还是和业务本身没有关系的事儿,这和代理人是一样的命啊……
这样,AOP 和代理思想,笔者用一头大象,还有一个明星经纪人的例子便顿悟了。

3. 分布式缓存问题(缓存雪崩,缓存穿透,缓存击穿)

参考地址:
《缓存穿透,缓存击穿,缓存雪崩解决方案分析》
《缓存穿透、缓存击穿、缓存雪崩区别和解决方案》

好的,把缓存逻辑从业务代码逻辑揪了出来,后一个问题就解决了,现在解决前一个问题:将集群中所有服务的缓存从本地缓存转为分布式缓存,降低缓存在服务中占用的资源。
由于业务组只有 Memcache 缓存集群,并没有搭起来 Redis,所以笔者还是选了 Memcache 作为分布式缓存工具。笔者用了一天时间封装了我们服务自己用的 MemcacheService,把初始化、常用的 get, set 方法封装完毕,测试也没有问题。由于具体过程只是对 Memcache 的 API 进行简单封装,故具体过程不表。但是进行到这里,笔者也只是简单的封装完毕,仍然有可以优化的空间。
集群服务的缓存,有三大问题:缓存雪崩、缓存穿透、缓存击穿。在并发量高的时候,这三个缓存问题很容易引起服务与数据库的宕机。虽然我们的小服务并不存在高并发的场景,但既然要做性能优化,就要尽量做到最好,所以笔者还是在我这小小的服务上事先了这几个缓存问题并加以解决。

(1) 缓存雪崩

缓存雪崩和缓存击穿都和分布式缓存的缓存过期时间有关。
缓存雪崩,指的是对于某些热点缓存,如果都设置了相同的过期时间,在过期时间范围之内是正常的。但等到经过了这个过期时间之后,大量并发再访问这些缓存内容,会因为缓存内容已经过期而失效,从而大量并发短时间内涌向数据库,很容易造成数据库的崩溃。
这样的情况发生的主要原因,在于热点数据设置了相同的过期时间。解决的方案是对这些热点数据设置随机的过期时间即可。比如笔者在封装 Memcache 接口的参数中有过期时间 int expireTime,并设置了默认的过期时间为 30min,这样的缓存策略确实容易产生缓存雪崩现象。此后笔者在传入的 expireTime 值的基础上,由加上了一个 0~300 秒的随机值。这样所有缓存的过期时间都有了一定的随机性,从而避免了缓存雪崩现象。

(2) 缓存击穿

假设有某个热点数据,该数据在数据库中存在该值,但缓存中不存在,那么如果同一时间大量并发查询该缓存,则会由于缓存中不存在该数据,从而将大量并发释放,大量并发涌向数据库,容易引起数据库的宕机。
看到这里也可以体会到,前面的缓存雪崩与缓存击穿有很大的相似性。缓存雪崩针对的是对一批在数据库中存在,但在缓存中不存在的数据;而缓存击穿针对的是一个数据。
《缓存穿透,缓存击穿,缓存雪崩解决方案分析》一文中提到了四种方式,笔者采用了类似于第一种方式的解决方法:使用互斥锁。由于这里的环境是分布式环境,所以这里的互斥锁指的其实是分布式锁。笔者又按照《缓存穿透、缓存击穿、缓存雪崩区别和解决方案》一文中的思路,以业务组的 Zookeeer 集群为基础实现了分布式锁,解决了缓存击穿的问题。伪代码如下:

    public Object getData(String key) {
        // 1. 从缓存中读取数据
        Object result = getDataFromMemcache(key);
        // 2. 如果缓存中不存在数据,则从数据库中 (或者计算) 获取
        if (result == null) {
            InterProcessMutex lock = new InterProcessMutex(client, "/service/lock/test1");
            // 2.1 尝试获取锁
            try {
                if (lock.acquire(10, TimeUnit.SECONDS)) {
                    // ※ 2.1.1 尝试再次获取缓存,如果获取值不为空,则直接返回
                    result = getDataFromMemcache(key);
                    if (result != null) {
                        log.info("获取锁后再次尝试获取缓存,缓存命中,直接返回");
                        return result;
                    }

                    // 2.1.2 从数据库中获取原始数据 (或者计算获取得到数据)
                    result = queryData(key);

                    // 2.1.3 将结果存入缓存
                    setDataToMemcache(key, result);
                }
                // 2.2 获取锁失败,暂停短暂时间,尝试再次重新获取缓存信息
                else {
                    TimeUnit.MILLISECONDS.sleep(100);
                    result = getData(key);
                }
            } catch (Exception e) {
                e.printStackTrace();
            } 
            // 2.3 退出方法前释放分布式锁
            finally {
                if (lock != null && lock.isAcquiredInThisProcess()) {
                    lock.release();
                }
            }
        }
        
        return result;
    }

笔者解决缓存击穿的思路,是集群中服务如果同时处理大量并发,且尝试获取同一数据时,所有并发都会尝试获取 InterProcessMutex 的分布式锁。这里的 InterProcessMutex,是 Curator 自带的一个分布式锁,它基于 Zookeeper 的 Znode 实现了分布式锁的功能。在 InterProcessMutex 的传参中,需要传入一个 ZNode 路径,当大量并发都尝试获取这个分布式锁时,只有一个锁可以获得该锁,其他锁需要等待一定时间 (acquire 方法中传入的时间)。如果经过这段时间仍然没有获得该锁,则 acquire 方法返回 false。

笔者解决缓存击穿的逻辑伪代码如上所示。逻辑比较简单,但其中值得一提的是,在 2.1.1 中,对于已经获取了分布式锁的请求,笔者又重新尝试获取一次缓存。这是因为 Memcache 缓存的存入与读取可能会不同步的情况。假想一种情况:对于尝试获取分布式锁的请求 req1, req2,如果 req1 首先获取到了锁,且将计算的结果存入了 Memcache,然后 req2 在等待时间内又重新获取到了该锁,如果直接继续执行,也就会重新从数据库中获取一次 req1 已经获取且存入缓存的数据,这样就造成了重复数据的读取。所以需要在获取了分布式锁之后重新再获取一次缓存,判断在争抢分布式锁的过程中,缓存是否已经处理完毕。

(3) 缓存穿透

缓存穿透,指的是当数据库与缓存中都没有某数据时,该条数据就会成为漏洞,如果有人蓄意短时间内大量查询这条数据,大量连接就很容易穿透缓存涌向数据库,会造成数据库的宕机。针对这种情况,比较普遍的应对方法是使用布隆过滤器 (Bloom Filter)进行防护。

布隆,来干活了!(弗雷尔卓德之心——Bloom Filter)

布隆过滤器和弗雷尔卓德之心有一些相似的地方,它的防御不是完全抵挡的,是不准确的。换句话说,针对某条数据,布隆过滤器只保证在数据库中一定没有该数据,不能保证一定有这条数据
布隆过滤器的最大的好处是,判断简单,消耗空间少。通常如果直接使用 Map 访问结果来判断是否存在数据是否存在,虽然可以实现,但 Map 通常的内存利用率不会太高,对于几百万甚至几亿的大数据集,太浪费空间。而布隆过滤器本身是一个 bitmap 的结构(笔者个人理解基本是一个很大很大的 0-1 数组),初始状态下全部为 0。当有值存入缓存时,使用多个 Hash 函数分别计算对应 Key 值的结果,结果转换为 bitmap 指定的位数,对应位上置 1。这样,越来越多的值存入,bitmap 上也填充了越来越多的 1。
这样如果有请求查询某个数据是否存在,则依旧利用相同的 Hash 函数计算结果,并在 bitmap 上查找计算结果的位置上是否全部为 1。只要有一个位置不为 1,缓存中就必然没有该数据。但是如果所有位置都为 1,那么也不能说明缓存中一定有这条数据。因为随着越来越多的数据存入缓存,布隆过滤器 bitmap 中的 1 值也越来越多,所以即使计算结果中所有位数的值都为 1,也有可能是其他若干计算结果将这些位置上的 1 给占据了。布隆过滤器虽然有误判率,但是有文章指出布隆过滤器的误判率在合适的参数设置之下会变得很低。具体可以见文章《使用BloomFilter布隆过滤器解决缓存击穿、垃圾邮件识别、集合判重》
除了不能判断数据库中一定存在某条数据之外,布隆过滤器还有一个问题,在于它不能删除某个值填充在 bitmap 中的结果。

笔者本来想用 guava 包中自带的 BloomFilter 来实现 Memcache 的缓存穿透防护,本来都已经研究好该怎么加入布隆的大盾牌了,但是后来一想,布隆过滤器应该是在 Memcache 端做的事情,而不是在我集群服务这里该做的。如果每个服务都建一个 BloomFilter,这几个过滤器的值肯定是不同步的,而且会造成大量的空间浪费,所以最后并没有付诸实践。

六、04.17—04.25 业务逻辑修正

与解决技术层面同步进行的,是对于业务逻辑的修正。修正的主要思路是调整消息订阅后的处理方式,以及方法、缓存的粒度调整(从粗粒度调整到细粒度)。涉及具体的业务逻辑,此处不表。

结语

经过一段长时间的奋战,我们的并发效率提升了二到三倍。
但笔者并不是感觉我们做的很好,笔者更认为这是项目整个过程中的问题爆发。由于去年项目赶的太紧,三个月下来几乎天天 9107 的节奏,小伙伴们都累的没脾气,自然而然产生了抵触心理,代码质量与效率也自然下降。整个过程下来,堆积的坑越攒越多,最终到了某个时间不得不改。
看着这些被修改的代码,有一部分确实都是自己的手笔,确实算是段悲伤的黑历史了。但历史已不再重要了,而是在这段解决问题的过程中积累学习的经验,是十分宝贵的。希望以后在工作中能够不再出现类似的问题吧。


本文于 2019.03.06 始,于 2019 五一劳动节终。

系列文章:

《服务假死问题解决过程实记(一)——问题发现篇》
《服务假死问题解决过程实记(二)——C3P0 数据库连接池配置引发的血案》
《服务假死问题解决过程实记(三)——缓存问题优化》

上一篇下一篇

猜你喜欢

热点阅读