缓存和分布式锁 笔记

概念

缓存的作用是减低对数据源的访问频率。从而提高我们系统的性能。

缓存的流程图

缓存分类

本地缓存

把缓存数据存储在内存中(Map <String,Object>),其实就是强引用,不会轻易被删除。

分布式缓存

数据冗余,效率不高

整合Redis

json 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-redis</artifactId>
    <version>1.3.2.RELEASE</version>
</dependency>
json 复制代码
spring:
    redis:
    host: 192.168.56.10
    port: 6379
json 复制代码
@ResponseBody
@RequestMapping("/index/test")
public Object test(){
    BoundValueOperations<String, String> boundValueOperations= stringRedisTemplate.boundValueOps("test");
    boundValueOperations.set("测试数据");
    return boundValueOperations.get();
}

redis缓存改造三级分类

json 复制代码
public Map<String, List<Catalog2VO>> getCatelog2JSON() throws IOException {
    String catalogJSON = stringRedisTemplate.opsForValue().get("Catelog2JSON");
    if (StringUtils.isEmpty(catalogJSON)) {
        Map<String, List<Catalog2VO>> map = getCatelog2JSONDb();
        String json = objectMapper.writeValueAsString(map);
        stringRedisTemplate.opsForValue().set("Catelog2JSON", json);
        return map;
    }
    Map<String, List<Catalog2VO>> map=objectMapper.readValue(catalogJSON, new TypeReference<Map<String, List<Catalog2VO>>>(){});
    return map;
}

缓存常见问题

缓存穿透

问题:

指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,造成数据库压力增大。

**解决:**直接把null结果缓存,并加入短暂的过期时间

缓存雪崩

**问题:**在我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

**解决:**原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

缓存击穿

**问题:**对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常"热点"的数据。如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都落到db。

**解决:**加锁,大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去db。

json 复制代码
synchronized (this) {
  。。。。。。
}
java 复制代码
public Map<String, List<Catalog2VO>> getCatelog2JSON() throws IOException {

String catalogJSON = stringRedisTemplate.opsForValue().get("Catelog2JSON");
if (StringUtils.isEmpty(catalogJSON)) {
    synchronized (this) {
        System.out.println("查数据库啊-----");
        Map<String, List<Catalog2VO>> map = getCatelog2JSONDb();
        if (null == map) {
            //加个null缓存,防止穿透
            stringRedisTemplate.opsForValue().set("Catelog2JSON", "null", 5, TimeUnit.SECONDS);
        } else {
            String json = objectMapper.writeValueAsString(map);
            //加个过期时间防止雪崩
            stringRedisTemplate.opsForValue().set("Catelog2JSON", json, 5, TimeUnit.SECONDS);
            return map;
        }
    }
}
Map<String, List<Catalog2VO>> map = objectMapper.readValue(catalogJSON, new TypeReference<Map<String, List<Catalog2VO>>>() {
});
return map;
}

本地锁的局限


**问题:**针对本地锁的问题,我们需要通过分布式锁来解决,那么是不是意味着本身锁在分布式场景下就不需要了呢?

**答:**因为如果分布式环境下的每个节点不控制请求的数量,那么分布式锁的压力会非常大,这时我们需要本地锁来控制每个节点的同步,来降低分布式锁的压力,所以实际开发中我们都是本地锁和分布式锁结合使用的。

分布式锁

分布式锁原理

分布式锁常用解决方案

https://blog.csdn.net/qq_33594592/article/details/133932876

数据库

通过在数据库中创建一个唯一索引的表,然后通过插入一条数据来获取锁,如果插入成功则获取锁成功,否则获取锁失败。释放锁的操作就是删除这条数据。这种方式的优点是实现简单,缺点是性能较低,因为涉及到数据库的操作。

可以利用MySQL隔离性:唯一索引

