@[toc]
开发人员使用”缓存+过期时间“的策略既可以加速数据读写,又保证数据的定时更新,这种模式基本满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:
在缓存失效的瞬间,有大量线程来重建缓存(如下图所示),造成后端负载加大,甚至可能会让应用奔溃。
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:
此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可,整个过程如下图所示。
下面代码使用Redis的setnx命令实现上述功能:
String get(String key) { // 从Redis中获取数据 String value = redis.get(key); // 如果value为空,则开始重构缓存 if(value == null) { // 只允许一个线程重构缓存,使用nx,并设置过期时间ex String mutexKey = "mutext:key:" + key; if(redis.set(mutexKey, "1", "ex 180", "nx")) { // 从数据源获取数据 value = db.get(key); // 回写Redis,并设置过期时间 redis.setex(key, timeout, value); // 删除key_mutex redis.delete(mutex_key); } // 其他线程休息50毫秒后重试 else { Thread.sleep(50); get(key); } } return value; }
“永远不过期”包含两层意思:
整个过程如下图所示。
从实战看,此方法有效地杜绝了热点key产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。下面代码使用Redis进行模拟:
String get(final String key) { V v = redis.get(key); String value = v.getValue(); // 逻辑过期时间 long logicTimeout = v.getLogicTimeout(); // 如果逻辑过期时间小于当前时间,开始后台构建 if(v.logicTimeout <= System.currentTimeMillis()) { String mutexKey = "mutex:key:" + key; if(redis.set(mutexKey, "1", "ex 180", "nx")) { // 重构缓存 threadPool.execute(new Runnable(){ public void run() { String dbValue = db.get(key); redis.set(key, dbvalue, newLogicTimeout); redis.delete(mutexKey); } }); } } return value; }
作为一个并发量较大的应用,在使用缓存时有三个目标:
下面将按照这三个维度对上述两种解决方案进行分析。
两种解决方法对比如下表所示。
解决方案 | 优点 | 缺点 |
---|---|---|
简单分布式锁 | 思路简单 保证一致性 | 代码复杂度增大 存在死锁的风险 存在线程池阻塞的风险 |
永远不过期 | 基本杜绝热点key问题 | 不保证一致性 逻辑过期时间增加代码维护成本和内存成本 |