做者:老钱前端
来源公众号:码洞git
---------------------------------------程序员
此次分享主要是围绕 Redis,分享在平时的平常业务开发中遇到的 9 个经典案例,但愿经过这次分享能够帮助你们更好的将 Redis 的高级特性应用到平常的业务开发中来。 github
首先介绍一下业务背景:总用户量大概是 5亿左右,月活 5kw,日活近 2kw 。服务端有 1000 多个 Redis 实例,100+ 集群,每一个实例的内存控制在 20g 如下。redis
第一个是最基础也是最经常使用的就是KV功能,咱们能够用 Redis 来缓存用户信息、会话信息、商品信息等等。算法
下面这段代码就是通用的缓存读取逻辑。数据库
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) // 异步写数据库
复制代码
这个过时时间很是重要,它一般会和用户的单次会话长度成正比,保证用户在单次会话内尽可能一直可使用缓存里面的数据。json
固然若是贵公司财力雄厚,又极致注重性能体验,能够将时间设置的长点甚至干脆就不设置过时时间。缓存
当数据量不断增加时,就使用 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 也给淘汰了,因此通常不会使用它。
下面咱们看第二个功能 —— 分布式锁,这个是除了 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 集群发生主从切换的几率也并不高,即便发生了主从切换出现锁丢失的几率也很低,由于主从切换每每都有一个过程,这个过程的时间一般会超过锁的过时时间,也就不会发生锁的异常丢失。
还有呢就是分布式锁遇到锁冲突的机会也很少,这正如一个公司里明星程序员也比较有限同样,老是遇到锁排队那说明结构上须要优化。
下面咱们继续看第三个功能 —— 延时队列。
前面咱们提到产品经理在遇到「请勿打扰」的牌子时能够选择多种策略,
干等待就是 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 或者是宕机。
分布式定时任务有多种实现方式,最多见的一种是 master-workers 模型。
master 负责管理时间,到点了就将任务消息仍到消息中间件里,而后worker们负责监听这些消息队列来消费消息。
著名的 Python 定时任务框架 Celery 就是这么干的。可是 Celery 有一个问题,那就是 master 是单点的,若是这个 master 挂了,整个定时任务系统就中止工做了。
另外一种实现方式是 multi-master 模型。这个模型什么意思呢,就相似于 Java 里面的 Quartz 框架,采用数据库锁来控制任务并发。
会有多个进程,每一个进程都会管理时间,时间到了就使用数据库锁来争抢任务执行权,抢到的进程就得到了任务执行的机会,而后就开始执行任务,这样就解决了 master 的单点问题。
这种模型有一个缺点,那就是会形成竞争浪费问题,不过一般大多数业务系统的定时任务并无那么多,因此这种竞争浪费并不严重。
还有一个问题它依赖于分布式机器时间的一致性,若是多个机器上时间不一致就会形成任务被屡次执行,这能够经过增长数据库锁的时间来缓解。
如今有了 Redis 分布式锁,那么咱们就能够在 Redis 之上实现一个简单的定时任务框架。
# 注册定时任务
hset tasks name trigger_rule
# 获取定时任务列表
hgetall tasks
# 争抢任务
set lock:${name} true nx ex=5
# 任务列表变动(滚动升级)
# 轮询版本号,有变化就重加载任务列表,从新调度时间有变化的任务
set tasks_version $new_version
get tasks_version
复制代码
若是你以为 Quartz 内部的代码复杂的让人看不懂,分布式文档又几乎没有,很难折腾,能够试试 Redis,使用它会让你少掉点头发。
Life is Short,I use Redis
https://github.com/pyloque/taskino
复制代码
若是你作过社区就知道,不可避免老是会遇到垃圾内容。一觉醒来你会发现首页忽然会某些莫名其妙的广告帖刷屏了。若是不采起适当的机制来控制就会致使用户体验收到严重影响。
控制广告垃圾贴的策略很是多,高级一点的经过 AI,最简单的方式是经过关键词扫描。还有比较经常使用的一种方式就是频率控制,限制单个用户内容生产速度,不一样等级的用户会有不一样的频率控制参数。
频率控制就可使用 Redis 来实现,咱们将用户的行为理解为一个时间序列,咱们要保证在必定的时间内限制单个用户的时间序列的长度,超过了这个长度就禁止用户的行为。它能够是用 Redis 的 zset 来实现。
图中绿色的部门就是咱们要保留的一个时间段的时间序列信息,灰色的段会被砍掉。统计绿色段中时间序列记录的个数就知道是否超过了频率的阈值。
# 下面的代码控制用户的 ugc 行为为每小时最多 N 次
hist_key = "ugc:${user_id}"
with redis.pipeline() as pipe:
# 记录当前的行为
pipe.zadd(hist_key, ts, uuid)
# 保留1小时内的行为序列
pipe.zremrangebyscore(hist_key, 0, now_ts - 3600)
# 获取这1小时内的行为数量
pipe.zcard(hist_key)
# 设置过时时间,节约内存
pipe.expire(hist_key, 3600)
# 批量执行
_, _, count, _ = pipe.exec()
return count > N
复制代码
技术成熟度稍微高一点的企业都会有服务发现的基础设施。一般咱们都会选用 zookeeper、etcd、consul 等分布式配置数据库来做为服务列表的存储。
它们有很是及时的通知机制来通知服务消费者服务列表发生了变动。那咱们该如何使用 Redis 来作服务发现呢?
这里咱们要再次使用 zset 数据结构,咱们使用 zset 来保存单个服务列表。多个服务列表就使用多个 zset 来存储。
zset 的 value 和 score 分别存储服务的地址和心跳的时间。服务提供者须要使用心跳来汇报本身的存活,每隔几秒调用一次 zadd。服务提供者中止服务时,使用 zrem 来移除本身。
zadd service_key heartbeat_ts addr
zrem service_key addr
复制代码
这样还不够,由于服务有多是异常终止,根本没机会执行钩子,因此须要使用一个额外的线程来清理服务列表中的过时项
zremrangebyscore service_key 0 now_ts - 30 # 30s 都没来心跳
复制代码
接下来还有一个重要的问题是如何通知消费者服务列表发生了变动,这里咱们一样使用版本号轮询机制。当服务列表变动时,递增版本号。消费者经过轮询版本号的变化来重加载服务列表。
if zadd() > 0 || zrem() > 0 || zremrangebyscore() > 0:
incr service_version_key
复制代码
可是还有一个问题,若是消费者依赖了不少的服务列表,那么它就须要轮询不少的版本号,这样的 IO 效率会比较低下。
这时咱们能够再增长一个全局版本号,当任意的服务列表版本号发生变动时,递增全局版本号。
这样在正常状况下消费者只须要轮询全局版本号就能够了。当全局版本号发生变动时再挨个比对依赖的服务列表的子版本号,而后加载有变动的服务列表。
咱们的签到系统作的比较早,当时用户量尚未上来,设计上比较简单,就是将用户的签到状态用 Redis的 hash 结构来存储,签到一次就在 hash 结构里记录一条,签到有三种状态,未签到、已签到和补签,分别是 0、一、2 三个整数值。
hset sign:${user_id} 2019-01-01 1
hset sign:${user_id} 2019-01-02 1
hset sign:${user_id} 2019-01-03 2
...
复制代码
这很是浪费用户空间,到后来签到日活过千万的时候,Redis 存储问题开始凸显,直接将内存飚到了 30G+,咱们线上实例一般过了 20G 就开始报警,30G 已经属于严重超标了。
这时候咱们就开始着手解决这个问题,去优化存储。咱们选择了使用位图来记录签到信息,一个签到状态须要两个位来记录,一个月的存储空间只须要 8 个字节。这样就可使用一个很短的字符串来存储用户一个月的签到记录。
优化后的效果很是明显,内存直接降到了 10 个G。由于查询整个月的签到状态 API 调用的很频繁,因此接口的通讯量也跟着小了不少。
可是位图也有一个缺点,它的底层是字符串,字符串是连续存储空间,位图会自动扩展,好比一个很大的位图 8m 个位,只有最后一个位是 1,其它位都是零,这也会占用1m 的存储空间,这样的浪费很是严重。
因此呢就有了咆哮位图这个数据结构,它对大位图进行了分段存储,全位零的段能够不用存。
另外还对每一个段设计了稀疏存储结构,若是这个段上置 1 的位很少,能够只存储它们的偏移量整数。这样位图的存储空间就获得了很是显著的压缩。
这个咆哮位图在大数据精准计数领域很是有价值,感兴趣的同窗能够了解一下。
https://juejin.im/post/5cf5c817e51d454fbf5409b0
前面提到这个签到系统,若是产品经理须要知道这个签到的日活月活怎么办呢?一般咱们会直接甩锅——请找数据部门。
可是数据部门的数据每每不是很实时,常常前一天的数据须要次日才能跑出来,离线计算是一般是定时的一天一次。那如何实现一个实时的活跃计数?
最简单的方案就是在 Redis 里面维护一个 set 集合,来一个用户,就 sadd 一下,最终集合的大小就是咱们须要的 UV 数字。
可是这个空间浪费很严重,仅仅为了一个数字要存储这样一个庞大的集合彷佛很是不值当。那该怎么办?
这时你就可使用 Redis 提供的 HyperLogLog 模糊计数功能,它是一种几率计数,有必定的偏差,偏差大约是 0.81%。
可是空间占用很小,其底层是一个位图,它最多只会占用 12k 的存储空间。并且在计数值比较小的时候,位图使用稀疏存储,空间占用就更小了。
# 记录用户
pfadd sign_uv_${day} user_id
# 获取记录数量
pfcount sign_uv_${day}
复制代码
微信公众号文章的阅读数可使用它,网页的 UV 统计它均可以完成。可是若是产品经理很是在意数字的准确性,好比某个统计需求和金钱直接挂钩,那么你能够考虑一下前面提到的咆哮位图。
它使用起来会复杂一些,须要提早将用户 ID 进行整数序列化。Redis 没有原生提供咆哮位图的功能,可是有一个开源的 Redis Module 能够拿来即用。
https://github.com/aviggiano/redis-roaring
最后咱们要讲一下布隆过滤器,若是一个系统即将会有大量的新用户涌入时,它就会很是有价值,能够显著下降缓存的穿透率,下降数据库的压力。
这个新用户的涌入不必定是业务系统的大规模铺开,也多是由于来自外部的缓存穿透攻击。
def get_user_state0(user_id):
state = cache.get(user_id)
if not state:
state = db.get(user_id) or {}
cache.set(user_id, state)
return state
def save_user_state0(user_id, state):
cache.set(user_id, state)
db.set_async(user_id, state)
复制代码
好比上面就是这个业务系统的用户状态查询接口代码,如今一个新用户过来了,它会先去缓存里查询有没有这个用户的状态数据
由于是新用户,因此确定缓存里没有。而后它就要去查数据库,结果数据库也没有。若是这样的新用户大批量瞬间涌入,那么能够预见数据库的压力会比较大,会存在大量的空查询。
咱们很是但愿 Redis 里面有这样的一个 set,它存放了全部用户的 id,这样经过查询这个 set 集合就知道是否是新用户来了。
当用户量很是庞大的时候,维护这样的一个集合须要的存储空间是很大的。
这时候就可使用布隆过滤器,它至关于一个 set,可是呢又不一样于 set,它须要的存储空间要小的多。
好比你存储一个用户 id 须要 64 个字节,而布隆过滤器存储一个用户 id 只须要 1个字节多点。可是呢它存的不是用户 id,而是用户 id 的指纹,因此会存在必定的小几率误判,它是一个具有模糊过滤能力的容器。
当它说用户 id 不在容器中时,那么就确定不在。当它说用户 id 在容器里时,99% 的几率下它是正确的,还有 1% 的几率它产生了误判。
不过在这个案例中,这个误判并不会产生问题,误判的代价只是缓存穿透而已。
至关于有 1% 的新用户没有获得布隆过滤器的保护直接穿透到数据库查询,而剩下的 99% 的新用户均可以被布隆过滤器有效的挡住,避免了缓存穿透。
def get_user_state(user_id):
exists = bloomfilter.is_user_exists(user_id)
if not exists:
return {}
return get_user_state0(user_id)
def save_user_state(user_id, state):
bloomfilter.set_user_exists(user_id)
save_user_state0(user_id, state)
复制代码
布隆过滤器的原理有一个很好的比喻,那就是在冬天一片白雪覆盖的地面上,若是你从上面走过,就会留下你的脚印。
若是地面上有你的脚印,那么就能够大几率判定你来过这个地方,可是也不必定,也许别人的鞋正好和你穿的如出一辙。但是若是地面上没有你的脚印,那么就能够 100% 判定你没来过这个地方
END
推荐一个专栏:
《从零开始带你成为JVM实战高手》
做者是我多年好友,之前团队的左膀右臂
一块儿经历过各类大型复杂系统上线的血雨腥风
现任阿里资深技术专家,对JVM有丰富的生产实践经验
专栏目录参见文末,能够扫下方海报进行试读
经过上面海报购买,再返你24元
领取方式:加微信号:Giotto1245,暗号:返现