java 复制代码
use test;
CREATE TABLE `DistributedLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁名',
  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_name` (`name`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

//数据库中的每一条记录就是一把锁,利用的mysql唯一索引的排他性
//lock和unlock只是函数名
lock(name,desc){
    insert into DistributedLock(`name`,`desc`) values (#{name},#{desc});
}

unlock(name){
    delete from DistributedLock where name = #{name}
}

乐观锁:乐观的任务数据不会出现数据安全问题,如果出现了就重试一次

java 复制代码
select ...,version;
update table set version+1 where version = xxx

Redis

setNX: setNX(key,value) :如果key不存在那么就添加key的值,否则添加失败,Redisson。

Zookeeper

其基本思想是创建一个临时有序节点,临时节点保证在客户端断开连接或者超时zookeeper会自动删除该节点,从而避免死锁的发生,有序节点可以实现一种公平锁,加锁顺序按照客户端进程请求顺序进行,加锁的为产生节点序号最小的那个客户端。

加锁:客户端在zookeeper创建临时节点,并判断自己是不是节点序号最小的,如果是则加锁成功,如果不是则watch监听它前一个节点删除事件,当收到前一个节点删除通知时重复前面逻辑,直到加锁完成

解锁:客户端完成业务逻辑,在zookeeper删除自己的临时节点,同时通知它后一个节点获取锁

Redis实现分布式锁

java 复制代码
public Map<String, List<Catalog2VO>> getCatelog2JSONDbWithRedisLock() {
    String keys = "catalogJSON";
    // 加锁
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "1111");
    if(lock){
        // 加锁成功
        Map<String, List<Catalog2VO>> data = getDataForDB(keys);
        // 从数据库中获取数据成功后,我们应该要释放锁
        stringRedisTemplate.delete("lock");
        return data;
    }else{
        // 加锁失败
        // 休眠+重试
        // Thread.sleep(1000);
        return getCatelog2JSONDbWithRedisLock();
    }
}




///如果getDataForDB出现异常,则会出现死锁,
///加上过期时间可以解决
// 给对应的key设置过期时间
stringRedisTemplate.expire("lock",20,TimeUnit.SECONDS);




///如果在expire方法执行之前就中断也会出现死锁
///创建锁时同时加上过期时间
// 加锁 在执行插入操作的同时设置了过期时间
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "1111",30,TimeUnit.SECONDS);




///如果获取锁的业务执行时间比较长,超过了我们设置的过期时间,那么就有可能业务还没执行完,锁就释放了,然后另一个请求进来了,并创建了key,这时原来的业务处理完成后,再去删除key的时候,那么就有可能删除别人的key,如何解决
///查询的锁的信息通过UUID来区分
public Map<String, List<Catalog2VO>> getCatelog2JSONDbWithRedisLock() {
    String keys = "catalogJSON";
    // 加锁 在执行插入操作的同时设置了过期时间
    String uuid = UUID.randomUUID().toString();
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid,30,TimeUnit.SECONDS);
    if(lock){
        // 给对应的key设置过期时间
        stringRedisTemplate.expire("lock",20,TimeUnit.SECONDS);
        // 加锁成功
        Map<String, List<Catalog2VO>> data = getDataForDB(keys);
        // 获取当前key对应的值
        String val = stringRedisTemplate.opsForValue().get("lock");
        if(uuid.equals(val)){
            // 说明这把锁是自己的
            // 从数据库中获取数据成功后,我们应该要释放锁
            stringRedisTemplate.delete("lock");
        }
        return data;
    }else{
        // 加锁失败
        // 休眠+重试
        // Thread.sleep(1000);
        return getCatelog2JSONDbWithRedisLock();
    }
}



///key的值和删除key其实不是一个原子性操作,这就会出现我查询出来key之后,时间过期了,然后key被删除了,然后其他的请求创建了一个新的key,然后原来的执行删除了这个key,又出现了删除别人key的情况
///脚本执行(lua)
String srcipts = "if redis.call('get',KEYS[1]) == ARGV[1]  then return redis.call('del',KEYS[1]) else  return 0 end ";
// 通过Redis的lua脚本实现 查询和删除操作的原子性
stringRedisTemplate.execute(new DefaultRedisScript<Integer>(srcipts,Integer.class)
,Arrays.asList("lock"),uuid);

