在互联网场景中缓存系统是一个重要系统,为了防止流量频繁访问数据库,一般会在数据库层前设置一道缓存层作为保护。
缓存是一个广义的概念,核心要义是将数据存放在离用户更近的地方,或者是将数据存放在访问更快的介质中。
缓存对应到实际应用中可以分为内存缓存、远程缓存。内存缓存常见工具例如 Guava、Ecache 等,远程缓存常见系统例如 Redis,memcache 等。本文以远程缓存 Redis 为例进行讲解。
缓存穿透和击穿是高并发场景下必须面对的问题,这些问题会导致访问请求绕过缓存直接打到数据库,可能会造成数据库挂掉或者系统雪崩,下面本文根据下图提纲来分析这些问题的原理和解决方案。
缓存穿透和击穿从最终结果上来说都是流量绕过缓存打到了数据库,可能会导致数据库挂掉或者系统雪崩,但是仔细区分还是有一些不同,我们分析一张业务读取缓存一般流程图。
我们用文字简要描述这张图:
(1) 业务查询数据时首先查询缓存,如果缓存存在数据则返回,流程结束 (2) 如果缓存不存在数据则查询数据库,如果数据库不存在数据则返回空数据,流程结束 (3) 如果数据库存在数据则将数据写入缓存并返回数据给业务,流程结束
假设业务方要查询 A 数据,缓存穿透是指数据库根本不存在 A 数据,所以根本没有数据可以写入缓存,导致缓存层失去意义,大量请求会频繁访问数据库。
缓存击穿是指请求在查询数据库前,首先查缓存看看是否存在,这是没有问题的。但是并发量太大,导致第一个请求还没有来得及将数据写入缓存,后续大量请求已经开始访问缓存,这是数据在缓存中还是不存在的,所以瞬时大量请求会打到数据库。
现在我们把缓存问题先放一放,一起来分析 CAS 这个概念的实例源码,后面我们编写缓存工具需要借鉴这个思想。
我们来看一道常见面试题,相信这个面试题大家并不会陌生:分析下面这段代码输出的值是多少。
class Data { volatile int num = 0; public void increase() { num++; }}
public class VolatileTest { public static void main(String[] args) { Data data = new Data(); // 100个线程操作num累加 for (int i = 1; i <= 100; i++) { new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(1000L); data.increase(); } catch (Exception ex) { System.out.println(ex.getMessage()); } } }).start(); } // 等待上述线程执行完 -> 数值2表示只有主线程和GC线程在运行 while (Thread.activeCount() > 2) { // 主线程让出CPU时间片 Thread.yield(); } System.out.println(data.num); }}
复制代码
运行结果 num 值一般不等于 100 而是小于 100,这是因为 num++不是原子性的,我们编写一段简单代码进行证明:
public class VolatileTest2 { volatile int num = 0;
public void increase() { num++; }}
复制代码
执行下列命令获取字节码:
javac VolatileTest2.javajavap -c VolatileTest2.class
复制代码
字节码文件如下所示:
$ javap -c VolatileTest2.classCompiled from "VolatileTest2.java"public class com.java.front.test.VolatileTest2 { volatile int num;
public com.java.front.test.VolatileTest2(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: iconst_0 6: putfield #2 // Field num:I 9: return
public void increase(); Code: 0: aload_0 1: dup 2: getfield #2 // Field num:I 5: iconst_1 6: iadd 7: putfield #2 // Field num:I 10: return}
复制代码
观察 num++代码片段,发现分为三个步骤:
(1) getfield (2) iadd (3) putfield
复制代码
getfield 读取 num 值,iadd 运算 num+1,最后 putfield 将新值赋值给 num。
这就不难理解为什么 num 最终会小于 100:因为线程 A 在执行到第二步后执行第三步前,还没来得及将新值赋给 num,数据就被线程 B 取走了,这时还是没有加 1 的旧值。
那么怎么解决上述问题呢?常见方案有两种:加锁方案和无锁方案。
加锁方案是对 increase 加上同步关键字,这样就可以保证同一时刻只有一个线程操作,这不是我们这篇文章重点,不详细展开了。
无锁方案可以采用 JUC 提供的 AtomicInteger 进行运算,我们看一下改进后的代码:
import java.util.concurrent.atomic.AtomicInteger;
class Data { volatile AtomicInteger num = new AtomicInteger(0); public void increase() { num.incrementAndGet(); }}
public class VolatileTest { public static void main(String[] args) { Data data = new Data(); for (int i = 1; i <= 100; i++) { new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(1000L); data.increase(); } catch (Exception ex) { System.out.println(ex.getMessage()); } } }).start(); } while (Thread.activeCount() > 2) { Thread.yield(); } System.out.println(data.num); }}
复制代码
这样改写之后结果正如我们预期等于 100,我们并没有加锁,那么为什么改用 AtomicInteger 就可以达到预期效果呢?
本章节我们以 incrementAndGet 方法作为入口进行源码分析:
class Data { volatile AtomicInteger num = new AtomicInteger(0); public void increase() { num.incrementAndGet(); }}
复制代码
进入 incrementAndGet 方法:
import sun.misc.Unsafe;
public class AtomicInteger extends Number implements java.io.Serializable { private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset;
public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; }}
复制代码
我们可以看到一个名为 Unsafe 的类。这个类并不常见,那么到底有什么用呢?Unsafe 是位于 sun.misc 包下的一个类,具有强大的操作底层资源能力。例如可以直接访问操作系统,操作特定内存数据,提供许多 CPU 原语级别的 API。
我们继续分析源码跟进 getAndAddInt 方法:
public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v + delta)); return v;}
复制代码
我们对参数进行说明:o 表示待修改的对象,offset 表示待修改字段在内存中的偏移量,delta 表示本次修改增量。
整个方法核心是一段 do-while 循环代码,其中方法 getIntVolatile 比较好理解,就是获取对象 o 偏移量为 offset 的某个字段值。
重点分析 while 中 compareAndSwapInt 方法:
public final native boolean compareAndSwapInt( Object o, long offset, int expected, int x);
复制代码
其中 o 和 offset 含义不变,expected 表示期望值,x 表更新值,这就引出了 CAS 核心操作三个值:内存位置值、预期原值及新值。
执行 CAS 操作时,内存位置值会与预期原值比较。如果相匹配处理器会自动将该位置值更新为新值,否则处理器不做任何操作。
Unsafe 提供的 CAS 方法是一条 CPU 的原子指令,底层实现即为 CPU 指令 cmpxchg,不会造成数据不一致。
我们再回过头分析这段代码:
public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!compareAndSwapInt(o, offset, v, v + delta)); return v;}
复制代码
代码执行流程如下:
(1) 线程 A 执行累加,执行到 getAndAddInt 方法,首先根据内存地址获取 o 对象 offset 偏移量的字段值 v1 (2) while 循环中 compareAndSwapInt 执行,这个方法将再次获取 o 对象 offset 偏移量的字段值 v2,此时判断 v1 和 v2 是否相等,如果相等则自动将该位置值更新为 v1 加上增量后的值,跳出循环 (3) 如果执行 compareAndSwapInt 时字段值已经被线程 B 改掉,则该方法会返回 false,所以无法跳出循环,继续执行直至成功,这就是自旋设计思想
通过上述分析我们知道,Unsafe 类和自旋设计思想是 CAS 核心,其中自旋设计思想会在我们缓存工具中体现。
在相同 JVM 进程中为了保证同一段代码块在同一时刻只能被一个线程访问,JAVA 提供了锁机制,synchroinzed、ReentrantLock 可以进行多线程并发控制。
如果在多个服务器的集群环境,每个服务器运行着一个 JVM 进程。如果希望对多个 JVM 进行并发控制,此时 JVM 锁就不适用了。这时就需要引入分布式锁。顾名思义分布式锁是对分布式场景下,多个 JVM 进程进行并发控制。
分布式锁在实现时小心踩坑:例如没有设置超时时间,如果获取到锁的节点由于某种原因挂掉没有释放锁,导致其它节点永远拿不到锁。
分布式锁有多种实现方式,可以通过 Redis 或者 Zookeeper 进行实现,也可以直接使用 Redisson 框架,本文不进行展开。
上述章节分析了 CAS 原理和分布式锁实现,现在我们要将上述知识结合起来,实现一个可以解决缓存击穿问题的缓存工具。
缓存工具核心思想是如果发现缓存中无数据,利用分布式锁使得同一时刻只有一个 JVM 进程可以访问数据库,并将数据写入缓存。
那么没有抢到分布式锁的进程怎么办呢?我们提供以下三种选择:
方案一:直接返回空数据 方案二:自旋直到获取到数据 方案三:自旋 N 次仍然没有获取到数据则返回空数据
方案三正是使用了 CAS 自旋思想,未获取到锁后进行一定次数的尝试,缓存工具代码如下:
/** * 业务回调 * * @author 微信公众号「JAVA前线」 * */public interface RedisBizCall {
/** * 业务回调方法 * * @return 序列化后数据值 */ String call();}
/** * 安全缓存管理器 * * @author 微信公众号「JAVA前线」 * */@Servicepublic class SafeRedisManager { @Resource private RedisClient RedisClient; @Resource private RedisLockManager redisLockManager;
public String getDataSafeRetry(String key, int lockExpireSeconds, int dataExpireSeconds, RedisBizCall bizCall, int retryMaxTimes) { boolean getLockSuccess = false; try { int currentTimes = 0; while(currentTimes < retryMaxTimes) { String value = redisClient.get(key); if (StringUtils.isNotEmpty(value)) { return value; } /** 竞争分布式锁 **/ if (getLockSuccess = redisLockManager.tryLock(key, lockExpireSeconds)) { value = redisClient.get(key); if (StringUtils.isNotEmpty(value)) { return value; } /** 查询数据库 **/ value = bizCall.call();
/** 数据库无数据则返回**/ if (StringUtils.isEmpty(value)) { return null; }
/** 数据存入缓存 **/ redisClient.setex(key, seconds, value); return value; } else { Thread.sleep(100L); logger.warn("尝试重新获取数据,key={}", key); currentTimes++; } } } catch (Exception ex) { logger.error("getDataSafeRetry", ex); return null; } finally { if (getLockSuccess) { redisLockManager.unLock(key); } } }}
复制代码
在上面代码中我们采用分布式锁,对访问数据库资源的行为进行了限制,同一时刻只有一个进程可以访问数据库资源。如果有数据则放入缓存,解决了缓存击穿问题。如果没有数据则结束循环,解决了缓存穿透问题。当然我们也可以使用 JVM 锁,这样串行化范围就设定为服务器节点级别,可以提高并发度。缓存工具使用方法如下:
/** * 缓存工具使用 * * @author 微信公众号「JAVA前线」 * */@Servicepublic class StudentServiceImpl implements StudentService { private static final String KEY_PREFIX = "stuKey_";
@Resource private StudentDao studentDao; @Resource private SafeRedisManager safeRedisManager;
public Student getStudentInfo(String studentId) { String studentJSON = safeRedisManager.getDataRetry(KEY_PREFIX + studentId, 30, 600, new RedisBizCall() { public String call() { StudentDO student = studentDao.getStudentById(studentId); if (null == student) { return StringUtils.EMPTY; } return JSON.toJSONString(student); }, 5); if(StringUtils.isEmpty(studentJSON) { return null; } return JSON.toJSONString(studentJSON, Student.class); } }}
复制代码
本文到第五章节缓存穿透的击穿问题从原理到解决方案已经讲清楚了,这个章节我想引申一个问题:到底是先写缓存还是先写数据库,或者说数据库与缓存一致性怎么保证?
我的结论非常清晰明确:先写数据库再写缓存。核心思想是数据库和缓存之间追求最终一致性,如无必要则无需保证强一致性。
(1) 在缓存作为提升系统性能手段的背景下,不需要保证数据库和缓存的强一致性。如果非要保证二者的强一致性,会增大系统复杂度
(2) 如果更新数据库成功,再更新缓存。此时存在两种情况:更新缓存成功则万事大吉。更新缓存失败没有关系,等待缓存失效,此处一定要合理设置失效时间
(3) 如果更新数据库失败,则操作失败,重试或者等待用户重新发起
(4) 数据库是持久化数据,是操作成功还是失败的判断依据。缓存是提升性能的手段,允许短时间和数据库的不一致
(5) 在互联网架构中,一般不追求强一致性,而追求最终一致性。如果非要保证缓存和数据库的一致性,本质上是在解决分布式一致性问题
(6) 分布式一致性问题解决方案有很多,例如两阶段提交、TCC、本地消息表、MQ 事务性消息
本文介绍了缓存击穿问题原因和解决方案,其中参考了 CAS 源码的自旋设计思想,结合分布式锁实现了缓存工具,希望文章对大家有所帮助。
领取专属 10元无门槛券
私享最新 技术干货