[ redis系列 ] 02| Redis常见使用场景
文章目录
分布式缓存
缓存数据模型特征:
-
命中率
命中率=返回正确结果数/请求缓存次数,命中率问题是缓存中的一个非常重要的问题,它是衡量缓存有效性的重要指标。命中率越高,表明缓存的使用率越高。
-
最大元素(最大空间)
缓存中可以存放的最大元素的数量,一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略。根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,从而更有效的时候缓存。
-
清空策略
如上描述,缓存的存储空间有限制,当缓存空间被用满时,如何保证在稳定服务的同时有效提升命中率?这就由缓存清空策略来处理,设计适合自身数据特征的清空策略能有效提升命中率
redis的过期策略和内存淘汰机制
在我们平常使用redis做缓存的时候,我们经常会给这个缓存设置一个过期时间,那么大家都知道如果我们在查过了过期时间的key时是不会有数据的。
那么所有过期key数据已经被删除了吗?是如何删除的?
其实如果一个key过期了,但是数据不一定已经被删除了,因为redis采用的是定期删除和惰性删除。
定期删除是指redis默认会每隔100ms会随机抽取一些设置了过期时间的key检查是否过期了,如果过期了就删除。
那么为什么不遍历删除所有的而是随机抽取一些呢?是因为可能redis中放置了大量的key,如果你每隔100ms都遍历,那么CPU肯定爆炸,redis也就GG了。
那么这样的话,为什么去查过期的key的话会查不到?
其实这就是redis的惰性删除,在你去查key的时候,redis会检查一下这个key是否设置了过期时间和是否已经过期了,如果是redis会删除这个key,并且返回空。
那么这样的话岂不是出大问题了,如果过期了又没有去查这个key,垃圾数据大量堆积,把redis的内存耗尽了怎么办?
其实当内存占用过多的时候,此时会进行内存淘汰,redis提供了如下策略:
- noeviction:当内存不足以容纳新写入数据时,新写入数据会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,会移除最近最少使用的key。
- allkeys-random:当内存不足以容纳新写入数据时,会随机移除某个key。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。
以上几个策略最常用的应该是allkeys-lru,其实这个也要根据业务场景去选择。
缓存与数据库双写一致性解决方案
Cache Aside Pattern原则
读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应。更新的时候,先删除缓存,然后再更新数据库。
方案一:队列串行化,当更新数据的时候,根据数据的唯一标识可以经过hash分发后搞到一个jvm内部队列,读取数据的时候,如果发现数据不在缓存中,那么将重新读取+ 更新缓存的操作也根据唯一标识发送到同一个jvm内部的队列,可以判断一下队列中是否已经有查询更新缓存的操作,如果有直接把更新缓存操作取消掉,然后每个队列单线程消费。但是这种方案有几个问题需要根据业务或者测试去完善优化,首先多实例服务怎么把请求根据数据的唯一标识路由到同一个实例;其次,读请求长阻塞、请求吞吐量、热点问题这些可能需要大量的压力测试和业务处理。
方案二:分布式锁,当读数据的时候如果缓存miss,可以去尝试根据唯一标识(例如userId)获取锁,如果获取不到直接从数据库查询数据返回即可,不更新缓存,反之则更新缓存,之后释放锁。当写请求过来时要保证获取到公平锁或者获取锁失败可以直接拒绝(公平性获取锁可以通过zookeeper的临时顺序节点来实现),在更新完数据库可以同时更新缓存(也可以不更新)。
分布式锁
在多线程环境下,通常会使用锁来保证有且只有一个线程来操作共享资源。利用操作系统提供的锁机制,可以确保多线程或多进程下的并发唯一操作。但如果在多机环境下就不能满足了,当A,B两台机器同时操作C机器的共享资源时,就需要第三方的锁机制来保证在分布式环境下的资源协调,也称分布式锁。
实现原理
由于Redis是单线程模型,命令操作原子性,所以利用这个特性可以很容易的实现分布式锁。 获得一个锁。
SET key uuid NX PX timeout
SET resource_name uniqueVal NX PX 30000
命令中的NX表示如果key不存在就添加,存在则直接返回。PX表示以毫秒为单位设置key的过期时间,这里是30000ms。 设置过期时间是防止获得锁的客户端突然崩溃掉或其他异常情况,导致redis中的对象锁一直无法释放,造成死锁。 Key的值需要在所有请求锁服务的客户端中,确保是个唯一值。 这是为了保证拿到锁的客户端能安全释放锁,防止这个锁对象被其他客户端删除。
举个例子:
- A客户端拿到对象锁,但在因为一些原因被阻塞导致无法及时释放锁。
- 因为过期时间已到,Redis中的锁对象被删除。
- B客户端请求获取锁成功。
- A客户端此时阻塞操作完成,删除key释放锁。
- C客户端请求获取锁成功。
- 这时B、C都拿到了锁,因此分布式锁失效。
要避免例子中的情况发生,就要保证key的值是唯一的,只有拿到锁的客户端才能进行删除。 基于这个原因,普通的del命令是不能满足要求的,我们需要一个能判断客户端传过来的value和锁对象的value是否一样的命令。遗憾的是Redis并没有这样的命令,但可以通过Lua脚本来完成:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
逻辑很简单,获取key中的值和参数中的值相比较,相等删除,不相等返回0
多实例分布式锁
上面是在单个Redis实例实现分布式锁的,这存在一个问题就是,如果这台实例因某些原因崩溃掉,那么所有客户端的锁服务全部失效。 Redis本身支持Master-Slave结构,可以一主多从,采用高可用方法,可以保证在master挂的时候自动切换到slave。 但是由于主从之间是异步同步数据的,所以redis并不能完全的实现锁的安全性。 举个例子来说:
- A客户端在master实例上获得一个锁。
- 在对象锁key传送到slave之前,master崩溃掉。
- 一个slave被选举成master。
- B客户端可以获取到同个key的锁,但A也已经拿到锁,导致锁失效。
在多台master情况下实现这个算法,并保证锁的安全性。 步骤如下:
- 客户端以毫秒为单位获取当前时间。
- 使用同样key和值,循环在多个实例中获得锁。 为了获得锁,客户端应该设置个偏移时间,它小于锁自动释放时间(即key的过期时间)。 举个例子来说,如果一个锁自动释放时间是10秒,那偏移时间应该设置在5~50毫秒的范围。 防止因为某个实例崩溃掉或其他原因,导致client在获取锁时耗时过长。
- 计算获取所有锁的耗时,即当前时间减去开始时间,得到a值。 用锁自动释放时间减去a值,在减去偏移时间,得到c值,如果获取锁成功的实例数量大于实际的数量一半,并且c大于0,那么锁就被获取成功。
- 锁获取成功,锁对象的有效时间是上面的c值。
- 若是客户端因为一些原因获取失败,原因可能是上面的c值为负数或者锁成功的数量小于实例数,以用N/2+1当标准(N为实例数)。 那么会释放所有实例上的锁。
上面描述可能不方便理解,用代码表示如下:
//锁自动释放时间
TimeSpan ttl=new TimeSpan(0,0,0,30000)
//获取锁成功的数量
int n = 0;
//记录开始时间
var startTime = DateTime.Now;
//在每个实例上获取锁
for_each_redis(
redis =>
{
if (LockInstance(redis, resource, val, ttl)) n += 1;
}
);
//偏移时间是锁自动释放时间的1%,根据上面10s是5-50毫秒推出。
var drift = Convert.ToInt32(ttl.TotalMilliseconds * 0.01);
//锁对象的有效时间=锁自动释放时间-(当前时间-开始时间)-偏移时间
var validity_time = ttl - (DateTime.Now - startTime) - new TimeSpan(0, 0, 0, 0, drift);
//判断成功的数量和有效时间c值是否大于0 if (n >= (N/2+1) && validity_time.TotalMilliseconds > 0) { }
参考:
https://tech.meituan.com/2017/03/17/cache-about.html
http://xuyangyang.club/articles/2018/11/11/1541950658138.html
https://zhuanlan.zhihu.com/p/29665317
https://juejin.im/post/5cee35d6f265da1b5f2637ae
文章作者 子不语
上次更新 2019-09-12