Redisson分布式锁

https://github.com/redisson/redisson/wiki/8.-分布式锁和同步器#81-可重入锁reentrant-lock

原理

加锁原理

lua 复制代码
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"

一个客户端要加锁,它首先会根据hash节点选择一台机器,这里注意仅仅只是选择一台机器,紧接着就会发送一段封装好的 lua 脚本到 redis 上,比如加锁的那个锁key就是"mylock",并且设置的时间是30秒,30秒后mylock锁就会被自动释放。

锁互斥机制

第一个线程加锁完成后,此时,如果客户端 2 来尝试加锁会如何呢?首先,第一个 if 判断会执行 exists myLock,发现 myLock 这个锁 key 已经存在了。接着第二个 if 判断,判断一下,myLock 锁 key 的 hash 数据结构中,是否包含客户端 2 的 ID,这里明显不包含客户端 2 的ID,因为那里包含的是客户端 1 的 ID。所以,客户端 2 会执行:

lua 复制代码
return redis.call('pttl', KEYS[1]);
//返回的是一个数字,这个数字就是 myLock 这个锁 key 的剩余生存时间

锁阻塞机制

当锁正在被其他线程占用的时候,等待获取锁的线程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决 不断的申请锁浪费资源的问题。

锁续期机制

【1】客户端 1 加锁的默认生存(等待)时间为30秒,如果超过了30秒,客户端 1 还想一直持有这把锁,怎么办?

【2】Redisson 提供了一个续期机制,只要客户端 1 一旦加锁成功,就会启动一个 Watch Dog (看门狗)。

【3】Watch Dog 它是一个后台定时任务线程,会每隔10秒(internalLockLeaseTime / 3)检查一下(遍历 EXPIRATION_RENEWAL_MAP 里面线程 id 然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时 间),如果客户端 1 还持有锁key,那么就会不断的延长锁key的生存时间,直至业务执行结束或者该线程主 动释放锁。

源码

java 复制代码
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    //尝试获取锁
    Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
    // lock acquired
    //获得锁直接返回
    if (ttl == null) {
        return;
    }

    //订阅与锁关联的Redis频道
    RFuture<RedissonLockEntry> future = subscribe(threadId);
    if (interruptibly) {
        commandExecutor.syncSubscriptionInterrupted(future);
    } else {
        commandExecutor.syncSubscription(future);
    }

    try {
        while (true) {
            ttl = tryAcquire(-1, leaseTime, unit, threadId);
            // lock acquired
            if (ttl == null) {
                break;
            }

            // waiting for message
            //如果ttl >= 0,表示等待获取锁的时间(在给定的TTL时间内)。在这个时间段内,线程将尝试获取锁。如果在此期间发生中断,并且interruptibly为真,则抛出中断异常;否则,再次尝试获取锁。
            if (ttl >= 0) {
                try {
                    future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } catch (InterruptedException e) {
                    if (interruptibly) {
                        throw e;
                    }
                    future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                }
            } else {
                if (interruptibly) {
                    future.getNow().getLatch().acquire();
                } else {
                    future.getNow().getLatch().acquireUninterruptibly();
                }
            }
        }
    } finally {
        //清理订阅的资源
        unsubscribe(future, threadId);
    }
    //        get(lockAsync(leaseTime, unit));
}
java 复制代码
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}
java 复制代码
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    RFuture<Long> ttlRemainingFuture;
    if (leaseTime != -1) {
        ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        //过期时间为看门狗的默认时间,30秒
        ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    }
    //开启一个定时任务进行不断刷新过期时间
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }

        // lock acquired
        if (ttlRemaining == null) {
            if (leaseTime != -1) {
                internalLockLeaseTime = unit.toMillis(leaseTime);
            } else {
                scheduleExpirationRenewal(threadId);
            }
        }
    });
    return ttlRemainingFuture;
}
java 复制代码
this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();

