1、缓存与数据库数据不一致
假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:
- 线程 B 读取到了旧值;
- 线程 B 是在缓存缺失的情况下读取的数据库,所以,它还会把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。
解决方案:
- 删除缓存值或更新数据库失败而导致数据不一致,可以使用重试机制确保删除或更新操作成功。(失败借助消息队列实现重试等)
- 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是
延迟双删
(延迟双删:在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作)。 - 借助Redisson的
读写锁
(进行写操作的时候才会互斥)来实现更新数据(更推荐这种方式
)
为什么需要读写锁?
读写锁适合于读多写少的场合,可以提高并发效率
有一些公共数据修改的机会比较少,而读的机会却是非常多的,此公共数据的操作基本都是读,如果每次操作都给此段代码加锁,太浪费时间了而且也很浪费资源,降低程序的效率。
因为读操作不会修改数据,只是做一些查询,所以在读的时候不用给此段代码加锁,可以共享的访问,只有涉及到写的时候,互斥的访问就行
/**
* @Description: 读写锁
* @Author: jianweil
* @date: 2022/1/27 20:04
*/
public class ReadWriteLockTest2 {
private static final String KEY_LOCKED = "myLock";
private static RedissonClient redissonClient = null;
private static void initRedissonClient() {
// 1. Create config object
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6380").setDatabase(1);
// 2. Create Redisson instance
redissonClient = Redisson.create(config);
}
public static void read() {
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(KEY_LOCKED);
readWriteLock.readLock().lock();
try {
System.out.println(LocalDateTime.now()+" "+ Thread.currentThread().getName() + "获取读锁,开始执行");
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println(LocalDateTime.now()+" "+ Thread.currentThread().getName() + "释放读锁");
readWriteLock.readLock().unlock();
}
}
public static void write() {
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(KEY_LOCKED);
readWriteLock.writeLock().lock();
try {
System.out.println(LocalDateTime.now()+" "+ Thread.currentThread().getName() + "获取写锁,开始执行");
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println(LocalDateTime.now()+" "+ Thread.currentThread().getName() + "释放写锁");
readWriteLock.writeLock().unlock();
}
}
public static void main(String[] args) {
initRedissonClient();
new Thread(() -> read(), "Thread1").start();
new Thread(() -> read(), "Thread2").start();
new Thread(() -> write(), "Thread3").start();
new Thread(() -> write(), "Thread4").start();
}
}
结果:
2022-06-07T22:48:15.295 Thread4获取写锁,开始执行
2022-06-07T22:48:16.296 Thread4释放写锁
2022-06-07T22:48:16.300 Thread2获取读锁,开始执行
2022-06-07T22:48:16.300 Thread1获取读锁,开始执行
2022-06-07T22:48:17.301 Thread2释放读锁
2022-06-07T22:48:17.301 Thread1释放读锁
2022-06-07T22:48:17.305 Thread3获取写锁,开始执行
2022-06-07T22:48:18.306 Thread3释放写锁
Thread4和Thread3是写锁,互斥,所以Thread4执行完成释放写锁,其他线程才能获取读锁或者写锁,因为Thread2和Thread1在Thread4释放写锁获得了读锁,读读是共享的,所以可以交替执行。而Thread3写锁必定在Thread4写锁之后执行。
2、缓存设置过期时间后是如何删除的
2.1、Redis对于过期键有三种清除策略
- 被动删除:
当读/写一个已经过期的key时
,会触发惰性删除策略,直接删除掉这个过期key - 主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以
Redis会定期(默认每100ms)主动淘汰一批已过期的key
,这里的一批只是部分过期key
,所以可能会出现部分key已经过期但还没有被清理掉的情况,导致内存并没有被释放- 配置文件hz参数来控制,取值范围1~500,默认是10,代表每秒运行10次
- 遍历所有的db
- 从db中设置了过期时间的key的集合中随机检查20个key
- 删除检查中发现的所有过期key
- 如果检查结果中25%以上的key已过期,则继续重复执行步骤2-3,否则继续遍历下一个db
- 配置文件hz参数来控制,取值范围1~500,默认是10,代表每秒运行10次
- 策略删除:当前
已用内存超过maxmemory限定时
,触发主动清理策略,redis.conf中配置的值
主动清理策略在Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略,总共8种:
-
针对设置了过期时间的key做处理:
- volatile-ttl:在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。
- volatile-random:就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
- volatile-lru:会使用 LRU 算法筛选设置了过期时间的键值对删除。
- volatile-lfu:会使用 LFU 算法筛选设置了过期时间的键值对删除。
-
针对所有的key做处理:
- allkeys-random:从所有键值对中随机选择并删除数据。
- allkeys-lru:使用 LRU 算法在所有数据中进行筛选删除。
- allkeys-lfu:使用 LFU 算法在所有数据中进行筛选删除。
-
不处理:
- noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息"(error) OOM command not allowed when used memory",此时Redis只响应读操作。
2.2、LRU 算法(Least Recently Used,最近最少使用)
淘汰很久没被访问过的数据,以最近一次访问时间作为参考。
2.3、LFU 算法(Least Frequently Used,使用的频率最低)
淘汰最近一段时间被访问次数最少的数据,以次数作为参考。
当存在热点数据时,LRU的效率很好
,但偶发性的、周期性的批量操作
会导致LRU命中率急剧下降,缓存污染情况比较严重。这时使用LFU可能更好点
。
根据自身业务类型,配置好maxmemory-policy(默认是noeviction),推荐使用volatile-lru。如果不设置最大内存,当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap),会让 Redis 的性能急剧下降。
当Redis运行在主从模式时,只有主结点才会执行过期删除策略
,然后把删除操作”del key”同步到从结点删除数据
2.4、主从模式从节点读取已过期数据问题
Redis 为了避免过多删除操作对性能产生影响,每次随机检查数据的数量并不多。如果过期数据很多,过期数据一直没有再被访问的话,这些数据就会留存在 Redis 实例中。业务应用之所以会读到过期数据,这些留存数据就是一个重要因素。
redis 3.2 之前的版本,从库在服务读请求时,并不会判断数据是否过期,而是会返回过期数据。在 3.2 版本后,Redis做了改进,如果读取的数据已经过期了,从库虽然不会删除,但是会返回空值,这就避免了客户端读到过期数据。
如何更有效的避免读取过期数据?
-
EXPIRE 和 PEXPIRE:它们给数据设置的是从命令执行时开始计算的存活时间;
- 假设当前时间是 2022 年 6 月 4 日上午 9 点,主从库正在同步,主库收到了一条命令:EXPIRE testkey 60,这就表示,testkey 的过期时间就是 4 日上午 9 点 1 分,主库直接执行了这条命令。但是,主从库全量同步花费了 2 分钟才完成。等从库开始执行这条命令时,时间已经是 9点 2 分了。而 EXPIRE 命令是把 testkey 的过期时间设置为当前时间的 60s 后,也就是 9点 3 分。如果客户端在 9 点 2 分 30 秒时在从库上读取 testkey,仍然可以读到 testkey的值。但是,testkey 实际上已经过期了。
-
EXPIREAT 和 PEXPIREAT:它们会直接把数据的过期时间设置为具体的一个时间点。
- 例如 EXPIREAT testkey 1654308000 ,可以让 testkey 在2022 年 6 月 4 日上午 10 点过期
在业务应用中使用 EXPIREAT/PEXPIREAT 命令,把数据的过期时间设置为具体的时间点,避免读到过期数据