Redis对于从事互联网技术工程师的各位来说并不陌生,几乎所有的大中型企业都在使用Redis作为缓存数据库。但是对于对大多数企业而言只会用到它最基础的K,V缓存功能,还有很多Redis的高级功能都未曾接触。今天就来和各位小伙伴一起来探讨一下Redis的高级特性,也希望对各位有所帮助。
KV缓存
kv缓存是我们开发过程最常用的一个功能,我们可以利用Redis的kv功能来缓存用户信息、商品信息、会话信息等等。
def get_user(user_id):
user = redis.get(user_id)
if not user:
user = db.get(user_id)
// 设置缓存过期时间
redis.setex(user_id, ttl, user)
return user
def save_user(user):
// 设置缓存过期时间
redis.setex(user.id, ttl, user)
// 异步写数据库
db.save_async(user)
这个过期时间非常重要,它通常会和用户的单次会话长度成正比,保证用户在单次会话内尽量一直可以使用缓存里面的数据。当然,如果公司财力雄厚,又极致注重性能体验,可以将时间设置的长点甚至干脆就不设置过期时间。当数据量不断增长时,就使用 Codis 或者 Redis-Cluster 集群来扩容。
除此之外 Redis 还提供了缓存模式,Set 指令不必设置过期时间,它也可以将这些键值对按照一定的策略进行淘汰。打开缓存模式的指令是:config set maxmemory 20gb ,这样当内存达到 20gb 时,Redis 就会开始执行淘汰策略,给新来的键值对腾出空间。这个策略 Redis 也是提供了很多种,总结起来这个策略分为两块:划定淘汰范围,选择淘汰算法。比如我们线上使用的策略是 allkeys-lru。这个 allkeys 表示对 Redis 内部所有的 key 都有可能被淘汰,不管它有没有带过期时间,而volatile只淘汰带过期时间的。Redis 的淘汰功能就好比企业遇到经济寒冬时需要勒紧裤腰带过冬需要进行一轮残酷的人才优化。它会选择只优化临时工呢,还是所有人一律平等都可能被优化。当这个范围圈定之后,会从中选出若干个名额,怎么选择呢,这个就是淘汰算法。最常用的就是 LRU 算法,它有一个弱点,那就是表面功夫做得好的人可以逃过优化。比如你乘机赶紧在老板面前好好表现一下,然后你就安全了。所以到了 Redis 4.0 里面引入了 LFU 算法,要对平时的成绩也进行考核,只做表面功夫就已经不够用了,还要看你平时勤不勤快。最后还一种极不常用的算法 —— 随机摇号算法,这个算法有可能会把 CEO 也给淘汰了,所以一般不会使用它。
常用的淘汰算法有下面几种:
(1)FIFO:First In First Out,先进先出
(2)LRU:Least Recently Used,最近最少使用
(3)LFU:Least Frequently Used,最不经常使用
注意LRU和LFU的区别。LFU算法是根据在一段时间里数据项被使用的次数选择出最少使用的数据项,即根据使用次数的差异来决定。而LRU是根据使用时间的差异来决定的。
一个优秀的缓存框架必须实现以上的所有缓存机制。例如:Ehcache就实现了上面的所有策略。
分布式锁
分布式锁,这个是除了 KV 缓存之外最为常用的另一个特色功能。比如一个很能干的资深工程师,开发效率很快,代码质量也很高,是团队里的明星。所以呢诸多产品经理都要来烦他,让他给自己做需求。如果同一时间来了一堆产品经理都找他,它的思路呢就会陷入混乱,再优秀的程序员,大脑的并发能力也好不到哪里去。所以呢他就在自己的办公室的门把上挂了一个请勿打扰的牌子,当一个产品经理来的时候先看看门把上有没有这个牌子,如果没有呢就可以进来找工程师谈需求,谈之前要把牌子挂起来,谈完了再把牌子摘了。这样其它产品经理也要来烦他的时候,如果看见这个牌子挂在那里,就可以选择睡觉等待或者是先去忙别的事。如是这位明星工程师从此获得了安宁。
这个分布式锁的使用方式非常简单,就是使用 Set 指令的扩展参数如下
# 加锁
set lock:$user_id owner_id nx ex=5
# 释放锁
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
# 等价于
del_if_equals lock:$user_id owner_id
一定要设置这个过期时间,因为遇到特殊情况 —— 比如地震(进程被 kill -9,或者机器宕机),产品经理可能会选择从窗户上跳下去,没机会摘牌,导致了死锁饥饿,让这位优秀的工程师成了一位大闲人,造成严重的资源浪费。同时还需要注意这个 owner_id,它代表锁是谁加的 —— 产品经理的工号。以免你的锁不小心被别人摘掉了。释放锁时要匹配这个 owner_id,匹配成功了才能释放锁。这个 owner_id 通常是一个随机数,存放在 ThreadLocal 变量里(栈变量)。
官方其实并不推荐这种方式,因为它在集群模式下会产生锁丢失的问题 —— 在主从发生切换的时候。官方推荐的分布式锁叫 RedLock,作者认为这个算法较为安全,推荐我们使用。不过这边一直还使用上面最简单的分布式锁,为什么我们不去使用 RedLock 呢,因为它的运维成本会高一些,需要 3 台以上独立的 Redis 实例,用起来要繁琐一些。另外呢 Redis 集群发生主从切换的概率也并不高,即使发生了主从切换出现锁丢失的概率也很低,因为主从切换往往都有一个过程,这个过程的时间通常会超过锁的过期时间,也就不会发生锁的异常丢失。还有呢就是分布式锁遇到锁冲突的机会也不多,这正如一个公司里明星程序员也比较有限一样,总是遇到锁排队那说明结构上需要优化。
延时队列
延时队列。前面我们提到产品经理在遇到「请勿打扰」的牌子时可以选择多种策略,1. 干等待 2. 睡觉 2. 放弃不干了 3. 歇一会再干。干等待就是 spinlock,这会烧 CPU,飙高 Redis 的QPS。睡觉就是先 sleep 一会再试,这会浪费线程资源,还会增加响应时长。放弃不干呢就是告知前端用户待会再试,现在系统压力大有点忙,影响用户体验。最后一种呢就是现在要讲的策略 —— 待会再来,这是在现实世界里最普遍的策略。这种策略一般用在消息队列的消费中,这个时候遇到锁冲突该怎么办?不能抛弃不处理,也不适合立即重试(spinlock),这时就可以将消息扔进延时队列,过一会再处理。
有很多专业的消息中间件支持延时消息功能,比如 RabbitMQ 和 NSQ。Redis 也可以,我们可以使用 zset 来实现这个延时队列。zset 里面存储的是 value/score 键值对,我们将 value 存储为序列化的任务消息,score 存储为下一次任务消息运行的时间(deadline),然后轮询 zset 中 score 值大于 now 的任务消息进行处理。
# 生产延时消息
zadd(queue-key, now_ts+5, task_json)
# 消费延时消息
while True:
task_json = zrevrangebyscore(queue-key, now_ts, 0, 0, 1)
if task_json:
grabbed_ok = zrem(queue-key, task_json)
if grabbed_ok:
process_task(task_json)
else:
sleep(1000) // 歇 1s
当消费者是多线程或者多进程的时候,这里会存在竞争浪费问题。当前线程明明将 task_json 从 zset 中轮询出来了,但是通过 zrem 来争抢时却抢不到手。这时就可以使用 LUA 脚本来解决这个问题,将轮询和争抢操作原子化,这样就可以避免竞争浪费。
local res = nil
local tasks = redis.pcall("zrevrangebyscore", KEYS[1], ARGV[1], 0, "LIMIT", 0, 1)
if #tasks > 0 then
local ok = redis.pcall("zrem", KEYS[1], tasks[1])
if ok > 0 then
res = tasks[1]
end
end
return res
为什么我要将分布式锁和延时队列一起讲呢,因为很早的时候线上出了一次故障。故障发生时线上的某个 Redis 队列长度爆表了,导致很多异步任务得不到执行,业务数据出现了问题。后来查清楚原因了,就是因为分布式锁没有用好导致了死锁,而且遇到加锁失败时就 sleep 无限重试结果就导致了异步任务彻底进入了睡眠状态不能处理任务。那这个分布式锁当时是怎么用的呢?用的就是 setnx + expire,结果在服务升级的时候停止进程直接就导致了个别请求执行了 setnx,但是 expire 没有得到执行,于是就带来了个别用户的死锁。但是后台呢又有一个异步任务处理,也需要对用户加锁,加锁失败就会无限 sleep 重试,那么一旦撞上了前面的死锁用户,这个异步线程就彻底熄火了。因为这次事故我们才有了今天的正确的分布式锁形式以及延时队列的发明,还有就是优雅停机,因为如果存在优雅停机的逻辑,那么服务升级就不会导致请求只执行了一半就被打断了,除非是进程被 kill -9 或者是宕机。
本文参考自:https://mp.weixin.qq.com/s/ZO0uFDtVTkPt2_e8V2vb3Q 如有冒犯,请联系博主删除