private long lockWatchdogTimeout = 30 * 1000;
java 复制代码
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
              // 首先判断锁是否存在
              "if (redis.call('exists', KEYS[1]) == 0) then " +
                      // 存在则获取锁
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  // 然后设置过期时间
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // hexists查看哈希表的指定字段是否存在,存在锁并且是当前线程持有锁
              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                      // hincrby自增一
                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      // 锁的值大于1,说明是可重入锁,重置过期时间
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // 锁已存在,且不是本线程,则返回过期时间ttl
              "return redis.call('pttl', KEYS[1]);",
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
java 复制代码
protected void scheduleExpirationRenewal(long threadId) {
    //该对象将用于存储需要刷新锁过期时间的线程ID
    ExpirationEntry entry = new ExpirationEntry();
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        //将当前线程ID添加到oldEntry中,表示该线程也需要参与锁的过期刷新
        oldEntry.addThreadId(threadId);
    } else {
        entry.addThreadId(threadId);
        //调用renewExpiration()方法来刷新锁的过期时间。这通常涉及到与Redis服务器交互,以更新锁的过期时间
        renewExpiration();
    }
}
java 复制代码
//主要目的是确保锁的过期时间能够定期自动刷新,以防止锁在使用过程中意外过期。它使用定时任务来定期检查并更新锁的过期时间,并在成功更新后重新调度任务,以便在未来继续刷新。如果更新失败或不再需要刷新,它会取消刷新任务并从映射中移除相关条目。这种方式可以有效地管理锁的生命周期,并减少因锁过期导致的问题。
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    //不需要刷新过期时间
    if (ee == null) {
        return;
    }

    //命令执行器的连接管理器创建一个新的定时任务,该任务将在内部锁续约时间的三分之一时长后运行(10秒)
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }

            //异步更新锁的过期时间,续约
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getRawName() + " expiration", e);
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }

                if (res) {
                    // reschedule itself
                    renewExpiration();
                } else {
                    cancelExpirationRenewal(null);
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

    ee.setTimeout(task);
}
java 复制代码
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getRawName()),
            internalLockLeaseTime, getLockName(threadId));
}

Redisson整合

java 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.1</version>
</dependency>
java 复制代码
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author guanglin.ma
 * @date 2023-12-24 22:45
 */
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient RedissonClientConfig(){
        Config config = new Config();
        //可以用"rediss://"来启用SSL连接
        config.useSingleServer().setAddress("redis://192.168.56.10:6379");
        return Redisson.create(config);
    }
}

可重入锁

https://blog.csdn.net/hkl_Forever/article/details/128164070

读写锁

在读写锁中,只有读读的行为是共享锁,相互之间不影响,只要有写的行为存在,那么就是一个互斥锁(排他锁)

