Redis面试题(2020最新版)
Redisson
Redisson实现分布式锁
数据一致性
**解决方案后面重要介绍。Redis缓存: |
---|
直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于 HashMap,HashMap 的优势就是查找和操作的时间复杂度都是O(1);
数据结构简单,对数据操作也简单,Redis 中的数据结构是专门进行设计的;
采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
使用多路 I/O 复用模型,非阻塞 IO;
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
Spring Boot Data Redis
提供了RedisTemplate
和StringRedisTemplate
。StringRedisTemplate是RedisTemplate子类。StringRedisTemplate:操作Key value都是字符串的数据
RedisTemplate:操作的Key value都可以是对象
//删除一个key
Boolean res = redisTemplate.delete("name");
//判断一个key是否存在
Boolean res = redisTemplate.hasKey("name");
//判断key所对应的类型
DataType res = redisTemplate.type("set1");
//获取所有的key
Set<String> res = redisTemplate.keys("*");
//获取key的超时时间 -1 永不超时 -2 key不存在
Long res = redisTemplate.getExpire("name");
//修改key的名字 要求key必须存在 不存在会报错(ERR no such key)
redisTemplate.rename("age", "age1");
//如果key存在才修改名字,否则不修改 不存在会报错(ERR no such key)
Boolean res = redisTemplate.renameIfAbsent("age", "age1");
//移动key到指定库
redisTemplate.move("name", 1);
// 重新设置Key的过期时间(前提redis存在设定过期时间的key)
Boolean res = redisTemplate.expire("ff", 200, TimeUnit.SECONDS);
// 新增String类型的数据
redisTemplate.opsForValue().set("name","xiaoMing");
// 新增ttl数据
redisTemplate.opsForValue().set("short-message","12345",120,TimeUnit.SECONDS);
// 不存在相同key执行
Boolean res = redisTemplate.opsForValue().setIfAbsent("hn_1", "120", 3600, TimeUnit.SECONDS);
// 查询指定key的value值
String name = redisTemplate.opsForValue().get("name");
// 保存
String[] student = {"xiaoming","xiaohong"};
redisTemplate.opsForList().leftPushAll("student", Arrays.asList(student));
// 查询List集合
List<String> names = redisTemplate.opsForList().range("student", 0, -1);
// 保留list的1-3元素,其他丢弃
redisTemplate.opsForList().trim("student", 1, 3);
//创建set,并放入多个元素
redisTemplate.opsForSet().add("sets", "zhangsan", "lisi", "wangwu");
// 取出内存中的set数据
Set<String> sets = redisTemplate.opsForSet().members("sets");
// 内存中set的size
Long size = redisTemplate.opsForSet().size("sets");
redisTemplate.opsForZSet().add("zsets", "zhangsan", 100);//创建并放入元素
redisTemplate.opsForZSet().add("zsets", "lisi", 90);//创建并放入元素
redisTemplate.opsForZSet().add("zsets", "wangwu", 80);//创建并放入元素
//获取指定分数范围的值
Set<ZSetOperations.TypedTuple<String>> zsets = redisTemplate.opsForZSet().rangeByScoreWithScores("zsets", 0, 90);
for (ZSetOperations.TypedTuple<String> zset : zsets) {
System.out.println(zset.getValue() + "::" + zset.getScore());
}
//获取指定分数范围的值的key
Set<String> zsets1 = redisTemplate.opsForZSet().rangeByScore("zsets", 0, 90);
// insert 第一种方式
redisTemplate.opsForHash().put("student1","name","zhangsan");
redisTemplate.opsForHash().put("student1","age","23");
redisTemplate.opsForHash().put("student1","address","shenzhen");
redisTemplate.opsForHash().put("student1","code","8888");
// insert 第二种方式
HashMap hashMap = new HashMap();
hashMap.put("name","zhangsan");
hashMap.put("age","23");
redisTemplate.opsForHash().putAll("student2",hashMap);
// 指定key的所有map value
List<Object> values = redisTemplate.opsForHash().values("student1");
// 指定key的所有map key
Set<Object> keys = redisTemplate.opsForHash().keys("student1");
// 获取多个指定key的value
String[] str = {"age","address"};
List<Object> student1 = redisTemplate.opsForHash().multiGet("student1", Arrays.asList(str));
1、通用对象桶
Redisson分布式对象RBucket,可以存放任意类型对象
2、二进制流
Redisson分布式对象RBinaryStream,InputStream和OutoutStream接口实现
3、地理空间对象桶
Reddisson分布式RGo,储存于地理位置有关的对象桶
4、BitSet
Reddisson分布式RBitSet,是分布式的可伸缩位向量
通过实现RClusteredBitSet接口,可以在集群环境下数据分片
5、布隆过滤器
Reddisson利用Redis实现了java分布式的布隆过滤器RBloomFilter
6、基数估计算法(RHyperLogLog)
可以在有限的空间通过概率算法统计大量数据
7、限流器(RRateLimiter )
可以用来在分布式环境下限制请求方的调用频率。适用于不同或相同的Reddisson实例的多线程限流。并不保证公平性
特别注意:以上实现步骤考虑到了使用分布式锁需要考虑的互斥性、防死锁、加锁和解锁必须为同一个进程等问题,但是锁的续期无法实现。所以,博主采用 Redisson 实现 Redis 的分布式锁,借助 Redisson 的 WatchDog 机制 能够很好的解决锁续期的问题,同样 Redisson 也是 Redis 官方推荐分布式锁实现方案,实现起来较为简单。
public void tryLock() {
// 创建锁
RLock lock = redissonSingle.getLock("myLock");
boolean lockFlag = false;
try {
// 等待超时时间5秒,执行任务时间使用WatchDog机制
lockFlag = lock.tryLock(5, -1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (lockFlag) {
// 获取锁成功,执行业务
try {
logger.info("获取锁成功,执行业务");
Thread.sleep(7000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放锁
lock.unlock();
logger.info("业务执行完成,释放锁");
}
} else {
logger.error("获取锁失败");
}
}
public void lock() {
// 创建锁
RLock lock = redissonSingle.getLock("myLock");
try {
// 默认加锁时间是 30s,没有最大等待时间,会一直等待直到锁释放,另一个线程会再次获取锁
lock.lock();
logger.info("获取锁成功, 执行任务!");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放锁
lock.unlock();
logger.info("任务执行完毕, 释放锁!");
}
}
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]);"
// 参数:
KEYS[1]: 存入Redis的key值
ARGV[1]:锁的生存时间 默认30秒
ARGV[2]:加锁客户端ID
最后面的一个 1 是为了后面可重入做的计数统计,后面会有讲解到。
// 加锁代码解释
if(redis中不存在相同key的锁){
加锁
给锁设定生存时间
}
if(已经加过锁的客户端再次要加锁 -》"重入锁"){
加锁
给锁设定生存时间
}
其他情况返回锁的剩余存活时间
A客户端抢到锁,在A未结束之前B尝试加锁就是返回剩余存活时间
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// 1.尝试获取锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
// 申请锁的耗时如果大于等于最大等待时间,则申请锁失败.
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
current = System.currentTimeMillis();
/**
* 2.订阅锁释放事件,并通过 await 方法阻塞等待锁释放,有效的解决了无效的锁申请浪费资源的问题:
* 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争.
*
* 当 this.await 返回 false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败.
* 当 this.await 返回 true,进入循环尝试获取锁.
*/
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// await 方法内部是用 CountDownLatch 来实现阻塞,获取 subscribe 异步执行的结果(应用了 Netty 的 Future)
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(threadId);
return false;
}
try {
// 计算获取锁的总耗时,如果大于等于最大等待时间,则获取锁失败.
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
/**
* 3.收到锁释放的信号后,在最大等待时间之内,循环一次接着一次的尝试获取锁
* 获取锁成功,则立马返回 true,
* 若在最大等待时间之内还没获取到锁,则认为获取锁失败,返回 false 结束循环
*/
while (true) {
long currentTime = System.currentTimeMillis();
// 再次尝试获取锁
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
// 超过最大等待时间则返回 false 结束循环,获取锁失败
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
/**
* 6.阻塞等待锁(通过信号量(共享锁)阻塞,等待解锁消息):
*/
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
//如果剩余时间(ttl)小于wait time ,就在 ttl 时间内,从Entry的信号量获取一个许可(除非被中断或者一直没有可用的许可)。
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
//则就在wait time 时间范围内等待可以通过信号量
getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 更新剩余的等待时间(最大等待时间-已经消耗的阻塞时间)
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(threadId);
return false;
}
}
} finally {
// 7.无论是否获得锁,都要取消订阅解锁消息
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
流程分析:
尝试获取锁,返回 null 则说明加锁成功,返回一个数值,则说明已经存在该锁,ttl 为锁的剩余存活时间。
如果此时客户端 2 进程获取锁失败,那么使用客户端 2 的线程 id(其实本质上就是进程 id)通过 Redis 的 channel 订阅锁释放的事件,。如果等待的过程中一直未等到锁的释放事件通知,当超过最大等待时间则获取锁失败,返回 false,也就是第 39 行代码。如果等到了锁的释放事件的通知,则开始进入一个不断重试获取锁的循环。
循环中每次都先试着获取锁,并得到已存在的锁的剩余存活时间。如果在重试中拿到了锁,则直接返回。如果锁当前还是被占用的,那么等待释放锁的消息,具体实现使用了 JDK 的信号量 Semaphore 来阻塞线程,当锁释放并发布释放锁的消息后,信号量的 release()
方法会被调用,此时被信号量阻塞的等待队列中的一个线程就可以继续尝试获取锁了。
特别注意:以上过程存在一个细节,这里有必要说明一下,也是分布式锁的一个关键点:当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true)
死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。
客户端 1 加锁的锁 key 默认生存时间才 30 秒,如果超过了 30 秒,客户端 1 还想一直持有这把锁,怎么办呢?
Redisson 提供了一个续期机制, 只要客户端 1 一旦加锁成功,就会启动一个 Watch Dog。
private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1L) {
return this.tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
} else {
// Watch Dog 模式
RFuture<Boolean> ttlRemainingFuture = this.tryLockInnerAsync(this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e == null) {
if (ttlRemaining) {
this.scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
}
注意:从以上源码我们看到
leaseTime
必须是 -1 才会开启 Watch Dog 机制,也就是如果你想开启 Watch Dog 机制必须使用默认的加锁时间为 30s。如果你自己自定义时间,超过这个时间,锁就会自定释放,并不会延长。
Watch Dog 机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个 RedissonLock.EXPIRATION_RENEWAL_MAP
里面,然后每隔 10 秒 (internalLockLeaseTime / 3)
检查一下
如果客户端 1 还持有锁 key(判断客户端是否还持有 key,其实就是遍历 EXPIRATION_RENEWAL_MAP
里面线程 id 然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间。
注意:这里有一个细节问题,如果服务宕机了,Watch Dog 机制线程也就没有了,此时就不会延长 key 的过期时间,到了 30s 之后就会自动过期了,其他线程就可以获取到锁。
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]);"
第一个 if 判断肯定不成立,
exists myLock
会显示锁 key 已经存在。第二个 if 判断会成立,因为 myLock 的 hash 数据结构中包含的那个 ID 即客户端 1 的 ID,此时就会执行可重入加锁的逻辑,使用:hincrby myLock 285475da-9152-4c83-822a-67ee2f116a79:52 1
对客户端 1 的加锁次数加 1。此时 myLock 数据结构变为下面这样:
加锁成功Redis中保存数据的结构 |
---|
@Override
public RFuture<Void> unlockAsync(long threadId) {
RPromise<Void> result = new RedissonPromise<Void>();
// 1. 异步释放锁
RFuture<Boolean> future = unlockInnerAsync(threadId);
// 取消 Watch Dog 机制
future.onComplete((opStatus, e) -> {
cancelExpirationRenewal(threadId);
if (e != null) {
result.tryFailure(e);
return;
}
if (opStatus == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
result.tryFailure(cause);
return;
}
result.trySuccess(null);
});
return result;
}
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 判断锁 key 是否存在
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
// 将该客户端对应的锁的 hash 结构的 value 值递减为 0 后再进行删除
// 然后再向通道名为 redisson_lock__channel publish 一条 UNLOCK_MESSAGE 信息
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
从以上代码来看,释放锁的步骤主要分三步:
删除锁(这里注意可重入锁,在上面的脚本中有详细分析)。
广播释放锁的消息,通知阻塞等待的进程(向通道名为 redisson_lock__channel
publish 一条 UNLOCK_MESSAGE
信息)。
取消 Watch Dog 机制,即将 RedissonLock.EXPIRATION_RENEWAL_MAP
里面的线程 id 删除,并且 cancel 掉 Netty 的那个定时任务线程。
Redisson 通过 Watch Dog 机制很好的解决了锁的续期问题。
和 Zookeeper 相比较,Redisson 基于 Redis 性能更高,适合对性能要求高的场景。
通过 Redisson 实现分布式可重入锁,比原生的 SET mylock userId NX PX milliseconds
+ lua 实现的效果更好些,虽然基本原理都一样,但是它帮我们屏蔽了内部的执行细节。
在等待申请锁资源的进程等待申请锁的实现上也做了一些优化,减少了无效的锁申请,提升了资源的利用率。
使用 Redisson 实现分布式锁方案最大的问题就是如果你对某个 Redis Master 实例完成了加锁,此时 Master 会异步复制给其对应的 slave 实例。但是这个过程中一旦 Master 宕机,主备切换,slave 变为了 Master。接着就会导致,客户端 2 来尝试加锁的时候,在新的 Master 上完成了加锁,而客户端 1 也以为自己成功加了锁,此时就会导致多个客户端对一个分布式锁完成了加锁,这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。所以这个就是 Redis Cluster 或者说是 Redis Master-Slave 架构的主从异步复制导致的 Redis 分布式锁的最大缺陷(在 Redis Master 实例宕机的时候,可能导致多个客户端同时完成加锁)。
有个别观点说使用 Watch Dog 机制开启一个定时线程去不断延长锁的时间对系统有所损耗(这里只是网络上的一种说法,博主查了很多资料并且结合实际生产并不认为有很大系统损耗,这个仅供大家参考)。
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RedissonClient redissonClient;
@ResponseBody
@GetMapping("/write")
public String write() {
String s = UUID.randomUUID().toString();
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("product-lock");
// 写锁
RLock writeLock = readWriteLock.writeLock();
try {
writeLock.lock();
redisTemplate.opsForValue().set("id:1", s);
TimeUnit.SECONDS.sleep(30);
} catch (Exception e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
return s;
}
@ResponseBody
@GetMapping("read")
public String read() {
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("product-lock");
// 读锁
RLock readLock = readWriteLock.readLock();
String s = "";
try {
readLock.lock();
readWriteLock.readLock();
s = redisTemplate.opsForValue().get("id:1");
} catch (Exception e) {
e.printStackTrace();
} finally {
readLock.unlock();
}
return s;
}
@GetMapping("park")
public void park() throws InterruptedException {
RSemaphore park = redissonClient.getSemaphore("park");
// 获取信号量(阻塞式获取)
park.acquire();
// 获取信号量(非阻塞式获取)
park.tryAcquire();
}
@GetMapping("/go")
public void go() throws InterruptedException {
RSemaphore go = redissonClient.getSemaphore("park");
// 释放信号量
go.release();
}
@GetMapping("close/door")
public String closeDoor() throws InterruptedException {
RCountDownLatch countDownLatch = redissonClient.getCountDownLatch("countDownLatch");
// 五个闭锁全部完成结束
countDownLatch.trySetCount(5);
// 等待闭锁全部完成
countDownLatch.await();
return "success";
}
@GetMapping("consumer/count/latch")
public String close(){
RCountDownLatch countDownLatch = redissonClient.getCountDownLatch("countDownLatch");
// 闭锁计数减一
countDownLatch.countDown();
return "success";
}
Jedis使用
可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。
可以使用 Redis 来统一存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
除基本的会话token之外,Redis还提供很简便的FPC平台。以Magento为例,Magento提供一个插件来使用Redis作为全页缓存后端。此外,对WordPress的用户来说,Pantheon有一个非常好的插件 wp-redis,这个插件能帮助你以最快速度加载你曾浏览过的页面。
例如 DNS 记录就很适合使用 Redis 进行存储。查找表和缓存类似,也是利用了 Redis 快速的查找特性。但是查找表的内容不能失效,而缓存的内容可以失效,因为缓存不作为可靠的数据来源。
List 是一个双向链表,可以通过 lpush 和 rpop 写入和读取消息。不过最好使用 Kafka、RabbitMQ 等消息中间件。
在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。可以使用 Redis 自带的 SETNX 命令实现分布式锁,除此之外,还可以使用官方提供的 RedLock 分布式锁实现。
Set 可以实现交集、并集等操作,从而实现共同好友等功能。ZSet 可以实现有序性操作,从而实现排行榜等功能。
如2.4Redisson
Reids原生 SET mylock userId NX PX milliseconds
+ lua
持久化就是把内存的数据写到磁盘中去,防止服务宕机了内存数据丢失。
RDB是Redis默认的持久化方式。按照一定的时间将内存的数据以快照的形式
保存到硬盘中,对应产生的数据文件为dump.rdb。通过配置文件中的save参数来定义快照的周期。
优点:
1、只有一个文件 dump.rdb,方便持久化。
2、容灾性好,一个文件可以保存到安全的磁盘。
3、性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 redis 的高性能
4.相对于数据集大时,比 AOF 的启动效率更高。
缺点
1、数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候)
AOF持久化(即Append Only File持久化),则是将Redis执行的每次写命令
记录到单独的日志文件中,当重启Redis会重新将持久化的日志中文件恢复数据。
优点:
1、数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次 命令操作就记录到 aof 文件中一次。
2、通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
3、AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令 进行合并重写),可以删除其中的某些命令(比如误操作的 flushall))
缺点
1、AOF 文件比 RDB 文件大,且恢复速度慢。
2、数据集大的时候,比 rdb 启动效率低。
一般来说, 如果想达到足以媲美PostgreSQL的数据安全性,你应该同时使用两种持久化功能。在这种情况下,当 Redis 重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
如果你非常关心你的数据, 但仍然可以承受数分钟以内的数据丢失,那么你可以只使用RDB持久化。
有很多用户都只使用AOF持久化,但并不推荐这种方式,因为定时生成RDB快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比AOF恢复的速度要快,除此之外,使用RDB还可以避免AOF程序的bug。
如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
我们都知道,Redis是key-value数据库,我们可以设置Redis中缓存的key的过期时间。Redis的过期策略就是指当Redis中缓存的key过期了,Redis如何处理。
Redis中同时使用了惰性过期和定期过期两种过期策略
Redis的内存淘汰策略是指在Redis的用于缓存的内存不足时,怎么处理新写入且需要申请额外空间的数据。
尽可能使用散列表(hash),散列表使用的内存非常小,所以你应该尽可能的将你的数据模型抽象到一个散列表里面。比如你的web系统中有一个用户对象,不要为这个用户的名称,姓氏,邮箱,密码设置单独的key,而是应该把这个用户的所有信息存储到一张散列表里面。
参考文档
略
第二种方案:更新数据库和更新缓存两步操作的方式
但数据库和缓存都更新,又存在先后问题,那对应的方案就有 2 个:
先更新缓存,后更新数据库
先更新数据库,后更新缓存
哪个方案更好呢?
先不考虑并发问题,正常情况下,无论谁先谁后,都可以让两者保持一致,但现在我们需要重点考虑「异常」情况。
因为操作分为两步,那么就很有可能存在「第一步成功、第二步失败」的情况发生。
这 2 种方案我们一个个来分析。
1) 先更新缓存,后更新数据库
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但数据库中是「旧值」。
虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存「失效」,就会从数据库中读取到「旧值」,重建缓存也是这个旧值。
这时用户会发现自己之前修改的数据又「变回去」了,对业务造成影响。
2) 先更新数据库,后更新缓存
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是「旧值」。
之后的读请求读到的都是旧数据,只有当缓存「失效」后,才能从数据库中得到正确的值。
这时用户会发现,自己刚刚修改了数据,但却看不到变更,一段时间过后,数据才变更过来,对业务也会有影响。
可见,无论谁先谁后,但凡后者发生异常,就会对业务造成影响。那怎么解决这个问题呢?
别急,后面我会详细给出对应的解决方案。
我们继续分析,除了操作失败问题,还有什么场景会影响数据一致性?
这里我们还需要重点关注:并发问题。
假设我们采用「先更新数据库,再更新缓存」的方案,并且两步都可以「成功执行」的前提下,如果存在并发,情况会是怎样的呢?
有线程 A 和线程 B 两个线程,需要更新「同一条」数据,会发生这样的场景:
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
也就是说,A 虽然先于 B 发生,但 B 操作数据库和缓存的时间,却要比 A 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。
同样地,采用「先更新缓存,再更新数据库」的方案,也会有类似问题,这里不再详述。
除此之外,我们从「缓存利用率」的角度来评估这个方案,也是不太推荐的。
这是因为每次数据发生变更,都「无脑」更新缓存,但是缓存中的数据不一定会被「马上读取」,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。
由此可见,这种「更新数据库 + 更新缓存」的方案,不仅缓存利用率不高,还会造成机器性能的浪费。
所以此时我们需要考虑另外一种方案:删除缓存。
经过前面的分析我们已经得知,但凡「第二步」操作失败,都会导致数据不一致。
这里我不再详述具体场景,你可以按照前面的思路推演一下,就可以看到依旧存在数据不一致的情况。
1) 先删除缓存,后更新数据库
如果有 2 个线程要并发「读写」数据,可能会发生以下场景:
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。
可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。
2) 先更新数据库,后删除缓存
依旧是 2 个线程并发「读写」数据:
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。
这种情况「理论」来说是可能发生的,但实际真的有可能发生吗?
其实概率「很低」,这是因为它必须满足 3 个条件:
仔细想一下,条件 3 发生的概率其实是非常低的。
因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的。
这么来看,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
所以,我们应该采用这种方案,来操作数据库和缓存。
好,解决了并发问题,我们继续来看前面遗留的,第二步执行「失败」导致数据不一致的问题。
前面我们分析到,无论是更新缓存还是删除缓存,只要第二步发生失败,那么就会导致数据库和缓存不一致。
保证第二步成功执行,就是解决问题的关键。
想一下,程序在执行过程中发生异常,最简单的解决办法是什么?
答案是:重试。
是的,其实这里我们也可以这样做。
无论是先操作缓存,还是先操作数据库,但凡后者执行失败了,我们就可以发起重试,尽可能地去做「补偿」。
那这是不是意味着,只要执行失败,我们「无脑重试」就可以了呢?
答案是否定的。现实情况往往没有想的这么简单,失败后立即重试的问题在于:
看到了么,虽然我们想通过重试的方式解决问题,但这种「同步」重试的方案依旧不严谨。
那更好的方案应该怎么做?
答案是:异步重试。什么是异步重试?
其实就是把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功。
或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。
到这里你可能会问,写消息队列也有可能会失败啊?而且,引入消息队列,这又增加了更多的维护成本,这样做值得吗?
这个问题很好,但我们思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目「重启」了,那这次重试请求也就「丢失」了,那这条数据就一直不一致了。
所以,这里我们必须把重试或第二步操作放到另一个「服务」中,这个服务用「消息队列」最为合适。这是因为消息队列的特性,正好符合我们的需求:
至于写队列失败和消息队列的维护成本问题:
所以,引入消息队列来解决这个问题,是比较合适的。这时架构模型就变成了这样:
那如果你确实不想在应用中去写消息队列,是否有更简单的方案,同时又可以保证一致性呢?
方案还是有的,这就是近几年比较流行的解决方案:订阅数据库变更日志,再操作缓存。
具体来讲就是,我们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。
那什么时候操作缓存呢?这就和数据库的「变更日志」有关了。
拿 MySQL 举例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal,使用这种方案的优点在于:
当然,与此同时,我们需要投入精力去维护 canal 的高可用和稳定性。
如果你有留意观察很多数据库的特性,就会发现其实很多数据库都逐渐开始提供「订阅变更日志」的功能了,相信不远的将来,我们就不用通过中间件来拉取日志,自己写程序就可以订阅变更日志了,这样可以进一步简化流程。
至此,我们可以得出结论,想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做。
到这里,还有 2 个问题,是我们没有重点分析过的。
第一个问题,还记得前面讲到的「先删除缓存,再更新数据库」方案,导致不一致的场景么?
这里我再把例子拿过来让你复习一下:
2 个线程要并发「读写」数据,可能会发生以下场景:
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。
第二个问题:是关于「读写分离 + 主从复制延迟」情况下,缓存和数据库一致性的问题。
在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」其实也会导致不一致:
最终 X 的值在缓存中是 1(旧值),在主从库中是 2(新值),也发生不一致。
看到了么?这 2 个问题的核心在于:缓存都被回种了「旧值」。
那怎么解决这类问题呢?
最有效的办法就是,把缓存删掉。
但是,不能立即删,而是需要「延迟删」,这就是业界给出的方案:缓存延迟双删策略。
按照延时双删策略,这 2 个问题的解决方案是这样的:
解决第一个问题:在线程 A 删除缓存、更新完数据库之后,先「休眠一会」,再「删除」一次缓存。
解决第二个问题:线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。
这两个方案的目的,都是为了把缓存清掉,这样一来,下次就可以从数据库读取到最新值,写入缓存。
但问题来了,这个「延迟删除」缓存,延迟时间到底设置要多久呢?
但是,这个时间在分布式和高并发场景下,其实是很难评估的**。**
很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。
所以你看,采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。
所以实际使用中,我还是建议你采用「先更新数据库,再删除缓存」的方案,同时,要尽可能地保证「主从复制」不要有太大延迟,降低出问题的概率。
看到这里你可能会想,这些方案还是不够完美,我就想让缓存和数据库「强一致」,到底能不能做到呢?
其实很难。
要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。
相反,这时我们换个角度思考一下,我们引入缓存的目的是什么?
没错,性能。
一旦我们决定使用缓存,那必然要面临一致性问题。性能和一致性就像天平的两端,无法做到都满足要求。
而且,就拿我们前面讲到的方案来说,当操作数据库和缓存完成之前,只要有其它请求可以进来,都有可能查到「中间状态」的数据。
所以如果非要追求强一致,那必须要求所有更新操作完成之前期间,不能有「任何请求」进来。
虽然我们可以通过加「分布锁」的方式来实现,但我们要付出的代价,很可能会超过引入缓存带来的性能提升。
所以,既然决定使用缓存,就必须容忍「一致性」问题,我们只能尽可能地去降低问题出现的概率。
同时我们也要知道,缓存都是有「失效时间」的,就算在这期间存在短期不一致,我们依旧有失效时间来兜底,这样也能达到最终一致。
1、想要提高应用的性能,可以引入「缓存」来解决
2、引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」
3、更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」和「机器性能浪费」的情况发生
4、在更新数据库 + 删除缓存的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案
5、在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据一致性
6、在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率
数据不一致示例 |
---|
数据不一致示例 |
---|
这个时候你要回答redis关键的一个特性:redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。
使用list类型保存数据信息,rpush生产消息,lpop消费消息,当lpop没有消息时,可以sleep一段时间,然后再检查有没有信息,如果不想sleep的话,可以使用blpop, 在没有信息的时候,会一直阻塞,直到信息的到来。redis可以通过pub/sub主题订阅模式实现一个生产者,多个消费者,当然也存在一定的缺点,当消费者下线时,生产的消息会丢失。
使用sortedset,使用时间戳做score, 消息内容作为key,调用zadd来生产消息,消费者使用zrangbyscore获取n秒之前的数据做轮询处理。
布隆过滤器 |
---|
布隆过滤器的原理,当一个元素加入布隆过滤器中的时候,会进行哪些操作::
- 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
- 根据得到的哈希值,在位数组中把对应下标的值置为 1。
再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作
- 对给定元素再次进行相同的哈希计算;
- 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。 (可以适当增加位数组大小或者调整我们的哈希函数来降低概率)