java 复制代码
@GetMapping("/writer")
@ResponseBody
public String writerValue(){
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
    // 加写锁
    RLock rLock = readWriteLock.writeLock();
    String s = null;
    rLock.lock(); // 加写锁
    try {
        s = UUID.randomUUID().toString();
        stringRedisTemplate.opsForValue().set("msg",s);
        Thread.sleep(30000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }
    return s;
}

@GetMapping("/reader")
@ResponseBody
public String readValue(){
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
    // 加读锁
    RLock rLock = readWriteLock.readLock();
    rLock.lock();
    String s = null;
    try {
        s = stringRedisTemplate.opsForValue().get("msg");
    }finally {
        rLock.unlock();
    }

    return s;
}

闭合锁

基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象 RCountDownLatch采用了与 java.util.concurrent.CountDownLatch相似的接口和用法。

java 复制代码
//先关门熄灯,await等待数量消费到0即可继续往下执行

@GetMapping("/lockDoor")
@ResponseBody
public String lockDoor(){
    RCountDownLatch door = redissonClient.getCountDownLatch("door");
    door.trySetCount(5);
    try {
        door.await(); // 等待数量降低到0
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "关门熄灯...";
}

@GetMapping("/goHome/{id}")
@ResponseBody
public String goHome(@PathVariable Long id){
    RCountDownLatch door = redissonClient.getCountDownLatch("door");
    door.countDown(); // 递减的操作
    return id + "下班走人";
}

信号量(Semaphore)

基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore采用了与 java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。

java 复制代码
@GetMapping("/park")
@ResponseBody
public String park(){
    RSemaphore park = redissonClient.getSemaphore("park");
    boolean b = true;
    try {
        // park.acquire(); // 获取信号 阻塞到获取成功
        b = park.tryAcquire();// 返回获取成功还是失败
    } catch (Exception e) {
        e.printStackTrace();
    }
    return "停车是否成功:" + b;
}

//释放10次,就有10个车位
@GetMapping("/release")
@ResponseBody
public String release(){
    RSemaphore park = redissonClient.getSemaphore("park");
    park.release();
    return "释放了一个车位";
}

缓存数据一致性问题

双写模式

https://baijiahao.baidu.com/s?id=1763573615688107095&wfr=spider&for=pc

更新过程

**策略一:**先更新db,再删除缓存(常用的Cache-Aside Pattern旁路缓存)

问题:如果更新db成功,删缓存失败,将导致数据不一致。

**策略二:**先更新db,再更新缓存

问题:

1、并发更新场景下,更新缓存会导致数据不一致。

2、根据读写比,考虑是否有必要频繁同步更新缓存,而且,如果构造缓存中数据过于复杂,或者数据更新频繁,但是读取并不频繁的情况,还会造成不必要的性能损耗。(此种方式不推荐)

**策略三:**先更新缓存,再更新db

问题:不推荐

**策略四:**先删缓存,再更新db

问题:先删缓存,虽然解决了策略1中,后删缓存如果失败的场景,但也会发生不一致的问题。

例如:请求 A 删除缓存,这时请求B来查,就会击穿到数据库,B读取到旧的值后写入缓存,A正常更新db,由于时间差导致数据不一致的情况。

**策略五:**缓存延时双删

问题:该策略兼容了策略1和策略4,解决了先删缓存还是后删缓存的问题,如策略1中,更新db后删缓存失败和策略4中的不一致场景,该策略可以将延时时间内(比如延时10ms)所造成的缓存脏数据,再次删除。但是,如果延时删缓存失败,策略4中不一致问题还会发生,同时延时的实现,如创建线程,或者引入mq异步,可能会增加系统复杂度问题。

**策略六:**变种双删,前置缓存过期时间

问题:该策略针对策略1中后删缓存失败的场景,前置一层缓存数据过期时间(具体时间根据自身系统本身评估,如可覆盖db读写耗时或一致性容忍度等),更新db后就算删缓存失败,在expire时间后也能保证缓存中无数据。同时,前置expire失败,或者更新db失败,都不会影响数据一致。

能够解决策略4中的问题:请求 A 删除缓存,这时请求B来查,就会击穿到数据库,B读取到旧的值后写入缓存,A正常更新db,由于时间差导致数据不一致的情况,描述图如下:

本策略中步骤1为expire缓存,不会发生击穿缓存到数据库的情况,数据将直接返回。除非更极端情况,如下图:

expire时间没有覆盖住更新db的耗时,类似策略1中极端场景

策略七:

问题:

失效模式

总结

针对于上的两种解决方案我们怎么选择?

  1. 缓存的所有数据我们都加上过期时间,数据过期之后主动触发更新操作
  2. 使用读写锁来处理,读读的操作是不相互影响的

无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?

  1. 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加 上过期时间,每隔一段时间触发读的主动更新即可
  2. 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
  3. 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
  4. 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心 脏数据,允许临时脏数据可忽略)

总结:

  • 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。

  • 我们不应该过度设计,增加系统的复杂性

  • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。

    SpringCache

    java 复制代码
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    java 复制代码
    spring:
    redis:
      host: localhost
      port: 6379
      #password:
      database: 0
    cache:
      redis:
        time-to-live: 1800000 #过期时间
    java 复制代码
    @EnableCaching
    ...
    @SpringBootApplication
    public class GulimallProductApplication { ... }
    java 复制代码
    SpringCache 如下注解为我们提供缓存的操作
    
      @Cacheable 触发将数据保存到缓存的操作。
      如果缓存中有数据,被标注的方法不调用。
      key 默认自动生成:缓存名字::SimpleKey [] ,如 category::SimpleKey [] 。
      缓存值默认使用 jdk 序列化机制将序列化后的数据存到缓存。
      默认过期时间是 -1。
    
      @CacheEvict 将数据从缓存删除。
      @CachePut 沉浸式更新缓存。
      @Caching 组合多个缓存操作。

使用

java 复制代码
//SpEL
//"#setmeal.categoryId" 表示获取变量 "setmeal" 的属性 "categoryId" 的值。
//"''+" 是一个字符串连接操作,用于在两个值之间添加下划线字符 ""。
//"#setmeal.status" 表示获取变量 "setmeal" 的属性 "status" 的值。

//@CachePut注解在执行添加操作时,将新的数据添加到Redis缓存中,以便于数据库查询的值和展示的数据一致。Redis底层执行的是set操作。
@PostMapping
@CachePut(value = "setmealCache",key = "#setmealDto.categoryId+'_'+#setmealDto.status")
public R<String> save(@RequestBody SetmealDto setmealDto){

    return R.success("新增套餐成功");
}
java 复制代码
//@CacheEvict注解执行修改、删除数据操作时删除对应的所有该名称(value=值)下的缓存数据,以便于数据库查询的值和展示的数据一致。Redis底层执行的是del(删除)操作。下次在删除修改完后查询时就会重新执行sql语句去数据库中查询数据,这样就使得数据库与缓存数据一致。
@PutMapping
@CacheEvict(value = "setmealCache",allEntries = true)
public R<String> update(@RequestBody SetmealDto setmealDto){


    return R.success("修改成功");
}
java 复制代码
//@Cacheable注解,将查询的数据放到Redis中,所以Redis底层代码也是做的set操作。
@GetMapping("/list")
//将返回的结果存入redis缓存中
@Cacheable(value = "setmealCache",key = "#setmeal.categoryId+'_'+#setmeal.status")
public R<List<Setmeal>> list(Setmeal setmeal){


    return R.success(list);
}

源码

配置类里面的选择器,加载把相关的配置类进行加载。对应yaml里面的type

其他

SpringCache的不足:

1).读模式

  • 缓存穿透:查询一个null的数据。可以解决 cache-null-values=true
  • 缓存击穿:大量并发进来同时查询一个正好过期的数据。解决方案:分布式锁 sync=true 本地锁
  • 缓存雪崩:大量的key同一个时间点失效。解决方案:添加过期时间 time-to-live=60000 指定过期时间

2).写模式

  • 读写锁
  • 引入canal,监控binlog日志文件来同步更新数据
  • 读多写多,直接去数据库中读取数据即可

总结:

  • 常规数据(读多写少):而且对及时性和数据的一致性要求不高的情况,我们完全可以使用SpringCache
  • 特殊情况:特殊情况特殊处理。
相关推荐
ReedFoley6 小时前
【笔记】动手学Ollama 第七章 应用案例1 搭建本地AI Copilot编程助手
人工智能·笔记·copilot
AKAMAI6 小时前
在分布式计算区域中通过VPC搭建私有网络
人工智能·分布式·云计算
长安即是故里7 小时前
Maxwell学习笔记
笔记·学习
卖寂寞的小男孩7 小时前
spark数据缓存机制
大数据·缓存·spark
咸甜适中9 小时前
rust语言 (1.88) egui (0.32.1) 学习笔记(逐行注释)(十四)垂直滚动条
笔记·学习·rust·egui
努力敲代码的小盆友9 小时前
[自用笔记]上传本地项目至github
笔记·github
何中应9 小时前
如何用Redis作为消息队列
数据库·redis·缓存
It_张10 小时前
Building Systems with the ChatGPT API 使用 ChatGPT API 搭建系统(第五章学习笔记及总结)
笔记·学习·chatgpt
The_Second_Coming11 小时前
Linux 学习笔记 - 集群管理篇
linux·笔记·学习
面带微笑向前走13 小时前
分布式集群压测+grafana+influxdb+Prometheus详细步骤
分布式·grafana·prometheus